Backend
API conventions
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.
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.
GET /api/notifications?page_size=20&cursor=cD0yMDI2LTEw
{"results": [...], "next_cursor": "cD0yMDI2LTA5", "previous_cursor": null}| Param | Example | Meaning |
|---|---|---|
| page | 2 | Page number, from 1. Page mode only. |
| page_size | 50 | Items per page. Default 20, at most 100. |
| cursor | cD0y… | Opaque position. Cursor mode only. |
| search | oat milk | Case insensitive match over the view's search_fields. |
| ordering | -price,name | Comma separated, - for descending. Only ordering_fields are accepted. |
| <field> | status=active | Exact 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:
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 = ProductFilterFor 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).
{
"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:
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.
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
| Code | Status | When |
|---|---|---|
| validation_error | 400 | One or more fields failed; see fields. |
| invalid_ordering | 400 | ordering names a field that is not in ordering_fields. |
| invalid_page | 404 | The page number is past the end. |
| not_authenticated | 401 | No session and no API key. |
| authentication_failed | 401 | Credentials were sent but are not valid. |
| permission_denied | 403 | Signed in, but not allowed. |
| csrf_failed | 403 | Unsafe request without a valid CSRF token. The client retries once on its own. |
| not_found | 404 | Missing, or belongs to another user (the API does not reveal which). |
| throttled | 429 | Rate limit hit; retry_after says when to try again. |
| banned | 403 | The 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 withremember(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:
| Method | Path | Purpose |
|---|---|---|
| GET | /csrf | Sets 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.
RateLimit-Limit: 10
RateLimit-Remaining: 7
RateLimit-Reset: 60Language
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.ETagandIf-None-Match: views usingETagMixinanswer 304 when nothing changed.