API docs
BM partial · kandungan halaman ini belum diterjemah sepenuhnya.
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.
Anonymous browsers and sessions read freely (an IP throttle of 120 requests/hour applies). Programmatic callers send an API key as Authorization: Bearer <key> for 600 requests/hour. 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.
API keys
Sign in (Google or X), then mint a key: POST /api/keys while signed in (browser console or curl with your session cookie). The key is shown once; only its SHA-256 hash is stored. Throttle: 600 requests per hour per key — exceeding it returns 429 with a Retry-After header.
List yours with GET /api/keys; revoke with DELETE /api/keys/<id>. Browser calls from this site keep working without a key.
curl -s 'https://semak.khursani.dev/api/incidents?limit=5' -H "Authorization: Bearer $SEMAK_KEY"
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.
Rows also carry change_kind (s66): new | updated | verified | debunked. verified/debunked mark a status transition — settlement flipped the incident's status after since (settlement writes don't touch updated_at, so those rows surface here even without an edit). When several apply, precedence is new > verified/debunked > updated; undetermined settlements never count. kind keeps its s65 two values.
curl -s 'https://semak.khursani.dev/api/changes?since=2026-09-19T12:00:00Z&limit=5' \
| jq '{total, ids: [.changes[].slug], kinds: [.changes[].kind], change_kinds: [.changes[].change_kind]}'
{
"changes": [
{ "id": 26, "slug": "anthony-loke-resigns-as-transport-minist-fda7d1", "title": "…", "updated_at": "2026-09-19T14:46:17+00:00", "kind": "updated", "change_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>'