Semak

public api

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>'