# Update — API Reference (developer integration guide)

This is the full reference for integrating with the **Update** platform: authenticating, submitting
updates from a custom client, moderating, and reading approved updates onto your own site.

- **Base URL:** `https://YOUR-HOST/update/api` (local XAMPP: `http://localhost/update/api`)
- **Format:** JSON in, JSON out. Send `Content-Type: application/json` on requests with a body.
- **Versioning:** unversioned in this phase. Breaking changes will be introduced under a `/v2`
  prefix; treat the current surface as `v1`.

> New to the platform? Read [README.md](README.md) for concepts (organizations, events,
> contributors, the routing engine) first. This document assumes those terms.
>
> **Building your own frontend** (public reader page or a custom console)? Start with
> **[FRONTEND_GUIDE.md](FRONTEND_GUIDE.md)** — it walks the two integration modes end to end and
> links back here for endpoint detail.

---

## Contents
1. [Response envelopes](#1-response-envelopes)
2. [Errors](#2-errors)
3. [Authentication](#3-authentication)
4. [Rate limiting](#4-rate-limiting)
5. [Quickstart flows](#5-quickstart-flows)
6. [Endpoints](#6-endpoints)
   - [Auth](#61-auth) · [Events](#62-events) · [Updates & moderation](#63-updates--moderation)
   - [Contributors](#64-contributors) · [API keys](#65-api-keys)
   - [Platform admin](#66-platform-admin) · [Webhooks](#67-webhooks)
   - [Public / widget](#68-public--widget-read-only)
7. [Object shapes](#7-object-shapes)
8. [Building a custom client (mobile / "Line")](#8-building-a-custom-client)
9. [CSCAfrica Live-Update feature parity](#9-cscafrica-live-update-feature-parity)

---

## 1. Response envelopes

There are **two** shapes depending on the endpoint family.

**Authenticated / write endpoints** wrap payloads in `data`:
```json
{ "ok": true, "data": { /* … */ } }
```

**Public read endpoints** (`/public/*`) and **webhooks** return the payload at the top level (still
with `ok`), so they can be consumed directly by browsers/CDNs without unwrapping:
```json
{ "ok": true, "event": { "code": "NGR2027" }, "updates": [ /* … */ ], "paging": { /* … */ } }
```

Each endpoint below states which shape it uses.

---

## 2. Errors

All errors share one shape and use the appropriate HTTP status:
```json
{ "ok": false, "error": { "code": "forbidden", "message": "This event belongs to another organization." } }
```

| HTTP | `error.code` (examples) | Meaning |
|---|---|---|
| 400 | `bad_request` | Malformed request. |
| 401 | `unauthorized`, `invalid_credentials`, `bad_signature` | Missing/invalid token, API key, or webhook signature. |
| 403 | `forbidden`, `org_inactive` | Authenticated but not allowed (e.g. cross-org access). |
| 404 | `not_found` | Resource does not exist. |
| 409 | `code_taken`, `email_taken`, `exists` | Uniqueness conflict. |
| 422 | `validation` | Missing/invalid fields. |
| 429 | `rate_limited` | Too many submissions (see [Rate limiting](#4-rate-limiting)). |
| 503 | `channel_unavailable` | A shared channel is not configured/active. |

A `401` on an authenticated call means your token expired or is invalid — re-authenticate.

---

## 3. Authentication

Three mechanisms, each for a different consumer:

### 3.1 Bearer JWT — org users & platform admins
Obtain a token from a login endpoint, then send it on every authenticated call:
```
Authorization: Bearer <token>
```
- HS256 access token, default lifetime 8h (`JWT_TTL`). Use **[`POST /auth/refresh`](#post-authrefresh-)**
  with the refresh token (default 30d, `REFRESH_TTL`) to get a new one without re-login.
- Claims include `typ` (`org_user` | `platform_admin`), `org_id`, `role` (`admin` | `moderator`).
- **The exact same token format is intended for a future mobile client** — nothing here is portal-specific.
- Building a browser console on a different origin? See **[CORS](#cors--authenticated-cross-origin)**.

### 3.2 API key — public read/widget consumers
Read-only, org-scoped. Send it as a header (preferred) or query param (for `<script>`):
```
X-API-Key: upd_xxxxxxxxxxxxxxxxxxxxxxxx
# or
GET /public/events/NGR2027/updates?key=upd_xxxx
```
Created via `POST /apikeys`; the plaintext is shown **once**. Stored hashed (`sha256`). A key may
only read its own organization's events (cross-org → `403`).

### 3.3 Webhook signatures — inbound providers
Webhook endpoints are authenticated by provider signatures, not tokens:
- WhatsApp: `X-Hub-Signature-256: sha256=<HMAC_SHA256(app_secret, rawBody)>`
- Telegram: `X-Telegram-Bot-Api-Secret-Token: <secret>`
- SMS/email: optional shared-secret / Mailgun signature.

See [CHANNEL_SETUP.md](CHANNEL_SETUP.md) for provider configuration.

---

## 4. Rate limiting

Fixed-window limits protect ingestion (configurable in `.env`):

| Scope | Var | Default |
|---|---|---|
| Per contributor, per event | `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW` | 10 per 60s |
| Per client IP | `RATE_LIMIT_IP_MAX` / `RATE_LIMIT_WINDOW` | 60 per 60s |

Exceeding a limit returns `429 { error.code: "rate_limited" }`. Client IP is resolved from
`CF-Connecting-IP` / `X-Forwarded-For` **only** when `TRUST_PROXY=true` (behind Cloudflare/nginx).

## CORS — authenticated cross-origin

**Public** endpoints (`/public/*`) send permissive CORS (`CORS_ALLOWED_ORIGINS`, `*` by default) —
any site with a valid API key can read them from the browser.

**Authenticated** endpoints only send CORS headers for origins an org has explicitly registered.
If you build a browser console on a different origin than the API, register it via
`PUT /org/settings { "allowed_origins": [...] }` (or the portal → *API keys & integration*).
Registered origins get `Access-Control-Allow-Origin` (reflected) plus `Authorization` in the
preflight's allowed headers. Unregistered origins get no CORS headers and the browser blocks the
call. Auth is a Bearer token (not cookies), so there is no `Allow-Credentials` and no CSRF surface.
You do **not** need this for same-origin or server-side callers.

---

## 5. Quickstart flows

### A. Read approved updates onto a website (most common)
```bash
# 1. In the portal (API keys) create a key -> upd_live_xxx
# 2. Fetch approved updates for an event
curl "https://YOUR-HOST/update/api/public/events/NGR2027/updates?limit=20" \
     -H "X-API-Key: upd_live_xxx"
```
Or drop the widget — see [EMBED_GUIDE.md](EMBED_GUIDE.md).

### B. Submit an update from a custom app / on-ground staff
```bash
TOKEN=$(curl -s -X POST https://YOUR-HOST/update/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@htcafrica.example","password":"…"}' | jq -r .data.token)

curl -X POST https://YOUR-HOST/update/api/updates \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"event_code":"NGR2027","content":"Keynote just started.","author_name":"Field team"}'
```

### C. Moderate a queue
```bash
curl "https://YOUR-HOST/update/api/events/1/updates?status=pending" -H "Authorization: Bearer $TOKEN"
curl -X POST https://YOUR-HOST/update/api/updates/42/approve -H "Authorization: Bearer $TOKEN"
```

---

## 6. Endpoints

Legend: 🔓 no auth · 🔑 org JWT · 👑 platform JWT · 🎟️ API key · 📡 provider signature.

### 6.1 Auth

#### `POST /auth/login` 🔓
Org-user login.
```json
// request
{ "email": "admin@htcafrica.example", "password": "password123" }
// 200 (data envelope)
{ "ok": true, "data": {
  "token": "eyJhbGci…",
  "user": { "id": 1, "name": "HTC Admin", "email": "admin@htcafrica.example", "role": "admin", "type": "org_user" },
  "organization": { "id": 1, "name": "HTC Africa", "slug": "htc-africa" }
}}
```

Login now also returns `refresh_token` and `expires_in` (access-token seconds).

#### `POST /auth/platform/login` 🔓
Platform-admin login. Same shape, `user.type: "platform_admin"`, no `organization`.

#### `POST /auth/refresh` 🔓
Exchange a refresh token for a new access token. **Rotating** — the old refresh token is revoked.
```json
// request
{ "refresh_token": "rt_…" }
// 200
{ "ok": true, "data": { "token": "eyJ…", "refresh_token": "rt_…", "expires_in": 28800 } }
```
Reusing a spent refresh token, or one past `REFRESH_TTL` (default 30d), returns `401 invalid_refresh`.

#### `POST /auth/logout` 🔓
`{ "refresh_token": "rt_…" }` — revokes the refresh token. Returns `{ logged_out: true }`.

#### `GET /me` 🔑/👑
Returns the current principal: `{ id, type, role, org_id, name }`.

#### `GET /org/settings` 🔑 · `PUT /org/settings` 🔑(admin)
Manage the org's **allowed origins** for authenticated cross-origin calls (a custom console on your
own domain). See [CORS](#cors--authenticated-cross-origin).
```json
// PUT request
{ "allowed_origins": ["https://console.htcafrica.org", "http://localhost:5173"] }
// -> { ok, data: { allowed_origins: [ … ] } }
```
Each origin must be `scheme://host[:port]` with no path; invalid ones are rejected (422).

---

### 6.2 Events
All org-scoped. Creating/editing/deleting requires role `admin`.

#### `GET /events` 🔑
List your org's events with rollup counts.
```json
{ "ok": true, "data": { "events": [ {
  "id": 1, "title": "Nigeria 2027 Summit", "event_code": "NGR2027",
  "description": "…", "status": "live", "auto_publish_min_trust": null,
  "starts_at": "2026-07-14 15:40:39", "ends_at": null, "created_at": "…",
  "pending_count": 12, "approved_count": 1, "total_count": 13,
  "whatsapp_hint": "#NGR2027",
  "public_feed_url": "https://YOUR-HOST/update/api/public/events/NGR2027/updates"
} ] } }
```

#### `POST /events` 🔑(admin)
```json
// request — event_code optional (auto-generated & unique if omitted)
{ "title": "Nigeria 2027 Summit", "event_code": "NGR2027", "status": "live",
  "description": "…", "auto_publish_min_trust": "high",
  "accepted_channels": ["whatsapp","telegram","web"],
  "starts_at": "2026-07-14T09:00:00Z", "ends_at": null }
// 201 -> { ok, data: { event: { … } } }
```
`status`: `draft` | `live` | `closed`. Only **live** events accept inbound channel messages.
`auto_publish_min_trust`: `null` (off) | `medium` | `high`.
`accepted_channels`: `null` / omitted / `"all"` = accept every channel (default); otherwise an
array (or comma string) of `whatsapp|telegram|sms|email|web`. A message arriving on a channel the
event doesn't accept is re-routed to that channel's fallback event if configured, else filed as
`unmatched` (reason `channel_not_accepted`). Configure a channel's catch-all via the platform
channels API: `config.fallback_event_id`.

#### `GET /events/{id}` 🔑 · `PUT /events/{id}` 🔑(admin) · `DELETE /events/{id}` 🔑(admin)
Show / update / delete. `409 code_taken` if you set an `event_code` already used platform-wide.

#### `GET /events/{id}/stats` 🔑
Analytics for charts:
```json
{ "ok": true, "data": {
  "by_status":  [ { "status": "approved", "c": 5 } ],
  "by_channel": [ { "channel_type": "whatsapp", "c": 8 } ],
  "by_day":     [ { "d": "2026-07-14", "c": 13 } ],
  "top_contributors": [ { "id": 3, "display_name": "Ada", "identity_type": "phone", "trust_level": "medium", "c": 6 } ]
}}
```

---

### 6.3 Updates & moderation

#### `POST /updates` 🔑/👑 — direct submission
The generic, client-agnostic write path (portal web form today; mobile "Line" later). The event is
chosen **explicitly**; content is submitted as plain text (no routing token needed).
```json
// request — provide event_id OR event_code
{ "event_code": "NGR2027", "content": "Keynote just started.",
  "author_name": "Field team",
  "media_url": "https://YOUR-HOST/update/public/uploads/2026/07/ab12.jpg",
  "media_type": "image",           // image|video|audio|document|link|location (optional)
  "latitude": 6.5244, "longitude": 3.3792 }   // optional geotag
// 201
{ "ok": true, "data": { "update_id": 42, "status": "pending",
  "event": { "id": 1, "event_code": "NGR2027", "title": "Nigeria 2027 Summit" } } }
```
`status` is `pending`, or `approved` if the event's auto-publish threshold is met. The contributor
is derived from the calling principal. Org users may only post to their own org's events.

Attach media by first uploading it (see [`POST /uploads`](#post-uploads--media-upload)) and passing
the returned `url` as `media_url`. `content` may be empty when `media_url` or a location is present
(media-only / location-only updates are allowed). If `media_type` is omitted it is inferred:
`location` when coordinates are present, else `link` for an external URL.

#### `POST /uploads` 🔑/👑 — media upload
`multipart/form-data` with a single field **`file`** (max 60 MB). The real MIME is detected
server-side; allowed: JPEG/PNG/GIF/WebP, MP4/MOV/WebM/3GP, MP3/OGG/M4A/AMR, PDF.
```bash
curl -X POST https://YOUR-HOST/update/api/uploads \
  -H "Authorization: Bearer $TOKEN" -F "file=@photo.jpg"
// 201
{ "ok": true, "data": {
  "url": "https://YOUR-HOST/update/public/uploads/2026/07/ab12….jpg",
  "path": "public/uploads/2026/07/ab12….jpg", "media_type": "image", "mime": "image/jpeg" } }
```
Stored locally with script execution disabled. Swap the storage backend (Cloudinary/S3) at
`src/Support/Storage.php::save()` — callers only depend on the returned `url`.

#### `GET /events/{id}/updates?status=&type=&page=` 🔑 — moderation queue
Unified across all channels. `status`: `pending` (default) | `approved` | `rejected` | `flagged`.
`type`: `question` | `statement`. Paginated (25/page).
```json
{ "ok": true, "data": {
  "event": { "id": 1, "title": "Nigeria 2027 Summit", "event_code": "NGR2027" },
  "pagination": { "page": 1, "page_size": 25, "total": 12, "pages": 1 },
  "updates": [ {
    "id": 14, "content_text": "Stage lights are up…", "update_type": "statement",
    "media_url": null, "status": "pending", "channel_type": "telegram",
    "submitted_at": "…", "moderated_at": null,
    "contributor": { "id": 3, "name": "Chidi", "identity_type": "telegram_id",
                     "trust_level": "low", "verified": false, "blocked": false }
  } ]
}}
```

#### `POST /updates/{id}/approve` 🔑 · `POST /updates/{id}/reject` 🔑
Optional body `{ "note": "…" }`. Returns `{ id, status }`. Actions are written to `moderation_log`.

#### `PUT /updates/{id}` 🔑 — edit (and optionally approve)
```json
{ "content": "Corrected text.", "media_url": null, "approve": true }
```
Content is re-sanitized and `update_type` recomputed. Returns `{ id, status, content_text }`.

#### `POST /updates/bulk` 🔑
```json
{ "ids": [14, 15, 16], "action": "approve" }   // or "reject"; max 200 ids
// -> { ok, data: { action, affected, requested } }
```

---

### 6.4 Contributors

#### `GET /contributors/people` 🔑
The same contributors **grouped by person**: one entry per account (email), listing every linked
platform identity. Standalone identities that haven't logged in appear as their own person.
```json
{ "ok": true, "data": { "people": [ {
  "person_key": "a1", "account_id": 1, "name": "Jane Doe", "email": "jane@x.com",
  "verified": true, "logged_in": true, "total_updates": 3, "last_seen": "…",
  "platforms": [
    { "contributor_id": 1, "channel": "whatsapp/sms", "handle": "+23******01",
      "platform_name": "Jane (WhatsApp)", "trust_level": "medium", "logged_in": true, "updates_here": 2 },
    { "contributor_id": 2, "channel": "telegram", "handle": "tg-1",
      "platform_name": "janed_tg", "trust_level": "low", "updates_here": 1 }
  ]
} ] } }
```
`name` is the account's **registration name** — the same name shown as the author on all their
updates, regardless of their current per-platform handle (`platform_name`, refreshed each message).

#### `GET /contributors?event_id=` 🔑
Contributors (flat, per platform identity) who have posted to your events. Identities are lightly
masked (phone/email); each item includes `account_email` and `logged_in`.
```json
{ "ok": true, "data": { "contributors": [ {
  "id": 3, "identity_type": "phone", "identity_value": "+23******01",
  "display_name": "Ada", "verified": true, "trust_level": "medium", "blocked": false,
  "updates_here": 6, "last_submitted_at": "…", "first_seen_at": "…"
} ] } }
```

#### `POST /contributors/{id}/flag` 🔑(admin)
```json
{ "trust_level": "high", "blocked": false, "session": "grant" }   // any field optional
```
`session`: `"grant"` starts a 30-day posting session immediately (bypass OTP — e.g. trusted staff);
`"end"` forces the contributor to log in again. Contributor list items include `account_email`,
`logged_in`, and `session_expires_at`.
> Contributors are **platform-wide identities**, so a trust/block change applies across all orgs
> the contributor has interacted with (documented behavior for this phase).

---

### 6.5 API keys

#### `GET /apikeys` 🔑
Lists keys (never returns the secret): `{ id, label, key_prefix, scopes, created_at, revoked_at, active }`.

#### `POST /apikeys` 🔑(admin)
```json
{ "label": "Website widget" }
// 201 — secret shown ONCE
{ "ok": true, "data": { "id": 1, "label": "Website widget", "key_prefix": "upd_e975741d",
  "secret": "upd_e975741df17700fe5914dabd0bbe0ad5…", "note": "Copy this key now. …" } }
```

#### `POST /apikeys/{id}/revoke` 🔑(admin)
Returns `{ revoked: true }`. Sites using the key stop working immediately.

---

### 6.6 Platform admin
All require a **platform-admin** token (👑). These manage the shared channels and cross-org support.

| Endpoint | Purpose |
|---|---|
| `GET /admin/overview` | Cross-org counts + channel status. |
| `GET/PUT /admin/settings` | Runtime settings (validated whitelist; blank reverts a key to its `.env` default). |
| `POST /admin/accounts/{id}/erase` | Right-to-erasure: delete the account, anonymize its channel identities, strip raw payloads (content kept, author anonymized). |
| `GET /admin/orgs` | List all organizations. |
| `POST /admin/orgs` | Onboard an org + its first admin user. Body: `name`, `contact_email`, `admin_name`, `admin_email`, `admin_password` (8+), optional `slug`. |
| `GET /admin/channels` | Shared channels (secret values redacted; only configured key names returned). |
| `POST /admin/channels` | Add a channel. Body: `channel_type`, `platform_identifier`, optional `config` (object), `status`. One row per type. |
| `PUT /admin/channels/{id}` | Update identifier/status; `config` is **merged** (send only changed keys). |
| `DELETE /admin/channels/{id}` | Delete, or auto-deactivate if updates reference it. |
| `GET /admin/unmatched?resolved=` | Triage queue of unroutable inbound messages. |
| `POST /admin/unmatched/{id}/assign` | Promote to an update. Body: `event_id` **or** `event_code`. |
| `POST /admin/unmatched/{id}/discard` | Mark resolved without creating an update. |

---

### 6.7 Webhooks
📡 Provider-authenticated. All funnel through the **same** routing engine + ingest pipeline, so a
message on any channel produces a normalized `update` (or an `unmatched_message`). Full provider
setup: [CHANNEL_SETUP.md](CHANNEL_SETUP.md).

| Endpoint | Notes |
|---|---|
| `GET /webhook/whatsapp` | Meta subscription verification (echoes `hub.challenge`). |
| `POST /webhook/whatsapp` | Verifies `X-Hub-Signature-256`. Parses Meta `entry[].changes[].value.messages[]`. |
| `POST /webhook/telegram` | Verifies `X-Telegram-Bot-Api-Secret-Token`. Parses `message`/`channel_post`. |
| `POST /webhook/sms` | Termii/Africa's-Talking-style payload; field mapping in `src/Webhooks/SmsWebhook.php`. |
| `POST /webhook/email` | Mailgun/SendGrid inbound-parse-style; token read from body first line then subject. |

**Routing token convention** (first line of the message): `#EVENTCODE` → event; `#EVENTCODE text`
single-line form also supported; `_LineTag` → reserved for the future Line app (recognized, replies
"not active yet"); `REGISTER <name>` → contributor registration (see below). Content ending in `?`
is classified `question`, else `statement`.

**Contributor channel login** (when `REQUIRE_CHANNEL_LOGIN=true`). A contributor must log in with
**email + a one-time code** before their channel messages post. It's a conversational flow the
Routing Engine runs *before* routing:

1. First contact → the platform replies asking for their **email**.
2. They reply an email → if it's a known account, a 6-digit **OTP is emailed**; if unknown, they're
   asked for a name (registration), then the OTP is emailed.
3. They reply the **code** → verified → a **30-day posting session** starts on that channel.
4. Subsequent `#EVENTCODE …` messages post as normal (still moderated). `LOGOUT` ends the session;
   `RESEND` re-sends a code.

The account is keyed by **email**, so the same person is recognized **across channels** — logging in
with the same email on a new channel links that channel to the same account. Direct submissions via
`POST /api/updates` (signed-in staff / apps) bypass the gate. OTP delivery uses a transactional
email provider (`MAIL_PROVIDER` = `mailgun` | `sendgrid` | `log`; `log` writes the code to
`webhook_log` for development). Moderators can force a session via
`POST /contributors/{id}/flag { "session": "grant" | "end" }`.

**Idempotency:** dedupe on the provider message id — retried webhooks won't double-post. Webhooks
return `200 { ok: true, processed: [ { id, result } ] }` even for unmatched input (so providers
don't retry needlessly); signature failures return `401`.

---

### 6.8 Public / widget (read-only)

#### `GET /public/health` 🔓
`{ "ok": true, "service": "update", "time": "2026-07-14T14:42:32+00:00" }`

#### `GET /public/events` 🎟️
The API key's org events (live first, then closed) — for a reader page's "other events" list.
Items: `{ code, title, description, status, starts_at, ends_at, updates_count }`.

#### `GET /public/events/{event_code}` 🎟️
Event metadata + aggregate stats for a cover header.
```json
{ "ok": true,
  "event": { "code": "NGR2027", "title": "…", "description": "…", "status": "live",
             "starts_at": "…", "ends_at": null, "hashtag": "#NGR2027" },
  "stats": { "updates": 128, "voices": 34, "likes": 512 } }
```

#### `GET /public/events/{event_code}/voices?limit=` 🎟️
Top voices (most active people among approved updates): `{ name, updates }`.

#### `GET /public/events/{event_code}/stream` 🎟️
**Server-Sent Events** feed of new approved updates (setting `realtime_enabled`). Each event is
`id: <n>` + `data: <update json>`. Clients use `EventSource` (pass the key as `?key=`, since
`EventSource` can't set headers) and reconnect automatically with `Last-Event-ID`. Returns `204`
when realtime is disabled, so clients fall back to polling `/updates`.

#### `GET /public/events/{event_code}/updates?limit=&before=` 🎟️
Approved updates, newest first. **Top-level envelope** (no `data`).
```json
{ "ok": true,
  "event": { "code": "NGR2027", "title": "Nigeria 2027 Summit", "status": "live" },
  "updates": [ {
    "id": 14, "text": "Stage lights are up…", "type": "statement",
    "media_type": "image", "media_url": "https://…/uploads/…jpg",
    "location": { "lat": 6.5244, "lng": 3.3792, "map_url": "https://www.google.com/maps?q=6.5244,3.3792" },
    "likes": 3, "channel": "telegram", "author": "Chidi",
    "submitted_at": "2026-07-14T15:43:04+00:00"
  } ],
  "paging": { "limit": 20, "next_before": 3, "count": 2 } }
```
`location` is `null` unless the update is geotagged; `media_url` is `null` for text-only updates.
- **Pagination** is keyset-based: pass `before=<paging.next_before>` for the next (older) page.
  `limit` max 100 (default 20).
- **Caching:** responses send `Cache-Control: public, max-age=30` and a weak `ETag`. Send
  `If-None-Match: <etag>` to receive `304 Not Modified` — cache the last good body for resilience.
- **CORS:** governed by `CORS_ALLOWED_ORIGINS`. Preflight `OPTIONS` returns `204`.
- `403` if the API key's org doesn't own the event.

#### `GET /public/events/{event_code}/top?limit=` 🎟️
"Top moments" — most-liked approved updates for the event. Same item shape as the feed.

#### `POST /public/updates/{id}/like` 🎟️ · `DELETE /public/updates/{id}/like` 🎟️
Like / unlike an approved update. Anonymous — no reader account. Deduped per `voter_token` (an
opaque id the widget generates and stores in `localStorage`); IP rate-limited.
```json
// request body
{ "voter_token": "v9x2…" }
// response
{ "ok": true, "update_id": 42, "like_count": 3, "liked": true }
```
If `voter_token` is omitted, a coarse per-IP token is used as a fallback.

#### `POST /public/events/{event_code}/follow` 🎟️
Capture a "follow"/subscribe. Provide `email` or `device_token`. Records the subscription and
returns the follower count. **Notification delivery (email/push) is a future concern** — this only
stores intent.
```json
{ "email": "reader@example.com" }
// -> { "ok": true, "event": "NGR2027", "followers": 128, "following": true }
```

#### `GET /public/events/{event_code}/widget.js` 🔓
Returns a self-contained JS widget (`Content-Type: application/javascript`) that renders text,
images, video, location links, and like buttons. The API key is read from the embedding
`<script data-key>` at runtime, so this file is cacheable across sites. See
[EMBED_GUIDE.md](EMBED_GUIDE.md) for the `data-*` attributes.

---

## 7. Object shapes

### Update (public feed form)
| Field | Type | Notes |
|---|---|---|
| `id` | int | Keyset cursor for `before`. |
| `text` | string | Sanitized plain text (HTML stripped). May be empty for media/location-only. |
| `type` | string | `statement` \| `question` (trailing `?`). |
| `media_type` | string | `none` \| `image` \| `video` \| `audio` \| `document` \| `link` \| `location`. |
| `media_url` | string\|null | http(s) only. |
| `location` | object\|null | `{ lat, lng, map_url }` when geotagged. |
| `likes` | int | Like count. |
| `channel` | string | `whatsapp` \| `telegram` \| `sms` \| `email` \| `web`. |
| `author` | string\|null | The contributor's **registration name** (their account/display name), consistent across every platform they post from and unaffected by later profile-name changes. Falls back to the platform name when the contributor has no account. |
| `submitted_at` | string | ISO-8601 UTC. |

`voices` counts (event stats) and "top voices" are per **person** — someone posting from two
channels counts once, under their registration name.

### Trust levels
`low` (default for SMS/telegram/email/unverified) → `medium` (WhatsApp/verified, logged-in app
users) → `high` (granted by an org). An event's `auto_publish_min_trust` decides which levels skip
moderation.

### Statuses
Update: `pending` → `approved` | `rejected` | `flagged`. Event: `draft` | `live` | `closed`.

---

## 8. Building a custom client

To build a mobile app or server integration that both **reads** and **posts**:

1. **Authenticate** with `POST /auth/login` and store the JWT (same token the portal uses).
2. **Show events** with `GET /events`; let the user pick one (you have the code + title).
3. **Post** with `POST /updates` using an explicit `event_id`/`event_code` — no free-text token
   parsing needed (that's only for messaging channels). Attach a `media_url` you host (e.g. a CDN).
4. **Read** either the org-scoped moderation views (with the JWT) or the public feed (with an API
   key) depending on whether you're building an editor or a reader surface.

This is exactly the path the planned **"Line"** app will take — the backend needs no changes to
support it. The `_LineTag` routing prefix is already recognized by the engine for when Line defines
its own streams.

---

## 9. CSCAfrica Live-Update feature parity

How the prior CSCAfrica live-update behaviors map onto this API. This platform is the multi-tenant
successor; some reader-engagement features are intentionally **not** in this backend-only phase.

| CSCAfrica feature | Status here | Where / note |
|---|---|---|
| Event with feed of short posts | ✅ Supported | Events + `updates`, read via `/public/events/{code}/updates`. |
| Hashtag routing (`#EVENT …`) | ✅ Supported | Routing engine, `#EVENTCODE` token. |
| WhatsApp inbound → update | ✅ Supported | `POST /webhook/whatsapp`. |
| Whitelisted numbers auto-publish | ✅ Equivalent | Trust-based auto-publish: verified/WhatsApp = `medium`; event `auto_publish_min_trust`. |
| Moderation queue for others | ✅ Supported | `GET /events/{id}/updates?status=pending` + approve/reject/edit/bulk. |
| Media in posts (image/video/embeds) | ✅ Supported | `media_type`/`media_url` on updates; upload via `POST /uploads`; widget renders image/video; external links kept as `link`. |
| WhatsApp/Telegram photo/video/location inbound | ✅ Supported | Webhook handlers extract media (re-hosted via `MediaResolver`) and geotags → `location`. |
| Cloudinary-style direct upload (≤60 MB) | ✅ Supported | `POST /uploads` (local storage; swap `Storage::save()` for Cloudinary/S3). |
| Likes / engagement counts | ✅ Supported | `POST`/`DELETE /public/updates/{id}/like`; `likes` on feed items. |
| Follow / subscribe to event | ✅ Supported (capture) | `POST /public/events/{code}/follow`. Delivery of notifications is still a future concern. |
| Top moments (most-liked) | ✅ Supported | `GET /public/events/{code}/top`. |
| Per-event "Accept WhatsApp" toggle | ✅ Supported | `events.accepted_channels` opt-in; enforced in routing. |
| Fallback event for no/unknown hashtag | ✅ Supported | Per-channel `config.fallback_event_id`; unmatched inbound routes there instead of triage. |
| Auto-syncing feed ("↑ N new") | ⚠️ Client-side polling | Widget `data-poll` + `before`/`ETag`. No server push/websockets yet. |
| Top voices (most active contributors) | ⚠️ Editor-side only | `GET /events/{id}/stats` returns top contributors; not yet a public endpoint. |
| Public reader page (`/live/{slug}`) | ❌ By design | Distributes to **org-owned** sites via the widget, not a hosted reader page. |
| Word limits / live countdown | ❌ Client concern | Server caps content at 4000 chars; word limits are a UI policy. |

**Still open** (say the word and I'll add them): real-time push for the auto-syncing feed
(SSE/websockets), a public "top voices" endpoint, and actual delivery for event follows
(email/push). Each is additive to this same API.
