Backend

API conventions

Every endpoint follows the same rules for lists, errors, CSRF, authentication, rate limits and language, so one client handles them all.

All paths in these guides are relative to where you mount pleate_backend.urls, normally /api. JSON in, JSON out, except uploads (multipart) and streams (Server-Sent Events). Ids are strings.

Lists

Every collection endpoint speaks the same query language, implemented once in core.pagination and core.filters. Two pagination modes exist.

Page mode, for tables

The default (PagePagination). Ideal for DataTable or Table with Pagination: the answer carries the total and the page count.

http
GET /api/catalog/products?page=2&page_size=20&search=oat&ordering=-price,name&status=active

{"results": [...], "count": 134, "page": 2, "page_size": 20, "page_count": 7}

Cursor mode, for feeds

CursorPagination, newest first by created_at. Stable while new items arrive, so it is used by notifications, activity and chat history.

http
GET /api/notifications?page_size=20&cursor=cD0yMDI2LTEw

{"results": [...], "next_cursor": "cD0yMDI2LTA5", "previous_cursor": null}
ParamExampleMeaning
page2Page number, from 1. Page mode only.
page_size50Items per page. Default 20, at most 100.
cursorcD0y…Opaque position. Cursor mode only.
searchoat milkCase insensitive match over the view's search_fields.
ordering-price,nameComma separated, - for descending. Only ordering_fields are accepted.
<field>status=activeExact filters from filterset_fields or a FilterSet.

A view opts in by declaring what is allowed. Anything else is rejected with a stable error rather than silently ignored:

python
class ProductViewSet(viewsets.ModelViewSet):
    search_fields = ["name", "sku", "category"]
    ordering_fields = ["name", "price", "stock", "created_at"]
    ordering = ["-created_at"]
    filterset_fields = ["status", "category"]   # or filterset_class = ProductFilter

For a feed, set pagination_class = CursorPagination from pleate_backend.core.pagination. The full recipe with a table is in Lists and tables.

Errors

Every error, from a missing field to a rate limit, has the same body. code is stable snake_case, safe to switch on or translate; message is already translated into the request's language; fields appears on validation errors with one list per field (nested fields use dots).

json
{
  "error": {
    "status": 400,
    "code": "validation_error",
    "message": "Some fields are not valid.",
    "fields": {
      "email": [{ "code": "email_taken", "message": "An account with this email already exists." }],
      "password": [{ "code": "weak_password", "message": "This password is too common." }]
    }
  }
}

Some errors carry extra keys: retry_after on 429, attempts_left on a wrong 2FA code, max_size or quota on uploads. In Python, raise ApiError:

python
from django.utils.translation import gettext as _
from pleate_backend.core.exceptions import ApiError

raise ApiError("out_of_stock", _("This product is sold out."), 409, {"available": 0})

In TypeScript every failed call throws ApiError with status, code, fields, fieldErrors() (first message per field, the shape pleate forms take) and retryAfter. A network failure has status 0 and code network_error.

ts
import { isApiError } from "pleate-client";

try {
  await client.auth.login({ email, password, remember: true });
} catch (error) {
  if (isApiError(error, "too_many_attempts")) wait(error.retryAfter);
  else if (isApiError(error)) setFieldErrors(error.fieldErrors());
}

Common codes

CodeStatusWhen
validation_error400One or more fields failed; see fields.
invalid_ordering400ordering names a field that is not in ordering_fields.
invalid_page404The page number is past the end.
not_authenticated401No session and no API key.
authentication_failed401Credentials were sent but are not valid.
permission_denied403Signed in, but not allowed.
csrf_failed403Unsafe request without a valid CSRF token. The client retries once on its own.
not_found404Missing, or belongs to another user (the API does not reveal which).
throttled429Rate limit hit; retry_after says when to try again.
banned403The client address is in an active IP ban.

Authentication

Two ways to be someone, checked in this order on every request:

  • API key. Authorization: Bearer pl_…. For scripts and servers. No cookies, so no CSRF. Read only keys may only use GET, HEAD and OPTIONS. See API keys.
  • Session cookie. Set by login, signup, OAuth or 2FA. HttpOnly, SameSite=Lax, two weeks by default (SESSION_COOKIE_AGE), one year with remember (REMEMBER_ME_AGE). Sessions are stored in MongoDB with device and IP, so users can list and revoke them.

The default permission is IsAuthenticated: an endpoint is private unless it says otherwise.

CSRF

Cookie authentication needs CSRF protection for POST, PUT, PATCH and DELETE. The flow is the standard Django one:

MethodPathPurpose
GET/csrfSets the csrftoken cookie and returns {csrf_token}.

The client does this for you: before the first unsafe request it reads the csrftoken cookie (or calls /csrf) and sends it as X-CSRFToken. If the server answers csrf_failed, it refreshes the token and retries once. Login, signup, password reset and the other endpoints that set cookies for anonymous visitors enforce CSRF too (CsrfProtectedMixin), so another site cannot sign a victim into an attacker's account.

Rate limits

Throttled responses carry the standard headers, and a 429 has retry_after in the body. Scopes and their rates live in REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]; see Rate limiting and cache.

http
RateLimit-Limit: 10
RateLimit-Remaining: 7
RateLimit-Reset: 60

Language

Messages and emails are translated. The language is the locale cookie if set, else the signed in user's locale, else Accept-Language; responses say which one was used in Content-Language. Pass locale to the client to send Accept-Language explicitly. See Internationalization.

Other headers

  • X-Request-ID: echoed if you send one (8 to 64 safe characters), generated otherwise. It appears in every JSON log line and in activity entries, so a user's bug report can be traced end to end.
  • ETag and If-None-Match: views using ETagMixin answer 304 when nothing changed.