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

EndpointMethodDescriptionTry it
/api/searchGETSearch meetings, documents, motions and spoken word (recommended)begroting in Soest
/api/statsGETIndex statistics (document count, organization count)live
/api/sourcesGETList available data sourceslive
/api/statusGETImport freshness per organization and per source system, in one calllive
/api/entities/{entity_id}GETFull entity detail (text, agenda, motions with votes, recordings with transcript, download URL)
/api/entities/{entity_id}/pdf/page/{n}GETRendered PDF page as JPEG image
/api/export/snapshotGETBulk export: current state per source (NDJSON)
/api/export/changesGETBulk 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:

RequestCost
/api/search with limit=24 or lower (the default)1 unit
/api/search with a higher limit1 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.

CodeStatusMeaning
unknown_entity_type400entityType is not one of the four documented values
unknown_organization400organization is not a key from /api/sources
unknown_sort400sort is not one of the four documented orders
invalid_date400dateFrom or dateTo is not a bare YYYY-MM-DD calendar date
invalid_limit400limit is not an integer, or is below 1
invalid_offset400offset is not an integer, or is negative
unsupported_phrase_slop400query uses the proximity notation "a b"~10
invalid_page_number400the page number in a PDF page URL is not a positive integer
invalid_scale400scale on a PDF page URL is not 1 or 2
missing_export_source400an export call omitted source
unknown_export_source400an export call named a source that does not exist
invalid_export_cursor400cursor was not produced by an earlier export response
entity_not_found404no entity with that id
pdf_not_found404the entity exists but carries no PDF
pdf_page_not_found404the PDF exists but has no such page
rate_limited429budget spent; see Rate limits
search_failed500the search could not be completed
stats_failed500/api/stats could not be assembled
status_failed500/api/status could not be assembled
export_failed500an export page could not be read
pdf_fetch_failed500, 502the source PDF could not be retrieved
pdf_render_failed500the page could not be rendered to an image
entity_content_failed500the 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.


GET /api/search

The recommended search endpoint. Returns grouped, deduplicated results with document-level grouping of page hits.

Query parameters:

ParameterTypeDescription
querystringSearch query. Required unless organization is given — a source key on its own browses that source without a search term.
organizationstringFilter by source key (e.g. soest, amsterdam). Case-sensitive; an unknown key returns 400. See /api/sources.
entityTypestringFilter by type: Meeting, Document, Motion or Recording (spoken word; matches resolve to their meeting). Case-sensitive; any other value returns 400.
sortstringSort 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.
dateFromstringEarliest 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.
dateTostringLatest 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".
offsetintegerPagination offset (default: 0). Must be zero or greater.
limitintegerResults 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:

TypesortDate is
MeetingThe meeting's start, as scheduled by the organization
DocumentThe 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
MotionThe meeting at which the motion was last discussed, else the date the source system gives it
RecordingThe 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:

ParameterTypeDescription
implementedstringSet 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:

stateMeaning
okAn import succeeded within windowHours
staleNo success within windowHours, but the last run did not fail (queued, running, or never finished)
failingThe last run failed and nothing has succeeded within windowHours
never_importedIn the catalog, but no import has ever run
not_implementedWe no longer import from this organization; its existing data still answers searches
discontinuedThe 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:

stateMeaning
okImports are landing
degradedMore runs failed than succeeded within windowHours
downAt least 5 runs attempted within windowHours and none succeeded
idleNothing 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:

FieldMeaning
lastSuccessAtWhen a full import last succeeded. A partially successful import counts: it means the source was reached and most of it landed
lastRunAt / lastRunStatusThe most recent full import attempt, whatever its outcome
lastErrorMessageWhy the last run failed. Present only when it did. Query strings are stripped
latestContentDateNewest meeting date held for this organization. Often in the future — an agenda is published before the meeting happens
lastIndexedAtWhen anything was last written to the search index for this organization
indexedDocumentsDocuments 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
coveragePresent 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
discontinuedAtThe 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:12345document%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/jpeg
  • Cache-Control: public, max-age=31536000, immutable
  • X-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:

  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

ParameterTypeDescription
sourcestringSource key (required, see /api/sources)
cursorstringX-Next-Cursor from the previous page
limitintegerRecords 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

ParameterTypeDescription
sourcestringSource key (required, see /api/sources)
cursorstringPosition in the change log. Omit to start from the beginning.
limitintegerRecords 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 in payload.derived_content.markdown_key.
  • op is "upsert" or "delete". A delete record (tombstone) has no payload; remove the entity from your copy.
  • 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

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 pdfUrl and downloadUrl directly, and pdfEntityId — the document id to pass to /pdf/page/{n} 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:

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

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:

FieldMeaning
media_typevideo or audio
stream_urlThe seekable stream (HLS). Without it a player can only start at zero.
player_urlThe supplier's own player page
duration_secondsLength of the registration
transcript_kindasr 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 attributionspeakers[] 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:

SchemaDescription
meeting.schema.jsonCouncil or committee meeting
document.schema.jsonAttached document or media object
committee.schema.jsonCommittee or organisation
motion.schema.jsonMotie/amendement with outcome and vote breakdown
recording.schema.jsonVideo/audio registration with chapters and transcript
vote.schema.jsonVote record (shape reused inside Motion.votes)
entity-commit.schema.jsonCloudEvents envelope

These define the structure of entity detail responses.