# Chizumulu API > Read-only public JSON API over public NRFA (Northern Region Football Association, Malawi) football updates. No authentication, no API key, no signup. Unofficial: not operated by the NRFA or any club. Base URL: https://api.chizumulu.net Documentation: https://api.chizumulu.net/docs/ (English) and https://api.chizumulu.net/docs/ko/ (Korean) OpenAPI 3.1 (English): https://api.chizumulu.net/openapi.json ## Which endpoint should I use? - Show current posts with parsed data (a site, a widget, an assistant answering "what happened"): `/v1/structured`. - See how one post changed, or every version of it: `/v1/structured/{rawUpdateId}/history`. - Mirror or sync everything, never miss an edit or a late post: `/v1/feed` with a stored checkpoint. - Just the original text, one row per message: `/v1/updates` (or `/v1/latest` for the newest). - A live score, or the matches that are on right now: `/v1/live`. One match followed across its updates: `/v1/matches/{matchId}`. - Is the service healthy, or is the source just quiet: `/v1/status`. ## Endpoints (all GET, all JSON) - https://api.chizumulu.net/v1/structured : one item per source message, newest first, with the latest observation's text, its processing state and the derived schema-v2 `structured` data. Params: `limit`, `cursor`, and optional exact-match filters `competition` (a competition code, matches the post-level or any match-level `competitionCode`), `teamId` (recommended for integrations: a canonical team id such as `chizumulu_united`, matches `homeTeam` or `awayTeam` against an explicit list of known source spellings, e.g. `Chizumulu United` and `Chizumulu United FC`), `teamAlias` (human-friendly shorthand from an explicit reviewed table, matched exactly except that letter case is ignored (`Chizumulu`, `chizumulu`, `CHIZUMULU`), resolves to a `teamId`; e.g. `Chizumulu` = `chizumulu_united`), `team` (exact, case-sensitive source name, matches `homeTeam` or `awayTeam`; no aliases) and `postType` (exact enum value). Filters combine with each other and with paging, apply to the latest `done` result, and return whole items; an unmatched value (including an unknown `teamId`) gives an empty page, an empty value is a 400. Team names in responses are always as written in the source; ids and aliases are only for filtering and never appear in responses. Known `teamId` values: `chibavi_real_stars`, `jenda_united`, `chizumulu_united`, `chintheche_united`, `chilumba_barracks`, `mafu_stars`, `mmbelwa_warriors`, `chipolopolo_boys`, `ekwendeni`, `lube_masters`, `chihame_all_stars`, `raiply`, `euthini_veterans`, `vision_s_academy`, `luviri`; a spelling that is not listed has no id (use `team` with the exact name). Known `teamAlias` values: `Chibavi`, `Jenda`, `Chizumulu`, `Chintheche`, `Chilumba`, `Mafu`, `M'mbelwa`, `M’mbelwa`, `M'mberwa`, `Chipolopolo`, `Lube`, `Euthini`, `Chihame`, `Luviri`; ambiguous shorthand such as `Vision` is not an alias. - https://api.chizumulu.net/v1/structured/{rawUpdateId}/history : every observation of one message (edits included), oldest first (at most 200), each with its own structured result. - https://api.chizumulu.net/v1/feed : every observation in ingestion order. Params: `after`, `limit`. The lossless way to consume the data incrementally. - https://api.chizumulu.net/v1/updates : raw messages, newest first. Params: `limit`, `cursor`, `since`, `chizumulu`. - https://api.chizumulu.net/v1/latest : the newest raw message. - https://api.chizumulu.net/v1/status : collector, delivery, data freshness, processing and media health. - https://api.chizumulu.net/v1/live : derived matches that are credibly live right now, most recently updated first: `{ matches: [ { matchId, competition: { code }, homeTeam: { id, name }, awayTeam: { id, name }, status, score: { home, away }, minute, date, round, firstSeenAt, lastSeenAt, completedAt, updateCount, quality } ], asOf }`. No parameters, always 200 (`matches` is empty when nothing is live). INTENTIONALLY CONSERVATIVE: a match is listed only while `status` is `live` or `half_time`, its last update is at most 45 minutes old, the literal minute advanced by the time since it was read is plausible, it is under 4 hours since its first live update, and nothing contradicts it or is unresolved; if uncertain it is omitted, so a match with no news for 45+ minutes drops out and returns with its next update. Omission is not deletion: the match stays stored and available at `/v1/matches/{matchId}`. - https://api.chizumulu.net/v1/matches/{matchId} : one derived match (the fields above) plus `updates` (every attached observation, oldest first: `rawUpdateId`, `observationId`, `matchIndex`, `sourceTimestamp`, `outcome`, `status`, `homeScore`, `awayScore`, `minute`) and `unresolved` (observations that fitted several matches and were attached to none). 400 for a malformed id, 404 for an unknown one. - https://api.chizumulu.net/v1/videos : a small, separate pass-through of ONE public YouTube channel feed (the Korean fan channel 창박골 Changbakgol; not NRFA, not derived from the data above): `{ videos: [{ videoId, title, publishedAt, url, thumbnailUrl }], fetchedAt }` in the feed's order (newest first, at most 15). No parameters. Cached about 10 minutes; if YouTube is unreachable the last cached list is served (header `x-videos-cache: STALE`, see `fetchedAt`), and with nothing cached the response is 502 `{"error":"video feed unavailable"}`. - https://api.chizumulu.net/v1/videos/latest : the entry of that feed with the newest `publishedAt`, as one object (same fields, no wrapper); 404 `no videos yet` if the feed is empty. ## Identifiers - `rawUpdateId` : number, one per logical source message. It is `id` in `/v1/updates` and `/v1/latest`, and `rawUpdateId` in `/v1/feed` and `/v1/structured`. Use it for `/history`. - `id` in `/v1/feed` (and `latestObservation.id` / `observations[].id`) : the observation's ingestion sequence number. It is the `after` checkpoint key. Not the same number as `rawUpdateId`. - `observationId` : stable string id of one observation (one time the collector saw the post). - `sourceMessageId` : the source's own message key. Together with `source` it identifies the message. - `sourceServerId` : optional secondary id from the source; `null` when absent. It is an alias, not a key: do not use it for identity or joins. - `sourceTimestamp` is what the source said (UTC); `receivedAt` is when the API stored it. Do not rely on `sourceTimestamp` for order. ## Raw vs structured - An observation is one time the collector saw a post (text or caption). Observations are preserved: stored as received and never modified. An edit is a new observation of the same message (same `rawUpdateId`, higher `id`, `observationCount` goes up); earlier observations stay. The API records what the collector observed; it does not claim to know the source's own revision order. - Structured data is derived from one observation's text. It is never a replacement for the original text, which is always returned next to it. - Which text a route returns: `/v1/updates` and `/v1/latest` the FIRST observation's; `/v1/feed` each observation as its own item; `/v1/structured` the LATEST observation's ("latest" = highest ingestion `id`); `/history` every observation. ## Structured data (schema v2) `structured` = `{ schemaVersion: 2, postType, competition, matches[], facts[] }`. - `postType`: `result`, `live_update`, `fixtures`, `match_update`, `standings`, `player_news`, `administrative`, `promotional`, `community`, `other`. - `competition` (post level): `{ code, conflict, evidence[] }`. `conflict` is true when the post signals more than one competition (then `code` is `null`). - `matches[]`: zero or more, one per match mentioned. Fields: `index`, `homeTeam`, `awayTeam`, `homeScore`, `awayScore`, `status` (`scheduled`, `live`, `half_time`, `full_time`, `postponed`, `cancelled`), `minuteText`, `competition` (text), `competitionCode`, `competitionBasis`, `venue`, `dateText`, `timeText`, `roundText`, `competitionGroupText`, `evidence`, `events[]`. - `events[]` (inside a match): things the post reports as happening: `type` (`goal`, `own_goal`, `penalty`, `half_time`, `full_time`, `substitution`, `yellow_card`, `red_card`, ...), `minuteText`, `team`, `homeScore`, `awayScore`, `players[]` (`{name, role}`). A `full_time` event is optional: rely on the match `status`. - `facts[]`: things that are not matches. `{ index, type, data, evidence }`, `type` one of `call_up`, `player_stats`, `standings`, `notice`, `transfer`, `community`. Keys inside `data` are snake_case (match and event fields are camelCase); both are stable. - A post may have zero matches and zero facts and still be a successful result (for example a greeting). - `korean`: `{ text, model }`, a machine translation of the whole post text. `null` unless the state is `done` and a translation exists. Derived, may contain errors. ## Processing fields (`processing`) - `state`: `unregistered` (stored, not picked up yet; only `state` is present), `pending`, `awaiting_ai`, `done`, `failed`. `structured` and `korean` are `null` unless `done`, and an older observation's data is never shown as the current state of an edited message. - `method` (once `done`): `ai` (a language model, constrained by the deterministic parser when the parser could read the post), `deterministic` (parser only), `reused` (copied from an earlier observation with identical text; see `reusedFromObservationId`), `none` (no text to process), `null` before `done`. - `model`: the model behind an `ai` result, otherwise `null`. - `translationState`: `none`, `done` or `failed`. - `error` is a short diagnostic for `failed`; its wording is not stable. Ignore fields and values you do not know. - When the language-model step is available, a complete deterministic parse is still sent through it: the parser output is a baseline the AI may complete (events, translation) but must not contradict, and `method` is `ai`. If the model cannot finish, the deterministic parse can be stored as the result (`method: deterministic`, possibly without a Korean translation). `method` therefore says how a result was produced, not how reliable it is. - A `done` observation with no text (an image-only post) has `method: "none"` and `structured: null`. ## Cursors and paging - `/v1/feed`: send `after` (an observation `id`, default 0); store the returned `nextAfter`. Ordering is ingestion order, so late or edited posts are never missed. `hasMore` is true when the page was full. - `/v1/updates` and `/v1/structured`: `cursor` is opaque; take it from `nextCursor`. It pages a newest-first VIEW and is not a sync primitive: a message stored later can appear ahead of your cursor. `nextCursor` is present when the page was full (the next page may be empty) and `null` when it was short. - `limit` defaults to 20 and is clamped to 1..100. `/v1/structured` filters are applied before the page is cut; send the same filters with every page. - `/v1/feed` is lossless only for observations that were successfully ingested by Chizumulu API. It cannot include a post the source never delivered, the collector never saw, or that has not reached the API yet (see `delivery` in `/v1/status`). ## Media Images that come with posts can be retained (see `media` in `/v1/status`). The API does not serve them. A retained image may be read for one kind of structured data: a league standings table. It becomes a `standings` fact in `facts[]` whose `evidence` is `{"text": false, "attachments": []}` (text-derived items have `text: true`). It is added next to the text-derived data and never changes it; a cell that is not clearly readable is `null`, never inferred; goals-for/against columns are not captured. Nothing else in an image is interpreted: fixture or result graphics, lineups and photos are not read, so their information is absent. The table is read whether or not the text produced a structured result: when there is none (an image-only post with `method: "none"`, or failed text processing; `structured: null`), the fact is returned as `attachmentFacts[]` instead of `structured.facts[]`. A caption is processed like any other text. ## Fidelity rules - Unknown stays unknown: any value the source does not state is `null`. Nothing is guessed, corrected or normalized: team names, spellings, dates (`dateText`), minutes (`minuteText`) and scores are copied as written, including source mistakes. - `matches[].competition` is only text written as words in the post; a hashtag is never expanded into a name. `competitionCode` (`nrfa_league_one`, `nrfa_league_two`) is a separate deterministic classification, `null` when there is no unambiguous signal. Filter on `competitionCode`. - `homeTeam` is the left-hand team ("A vs B", "A 1-0 B"); when the source does not establish home/away both are `null`. - `matches[].index` is only a reference inside one observation, not a global match identity. `structured` does not link posts about the same match, compute standings, or infer results from missing data. - Derived matches are a separate projection over structured observations (`/v1/live`, `/v1/matches/{matchId}`). `matchId` is an opaque, stable identifier assigned by this API (not a WhatsApp, message or observation id). Observations are merged only when they clearly are the same real match (same canonical teams, or exactly the same written name for clubs without a reviewed id; no conflicting competition or explicit date; inside a time window; scores and status only moving forward). A false merge is treated as worse than a missed one, so one real match can occasionally appear as two, and an observation that fits several matches is attached to none (`unresolved`). Derived data can be missing or wrong. - Errors are JSON `{"error": ""}`: 400 for an invalid `cursor`, `since`, `after`, `rawUpdateId` or `matchId`, 404 for an unknown route, an unknown message or an empty store. - No rate limit is documented and no uptime guarantee is made. Upstream information may be missing, late, edited or in a different format. CORS: public GET responses under /v1/*, /openapi.json and /llms.txt send `Access-Control-Allow-Origin: *` (browser pages on any origin may read them); OPTIONS on the public read routes allows only GET and OPTIONS, no custom headers, no credentials. Status page (JSON): https://api.chizumulu.net/v1/status