Skip to content
JSON API

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/json

returns 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/json

DELETE /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.