JSON API
Every resource endpoint negotiates content: send
Accept: application/json and the same handler that renders HTML returns
JSON — same auth, same permissions, same policies, same validation. There
is no separate API layer to keep in sync.
Listing
GET /app/posts?page=2&per_page=20&q=go&sort=-ID
Accept: application/json{ "items": [ { "ID": 21, "Title": "…" } ], "total": 137, "page": 2, "per_page": 20 }page and per_page match the keys in the response, so a response can be
paged forward without renaming anything. sort takes a field path, prefixed
- for descending; q is quick search. Filters use f_ plus the field
path (f_Status), with _to appended for the upper bound of a range —
the same names the filter panel’s form submits.
A filter path may cross one relation hop (f_Tags.ID=3,
f_Author.Name=Alice), including through a many-to-many pivot; see
filtering across a relation. sort may
not — it needs a column on the resource’s own table.
Single record
GET /app/posts/21
Accept: application/jsonreturns the record as stored (relations included when the grid preloads them).
Schema
GET /app/posts/_schema describes the form for headless clients:
{
"slug": "posts", "title": "Posts", "key": "ID",
"fields": [
{ "name": "Title", "kind": "text", "label": "Title", "required": true, "rules": "required|max:255" }
]
}Mutations
Create, update, and delete return dcat-admin’s envelope — one shape for every mutation:
POST /app/posts (form-encoded fields)
PUT /app/posts/21
DELETE /app/posts/21 (comma-separate ids for batch: /posts/1,2,3){ "status": true, "data": { "message": "Saved.", "type": "success", "then": { "action": "redirect", "value": "/app/posts" } } }then.action is one of redirect, location, download, refresh,
script — a hint your client may honor or ignore. Validation failures come
back as 422:
{ "status": false, "errors": { "Title": ["Title is required."] } }Custom actions (POST /app/posts/_action/{name} with ids=1,2) return
the same envelope; your handlers build it with steward.Success,
steward.Error, steward.Warning, steward.Info and the chainable
Redirect / Refresh / Download / Script / Code helpers.
Authentication
Two ways in. Browsers use the session cookie; API scripts and mobile apps use a bearer token.
Bearer tokens
Set EnableTokenAuth — it is off by default, because turning it on exposes
an endpoint that trades a password for a credential:
app, _ := steward.New(steward.Config{
DB: db, SecretKey: key,
EnableTokenAuth: true,
TokenTTL: 14 * 24 * time.Hour, // 0 = 30 days, negative = never expires
TokenRateLimit: 5, // 0 = 5 per window, negative disables
TokenRateWindow: time.Minute, // 0 = 1 minute
})Mint a token — this is the login step for a mobile client:
POST /app/auth/token
Content-Type: application/json
{ "username": "admin", "password": "…", "name": "iPhone" }{
"token": "stw_xF3…",
"expires_at": "2026-08-12T09:14:00Z",
"user": { "id": 1, "username": "admin", "name": "Administrator" }
}The raw token is returned once and never again — only its SHA-256 is stored, so a database leak yields no usable credentials. Send it on every subsequent request:
GET /app/posts
Authorization: Bearer stw_xF3…
Accept: application/jsonDELETE /app/auth/token revokes the token you present — logout for a
client that holds no cookie. Form encoding works too if JSON is awkward.
A token carries exactly its user’s roles, permissions, and policies. To
scope an API client, give it its own AdminUser with a restricted role
rather than the administrator account — row- and field-level policies then
apply to it the same way they apply in the panel.
Deleting a user invalidates their tokens, since the owning account is resolved on every request.
Rate limiting
/auth/token is throttled. TokenRateLimit attempts are allowed per
TokenRateWindow per username, and six times that per client IP —
looser, because a proxy puts every client behind one address, so the
per-username bound is what actually protects an account. Both successful and
failed attempts count, and the budget is spent before bcrypt runs, so
guessing cannot be amplified into CPU load.
Exceeding it returns 429 with Retry-After in seconds:
{ "status": false, "data": { "message": "Too many attempts — try again shortly.", "type": "error" } }Two limits of the implementation worth knowing. Counting is in-process,
so N replicas admit N times the configured rate — put a limiter at the edge
if you need a strict global bound. And X-Forwarded-For is deliberately
ignored when identifying a client, because it is caller-controlled and
trusting it would let one attacker mint a fresh bucket per request.
CSRF
Cookie-authenticated writes need the CSRF token — read it from the
csrf-token meta tag and send it as X-CSRF-Token.
Bearer requests are exempt. CSRF protects against a browser attaching an
ambient cookie to a cross-site request; a token sent in an explicit header is
never attached automatically. So a token client can POST, PUT, and
DELETE with no CSRF handshake at all.
Unauthenticated JSON requests get 401 with an envelope rather than a
redirect. A bearer token that is unknown, expired, or revoked also gets
401 — it is never quietly ignored in favour of a session.
Responses set Vary: HX-Request, Accept, so caches never mix the HTML,
fragment, and JSON representations of one URL.