tender-db API

Public procurement data — REST, live feeds, read-only SQL and webhooks. This page documents the API served at this host.

Base URL: https://tenders.zebreus.click. All responses are JSON (SSE excepted). The historical backfill is ongoing, so collection counts grow over time. Domain terms (Tender, Lot, Bid, Notice, Source, Organization) are defined in the project's CONTEXT.md.

Conventions

Collections

Four collection endpoints. Each returns a page of rows and — with Accept: text/event-stream — becomes a live subscription (SSE).

EndpointReturns
GET/v1/tendersTenders (current version of each), ascending id by default — see ordering for newest-first and closes-soon.
GET/v1/tenders/{id}One Tender in full — see detail.
GET/v1/lotsLots (subdivisions of Tenders).
GET/v1/organizationsCanonical Organizations (buyers, bidders, winners).
GET/v1/organizations/{id}One Organization by id — the counterpart of a detail's parties[].organization_id.
GET/v1/noticesRaw import records. No canonical change rows, so an SSE subscription here is a snapshot then silence.
GET/v1/notices/{id}One Notice by id — the counterpart of a version's caused_by_notice_id.
GET/v1/notices/{id}/contentEverything the parser extracted from that Notice — see notice content.
curl -s "https://tenders.zebreus.click/v1/tenders?limit=2"

Envelope: {"items": [ … ], "next_cursor": "1234"|null, "more": true|false, "ignored_filters": []}. ignored_filters names any filter you sent that this collection does not apply (see below) — an empty array means every filter applied.

Tender rows echo the cpv (CPV codes) and country (NUTS place codes) they carry, so you can see why a row matched a cpv/country filter.

Filters & pagination

Every collection accepts the same filter vocabulary — a subscription is a collection query plus its filters — but each applies only the subset that is meaningful to it (see which filters apply where below):

ParamMeaning
sourceSource key, e.g. ted.
countryA NUTS place-code prefix matched against the tender's places. At the country level NUTS is ISO-3166 alpha-2, so Germany is DE (not DEU); a longer prefix narrows to a region, e.g. DE1 (Baden-Württemberg) or DEB35 (a specific place). NUTS is not ISO everywhere: Greece is EL and the United Kingdom is UKGB is not a NUTS code at all and matches nothing, and GR is Greece's pre-2013 NUTS spelling, which the corpus still holds on notices published under it.
cpvCPV code prefix, e.g. 45 (construction).
buyerOrganization id that is the buyer.
winnerOrganization id that won at least one Lot.
bidderOrganization id that submitted a bid on at least one Lot — won or not, a superset of winner.
statusopen or closed (by submission deadline). On /v1/lots the deadline may be the procedure's rather than the lot's — see caveats → Dates.
min_value / max_valueValue in EUR cents, compared against the tender's highest amount converted to EUR at its publication date (the derived eur_cents — see caveats). A tender with no convertible amount never matches a value bound.
currencyISO 4217 code, case-insensitive (e.g. EUR, sek) — Tenders/Lots whose current version publishes at least one amount in that currency, as published.
langPreferred language for the picked text values (the title on tenders, lots and the detail header): ISO 639 code, case-insensitive (de and DEU both work). Fallback chain: requested → English → the notice's original language → any labelled → unlabelled. A selector, not a filter — it changes which title a row serves, never which rows match, so it is never reported in ignored_filters. The detail's texts array always carries every stored language variant regardless.
kindTender/Lot kind flag; on /v1/organizations, the identifier scheme (e.g. VAT).
tenderRestrict Lots to one Tender id; on /v1/notices, list the Notices that caused that Tender's versions.
publication_idThe official notice number a source prints on its notices (e.g. a TED OJS number) — exact match. On /v1/notices the notice itself; on /v1/tenders the tender it caused. See lookups.
identifierAn Organization's official identifier value (e.g. a VAT number); pair with kind for the scheme. See lookups.
name_prefixOrganization-name prefix, Unicode case-insensitive ( matches MÜLLER); switches the list to name order. Must not be empty. See lookups.
published_after
published_before
Bound Tenders by their current version's publication time. Unix seconds or RFC 3339; a single bound implies sort=published_at. See ordering.
deadline_after
deadline_before
Bound Tenders by submission deadline (rows without one never match). A single bound implies sort=deadline.
sortTenders only: id (default), published_at or deadline. See ordering.
orderasc | desc. Defaults per sort: published_at newest-first, deadline soonest-first, id ascending (its only direction).
limitPage size, default 100. Must be 1–1000 inclusive — a value outside that range is a 400, not silently clamped.
cursorOpaque page position — pass back the previous page's next_cursor, to the same query shape (a cursor is specific to its sort).

