# Open Besluitvorming – API This page documents the API for Open Besluitvorming (Woozi), which indexes public Dutch government documents — council meetings, agendas, minutes, and attached documents — from municipalities (_gemeenten_), provinces (_provincies_), and water boards (_waterschappen_). ## Base URL ``` https://openbesluitvorming.nl ``` ## Endpoints overview | Endpoint | Method | Description | Try it | |----------|--------|-------------|--------| | [`/api/search`](#search) | GET | Search meetings, documents, motions and spoken word (recommended) | [begroting in Soest](https://openbesluitvorming.nl/api/search?query=begroting&organization=soest&limit=5) | | [`/api/stats`](#index-statistics) | GET | Index statistics (document count, organization count) | [live](https://openbesluitvorming.nl/api/stats) | | [`/api/sources`](#sources) | GET | List available data sources | [live](https://openbesluitvorming.nl/api/sources) | | [`/api/status`](#status) | GET | Import freshness per organization and per source system, in one call | [live](https://openbesluitvorming.nl/api/status) | | [`/api/entities/{entity_id}`](#entity-detail) | GET | Full entity detail (text, agenda, motions with votes, recordings with transcript, download URL) | | | [`/api/entities/{entity_id}/pdf/page/{n}`](#pdf-page-rendering) | GET | Rendered PDF page as JPEG image | | | [`/api/export/snapshot`](#bulk-export) | GET | Bulk export: current state per source (NDJSON) | | | [`/api/export/changes`](#bulk-export) | GET | Bulk export: change feed per source (NDJSON) | | No authentication is required. All endpoints are read-only. Looking for voting behaviour per party? See [Use case: voting data](#use-case-voting-data). Looking for what was actually said in a debate? See [Use case: spoken word](#use-case-spoken-word). Planning something that makes a lot of requests? See [Rate limits](#rate-limits). --- ## Rate limits `/api/*` is rate limited per client at **60 units per minute**, refilling continuously (a token bucket, not a fixed window — you do not have to wait for a minute boundary). Most requests cost **1 unit**. Two exceptions: | Request | Cost | |---------|------| | `/api/search` with `limit=24` or lower (the default) | 1 unit | | `/api/search` with a higher `limit` | 1 unit, plus 1 further unit per 250 results above the first 24 — so `limit=100` costs 1.3 | | `/api/entities/{id}/pdf/page/{n}` | 1/8 unit — a long agenda's thumbnails should not drain your budget | Nearly all of a search's cost is the query itself, evaluated over the index, and that is charged once whatever the page size; the rows it returns add about 1/250 of it each. So **asking for fewer, larger pages is the cheap way to read in bulk**: 1000 results at `limit=100` costs 13 units, where paging the same 1000 at the default size costs 42. Harvesting the whole corpus is better served by the [bulk export API](#bulk-export) than by paging through search. Every response carries the current state, so you can pace yourself without guessing: ``` RateLimit-Limit: 60 RateLimit-Remaining: 43 RateLimit-Reset: 17 ``` `RateLimit-Reset` is the number of seconds until the bucket is back at full capacity. Exceeding the limit returns **`429 Too Many Requests`** with a JSON body and a `Retry-After` header giving the seconds until *your* next request would fit: ```json { "error": "Te veel verzoeken. Probeer het over 3 seconde(n) opnieuw.", "limit_per_minute": 60, "retry_after_seconds": 3, "hint": "Zware verzoeken tellen zwaarder: een zoekopdracht kost 1 eenheid per 24 resultaten, dus limit=100 kost 5. …", "documentation": "https://openbesluitvorming.nl/docs/api" } ``` Treat `429` as "slow down", not as an error: honour `Retry-After` and continue. For bulk work, prefer the [export endpoints](#bulk-export) over paging through `/api/search` — they are built to hand over a whole source in one stream and cost one unit per call. --- ## Errors Errors are JSON, and every one carries a machine-readable `code` beside the human-readable `error`: ```json { "code": "invalid_date", "error": "Parameter dateFrom moet een datum in de vorm JJJJ-MM-DD zijn, kreeg \"bogus\".", "hint": "Alleen de kale datum wordt geaccepteerd; een tijd of tijdzone erachter niet." } ``` **Branch on `code`, never on `error`.** The message is Dutch prose: it may be reworded, given a clearer hint, or one day offered in another language, and a consumer matching on the text breaks silently when that happens. Codes are added as needed; an existing one is not renamed or removed without a note here. | Code | Status | Meaning | |------|--------|---------| | `unknown_entity_type` | 400 | `entityType` is not one of the four documented values | | `unknown_organization` | 400 | `organization` is not a key from `/api/sources` | | `unknown_sort` | 400 | `sort` is not one of the four documented orders | | `invalid_date` | 400 | `dateFrom` or `dateTo` is not a bare `YYYY-MM-DD` calendar date | | `invalid_limit` | 400 | `limit` is not an integer, or is below 1 | | `invalid_offset` | 400 | `offset` is not an integer, or is negative | | `unsupported_phrase_slop` | 400 | `query` uses the proximity notation `"a b"~10` | | `invalid_page_number` | 400 | the page number in a PDF page URL is not a positive integer | | `invalid_scale` | 400 | `scale` on a PDF page URL is not `1` or `2` | | `missing_export_source` | 400 | an export call omitted `source` | | `unknown_export_source` | 400 | an export call named a source that does not exist | | `invalid_export_cursor` | 400 | `cursor` was not produced by an earlier export response | | `entity_not_found` | 404 | no entity with that id | | `pdf_not_found` | 404 | the entity exists but carries no PDF | | `pdf_page_not_found` | 404 | the PDF exists but has no such page | | `rate_limited` | 429 | budget spent; see [Rate limits](#rate-limits) | | `search_failed` | 500 | the search could not be completed | | `stats_failed` | 500 | `/api/stats` could not be assembled | | `status_failed` | 500 | `/api/status` could not be assembled | | `export_failed` | 500 | an export page could not be read | | `pdf_fetch_failed` | 500, 502 | the source PDF could not be retrieved | | `pdf_render_failed` | 500 | the page could not be rendered to an image | | `entity_content_failed` | 500 | the entity's content could not be assembled | A failing search adds a `request_id`: ```json { "code": "search_failed", "error": "Zoeken mislukt. Probeer het opnieuw of meld deze fout met het request ID.", "request_id": "3f9c1a2b" } ``` Quote that id when reporting a problem — the full detail is in the server log under the same id. The response deliberately does not carry it: an earlier version returned the search engine's own message, which exposed the generated query and internal identifiers while telling the caller nothing they could act on. ### Punctuation in queries `query` is free text, not a query language. Punctuation is stripped and the remaining words are combined with AND, so `kosten/baten` finds documents containing both words, and `14:30` finds both parts. Double quotes are the one exception: `"sociale huurwoningen"` is searched as a phrase, the words adjacent and in that order. Everything outside the quotes is still combined with AND. The proximity form `"woord1 woord2"~10` is **not** supported and returns `400`. It used to be accepted, with the `~10` searched as the word `10` — which narrowed the result where a slop is supposed to widen it, and quietly answered a different question than the one asked. A query consisting only of punctuation returns zero results rather than everything. --- ## Search ### `GET /api/search` The recommended search endpoint. Returns grouped, deduplicated results with document-level grouping of page hits. **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `query` | string | Search query. Required **unless** `organization` is given — a source key on its own browses that source without a search term. | | `organization` | string | Filter by source key (e.g. `soest`, `amsterdam`). Case-sensitive; an unknown key returns `400`. See [`/api/sources`](#sources). | | `entityType` | string | Filter by type: `Meeting`, `Document`, `Motion` or `Recording` (spoken word; matches resolve to their meeting). Case-sensitive; any other value returns `400`. | | `sort` | string | Sort order: `date_desc` (default), `date_asc`, `title_asc` or `relevance`. Any other value returns `400`. `title_asc` orders the fetched window rather than the whole result set. | | `dateFrom` | string | Earliest date, `YYYY-MM-DD` only (e.g. `2024-01-01`), inclusive. Filters on the result's `sortDate`, see [What the date is](#what-the-date-is). A value that is not a calendar date returns `400`; a time or time zone on the end is not accepted. | | `dateTo` | string | Latest date, same format, **inclusive of the whole day**: `dateFrom=2026-01-01&dateTo=2026-01-01` returns everything dated 1 January 2026, not nothing. It is a "tot en met", not a "tot". | | `offset` | integer | Pagination offset (default: 0). Must be zero or greater. Pages are cut from the deduplicated results, which are read from the top, so paging reaches a limited depth (a few hundred to about a thousand results). To go further, narrow with `dateFrom` and `dateTo`, or harvest through the [export API](#bulk-export). | | `limit` | integer | Results per page (default: 24, minimum 1, values above 100 are capped at 100). | Parameters that cannot be honoured are refused with `400` rather than ignored: an unknown `entityType` used to drop the filter and quietly return everything, which is harder to notice than an error. **Example:** ```bash curl "https://openbesluitvorming.nl/api/search?query=begroting&organization=soest&sort=date_desc&limit=10" ``` **Response:** ```json { "results": [ { "entityId": "document:notubiz:gemeente:soest:12345", "entityType": "Document", "entityTypeLabel": "Document", "organization": "Soest", "date": "7 november 2024", "sortDate": "2024-11-07 00:00:00", "title": "Raadsvoorstel begroting 2024", "summary": "De begroting voor 2024 bedraagt...", "summaryHtml": "De begroting voor 2024 bedraagt...", "downloadUrl": "https://...", "matchedPage": 3, "pageCount": 12, "previewImageUrl": "/api/entities/document%3Anotubiz%3Agemeente%3Asoest%3A12345/pdf/page/3" } ], "totalCount": 42, "totalIsApproximate": true, "hasMore": true } ``` --- ### What the date is Every result carries one date, `sortDate` (also rendered as `date`), and that is what `dateFrom`, `dateTo` and the date sort orders use. Which date it is depends on the type of result: | Type | `sortDate` is | |------|---------------| | Meeting | The meeting's start, as scheduled by the organization | | Document | The start of the meeting at which the document was (last) discussed. A register document (ingekomen stukken, raadsvragen, toezeggingen) gets the date of its register entry, the day the questions were asked; the entity detail also carries `dateModified`, the day the source system last changed the entry, which for questions is the day the answer was added | | Motion | The meeting at which the motion was last discussed, else the date the source system gives it | | Recording | The start of the meeting it records | It is never the date a document was written or published by its author: the source systems do not expose that reliably. The value is a UTC timestamp; the underlying local time is Dutch time (CET/CEST). ## Index statistics ### `GET /api/stats` Returns the total number of indexed documents and unique organizations. **Response:** ```json { "documentCount": 3045470, "organizationCount": 124 } ``` Cached for 1 hour. --- ## Sources ### `GET /api/sources` Lists all configured data sources. **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `implemented` | string | Set to `true` to only return active sources | **Response:** ```json { "sources": [ { "key": "soest", "label": "Soest", "supplier": "notubiz", "organizationType": "gemeente", "implemented": true, "isAggregate": false } ] } ``` --- ## Status ### `GET /api/status` How current our data is, for every organization and every source system, in a single call. Built for status dashboards: there is no need to ask 330 times. No parameters. Cached for 10 minutes. **Response:** ```json { "generatedAt": "2026-08-16T09:00:00.000Z", "windowHours": 36, "indexActivityAvailable": true, "suppliers": [ { "supplier": "ibabs", "label": "iBabs", "state": "down", "sourceCount": 166, "okSourceCount": 0, "runCount": 332, "succeededCount": 0, "failedCount": 332, "lastSuccessAt": "2026-08-05T23:14:02.118Z", "lastErrorMessage": "iBabs blocks requests from this host (403 \"The request is blocked\")." } ], "sources": [ { "sourceKey": "soest", "sourceRef": "ibabs:gemeente:soest", "label": "Soest", "supplier": "ibabs", "organizationType": "gemeente", "cbsId": "GM0342", "state": "failing", "lastSuccessAt": "2026-08-05T22:41:09.883Z", "lastRunAt": "2026-08-16T00:00:00.396Z", "lastRunStatus": "failed", "lastErrorMessage": "iBabs blocks requests from this host (403 \"The request is blocked\").", "latestContentDate": "2026-08-20T13:30:00.000Z", "lastIndexedAt": "2026-08-05T22:40:51.000Z" } ] } ``` Every organization in the catalog is listed, including the handful we no longer import from — their data is still searchable. Ten of them no longer exist: municipal reorganizations (*herindelingen*) merged them into a successor, so no further data is coming and none ever will. Those are `discontinued` rather than `stale` or `failing`, and they carry the date they ceased to exist and who took over: ```json { "sourceKey": "weesp", "label": "Weesp", "state": "discontinued", "discontinuedAt": "2022-03-24", "succeededBy": { "cbsId": "GM0363", "label": "Amsterdam", "sourceKey": "amsterdam" }, "latestContentDate": "2022-07-12T00:00:00.000Z" } ``` The list is verified against CBS *Gebieden in Nederland* — the year each CBS code last appears in. `succeededBy.sourceKey` is absent when we do not import the successor, which today is the case for the five organizations Land van Cuijk took over. **Source states:** | `state` | Meaning | |---------|---------| | `ok` | An import succeeded within `windowHours` | | `stale` | No success within `windowHours`, but the last run did not fail (queued, running, or never finished) | | `failing` | The last run failed *and* nothing has succeeded within `windowHours` | | `never_imported` | In the catalog, but no import has ever run | | `not_implemented` | We no longer import from this organization; its existing data still answers searches | | `discontinued` | The organization was merged away by a herindeling. Nothing will ever be added; its existing data still answers searches | Only full imports count. Reindexes and other internal replays rebuild from data we already hold without contacting the source system, so a successful one says nothing about whether new data is arriving — counting it would report an organization as current in the middle of an outage. A single failed run does not make a source `failing`. Each import covers seven days either side of today, so a source that misses one night is picked up by the next — `ok` with `lastRunStatus: "failed"` is a normal, self-correcting state. `failing` is the state that does not fix itself on its own. **Source system states:** | `state` | Meaning | |---------|---------| | `ok` | Imports are landing | | `degraded` | More runs failed than succeeded within `windowHours` | | `down` | At least 5 runs attempted within `windowHours` and **none** succeeded | | `idle` | Nothing ran within `windowHours` | For a `down` source system, `lastSuccessAt` is when it last delivered anything at all — i.e. how long the outage has lasted. **Fields:** | Field | Meaning | |-------|---------| | `lastSuccessAt` | When a full import last succeeded. A partially successful import counts: it means the source was reached and most of it landed | | `lastRunAt` / `lastRunStatus` | The most recent full import attempt, whatever its outcome | | `lastErrorMessage` | Why the last run failed. Present only when it did. Query strings are stripped | | `latestContentDate` | Newest meeting date held for this organization. Often in the future — an agenda is published before the meeting happens. Dates more than two years ahead are typos at the source and are left out | | `lastIndexedAt` | When anything was last written to the search index for this organization | | `indexedDocuments` | Documents in the search index for this organization, exact at the moment of the request: one per document, pages not counted. The number to reconcile against the source system's own list. Absent when the index did not answer | | `coverage` | Present once the weekly coverage check has covered this organization. What the source system's own API listed for a date window, against what the index holds: `supplierDocuments`, `heldDocuments`, `missingDocuments` (the first two partition the third), `ratio` (held over supplier, so the share that is complete; 1 means complete; `null` when the supplier listed no documents in the window, because 0 of 0 says nothing about completeness), `windowFrom`/`windowTo` (calendar dates, both inclusive: the window is `windowFrom` 00:00 through the whole of `windowTo`), `checkedAt`, `missingSample` (up to 25 missing document ids, highest first, which for suppliers that number their documents as they create them means the most recent), `lowerBound` (true when some supplier requests failed, so the gap may be larger), and `error` when the check itself failed. When `error` is set the four numbers `supplierDocuments`, `heldDocuments`, `missingDocuments` and `ratio` are `null`: the check measured nothing, which is not the same as measuring zero. `state: "ok"` says the last import ran; `coverage` says whether it asked for everything | | `discontinuedAt` | The date the organization ceased to exist. Only on `discontinued` | | `succeededBy` | `{ cbsId, label, sourceKey }` of the organization that took over. `sourceKey` is absent when we do not import the successor. Only on `discontinued` | `latestContentDate` and `lastIndexedAt` come from the search index. If it cannot be reached, `indexActivityAvailable` is `false` and both are omitted everywhere; the import half of the answer is unaffected. --- ## Entity detail ### `GET /api/entities/{entity_id}` Returns the full content for one entity. A document answers with its text and download URL, a meeting with its agenda plus the motions decided in it and the recordings of it, a motion with its outcome and votes. > **Note:** `entity_id` values contain colons. URL-encode them: `document:notubiz:gemeente:soest:12345` → `document%3Anotubiz%3Agemeente%3Asoest%3A12345`. **Example:** ```bash curl "https://openbesluitvorming.nl/api/entities/document%3Anotubiz%3Agemeente%3Asoest%3A12345" ``` **Response (document):** ```json { "entityId": "document:notubiz:gemeente:soest:12345", "entityType": "Document", "entityTypeLabel": "Document", "title": "Raadsvoorstel begroting 2024", "organization": "Soest", "date": "7 november 2024", "sortDate": "2024-11-07 00:00:00", "markdownText": "# Raadsvoorstel begroting 2024\n\n...", "downloadUrl": "https://...", "contentType": "application/pdf", "pdfUrl": "https://...", "meetingId": "meeting:notubiz:gemeente:soest:830424" } ``` **Response (meeting):** ```json { "entityId": "meeting:notubiz:gemeente:soest:830424", "entityType": "Meeting", "entityTypeLabel": "Vergadering", "title": "Raadsvergadering 2024-11-07", "organization": "Soest", "date": "7 november 2024", "sortDate": "2024-11-07 20:00:00", "agenda": [ { "id": "...", "title": "Opening", "number": "1", "documents": [ { "id": "document:notubiz:gemeente:soest:12345", "name": "Raadsvoorstel begroting 2024", "original_url": "https://...", "downloadUrl": "/api/entities/document%3Anotubiz%3Agemeente%3Asoest%3A12345/pdf" } ], "agenda_items": [] } ], "motions": [ { "id": "motion:notubiz:gemeente:soest:...", "name": "M1 Woningbouw Soesterberg", "result": "aangenomen", "tally": { "in_favour": 20, "against": 11 }, "votes": [], "agenda_item": "agenda_item:notubiz:gemeente:soest:...", "attachment_id": "document:notubiz:gemeente:soest:...", "download_url": "https://..." } ], "recordings": [ { "id": "recording:notubiz:gemeente:soest:...", "media_type": "video", "stream_url": "https://...m3u8", "duration_seconds": 12304, "transcript_kind": "asr", "chapters": [ { "title": "1 Opening", "start_seconds": 14, "end_seconds": 166, "agenda_item": "agenda_item:notubiz:gemeente:soest:..." } ], "segments": [ { "start_seconds": 0, "end_seconds": 119.2, "text": "Goedenavond allemaal…" } ] } ] } ``` `original_url` is the supplier's own link and for part of iBabs answers `403` for a document we hold. A document that looks like a PDF also carries `downloadUrl`, a path on this API that serves our stored copy; prefer it over `original_url`. `motions` and `recordings` are present only on a `Meeting`, and only when the source publishes them. See [voting data](#use-case-voting-data) and [spoken word](#use-case-spoken-word) below for what to expect from each. --- ## PDF page rendering ### `GET /api/entities/{entity_id}/pdf/page/{page_number}` Optional `scale=2` renders the page at 192 dpi instead of 96, for screens with two or more device pixels per CSS pixel; any other value is a `400`. Returns a rendered page of a PDF document as a JPEG image. Pages are rendered at 96 DPI and cached permanently. **Response headers:** - `Content-Type: image/jpeg` - `Cache-Control: public, max-age=31536000, immutable` - `X-Pdf-Page-Count: 12` (total pages in the document) **Example:** ```bash curl -o page1.jpg "https://openbesluitvorming.nl/api/entities/document%3Anotubiz%3Agemeente%3Asoest%3A12345/pdf/page/1" ``` --- ## Bulk export The export endpoints are the supported way to harvest or synchronize data in bulk. Do not use the search endpoints for harvesting. Synchronization works in two steps: 1. **Initial sync** — page through `/api/export/snapshot` for the current state of a source. Remember the `X-Changes-Cursor` header from the *first* page. 2. **Stay in sync** — periodically call `/api/export/changes` with that cursor. Each response header `X-Next-Cursor` is the cursor for the next call. This feed includes late mutations (e.g. a document attached to an old meeting), corrections, and deletions — there is no need to re-harvest. Both endpoints return NDJSON (`application/x-ndjson`): one record per line. ### `GET /api/export/snapshot` | Parameter | Type | Description | |-----------|------|-------------| | `source` | string | Source key (required, see `/api/sources`) | | `cursor` | string | `X-Next-Cursor` from the previous page | | `limit` | integer | Records per page (default 500, max 1000) | **Response headers:** `X-Next-Cursor`, `X-Has-More`, `X-Changes-Cursor` (cursor to start the changes feed from; take it from the first page). ### `GET /api/export/changes` | Parameter | Type | Description | |-----------|------|-------------| | `source` | string | Source key (required, see `/api/sources`) | | `cursor` | string | Position in the change log. Omit to start from the beginning. | | `limit` | integer | Records per page (default 500, max 1000) | **Response headers:** `X-Next-Cursor`, `X-Has-More`. An empty body with `X-Has-More: false` means you are caught up; store the cursor and poll later. ### Record format ```json { "seq": 42, "op": "upsert", "time": "2026-07-10T12:00:00.000Z", "entity_id": "document:notubiz:gemeente:soest:12345", "entity_type": "Document", "source_key": "soest", "supplier": "notubiz", "commit_id": "commit:document:notubiz:gemeente:soest:12345:abc123def456", "content_hash": "sha256:...", "schema_version": "v1alpha1", "payload": { "type": "Document", "name": "...", "original_url": "...", "derived_content": { "markdown_key": "..." }, "media_urls": [ ... ] } } ``` - Records are compact by design: full document text is never inlined. Fetch markdown via `GET /api/entities/{entity_id}` or the object key in `payload.derived_content.markdown_key`. - `op` is `"upsert"` or `"delete"`. A delete record (tombstone) has no `payload`; remove the entity from your copy. Since October 2026 a delete record says why in `reason`: `"takedown"` (removed on request, e.g. a privacy report), `"removed_at_source"` (an import found the document taken off its meeting and the source confirmed it can no longer be downloaded; `meeting_id` names that meeting) or `"source_purged"` (a whole source was withdrawn). Older delete records have no `reason`. A document removed at the source can come back as a new `upsert` if the source publishes it again. - The feed is deduplicated on `content_hash`: re-indexing unchanged data adds no records, so polling stays cheap. - `seq` is monotonic per source. Cursors are stable: the same cursor always resumes at the same position. > **Note:** the export log is populated from ingests going forward. A source's > history appears in the feed after its next full import; until then the > snapshot may be empty or partial for that source. --- ## Typical workflow 1. Search with `/api/search` to find relevant documents 2. Take the `entityId` from a result 3. Call `/api/entities/{entityId}` to retrieve the full text or meeting agenda 4. Use `meetingId` on a document to navigate to the parent meeting 5. Use `/api/entities/{entityId}/pdf/page/{n}` to render PDF pages --- ## Use case: voting data Moties and amendementen are published as `Motion` entities. Where the council uses a digital voting module, each one carries the vote of every individual member, with their party. ### Find motions ```bash curl "https://openbesluitvorming.nl/api/search?query=woningbouw&entityType=Motion&limit=10" ``` Add `organization=` to scope to one municipality. Note that a search **requires a `query`** — an empty query returns no results for any entity type. ### Fetch one motion ```bash curl "https://openbesluitvorming.nl/api/entities/motion%3Aibabs%3Agemeente%3Ahouten%3A493049d8-2b51-4a8f-a885-bcd48efc1a2f" ``` The response carries a `motion` object: ```json { "entityId": "motion:ibabs:gemeente:houten:...", "entityType": "Motion", "entityTypeLabel": "Motie", "motion": { "name": "045-2022 M Essenkade verhogen duurzaamheid", "motion_type": "Moties", "status": "Motie aangenomen", "result": "aangenomen", "tally": { "in_favour": 20, "against": 11 }, "votes": [ { "option": "tegen", "voter": "person:ibabs:gemeente:houten:4fe947d1-...", "voter_name": "Kasius, S.", "group": "party:ibabs:gemeente:houten:a93295fa-...", "group_name": "Partij ITH" } ] }, "meetingId": "meeting:ibabs:gemeente:houten:..." } ``` `result` is normalised to `aangenomen`, `verworpen`, `ingetrokken`, `aangehouden` or `overig`; `status` keeps the supplier's own wording. ### The text of a motion A motion is a registry entry, not a file: its text lives in an attached document. Both routes to it are in the response. - Fetching **one motion** gives you `pdfUrl` and `downloadUrl` directly, and `pdfEntityId` — the document id to pass to [`/pdf/page/{n}`](#pdf-page-rendering) if you want rendered pages. - The **`motions[]` array on a meeting** gives each entry an `attachment_id` and, where it resolves, a `download_url`. Around 40% of motions have no attachment at all; those carry the outcome and the votes but no text. ### Bulk: every motion of a source For analysis, use the export feed rather than paging search. Entity ids sort alphabetically, so `cursor=motion` jumps straight past the documents: ```bash curl "https://openbesluitvorming.nl/api/export/snapshot?source=houten&cursor=motion&limit=200" ``` Each line is a record whose `payload` holds the same fields as above. Follow `x-next-cursor` until `x-has-more` is `false`, then switch to `/api/export/changes` with `x-changes-cursor` to stay in sync. ### What to expect Measured across all 153 iBabs sitenames (2026-07-31), and against the first imported sources: - **67 of 142 municipalities publish per-member votes.** The rest publish the motion and its outcome but no breakdown. - Within those, roughly **62% of motions in the vote-era carry vote records** — a withdrawn motion never reaches a vote, and unanimous ones are not always recorded. Of 200 Houten motions sampled from the live feed: 127 had a `result`, 116 had `votes`, 130 linked to a meeting. - Coverage starts when the municipality adopted the module, between 2017 and 2026 — check the oldest motion with `votes` per source rather than assuming. ### Limitations worth designing around - **`option` is only `voor` or `tegen`.** Abstentions and absences are indistinguishable: a member who did not vote is simply absent from the array. Meeting attendee lists are empty in the public supplier API, so turnout cannot be reconstructed. - **Use `votes[].group_name` for party, not `parties`.** The `parties` field is derived from the proposer strings and is empty for many sources; the vote records always carry the fractie. - **Identifiers are per-municipality.** `person:` and `party:` ids are stable within a source but there is no national register link, and party names vary in spelling (`CDA`, `Fractie CDA`, `raadsleden cda`). Roughly 42% of distinct party names map to a national party; the remainder are local lists. - **Notubiz sources have no per-member votes**, only the outcome and the submitting parties. A few publish a vote breakdown as free text in `vote_summary`, stored verbatim. - **Not every motion links to a meeting.** `meetingId` is absent when the reference could not be resolved; `motion.agenda_item_hint` then holds the supplier's raw text. ## Use case: spoken word Meetings are also published as `Recording` entities: the video or audio registration, a chapter per agenda item, and — where the supplier runs speech recognition — a transcript. This is what makes a debate searchable on what was actually said rather than only on what was written down afterwards. ### Search what was said ```bash curl "https://openbesluitvorming.nl/api/search?query=stikstof&entityType=Recording&limit=10" ``` Hits come back as the **meeting**, not the recording, with the spoken fragment as `summary`: ```json { "entityId": "meeting:notubiz:gemeente:putten:1423776", "entityType": "Meeting", "organization": "Putten", "date": "25 juni 2026", "summary": "… voor ons als SGP is daarbij het stikstof plan van de voet" } ``` `summaryHtml` carries the same fragment with the matched term wrapped in ``, for highlighting. The result carries no timestamp. To place a fragment in time, fetch the meeting and find the matching entry in `recordings[].segments`, each of which has `start_seconds` and `end_seconds`. ### Fetch a meeting's recordings ```bash curl "https://openbesluitvorming.nl/api/entities/meeting%3Anotubiz%3Agemeente%3Amidden-groningen%3A1270344" ``` `recordings[]` holds, per registration: | Field | Meaning | |-------|---------| | `media_type` | `video` or `audio` | | `stream_url` | The seekable stream (HLS). Without it a player can only start at zero. | | `player_url` | The supplier's own player page | | `duration_seconds` | Length of the registration | | `transcript_kind` | `asr` where the transcript is machine-generated | | `chapters[]` | `title`, `start_seconds`, `end_seconds`, `agenda_item` — the timeline that ties the video to the agenda | | `segments[]` | `start_seconds`, `end_seconds`, `text` — the transcript itself | The media bytes are never stored or proxied: a two-day council meeting is ~10 GB. `stream_url` and `player_url` point at the supplier. ### What to expect Measured against the live index (2026-08-10), sampling 400 of 3,781 recordings: - **3,781 recordings across 112 sources.** Concentrated: Haarlem alone has 226. - **~88% carry a transcript** (`transcript_kind: asr`), **~94% carry chapters**. - **97% is video**, the rest audio-only. - Transcripts are speech recognition, not minutes: no punctuation you can rely on, names are often mangled, and there is **no speaker attribution** — `speakers[]` was empty in all 400 sampled. Treat a segment as "this was said in this meeting at this moment", not as a quote attributable to a named member. - The transcript is not in the search payload — it is ~30 KB per meeting — so only `/api/entities/{id}` returns `segments`, never `/api/search`. ## Schemas Canonical entity schemas are published as JSON Schema documents: | Schema | Description | |--------|-------------| | [meeting.schema.json](/schemas/meeting.schema.json) | Council or committee meeting | | [document.schema.json](/schemas/document.schema.json) | Attached document or media object | | [committee.schema.json](/schemas/committee.schema.json) | Committee or organisation | | [motion.schema.json](/schemas/motion.schema.json) | Motie/amendement with outcome and vote breakdown | | [recording.schema.json](/schemas/recording.schema.json) | Video/audio registration with chapters and transcript | | [vote.schema.json](/schemas/vote.schema.json) | Vote record (shape reused inside `Motion.votes`) | | [entity-commit.schema.json](/schemas/entity-commit.schema.json) | CloudEvents envelope | These define the structure of entity detail responses.