Skip to content

JSON API v1

Shipped. This is the machine-readable face of the display tier: the same database, the same models, and the same roles, bans and policies as the web UI, addressed over JSON so native clients can read and write it. It is not a second system.

Base URL: {APP_URL}/api/v1

Source of truth: demostatics-web/routes/api.php and demostatics-web/docs/api-v1.md. They agree endpoint for endpoint. Where this page and either of them differ, the route file wins.

Every request must send Accept: application/json. Without it Laravel may negotiate an HTML error page instead of a JSON body.

ShapeBody
Single resource{ "data": { ... } }
Lookup collection (unpaginated){ "data": [ ... ] }
Paginated collection{ "data": [ ... ], "meta": { "current_page", "last_page", "per_page", "total" } }
Validation failure (422){ "message": "...", "errors": { "field": ["..."] } }
Auth / permission / missing (401, 403, 404){ "message": "..." }
Banned account (403){ "message": "...", "banned": true, "ban_reason": "..." }
Unverified email (403){ "message": "...", "email_unverified": true }

Page size comes from config('demostatics.per_page') (default 10), overridable per request with ?per_page=. ApiController::perPage() clamps it to the range 1–50, so ?per_page=100000 cannot be used as an amplification lever. Pass ?page= to paginate.

GET /health is deliberately unwrapped — no data envelope — because it is metadata about the API rather than a resource, and a monitor should be able to read it without knowing the resource conventions.

This is the single most common way to get a client wrong, so it gets its own section.

A 403 from this API means one of three different things, and the client has to tell them apart to say anything useful to the user:

Body containsMeaningWhat the client should do
"banned": true plus ban_reasonThe account is bannedShow the reason. Signing in again will not help
"email_unverified": trueThe address is not verifiedOffer “resend verification link”
Neither flagAn ordinary permission denialShow the message

Branch on the flags, never on the bare status.

Sanctum personal access tokens — opaque, database-backed, of the form 3|xxxxxxxx. Send them as Authorization: Bearer {token}.

Tokens are issued at exactly two places, POST /auth/register (201) and POST /auth/login (200), both responding:

{ "data": { "id": 1, "name": "..." }, "token": "3|xxxxxxxx" }

Each token is named after the device_name the client supplies — a required field on both routes — so a user can see and revoke individual devices.

SANCTUM_EXPIRATION bounds tokens; config/sanctum.php defaults it to 30 days. Expired rows are pruned daily by sanctum:prune-expired --hours=168, registered in routes/console.php.

A client must treat a 401 on a previously working token as “sign in again”, not as an error state.

ActionEffect
POST /auth/logoutDeletes only the calling token. Other devices keep working
POST /auth/reset-passwordDeletes all of that user’s tokens, inside the reset transaction
DELETE /auth/accountDeletes all tokens, then the user
A banned account making any requestEnsureNotBanned deletes all of that user’s tokens

The password reset behaviour is deliberate: the web flow gets this for free by rotating remember_token, but API tokens are independent of it and would otherwise survive a reset in an attacker’s hands.

ResolveApiUser wraps the entire v1 group and calls Auth::shouldUse('sanctum'), so a bearer token is resolved even on endpoints that do not require one.

That is what makes a signed-in read differ from a guest read: the can blocks reflect your real permissions, user_vote_option_id shows which option you picked, and your own email address comes back on your own records.

TierMiddlewareWhat it covers
publicnoneForum, polls, editorial, geo, health, moderation board, presence
authauth:sanctum, api.banned, api.presenceAccount management
verifiedthe above plus api.verifiedEvery write, plus Reports and Database

Any authenticated request marks the user online for config('demostatics.presence_ttl') seconds (default 300), exactly as the web middleware does, so API users appear in the “Authorized Services” roster.

App\Support\TokenAbility declares the vocabulary:

AbilityMeaning
data:readRead the display tier
forum:writePost, comment, vote
account:writeChange the account itself
moderateModerator and admin actions
ingest:writeWrite observations into the data tier — for a worker credential

forInteractiveDevice() grants the first four, rather than Sanctum’s default *. ingest:write is deliberately withheld from anything a person signs in with.

45 endpoints in total: 25 public, 6 authenticated, 14 verified.