An unknown or misspelled query parameter is rejected with 400 rather than silently ignored, so a typo (cvp for cpv) never reads as "everything matched".

Which filters apply where

A filter that has no meaning for a collection is accepted but not appliedcpv on /v1/organizations, say, does not narrow anything, because an Organization carries no CPV. So that an unfiltered page can never look filtered, every list response names the filters it dropped in ignored_filters; an empty array means all of them applied. The full map:

CollectionAppliesAccepted but ignored
/v1/tenderssource, country, cpv, buyer, winner, bidder, status, min_value, max_value, currency, kind, publication_id, published_after/_before, deadline_after/_before (+ sort/order)tender, identifier, name_prefix
/v1/lotssource, country, cpv, buyer, winner, bidder, status, min_value, max_value, currency, kind, tenderpublication_id, identifier, name_prefix, the date bounds
/v1/organizationscountry, kind, buyer, identifier, name_prefixsource, cpv, winner, bidder, status, min_value, max_value, currency, tender, publication_id, the date bounds
/v1/noticessource, kind, publication_id, tendercountry, cpv, buyer, winner, bidder, status, min_value, max_value, currency, identifier, name_prefix, the date bounds

So GET /v1/notices?country=DE returns every notice with "ignored_filters": ["country"] in the envelope — not the German ones, and the field says so.

Paginate by following next_cursor until more is false:

curl -s "https://tenders.zebreus.click/v1/tenders?country=DE&status=open&limit=50"
curl -s "https://tenders.zebreus.click/v1/tenders?country=DE&status=open&limit=50&cursor=14327"

Ordering tenders

Every collection lists in ascending id by default — a stable keyset order for pagination, not a domain order. /v1/tenders additionally sorts by domain time:

QueryReturns
?sort=published_atNewest first (default order=desc) — "what's new". order=asc for oldest-first.
?sort=deadlineSoonest submission deadline first (default order=asc) — "closes soon". Only tenders that have a deadline appear; pair with status=open and deadline_after for still-open ones.
?sort=idThe default ascending list, named explicitly. Descending id is not supported — use sort=published_at for newest-first.

The date bounds imply their ordering: a published_after/published_before bound alone implies sort=published_at, a deadline_after/deadline_before bound implies sort=deadline. Bounds on both columns need an explicit sort to pick the ordering, else 400. Instants are unix seconds, RFC 3339 (the format the API itself serves; an unencoded +01:00 offset pasted into a URL works), or the literal now — which is what makes ?deadline_after=now&sort=deadline&order=asc the “closes soon” query without the caller computing a timestamp that goes stale the moment it is saved. No other word is accepted; today would need a timezone this API has no notion of. All of this composes with the other filters, and pagination is unchanged: follow next_cursor, back into the same query shape — a cursor is specific to its sort. Sorted reads are REST-only; an SSE subscription snapshots in id order and then follows the change log, so sort/order on a stream is 400.

# the five newest tenders
curl -s "https://tenders.zebreus.click/v1/tenders?sort=published_at&limit=5"

# open German tenders closing soonest
curl -s "https://tenders.zebreus.click/v1/tenders?sort=deadline&status=open&country=DE&deadline_after=1786910000"

# everything published since August 1st, newest first (bound implies the sort)
curl -s "https://tenders.zebreus.click/v1/tenders?published_after=2026-08-01T00:00:00Z"

Lookups by real-world key

The keys a consumer actually holds — an official notice number, a VAT number, a company name — resolve directly, without knowing any internal id:

You holdQuery
An official notice numberGET /v1/notices?publication_id=123456-2026
An organization's official identifier (VAT & co.)GET /v1/organizations?identifier=RO42283735&kind=VAT
An organization's nameGET /v1/organizations?name_prefix=müller
# VAT → canonical org → everything they ever bid on
curl -s "https://tenders.zebreus.click/v1/organizations?identifier=RO42283735"
curl -s "https://tenders.zebreus.click/v1/tenders?bidder=2"

Tender detail

