# 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.