MethodPathAccessBody
POST/auth/registerpublicname, email, password, password_confirmation, device_name
POST/auth/loginpublicemail, password, device_name
POST/auth/forgot-passwordpublicemail
POST/auth/reset-passwordpublictoken, email, password, password_confirmation
GET/auth/meauth
POST/auth/logoutauth
PATCH/auth/profileauthname, email
PUT/auth/passwordauthcurrent_password, password, password_confirmation
DELETE/auth/accountauthpassword
POST/auth/email/verification-notificationauth

The four public auth routes carry throttle:5,1; the verification resend carries throttle:6,1. Throttling is per IP and route, unlike the web flow’s per email and IP rate limiter.

Password rules come from config('demostatics.password') — 12 characters, mixed case, numbers and symbols by default — so the API and the web registration form cannot drift apart.

Changing your email via /auth/profile clears email_verified_at and re-sends the verification mail, dropping the account out of the verified tier until it is confirmed. Re-read /auth/me after any profile update.

login on a banned account returns 403 with banned: true and the reason. The ban is checked before a token is minted.

Verification uses Laravel’s standard signed-URL email. There is no API endpoint that marks an address verified — the user opens the link from their mail client. /auth/email/verification-notification re-sends it.

In local development MAIL_MAILER=log, so the link is written to storage/logs/laravel.log. Seeded demo accounts are already verified:

admin@demostatics.test / password (admin)
editor@demostatics.test / password (editor)
member@demostatics.test / password (user)
{
"id": 1,
"name": "Demo Admin",
"email": "admin@demostatics.test",
"email_verified": true,
"roles": ["admin"],
"is_banned": false,
"is_online": true,
"created_at": "2026-07-18T01:52:00+00:00"
}

email is present only on the caller’s own record. Other users’ addresses are never exposed.

MethodPathAccess
GET/postspublic
GET/posts/{id}public
POST/postsverified
PUT/posts/{id}verified + owner
DELETE/posts/{id}verified + owner or higher role
GET/communitiespublic

GET /posts query parameters: community_id, search (case-insensitive over title and content), sort (latest default, most_views, most_comments), page, per_page.

POST / PUT body: community_id, title (≤ limits.title), content (≤ limits.content).

{
"id": 3,
"title": "Data sources for real-time transport flows",
"content": "...",
"views": 42,
"comments_count": 4,
"created_at": "...",
"updated_at": "...",
"author": { "id": 1, "name": "..." },
"community": { "id": 1, "name": "General" },
"can": { "update": false, "delete": false }
}

The can block is computed from the same policies the web UI uses — PostPolicy, PollPolicy, CommentPolicy — so a client renders or hides edit and delete affordances without reimplementing permission rules. It is always present, and all-false for guests.

Threaded, and shared between posts and polls exactly as the comments table is.

MethodPathAccess
GET/posts/{id}/commentspublic
GET/polls/{id}/commentspublic
GET/comments/{id}/repliespublic
POST/posts/{id}/commentsverified
POST/polls/{id}/commentsverified
PUT/comments/{id}verified + owner
DELETE/comments/{id}verified + owner or higher role

The two GET .../comments endpoints return top-level comments (parent_id = null) with their immediate replies embedded one level deep, plus a replies_count. Deeper threads are fetched on demand from /comments/{id}/replies.

POST body: content (1–20000), optional parent_id.

MethodPathAccess
GET/pollspublic
GET/polls/{id}public
POST/pollsverified
DELETE/polls/{id}verified + owner or higher role
POST/polls/{id}/votesverified

GET /polls takes the same community_id / search / sort / page / per_page parameters as /posts.

POST /polls body: community_id, title, content, voting_length_in_days (1–7), and options — an array of 2–6 non-empty strings. Created in a transaction with its options.

POST /polls/{id}/votes body: poll_option_id. Re-voting moves an existing vote rather than adding a second one. Voting on a closed poll, or with an option belonging to another poll, is a 404.

{
"id": 1,
"title": "Which risk dimension matters most?",
"views": 12,
"comments_count": 3,
"voting_length_in_days": 7,
"closes_at": "...",
"is_open": true,
"votes_count": 9,
"user_vote_option_id": 2,
"options": [ { "id": 1, "content": "Ecological", "votes_count": 4 } ],
"author": { "id": 1, "name": "..." },
"community": { "id": 1, "name": "General" },
"can": { "delete": false }
}

