The JSON API¶
Anything the panel's UI can do to a record, a script can do too — through the exact
same permission and validation path, never a parallel one. vantage.api is a thin HTTP
skin over the code that already answers "which rows, which columns, is this allowed,
does this validate": vantage.permissions, vantage.listquery,
vantage.filters, vantage.pagination and vantage.serialize. It adds no new
dependency — plain Django views returning JsonResponse, same as everything else in
this package.
Only models registered with the panel are reachable, and never a framework table
Vantage itself excludes — auth.Permission, sessions, and so on answer 404, the same
guard the autocomplete endpoint uses.
Mounting¶
Already there. path("admin/", site.urls) mounts the whole panel, API included; there
is nothing extra to add to urls.py.
Authentication¶
Session, exactly like every other page in the panel — the same cookie, the same
request.user, no separate login and no API keys. A script authenticates the way a
browser does:
import requests
session = requests.Session()
# 1. Get a CSRF cookie and submit the login form, same as a browser would.
session.get("https://example.com/admin/accounts/login/")
session.post(
"https://example.com/admin/accounts/login/",
data={
"username": "alice",
"password": "hunter2",
"csrfmiddlewaretoken": session.cookies["csrftoken"],
},
headers={"Referer": "https://example.com/admin/accounts/login/"},
)
# 2. Every further request rides the session cookie `requests` is already keeping.
session.headers["X-CSRFToken"] = session.cookies["csrftoken"]
response = session.get("https://example.com/admin/_/api/shop/order/")
A caller must be signed in and is_staff — the same bar every HTML page in the
panel sets. An anonymous request gets 401; a signed-in non-staff account gets 403.
Beyond that, every endpoint checks the model-level permission for the verb
(view/add/change/delete) through
get_permissions(), object-level permissions on a single record, and
row scoping — including multi-tenancy, if it is on — on every list
and retrieve. Nothing here can reach a row the HTML pages could not.
CSRF¶
Never exempted. GET/HEAD need no token; POST/PUT/PATCH/DELETE are
checked by CsrfViewMiddleware exactly like an HTML form post, and the token goes in
an X-CSRFToken header because a JSON body has no hidden input to carry it in. Every
authenticated response — reads included — refreshes the csrftoken cookie, so a script
that only ever talks to this API and never renders the login page's own form still has
something to send back on its first write.
Django's default CSRF failure is an HTML page — the one thing this whole module exists
to avoid handing a JSON client. Point the whole project's failure view at
vantage.api.csrf_failure to make that one response JSON too:
This is project-wide, not API-only — there is one CSRF_FAILURE_VIEW per Django
project — so only set it if you are comfortable with every CSRF rejection in the
project answering JSON, including one from a stray HTML form outside the panel.
Errors¶
Every refusal is a JsonResponse, never an HTML page — a JSON client cannot read one.
The shapes:
| Status | When | Body |
|---|---|---|
400 |
Malformed JSON, a body without "fields", an unknown or read-only field name, a cursor that will not decode, a sort that cannot be cursor-paged |
{"detail": "..."} |
401 |
Not signed in | {"detail": "Authentication required."} |
403 |
Signed in but not staff, or the model/object permission refuses this verb, or a CSRF check fails | {"detail": "..."} |
404 |
The model is not registered, or the row does not exist for this caller — a row outside your scope answers 404, never 403, so it cannot confirm the row exists at all | {"detail": "..."} |
405 |
The verb is not supported on this endpoint | {"detail": "Method not allowed."}, with an Allow header |
409 |
The record changed since the version you read (see Concurrency), or deleting it would leave a PROTECT/RESTRICT relation dangling |
see below |
422 |
The submitted values do not validate | {"detail": "Validation failed.", "errors": {"field": ["message", ...], "__all__": [...]}} |
A 409 conflict carries the other side of the story:
{
"detail": "This record was changed by someone else since you read it.",
"conflict": {
"pk": 42,
"version": "2026-08-14T09:03:11.482Z",
"label": "Order #4821",
"fields": {
"status": {
"mine": "paid", "mine_display": "Paid",
"theirs": "refunded", "theirs_display": "Refunded",
"conflict": true
}
}
}
}
errors and conflict.fields are always keyed by field name, plus __all__ for a
cross-field error — the same shape vantage.concurrency.field_diff and the change
form's own validation already use, so a client that renders one renders the other.
Endpoints¶
| Method | Path | Does |
|---|---|---|
GET |
/_/api/schema/ |
Every model this caller may view — fields, kinds, choices, permissions |
GET |
/_/api/<app_label>/<model_name>/ |
List: filtered, searched, sorted, cursor-paged |
POST |
/_/api/<app_label>/<model_name>/ |
Create |
GET |
/_/api/<app_label>/<model_name>/<pk>/ |
Retrieve one record, in full |
PUT |
/_/api/<app_label>/<model_name>/<pk>/ |
Replace — every editable field is required |
PATCH |
/_/api/<app_label>/<model_name>/<pk>/ |
Update only the fields sent |
DELETE |
/_/api/<app_label>/<model_name>/<pk>/ |
Delete |
<app_label>/<model_name> are the same two words that make up a Django model label —
shop.Order is shop/order. Get the exact set of models, and their labels, from the
schema endpoint rather than guessing.
Schema¶
>>> GET /admin/_/api/schema/
{
"models": {
"shop.order": {
"model": "shop.order",
"label": "Order",
"label_plural": "Orders",
"actions": ["view", "change"],
"list_url": "/admin/_/api/shop/order/",
"list_display": ["number", "customer", "status", "total"],
"list_filter": ["status", "created_at"],
"list_search": ["number", "customer__name"],
"fields": [
{
"name": "status",
"label": "Status",
"kind": "choice",
"readonly": false,
"required": true,
"help_text": "",
"widget": {
"kind": "choice",
"choices": [
{"value": "pending", "label": "Pending"},
{"value": "paid", "label": "Paid"}
],
"...": "..."
},
"initial": "pending"
}
]
}
}
}
Only models the caller may view appear at all — the same
visible_panels rule the sidebar and global search use. actions is
this caller's own allowed verbs on that model, so a client can grey out a "New" button
without a failed request first. A relation field's widget carries options_url: the
autocomplete endpoint, pre-filled with ?model=, for looking up what a
foreign key or many-to-many may point at without loading every row.
List¶
GET /_/api/shop/order/?status=paid&q=ACME&sort=-created_at&page_size=25&fields=number,customer,total
- Filtering — one query parameter per declared filter, exactly what the list page's
URL carries; a filter with more than one value ORs them, several filters AND
together.
GETthe endpoint with no filter params and readfiltersin the response to see what is available and how each is currently applied — the sameFilterobjects Lists documents. - Search —
?q=, across the panel'slist_searchfields. - Sort —
?sort=fieldor?sort=-fieldfor descending, restricted to whatever?fields=(or the panel's own columns, by default) named — a client can sort by anything it can read. A sort across a relation (?sort=customer__name) cannot be cursor-paged and is refused with400rather than silently falling back to an expensiveOFFSET; choose a plain column instead. - Columns —
?fields=a,b,cpicks which columns come back in each row'scells; omit it and the panel's ownlist_displayis used. Asking for more columns costs moreSELECT/prefetch joins and nothing else — see Query cost.
A row:
{
"pk": 4821,
"display": "Order #4821",
"version": "2026-08-14T09:03:11.482Z",
"url": "/admin/_/api/shop/order/4821/",
"html_url": "/admin/shop/order/4821/",
"cells": {
"number": {"value": "4821", "display": "4821"},
"status": {"value": "paid", "display": "Paid"},
"total": {"value": "129.00", "display": "$129.00"}
}
}
Every value comes twice: value is what a client hands back on a write; display is
what a person reads, rendered by the same function the HTML table cell uses. version
is the optimistic-lock token — on every row, not only on retrieve, so a
client editing straight from a list never needs a round trip just to learn it.
Pagination is cursor-only¶
LIMIT 20 OFFSET 100000 makes the database produce and discard a hundred thousand rows
to hand back twenty. There is no page-number mode here to fall back to:
{
"results": [ ... ],
"next": "eyJ2IjpbIjIwMjYtMDgtMTQiLCA0ODIxXSwgImQiOiAibmV4dCJ9",
"previous": null,
"query": "ACME",
"sort": {"path": "created_at", "descending": true, "order_by": ["-created_at", "-pk"]},
"filters": [ ... ]
}
Pass next back as ?cursor= for the following page. There is no total count, and no
way to jump to "page 40" — a page is defined relative to the row before it, and a
cursor anchored to a row cannot skip or duplicate under concurrent writes the way an
OFFSET page can. ?page_size= defaults to the panel's own page size and is clamped
to between 1 and 200 rather than rejected outright — a value below 1, above 200, or not
a number at all silently becomes the nearest valid size instead of a 400, which is the
right trade for a parameter that only ever shrinks or grows a response, never changes
what it means. See vantage.pagination.KeysetPaginator
for the mechanism.
Retrieve¶
>>> GET /admin/_/api/shop/order/4821/
{
"pk": 4821,
"display": "Order #4821",
"version": "2026-08-14T09:03:11.482Z",
"url": "/admin/_/api/shop/order/4821/",
"html_url": "/admin/shop/order/4821/",
"fields": {
"status": {"label": "Status", "kind": "choice", "value": "paid", "display": "Paid"},
"customer": {"label": "Customer", "kind": "relation_one", "value": 12, "display": "Acme Ltd"}
}
}
A row you may not see does not exist: an out-of-scope or nonexistent primary key both
answer 404, never 403 — the same rule
vantage.formviews.get_record applies to the HTML
detail page, so this cannot leak that a record exists and is merely off-limits. A
primary key of the wrong shape — a letter where the model expects an integer, say —
answers 404 too rather than a 500; the lookup is refused before it ever reaches the
database.
Create, update, partial update¶
The body is always {"fields": {...}}. A relation is its primary key (or a list of
them for a many-to-many); a boolean is a real true/false; a JSON field is a real
object or array, not a string.
>>> POST /admin/_/api/shop/order/
{"fields": {"number": "4900", "customer": 12, "status": "pending", "total": "42.00"}}
201 Created
{"pk": 4900, "display": "Order #4900", "version": null, "fields": {...}}
A field the caller leaves out on create falls back to the model's own default when
it has one — an HTML create page can never actually omit such a field either, because
its widget renders pre-filled with that default and an untouched control still submits
it. A field with no default and no blank=True is required and reports itself with
422 like any other missing value.
PUT is a full replacement: every editable field must be present, and a client that
only wants to change one thing should use PATCH instead, which validates and writes
only the field names actually sent — the same narrowing vantage.inline's row editor
uses for the same reason: a partial write must not demand a value for a field nobody
is touching.
A field that is read-only — an auto-generated primary key, an auto_now/
auto_now_add timestamp, anything the panel declared in readonly_fields — is not in
the form at all, on any of the three verbs. Sending one back is a 400, not a value
that gets quietly dropped: hiding a control is a UI affordance, refusing the field
entirely is the security boundary, and it is the same boundary
vantage.formviews documents for the change form.
Delete¶
If deleting would leave a PROTECT/RESTRICT relation dangling, nothing is deleted
and the response is 409 naming what is in the way — the same check the HTML confirm
page runs via vantage.formviews.collect_cascade.
Concurrency¶
Every row carries version — an opaque token off the model's own auto_now timestamp
or an explicit integer counter column, whichever it has; see
vantage.concurrency. Send it back on PUT/PATCH
to get a compare-and-set: the write only lands if nobody else's write landed first.
Omit version and the write proceeds unguarded — last writer wins, exactly what the
HTML change form does today. Passing it is how a script opts into the same protection
vantage.inline's row editor already gives the grid.
Query cost does not grow with row count¶
The list endpoint derives select_related/prefetch_related from the columns actually
requested — the panel's own inferred joins, plus
vantage.optimizer.plan_related over whatever
?fields= asked for beyond them. Asking for two more relation columns over a
fifteen-row page costs the same number of queries as asking for none; it never costs
fifteen more. Paging five cursors deep costs the same as paging one deep. Both are
asserted in tests/test_api.py with django.test.utils.CaptureQueriesContext, and
mirror the guarantees tests/test_scale.py already holds the HTML list page to.
Multi-tenancy¶
If VANTAGE["TENANT_MODEL"] is set, every endpoint here inherits row
scoping automatically: list, retrieve, create and update all go through
get_permissions().filter_queryset(), which vantage.tenancy.enforce_on wraps for
every configured resolver. Nothing in vantage.api mentions tenancy at all — that is
the point, and tests/test_api.py::TestTenancyComposesAutomatically proves it end to
end: a user of one tenant reading another tenant's row through the API gets the same
404 a mis-scoped HTML page would give, not a leak.
What this API does not do¶
- No file or image uploads. A JSON body cannot carry binary data. A model with a
FileField/ImageFieldis still fully readable and its other fields are still writable through this API; use the HTML form for the upload itself. - No bulk actions, saved views or import/export. Those are list-page features with their own request shapes; nothing here stops a future endpoint from exposing them the same way this one exposes create/read/update/delete.