Overview
Base URL: https://semak.khursani.dev. All endpoints speak UTF-8. JSON endpoints return application/json; errors come back as {"error": "…"} with a 4xx/5xx status.
Read endpoints need no key. Write endpoints need a session cookie from social sign-in (/api/auth). The API sends no CORS headers, so browser clients must be same-origin; curl and server-side clients are unaffected.
Incident statuses move unverified → community-verified | debunked through weekly settlement; frozen items are held out of settlement. Incidents merged into another topic are hidden everywhere and reject votes.
Public JSON
GET/api/incidents
The live board: unmerged incidents, unverified first, then newest. Every incident carries its vote tallies and the source posts behind it (platform, handle, link, post time). This is the same feed the board UI renders.
Query params (s64): date=YYYY-MM-DD keeps only incidents created that UTC day; limit (1–100, default 100) and offset (≥ 0, default 0) page the list; total in the response is the match count ignoring limit/offset, so a client knows when to stop paging. A malformed date returns 400 rather than dropping the filter.
curl -s 'https://semak.khursani.dev/api/incidents?date=2026-09-19&limit=20&offset=0' \
| jq '{total, count, ids: [.incidents[].id], dates: [.incidents[].created_at[0:10]] | unique}'
curl -s https://semak.khursani.dev/api/incidents | jq '.incidents[0]'
Sample response (trimmed):
{
"incidents": [
{
"id": 10,
"slug": "second-red-bellied-piranha-caught-in-kla-d6928e",
"title": "Second red-bellied piranha caught in Klau River, Pahang…",
"summary": "A community report says a second red-bellied piranha…",
"category": "other-civic",
"location": "Klau River, Pahang",
"confidence": 0.5,
"status": "unverified",
"created_at": "2026-09-19T01:00:30+00:00",
"votes_true": 0,
"votes_false": 0,
"sources": [
{
"incident_id": 10,
"platform": "reddit",
"handle": "u/ReimuSan003",
"url": "https://www.reddit.com/r/malaysia/comments/1wk7yum/…",
"posted_at": "2026-09-19T00:59:01+00:00"
}
]
}
]
}
Fields: confidence is the ingest-time estimate (0–1), never the settlement verdict. Filter client-side, e.g. only verified topics:
curl -s https://semak.khursani.dev/api/incidents \
| jq '[.incidents[] | select(.status == "community-verified")]'
GET/api/incidents/search
Filtered search over the same feed; filters combine with AND. q is a case-insensitive substring over title, summary, location and category. category and status take comma-separated exact values. date=YYYY-MM-DD (s64) keeps only incidents created that UTC day. limit clamps to 1–100, default 50; offset (≥ 0) pages, and total is the match count ignoring paging. The response echoes the parsed filters and carries facets — the distinct categories and statuses with live counts — so a client can build filter menus without a second call.
curl -s 'https://semak.khursani.dev/api/incidents/search?q=piranha&status=unverified' \
| jq '{count, ids: [.incidents[].id], params}'
{
"incidents": [ …same shape as /api/incidents… ],
"count": 1,
"total": 1,
"params": { "q": "piranha", "categories": [], "statuses": ["unverified"], "date": "" },
"limit": 50,
"offset": 0,
"facets": {
"categories": [ { "name": "crime-public-safety", "count": 4 }, … ],
"statuses": [ { "name": "unverified", "count": 6 }, … ]
}
}
LIKE wildcards in q (%, _) match literally, not as patterns; an empty filter set returns the same rows as /api/incidents (within the limit). Facets count all unmerged incidents, not the filtered subset. A malformed date returns 400.
GET/api/changes
Incidents changed since a timestamp (s65): rows with updated_at after since, newest first. since is an ISO timestamp — YYYY-MM-DD reads as UTC midnight, a time without an offset reads as UTC, and Z/±HH:MM are honored. Each row carries kind: new if the incident was created after since, updated if only touched after. Merged incidents stay hidden. limit clamps to 1–100, default 100; offset (≥ 0) pages, and total is the change count ignoring paging — the number a "N new since your last visit" banner shows. A missing or malformed since returns 400.
curl -s 'https://semak.khursani.dev/api/changes?since=2026-09-19T12:00:00Z&limit=5' \
| jq '{total, ids: [.changes[].slug], kinds: [.changes[].kind]}'
{
"changes": [
{ "id": 26, "slug": "anthony-loke-resigns-as-transport-minist-fda7d1", "title": "…", "updated_at": "2026-09-19T14:46:17+00:00", "kind": "updated" },
…
],
"count": 5,
"total": 12,
"limit": 5,
"offset": 0,
"since": "2026-09-19 12:00:00"
}
GET/api/vouches
The public vouch list: up to 200 members ordered by vouches received. Vouched members carry extra vote weight in settlement (1 + min(vouches, 3)).
curl -s https://semak.khursani.dev/api/vouches
{
"vouches": [
{ "user_id": "s30a2-user-b", "name": "s30-a2 settle check B", "image": null, "vouches": 1 }
]
}
GET/api/me
The signed-in user for the request's session cookie, or null when anonymous. Useful to check a login before posting a vote.
curl -s https://semak.khursani.dev/api/me
{"user":null}
Public pages & media
GET/i/{id|slug}
Per-incident share page (HTML) with OG meta tags and a WhatsApp share link. Accepts the numeric id or the slug; both resolve to the same page.
curl -s https://semak.khursani.dev/i/second-red-bellied-piranha-caught-in-kla-d6928e | head -8
curl -s https://semak.khursani.dev/i/10 -o /dev/null -w '%{http_code}\n'
GET/og/{id}.png
1200×630 PNG share card for an incident (title, status badge, source count), rendered on demand. Takes the numeric incident id. The same image the share page advertises in og:image.
curl -s -o card.png https://semak.khursani.dev/og/10.png && file card.png
GET/digest
The weekly digest page (HTML), rendered live from the current ISO week's incidents: statuses, confidence, sources, share links.
curl -s https://semak.khursani.dev/digest | head -12
GET/trust
Trust & moderation page: verification methodology, moderation policy, takedown path.
curl -s -o /dev/null -w '%{http_code}\n' https://semak.khursani.dev/trust
GET/sitemap.xml
Sitemap: the board, trust, digest, docs, and up to 500 live incident pages.
curl -s https://semak.khursani.dev/sitemap.xml | head -6
Authenticated write API — session required
These endpoints need a session cookie from social sign-in (Google or X). Without one they return 401 {"error":"login required"}. Sessions come from the better-auth mount below; the board UI handles the flow.
POST/api/vote
Cast or change your vote on an incident. One vote per member per topic; posting again updates it. Votes settle weekly (Monday 00:00 UTC) once the topic passes its 7-day report window.
curl -s -X POST https://semak.khursani.dev/api/vote \
-H 'content-type: application/json' \
-H 'cookie: better-auth.session_token=…' \
-d '{"incident_id": 10, "value": 1}'
value: 1 = true, -1 = false. Errors: 400 missing fields, 404 unknown incident, 409 incident merged into another topic.
POST/api/vouch
Vouch for another member. Idempotent; no self-vouch.
curl -s -X POST https://semak.khursani.dev/api/vouch \
-H 'content-type: application/json' \
-H 'cookie: better-auth.session_token=…' \
-d '{"vouched_id": "s30a2-user-b"}'
/api/auth/* — sign-in mount
better-auth handles social sign-in and sessions under /api/auth (e.g. POST /api/auth/sign-in/social for Google or X, POST /api/auth/sign-out). Providers activate only when their OAuth secrets are configured; otherwise the mount returns an explicit provider-not-configured error.
Operator endpoint
POST/api/cron/settle — Bearer token
Runs the weekly settlement outside the Monday 00:00 UTC cron. Requires Authorization: Bearer <SETTLE_TOKEN>. Append ?force=1 to settle topics still inside their report window (test path).
curl -s -X POST 'https://semak.khursani.dev/api/cron/settle' \
-H 'authorization: Bearer <SETTLE_TOKEN>'