user_vote_option_id is null for guests and for users who have not voted. There is no closes_at column — it is computed from created_at plus voting_length_in_days.

The worker-produced tiers. Both sit behind verified, matching the web routes.

MethodPathAccess
GET/reportsverified
GET/database-itemsverified

Shared filters — any may be omitted, and 0 is treated as “unset”, not as id zero:

region_id, subregion_id, country_id, state_id, city_id, category_id, subcategory_id, start_date (YYYY-MM-DD), end_date, search, page, per_page.

Reports order by published_at descending with NULLs forced last; database items order by measured_at the same way. Both use a portable CASE expression, because PostgreSQL otherwise sorts NULLs first on DESC.

Export to xlsx, doc, json and csv stays a web-only concern — native clients read the JSON directly.

MethodPathAccess
GET/editorial-articlespublic
GET/editorial-articles/{id}public
GET/about-us-articlespublic
GET/categoriespublic
GET/categories/{id}/subcategoriespublic

/editorial-articles filters: category_id, subcategory_id, search, page, per_page. Ordered by date descending.

Read-only passthrough to the external world store, via App\Support\GeoData.

MethodPath
GET/regions
GET/regions/{id}/subregions
GET/subregions/{id}/countries
GET/countries/{id}/states
GET/states/{id}/cities

All return { "data": [ { "id", "name" } ] }.

Every one of these degrades to an empty data array when the store is unreachable, rather than returning a 500 — the same contract the web dropdowns rely on.

MethodPathAccess
GET/moderation-boardpublic
GET/presence/authorized-servicespublic
POST/moderation/bansverified + higher role
DELETE/moderation/bans/{id}verified + higher role

/presence/authorized-services returns the same rosters the forum sidebar renders:

{
"data": {
"admins": [],
"technical_staff": [],
"editors": [],
"moderators": [],
"online_user_count": 2
}
}

Ban rules come from User::hasHigherPermissionsThan() — moderators may ban plain users, admins may ban anyone who is not an admin. Anything else is 403. Banning an already-banned user is 409.

GET /health — no auth, no token:

{
"status": "ok",
"app": "Demostatics",
"api": "v1",
"world_store": true,
"can_register": false,
"queue": "requires_worker"
}

Everything after api reports a dependency whose absence is silent — the failures that leave the API looking healthy while a whole tier is dead.

FieldWhat it means when false or degraded
world_storeThe geo store is unreachable. The cascade degrades to empty arrays by design, so this is the only way to tell “no regions exist” from “the geo store is down”
can_registerVerification mail cannot be delivered, so signup is broken on this instance however healthy everything else looks. False whenever MAIL_MAILER is log, array or unset
queueprocessed when the driver runs inline (sync), requires_worker otherwise. A database queue with no supervised worker accepts jobs and never runs them — invisible until the first payment webhook

Check this endpoint first on any deployment. See Deployment Readiness.

Decisions taken while building this out, recorded because each is a place a client could otherwise be surprised.

meta is a superset of the documented table. Laravel’s paginator also emits from, to, path and a links array. The four documented keys are the ones worth depending on; the rest may change with a framework upgrade.

Pagination links keep their filters. Every paginated endpoint calls withQueryString(), so links.next on a filtered list carries search, sort, community_id and the rest. A client may follow those URLs blindly instead of rebuilding the query.

PUT /posts/{id} treats community_id as optional. The web edit form never submits it and leaves the post where it is; sending it moves the post. Both shapes are accepted.

Reading a post or poll bumps updated_at. GET /posts/{id} increments the view counter, and Eloquent’s increment() touches updated_at as it goes. This matches the web pages exactly, but it means a read mutates a row — do not sort a cache on updated_at and expect it to mean “last edited”.

current_password is checked against the sanctum guard explicitly on /auth/password and /auth/account, rather than relying on auth:sanctum having already made it the request default.

Three things the web face does that the JSON face deliberately does not:

CapabilityWeb routeWhy not in the API
Report export to xlsx / doc / json / csvGET /reports/exportNative clients read the JSON directly
Email verification link handlerGET verify-email/{id}/{hash}It is a signed URL opened from a mail client
Abuse reportingPOST /users/{id}/reportsNo JSON counterpart exists