GET /v1/tenders/{id} returns the current version of a Tender plus its satellites: lots count and lot_details, texts, amounts, dates, classifications, parties, lot_results (award decisions, accumulating across framework/DPS rounds), bids, contracts, and versions — each version naming the caused_by_notice_id that produced it (the ADR-0001 traceability chain). A missing id is 404.

curl -s https://tenders.zebreus.click/v1/tenders/14327

Notice content

GET /v1/notices/{id}/content returns everything the parser extracted from one Notice — the full section tree with every typed field value, verbatim from the parse layer (source field ids like TED business terms, not canonical projections). Use it when the projected tender is not enough: to see a field the canonical layer does not model, or to check what a quarantined notice did yield. A held or unparsed notice returns its metadata row via /v1/notices/{id} but an empty sections array here; an unknown id is 404.

To ask whether a notice is held, read parse_state, not the presence of quarantine. The quarantine object on /v1/notices/{id} is the notice’s hold history: the row is kept after a member is reclaimed, so the reclaim campaign stays auditable, and reclaimed is what happened to most of them. A notice with parse_state: "parsed" and a non-null quarantine whose reprocessed_at is set was held once, was fixed, and is fully served — its content is right here. Only parse_state: "quarantined" means held now.

{
  "notice_id": 23555356,
  "sections": [
    {
      "section_id": "PROCEDURE", "kind": "Notice", "parent_section_id": null,
      "values": [ … ]
    },
    {
      "section_id": "LOT-0001", "kind": "Lot", "parent_section_id": "PROCEDURE",
      "values": [
        {"type": "text",    "field_id": "BT-21-Lot",  "ordinal": 0, "lang": "DEU", "value": "…"},
        {"type": "integer", "field_id": "BT-115-Lot", "ordinal": 0, "value": 1},
        {"type": "id",      "field_id": "BT-01(c)-Procedure", "ordinal": 0,
         "scheme": null, "is_ref": false, "value": "vgv"}
      ]
    }
  ]
}

Section ids are the source’s own (PROCEDURE, LOT-0001, ORG-0003…), not integers, and kind is the parser’s section vocabulary (Notice, Lot, Organization…); lang is the source’s own tag, uppercase ISO 639-2/T for eForms. The notice_id above is illustrative: entity ids are scoped to the feed’s generation and are reissued by a rebuild, so take one from /v1/notices rather than copying a literal.

Value types: text, code, classification, amount, date, integer, number, id — each carrying its own fields (see the OpenAPI schema). Values sort by (field_id, ordinal) within their section.

Change feed — poll

GET /v1/changes?since={cursor} returns everything the canonical layer learned after since (start at 0). Same events as SSE, without holding a connection open. Optional entity=tender|lot|organization narrows the stream.

curl -s "https://tenders.zebreus.click/v1/changes?since=0&limit=100"

Response: {"events": [ … ], "last_cursor": "193055", "more": false, "generation": 3, "ignored_filters": []}. Only entity narrows this feed. The collection filters (country, cpv, source, status, min_value…) parse but are not applied here, and each one you send is named back in ignored_filters rather than silently dropped. To follow a filtered subset, subscribe to the collection endpoint with Accept: text/event-stream, which does apply them. Loop, passing last_cursor as the next since, until more is false; then poll periodically for new ones. If you ever send a cursor this feed did not issue — one past the head, or one from before a rebuild — the answer carries a reset field (cursor_ahead or cursor_expired) with an empty events array and last_cursor: "0": drop your state, re-snapshot the collections, and resume from 0. That is the same verdict the SSE transport gives for the same cursor, so the two halves never disagree about where you are. The cursor is learn order (ingestion), independent of a notice's publication date — historical backfill and live updates share one monotonic sequence, which is what makes out-of-order ingestion harmless. Sort by published_at if you want domain-time order.

Store generation beside your cursor. It moves only when the dataset is rebuilt from scratch (rare, operator-initiated). Events across a rebuild do not compose: entity ids are reissued and your cursor indexes a feed that no longer exists. When the generation you read differs from the one you stored, drop your local state, re-fetch the collections you mirror, store the new generation, and continue polling from that response's last_cursor. GET /v1 also reports the current generation.

Live feed — Server-Sent Events

