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.
Conventions
Section titled “Conventions”Every request must send Accept: application/json. Without it Laravel may negotiate an
HTML error page instead of a JSON body.
| Shape | Body |
|---|---|
| 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.
The three 403s
Section titled “The three 403s”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 contains | Meaning | What the client should do |
|---|---|---|
"banned": true plus ban_reason | The account is banned | Show the reason. Signing in again will not help |
"email_unverified": true | The address is not verified | Offer “resend verification link” |
| Neither flag | An ordinary permission denial | Show the message |
Branch on the flags, never on the bare status.
Authentication
Section titled “Authentication”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.
Expiry
Section titled “Expiry”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.
Revocation
Section titled “Revocation”| Action | Effect |
|---|---|
POST /auth/logout | Deletes only the calling token. Other devices keep working |
POST /auth/reset-password | Deletes all of that user’s tokens, inside the reset transaction |
DELETE /auth/account | Deletes all tokens, then the user |
| A banned account making any request | EnsureNotBanned 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.
Send your token on public routes too
Section titled “Send your token on public routes too”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.
The three access tiers
Section titled “The three access tiers”| Tier | Middleware | What it covers |
|---|---|---|
| public | none | Forum, polls, editorial, geo, health, moderation board, presence |
| auth | auth:sanctum, api.banned, api.presence | Account management |
| verified | the above plus api.verified | Every 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.
Token abilities
Section titled “Token abilities”App\Support\TokenAbility declares the vocabulary:
| Ability | Meaning |
|---|---|
data:read | Read the display tier |
forum:write | Post, comment, vote |
account:write | Change the account itself |
moderate | Moderator and admin actions |
ingest:write | Write 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.
Endpoint reference
Section titled “Endpoint reference”45 endpoints in total: 25 public, 6 authenticated, 14 verified.
| Method | Path | Access | Body |
|---|---|---|---|
| POST | /auth/register | public | name, email, password, password_confirmation, device_name |
| POST | /auth/login | public | email, password, device_name |
| POST | /auth/forgot-password | public | email |
| POST | /auth/reset-password | public | token, email, password, password_confirmation |
| GET | /auth/me | auth | — |
| POST | /auth/logout | auth | — |
| PATCH | /auth/profile | auth | name, email |
| PUT | /auth/password | auth | current_password, password, password_confirmation |
| DELETE | /auth/account | auth | password |
| POST | /auth/email/verification-notification | auth | — |
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.
Email verification
Section titled “Email verification”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)User shape
Section titled “User shape”{ "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.
| Method | Path | Access |
|---|---|---|
| GET | /posts | public |
| GET | /posts/{id} | public |
| POST | /posts | verified |
| PUT | /posts/{id} | verified + owner |
| DELETE | /posts/{id} | verified + owner or higher role |
| GET | /communities | public |
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.
Comments
Section titled “Comments”Threaded, and shared between posts and polls exactly as the comments table is.
| Method | Path | Access |
|---|---|---|
| GET | /posts/{id}/comments | public |
| GET | /polls/{id}/comments | public |
| GET | /comments/{id}/replies | public |
| POST | /posts/{id}/comments | verified |
| POST | /polls/{id}/comments | verified |
| 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.
| Method | Path | Access |
|---|---|---|
| GET | /polls | public |
| GET | /polls/{id} | public |
| POST | /polls | verified |
| DELETE | /polls/{id} | verified + owner or higher role |
| POST | /polls/{id}/votes | verified |
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.
Reports and Database
Section titled “Reports and Database”The worker-produced tiers. Both sit behind verified, matching the web routes.
| Method | Path | Access |
|---|---|---|
| GET | /reports | verified |
| GET | /database-items | verified |
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.
Editorial news
Section titled “Editorial news”| Method | Path | Access |
|---|---|---|
| GET | /editorial-articles | public |
| GET | /editorial-articles/{id} | public |
| GET | /about-us-articles | public |
| GET | /categories | public |
| GET | /categories/{id}/subcategories | public |
/editorial-articles filters: category_id, subcategory_id, search, page,
per_page. Ordered by date descending.
Geo cascade
Section titled “Geo cascade”Read-only passthrough to the external world store, via App\Support\GeoData.
| Method | Path |
|---|---|
| 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.
Moderation and presence
Section titled “Moderation and presence”| Method | Path | Access |
|---|---|---|
| GET | /moderation-board | public |
| GET | /presence/authorized-services | public |
| POST | /moderation/bans | verified + 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.
Health
Section titled “Health”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.
| Field | What it means when false or degraded |
|---|---|
world_store | The 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_register | Verification 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 |
queue | processed 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.
Implementation notes
Section titled “Implementation notes”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.
Web-only capabilities
Section titled “Web-only capabilities”Three things the web face does that the JSON face deliberately does not:
| Capability | Web route | Why not in the API |
|---|---|---|
| Report export to xlsx / doc / json / csv | GET /reports/export | Native clients read the JSON directly |
| Email verification link handler | GET verify-email/{id}/{hash} | It is a signed URL opened from a mail client |
| Abuse reporting | POST /users/{id}/reports | No JSON counterpart exists |
- Web — Display Tier — the application behind this API
- Mobile — Expo Client — the only client of this API
- The Two Contracts — why there is a second, different API