Open Besluitvorming
September 10, 2026 · View on GitHub
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 | GET | Search meetings, documents, motions and spoken word (recommended) | begroting in Soest |
/api/stats | GET | Index statistics (document count, organization count) | live |
/api/sources | GET | List available data sources | live |
/api/status | GET | Import freshness per organization and per source system, in one call | live |
/api/entities/{entity_id} | GET | Full entity detail (text, agenda, motions with votes, recordings with transcript, download URL) | |
/api/entities/{entity_id}/pdf/page/{n} | GET | Rendered PDF page as JPEG image | |
/api/export/snapshot | GET | Bulk export: current state per source (NDJSON) | |
/api/export/changes | 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. Looking for what was actually said in a debate? See Use case: spoken word. Planning something that makes a lot of requests? See 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 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:
{
"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 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:
{
"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 |
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:
{
"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. |
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. 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. |
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:
curl "https://openbesluitvorming.nl/api/search?query=begroting&organization=soest&sort=date_desc&limit=10"
Response:
{
"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 <b>begroting</b> 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:
{
"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:
{
"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:
{
"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:
{
"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 |
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; 1 means complete), windowFrom/windowTo, checkedAt, missingSample (a few missing document ids), lowerBound (true when some supplier requests failed, so the gap may be larger), and error when the check itself failed. 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_idvalues contain colons. URL-encode them:document:notubiz:gemeente:soest:12345→document%3Anotubiz%3Agemeente%3Asoest%3A12345.
Example:
curl "https://openbesluitvorming.nl/api/entities/document%3Anotubiz%3Agemeente%3Asoest%3A12345"
Response (document):
{
"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):
{
"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://..."
}
],
"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…" }
]
}
]
}
motions and recordings are present only on a Meeting, and only when the
source publishes them. See voting data and
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/jpegCache-Control: public, max-age=31536000, immutableX-Pdf-Page-Count: 12(total pages in the document)
Example:
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:
- Initial sync — page through
/api/export/snapshotfor the current state of a source. Remember theX-Changes-Cursorheader from the first page. - Stay in sync — periodically call
/api/export/changeswith that cursor. Each response headerX-Next-Cursoris 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
{
"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 inpayload.derived_content.markdown_key. opis"upsert"or"delete". A delete record (tombstone) has nopayload; remove the entity from your copy.- The feed is deduplicated on
content_hash: re-indexing unchanged data adds no records, so polling stays cheap. seqis 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
- Search with
/api/searchto find relevant documents - Take the
entityIdfrom a result - Call
/api/entities/{entityId}to retrieve the full text or meeting agenda - Use
meetingIdon a document to navigate to the parent meeting - 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
curl "https://openbesluitvorming.nl/api/search?query=woningbouw&entityType=Motion&limit=10"
Add organization=<source key> 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
curl "https://openbesluitvorming.nl/api/entities/motion%3Aibabs%3Agemeente%3Ahouten%3A493049d8-2b51-4a8f-a885-bcd48efc1a2f"
The response carries a motion object:
{
"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
pdfUrlanddownloadUrldirectly, andpdfEntityId— the document id to pass to/pdf/page/{n}if you want rendered pages. - The
motions[]array on a meeting gives each entry anattachment_idand, where it resolves, adownload_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:
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 hadvotes, 130 linked to a meeting. - Coverage starts when the municipality adopted the module, between 2017 and
2026 — check the oldest motion with
votesper source rather than assuming.
Limitations worth designing around
optionis onlyvoorortegen. 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_namefor party, notparties. Thepartiesfield is derived from the proposer strings and is empty for many sources; the vote records always carry the fractie. - Identifiers are per-municipality.
person:andparty: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.
meetingIdis absent when the reference could not be resolved;motion.agenda_item_hintthen 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
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:
{
"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
<b>, 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
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}returnssegments, never/api/search.
Schemas
Canonical entity schemas are published as JSON Schema documents:
| Schema | Description |
|---|---|
| meeting.schema.json | Council or committee meeting |
| document.schema.json | Attached document or media object |
| committee.schema.json | Committee or organisation |
| motion.schema.json | Motie/amendement with outcome and vote breakdown |
| recording.schema.json | Video/audio registration with chapters and transcript |
| vote.schema.json | Vote record (shape reused inside Motion.votes) |
| entity-commit.schema.json | CloudEvents envelope |
These define the structure of entity detail responses.