Send Accept: text/event-stream to any collection endpoint (with any filters). The one exception is /v1/notices?tender=<id>, which is a lookup rather than a subscription and answers 400 to a stream request rather than quietly returning JSON; subscribe to /v1/notices without tender if you need a live feed. The protocol:

  1. Snapshot — one added event per row currently matching your filter, read in a single consistent transaction.
  2. A live marker carrying the snapshot's cursor.
  3. Diffchange events forever after, each re-evaluating your filter against the old and new version of the entity: a row moving into your filter is added, out of it removed, changed-within it changed.

Each event's SSE id is an opaque resume token (generation-qualified cursor, e.g. 3:193055). On reconnect the browser EventSource replays it as Last-Event-ID; for curl and scripts pass it as that header or as ?cursor=, verbatim. Resuming skips the snapshot and delivers exactly what you missed. A reset event means your token cannot resume and you must drop local state and re-subscribe fresh: {"reason":"cursor_expired"} (the log's retained horizon passed your token) or {"reason":"feed_rebuilt"} (the dataset was rebuilt — same event a poll client detects as a generation change). Add ?include_data=true to embed each entity's current JSON in its event.

# -N disables curl's buffering so events arrive as they happen
curl -N -H "Accept: text/event-stream" \
  "https://tenders.zebreus.click/v1/tenders?country=DE"

# resume from the last event id you processed, verbatim
curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 3:193000" \
  https://tenders.zebreus.click/v1/tenders

Streams are capped at 5 per client. Notices have no live diffs (snapshot then silence). A 15 s keep-alive comment holds the connection open.

Event schema

One shape across SSE change events, poll items and webhook batches, so you can move between transports without reparsing:

{
  "cursor":  "193055",          // opaque string; the SSE id
  "op":      "added|changed|removed",
  "entity":  "tender|lot|organization",
  "id":       14327,             // entity id
  "version":  3,                 // canonical version seq (null for notices)
  "changed_at": "2026-07-19T22:21:32Z"   // poll only
  // "data": { … }               // only with ?include_data=true on SSE
}

SQL endpoint

POST /v1/sql (token required) runs one read-only SELECT against the public schema. Send the SQL as the raw request body — it never travels in the URL, so it stays out of access logs. Discover the queryable tables and views at GET /v1/sql/schema (public, no token). The v_* views — v_tenders, v_lots, v_lot_results, v_organizations, the convenience views v_tender_buyers, v_awards, v_tender_classifications, v_tender_amounts, v_tender_dates, v_tender_notices, and v_fetches for path-free provenance — show the current version of each row and are the readable way to see the shape of the data.

But do not filter a view. A WHERE on a view is applied only after the whole view has been built, so even WHERE id = 12345 reads the entire corpus and hits the time limit. For anything filtered, query the base tables and take the current version through tenders.current_seq — measured at 17 ms for the point read below, against a view that cannot answer it at all.

curl -s -X POST https://tenders.zebreus.click/v1/sql \
  -H "Authorization: Bearer tdb_…" \
  --data 'SELECT t.id, t.current_title AS title, v.publication_id
            FROM tenders t
            JOIN tender_versions v
              ON v.tender_id = t.id AND v.seq = t.current_seq
           WHERE t.id = 12345'

Rules:

Response: {"columns": [ … ], "rows": [[ … ]], "row_count": N, "truncated": false}.

What you may build on (ADR-0015): the allow-listed table and view names, their existing columns, and this envelope are the contract — new columns and tables appear without notice, but nothing is renamed or removed without an entry in the repository's CHANGELOG.md. The SQL dialect itself is described, not promised: it is whatever the current embedded engine implements (the gaps above), and a representative set of query shapes is tested against every build so a dialect change is caught before it ships. The currency_rates table carries the full EUR-pivot rate series behind eur_cents (ECB daily 1999→, the daily ECU series 1993–1998 — data CC BY 4.0, Source: Eurostat — and the irrevocable euro conversion rates), so you can convert published amounts your own way.

Webhooks

Account holders register https URLs that receive change batches as signed POSTs. Manage them on the dashboard or over the API (token required); every endpoint is scoped to your account.

EndpointDoes
GET/v1/webhooksList your endpoints (no secrets).
POST/v1/webhooksRegister {"url": "https://…"}. Returns the signing secret once.
GET/v1/webhooks/{id}One endpoint with its recent delivery attempts.
DELETE/v1/webhooks/{id}Remove it.
POST/v1/webhooks/{id}/disablePause delivery.
POST/v1/webhooks/{id}/enableResume. Body {"from_now": true} drops the backlog; default replays what was missed.
curl -s -X POST https://tenders.zebreus.click/v1/webhooks \
  -H "Authorization: Bearer tdb_…" \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com/hooks/tenders"}'

