Raw vs structured
Messages and observations
Section titled “Messages and observations”The API separates what was seen from what was derived from it.
- A message is one logical post in the source. It has a
rawUpdateId(a number) and asourceMessageId(the source’s own key). - An observation is one time the collector saw that post: its text (or caption) at that moment. Every observation has an
id(ingestion sequence number, the key of/v1/feed) and anobservationId(a stable string). - Observations are stored as received and are never modified or deleted.
- Structured data is derived from one observation’s text. It is a separate layer: the original text is always returned next to it, never replaced by it.
If a post is edited in the source, the collector sees it again. The API stores that as another observation of the same message (same rawUpdateId, new id and observationId, observationCount goes up). The earlier observation is kept.
The same can happen for other reasons, such as the source re-delivering the same text. The API records what the collector observed; it does not claim to know the source’s own revision order.
Which route shows which text
Section titled “Which route shows which text”| Route | Text of the… |
|---|---|
/v1/updates, /v1/latest |
first observation of each message |
/v1/feed |
each observation, as its own item |
/v1/structured |
latest observation of each message (“latest” = the highest ingestion id, not a source timestamp) |
/v1/structured/{rawUpdateId}/history |
every observation, oldest first, at most 200 |
Below, message 4 was edited: the first version says 88 GOAL! and the edit says 88' GOAL!. Look at observationId, receivedAt and the different minuteText in each observation’s structured result.
curl "https://api.chizumulu.net/v1/structured/4/history"Response
{ "rawUpdateId": 4, "source": "whatsapp", "sourceMessageId": "3EB0A1F2C4D5E6F70004", "sourceServerId": null, "observations": [ { "id": 4, "observationId": "8f1c6a52-3b7e-4c1a-9d20-0a1b2c3d4e04", "sourceTimestamp": "2026-09-19T15:20:52.000Z", "receivedAt": "2026-09-19T15:20:54.451Z", "text": "88 GOAL! \n\nNorman Ndlovu completes his brace as Mafu Stars extend their lead! \n\nMafu Stars FC 3–1 Luviri FC \n\n#CINRFALeagueOne\n#NRFATransformingTheGame", "processing": { "state": "done", "method": "ai", "model": "deepseek/deepseek-v4-flash-0731", "postType": "live_update", "translationState": "done", "reusedFromObservationId": null, "error": null }, "structured": { "schemaVersion": 2, "postType": "live_update", "competition": { "code": "nrfa_league_one", "conflict": false, "evidence": [ { "code": "nrfa_league_one", "signal": "#CINRFALeagueOne", "kind": "hashtag" } ] }, "matches": [ { "index": 0, "homeTeam": "Mafu Stars FC", "awayTeam": "Luviri FC", "homeScore": 3, "awayScore": 1, "status": "live", "competition": null, "competitionCode": "nrfa_league_one", "competitionBasis": "post-signal", "venue": null, "dateText": null, "timeText": null, "roundText": null, "minuteText": null, "competitionGroupText": null, "evidence": { "text": true, "attachments": [] }, "events": [ { "index": 0, "type": "goal", "minuteText": null, "team": "Mafu Stars FC", "homeScore": null, "awayScore": null, "players": [ { "name": "Norman Ndlovu", "role": "scorer" } ] } ] } ], "facts": [] }, "korean": { "text": "88 골!\n\n노먼 은들로부가 멀티골을 완성하며 마푸 스타즈가 리드를 넓힙니다!\n\n마푸 스타즈 FC 3–1 루비리 FC\n\n#CINRFALeagueOne\n#NRFATransformingTheGame", "model": "deepseek/deepseek-v4-flash-0731" } }, { "id": 6, "observationId": "8f1c6a52-3b7e-4c1a-9d20-0a1b2c3d4e06", "sourceTimestamp": "2026-09-19T15:20:52.000Z", "receivedAt": "2026-09-19T15:26:31.207Z", "text": "88' GOAL! \n\nNorman Ndlovu completes his brace as Mafu Stars extend their lead! \n\nMafu Stars FC 3–1 Luviri FC \n\n#CINRFALeagueOne\n#NRFATransformingTheGame", "processing": { "state": "done", "method": "ai", "model": "deepseek/deepseek-v4-flash-0731", "postType": "live_update", "translationState": "done", "reusedFromObservationId": null, "error": null }, "structured": { "schemaVersion": 2, "postType": "live_update", "competition": { "code": "nrfa_league_one", "conflict": false, "evidence": [ { "code": "nrfa_league_one", "signal": "#CINRFALeagueOne", "kind": "hashtag" } ] }, "matches": [ { "index": 0, "homeTeam": "Mafu Stars FC", "awayTeam": "Luviri FC", "homeScore": 3, "awayScore": 1, "status": "live", "competition": null, "competitionCode": "nrfa_league_one", "competitionBasis": "post-signal", "venue": null, "dateText": null, "timeText": null, "roundText": null, "minuteText": "88'", "competitionGroupText": null, "evidence": { "text": true, "attachments": [] }, "events": [ { "index": 0, "type": "goal", "minuteText": "88'", "team": "Mafu Stars FC", "homeScore": null, "awayScore": null, "players": [ { "name": "Norman Ndlovu", "role": "scorer" } ] } ] } ], "facts": [] }, "korean": { "text": "88' 골!\n\n노먼 은들로부가 멀티골을 완성하며 마푸 스타즈가 리드를 넓힙니다!\n\n마푸 스타즈 FC 3–1 루비리 FC\n\n#CINRFALeagueOne\n#NRFATransformingTheGame", "model": "deepseek/deepseek-v4-flash-0731" } } ]}Compare the same message in /v1/updates (first observation’s text) with /v1/structured (latest observation’s text) in the endpoint examples.
Processing states
Section titled “Processing states”Every observation moves through processing on its own. processing.state of the latest observation is one of:
processing.state |
Meaning | structured |
korean |
|---|---|---|---|
unregistered |
Stored, not picked up yet. Only state is present in processing. |
null |
null |
pending |
Registered, waiting to be processed or being retried. | null |
null |
awaiting_ai |
Needs the language-model step, which has not run yet. | null |
null |
done |
Processed. | the result, when there is one | the translation, when there is one |
failed |
Processing gave up. processing.error holds a short diagnostic. The original text is unaffected. |
null |
null |
Rules you can rely on:
structuredis never shown for an observation that is notdone. In particular an older observation’s facts are never presented as the current state of an edited message.- A
donepost can legitimately have zero matches and zero facts (a birthday greeting is a valid result). That is different fromstructured: null. - A
doneobservation with no text (for example an image-only post) hasmethod: "none"andstructured: null: there was nothing to extract from.
When state is not unregistered, processing also contains:
| Field | Meaning |
|---|---|
method |
How the result was produced: deterministic (rule-based parser), ai (a language model, constrained by the parser when it could read the post), reused (copied from an earlier observation with identical text), none (no text to process), or null until done. |
model |
The model behind an ai result, otherwise null. |
postType |
The post type once done with a result, otherwise null. |
translationState |
none, done or failed. |
reusedFromObservationId |
Set when method is reused. |
error |
A short diagnostic for failed. Do not depend on its wording. |
See the awaiting-processing item in the second page example.
The Korean translation
Section titled “The Korean translation”korean is a machine translation of the whole post text, produced together with the structured data. It is derived, may contain mistakes, and never replaces latestObservation.text. It is null unless the observation is done and a translation exists.