Delivery & verification

Accounts & tokens

Accounts are username + password only, created on the dashboard. There is no email, so there is no password reset — lose the password and you lose the account and its tokens. API tokens (tdb_…) are shown once at creation. Check a token with:

curl -s https://tenders.zebreus.click/v1/me -H "Authorization: Bearer tdb_…"

Performance

How the API responds by query shape. The rule of thumb: anything reachable by id or a small page is index-served and returns in single-digit to tens of milliseconds; a filter on a selective value is the only thing that can be slow, and it is deliberately kept from affecting anything else.

Median of 5 warm server-side samples, measured 2026-08-15 against the live corpus (≈7.9M tenders / 14.3M notices; the external-key lookups and sorts 2026-08-16, ≈24.6M organizations). Absolute numbers drift as the corpus grows and the hardware changes — the shape is the durable part, not the exact milliseconds.

0 20 40 60 median response time (ms) metadata (/health, /docs) SQL — bounded SELECT list /v1/notices point /v1/…/{id} list /v1/tenders filter ?country=DE filter ?cpv=45 list /v1/organizations 0.5 1.0 1.1 1.9 19 39 47 65

main reader pool   isolated pool (filterable reads that can walk) — a sparse filter (e.g. ?buyer=<rare org>) can walk the whole corpus and is off-scale: it returns slowly or 503 under load, never blocking the bars above.

Query shapeExampleMedianPool
Point lookupGET /v1/{collection}/{id}<1–2 msmain
Small listGET /v1/notices, /v1/lots~1 msmain
Tender list (page)GET /v1/tenders?limit=50~19 msmain
Organization listGET /v1/organizations?limit=50~65 msmain
Lookup by external key?publication_id=…, ?identifier=…, ?name_prefix=…1–8 msmain
Ordered tender list?sort=published_at, ?sort=deadline2–13 msmain
Filter, common value?country=DE, ?cpv=45~40 msisolated
Filter, absent value?country=ZZ<1 ms*isolated
Filter, sparse value?buyer=<rare>, ?winner=<rare>, ?bidder=<rare>walks → up to a full scan; 503 under loadisolated
Change feedGET /v1/changes?since=0<1 msmain
SQL (bounded)POST /v1/sql (indexed SELECT)~1 msisolated, 10 s cap
Metadata/v1, /docs, /v1/openapi.json, /health<1 ms

* an absent filter value short-circuits to an empty page. Every isolation-routed filter has a reachability probe and the compiler will not accept one without it (issue 371). A value that is PRESENT but rare is a different case and still walks: ?currency=DEM answers in ~6 s, since a probe can only prove that nothing matches, never that a match is near.

Why the shape looks like this

Data caveats

The corpus is served as published. Where the source is wrong, odd, or silent, tender-db keeps the published value and documents the pattern here rather than “fixing” data underneath you. These are the measured patterns a consumer should know about; the dashboard carries the live per-era quality rates and the quarantine resolution ledger.

Coverage varies by era

Amounts

Dates

Codes and identities

Ingestion is strict by design: a notice the parser cannot fully and faithfully represent is held in quarantine — whole, diagnosed, and disclosed on the dashboard — rather than partially parsed. Current outstanding holds are a few hundred members out of 2.4M ever held, each with a documented verdict.

Service & licence

GET/v1Service info: version, revision, current cursor, endpoint list, source offer.
GET/v1/openapi.jsonThis API as an OpenAPI 3.0 document — machine-readable, CORS-enabled, for client generators and API tooling.
GET/healthLiveness probe (process up; does not query the DB — see /health/deep).
GET/metricsOperational gauges in Prometheus text format (cursor, RSS, disk/WAL, per-job durations, quarantine counts). An operator surface, not part of the data API.
GET/_sourceAGPL §13 corresponding-source offer for the running revision.

Browse and try the API interactively in Swagger UI or Redoc — both are the projects' hosted viewers, loading the spec straight from this server.

tender-db is free software under AGPL-3.0-or-later. The running server offers the source of its exact revision at /_source.