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.
{"cents": 1234, "currency": "EUR"} (integer minor units — never a float). Timestamps are ISO 8601. A source that published a date only should yield a date only, but does not yet everywhere: the stored instant carries no date-only marker, so some date-only publications render with a time (a German portal date of 2026-09-05 serves as 2026-09-04T22:00:00Z). Issue 367 carries the fix.Authorization: Bearer tdb_…. Create tokens on the dashboard.{"error": {"status": 404, "message": "no such tender"}} with the matching HTTP status — every status, including a 405 for a wrong method on a path that exists (which carries an Allow header beside the envelope)./v1; live streams capped at 5 per client; SQL has its own limits (below). Behind the proxy the client is keyed by X-Forwarded-For.Access-Control-Allow-Origin: *), SSE resume preflights included — build a client-side app directly against the API. The token-gated endpoints (SQL, webhooks, /v1/me) are not CORS-open; call them server-side.Four collection endpoints. Each returns a page of rows and — with
Accept: text/event-stream — becomes a live subscription (SSE).
| Endpoint | Returns |
|---|---|
| GET/v1/tenders | Tenders (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/lots | Lots (subdivisions of Tenders). |
| GET/v1/organizations | Canonical Organizations (buyers, bidders, winners). |
| GET/v1/organizations/{id} | One Organization by id — the counterpart of a detail's parties[].organization_id. |
| GET/v1/notices | Raw 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}/content | Everything 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.
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):
| Param | Meaning |
|---|---|
| source | Source key, e.g. ted. |
| country | A 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 UK — GB 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. |
| cpv | CPV code prefix, e.g. 45 (construction). |
| buyer | Organization id that is the buyer. |
| winner | Organization id that won at least one Lot. |
| bidder | Organization id that submitted a bid on at least one Lot — won or not, a superset of winner. |
| status | open 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_value | Value 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. |
| currency | ISO 4217 code, case-insensitive (e.g. EUR, sek) — Tenders/Lots whose current version publishes at least one amount in that currency, as published. |
| lang | Preferred 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. |
| kind | Tender/Lot kind flag; on /v1/organizations, the identifier scheme (e.g. VAT). |
| tender | Restrict Lots to one Tender id; on /v1/notices, list the Notices that caused that Tender's versions. |
| publication_id | The 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. |
| identifier | An Organization's official identifier value (e.g. a VAT number); pair with kind for the scheme. See lookups. |
| name_prefix | Organization-name prefix, Unicode case-insensitive (mü matches MÜLLER); switches the list to name order. Must not be empty. See lookups. |
published_afterpublished_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_afterdeadline_before | Bound Tenders by submission deadline (rows without one never match). A single bound implies sort=deadline. |
| sort | Tenders only: id (default), published_at or deadline. See ordering. |
| order | asc | desc. Defaults per sort: published_at newest-first, deadline soonest-first, id ascending (its only direction). |
| limit | Page size, default 100. Must be 1–1000 inclusive — a value outside that range is a 400, not silently clamped. |
| cursor | Opaque 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".
A filter that has no meaning for a collection is accepted but not applied
— cpv 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:
| Collection | Applies | Accepted but ignored |
|---|---|---|
| /v1/tenders | source, 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/lots | source, country, cpv, buyer, winner, bidder, status, min_value, max_value, currency, kind, tender | publication_id, identifier, name_prefix, the date bounds |
| /v1/organizations | country, kind, buyer, identifier, name_prefix | source, cpv, winner, bidder, status, min_value, max_value, currency, tender, publication_id, the date bounds |
| /v1/notices | source, kind, publication_id, tender | country, 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"
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:
| Query | Returns |
|---|---|
| ?sort=published_at | Newest first (default order=desc) — "what's new". order=asc for oldest-first. |
| ?sort=deadline | Soonest 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=id | The 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"
The keys a consumer actually holds — an official notice number, a VAT number, a company name — resolve directly, without knowing any internal id:
| You hold | Query |
|---|---|
| An official notice number | GET /v1/notices?publication_id=123456-2026 |
| An organization's official identifier (VAT & co.) | GET /v1/organizations?identifier=RO42283735&kind=VAT |
| An organization's name | GET /v1/organizations?name_prefix=müller |
publication_id is an exact match on the number the source printed on the notice; pair with source= if the same number could exist in two sources. An unknown number is an empty page, not a 404. The same number on /v1/tenders resolves the tender it caused — through any of its versions, so a corrigendum's number still finds the procedure. From the notice, /v1/notices/{id}/content gives its parsed payload and a tender detail's versions[].caused_by_notice_id links back the other way.identifier matches the official identifier value; kind names its scheme. This is the front door to participation history: resolve the identifier to a canonical org id, then ask /v1/tenders?buyer=, ?winner= or ?bidder= with it. An identifier can resolve to MORE THAN ONE canonical org — the identity index is deliberately not unique, and a shared VAT (an Organschaft) or an unfolded duplicate both occur — so take every id the lookup returns rather than the first (issue 329).name_prefix is a prefix match on the organization's name, case-insensitive across the whole of Unicode (müller, MÜLLER and Müller all match), and switches the response to name order (id order otherwise breaks name-ordered pagination). It composes with country=/kind=; an empty prefix is 400.# 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"
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
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.
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.
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:
added event per row currently matching your filter, read in a single consistent transaction.live marker carrying the snapshot's cursor.change 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.
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
}
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:
SELECT — no writes, PRAGMA, ATTACH, EXPLAIN, CTE-wrapped writes or multi-statement bodies.v_* views and the public business tables (canonical, notice, quarantine, changes). Account, webhook and operator tables — and the raw-fetch registry, whose paths are server infrastructure — are never queryable, and a table not on the list is denied by default.WHERE published_at LIKE '2012%' matches nothing. Put the FORMAT FIRST: strftime('%Y', published_at, 'unixepoch'). The reversed order, strftime(published_at,'unixepoch'), returns NULL for every row and raises no error — so it silently collapses a histogram into one empty bucket. Each timestamp column is flagged in the schema, which also carries per-table notes, enum vocabularies and worked examples."truncated": true.429 with Retry-After; any query past the time limit — a slow scan or a heavy aggregate alike — is 408. The ANSWER is abandoned, but the work is not always: the engine offers no interrupt, so a non-yielding aggregate keeps its slot until it finishes, and while it does, further queries can meet a 503 (issue 238). A 503 is different and means the query never ran: the backend had no capacity, so retry it unchanged rather than rewriting it.WITH RECURSIVE; window functions are partial (row_number and aggregate OVER work; rank/lead/lag and custom frames do not). A dialect or column error comes back as 400 with the engine's message.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.
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.
| Endpoint | Does |
|---|---|
| GET/v1/webhooks | List your endpoints (no secrets). |
| POST/v1/webhooks | Register {"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}/disable | Pause delivery. |
| POST/v1/webhooks/{id}/enable | Resume. 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"}'
{"cursor_from": "…", "cursor": "…", "events": [ … ]} using the event schema.webhook-id, webhook-timestamp, webhook-signature. The signature is v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}, keyed by your secret (the base64 body after the whsec_ prefix). Verify it and check the timestamp to reject replays.2xx; the change log is the queue, so a recovered endpoint automatically catches up — there is no separate outbox.generation (see the change feed). When the dataset is rebuilt, the next delivery to each endpoint is a reset notice — an empty batch marked {"reset": "feed_rebuilt", "generation": N, "events": []}, signed like any other — telling you your mirrored state no longer composes: drop it, re-fetch the collections you mirror, and resume from the cursor it carries. You receive this even if no events are flowing, so a rebuild is never silent.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_…"
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.
■ 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 shape | Example | Median | Pool |
|---|---|---|---|
| Point lookup | GET /v1/{collection}/{id} | <1–2 ms | main |
| Small list | GET /v1/notices, /v1/lots | ~1 ms | main |
| Tender list (page) | GET /v1/tenders?limit=50 | ~19 ms | main |
| Organization list | GET /v1/organizations?limit=50 | ~65 ms | main |
| Lookup by external key | ?publication_id=…, ?identifier=…, ?name_prefix=… | 1–8 ms | main |
| Ordered tender list | ?sort=published_at, ?sort=deadline | 2–13 ms | main |
| Filter, common value | ?country=DE, ?cpv=45 | ~40 ms | isolated |
| 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 load | isolated |
| Change feed | GET /v1/changes?since=0 | <1 ms | main |
| SQL (bounded) | POST /v1/sql (indexed SELECT) | ~1 ms | isolated, 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.
503 under contention, but it never slows point lookups, indexed lists, or other clients. (Measured: main-pool reads stayed under 18 ms while a walking filter ran.) A name_prefix search paired with country/kind is one of these walking shapes; alone it is index-served and fast.limit modest, and paginate with the returned next_cursor. Ascending id is the default order everywhere (a stable keyset order for pagination); the tender sorts and the org name search ride their own indexes, so they are equally page-cheap at any depth./v1/sql is bounded by design: one SELECT, a 10-second cap, and its own runtime, so an expensive query returns 408 instead of degrading the REST surface.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.
-1.00, the eForms SDK's marker
for a figure the buyer withheld. A much smaller set carries ordinary magnitudes
(−€151m on a Tromsø bank agreement, −€28m on waste
treatment, scrap metal, waste paper, land leasing) and reads as
revenue-side: the supplier pays the authority, and the minus sign is the
publisher saying so in a field with no sign convention. These are ordinary award notices
— subtypes 29 and 30, no concession marker — so nothing but the sign and the
subject matter distinguishes them, and this API does not model a direction (issue 376).
A negative value is therefore a figure to read, not a defect to discard.amounts as published; the derived EUR column the value filters
compare treats a 0 as an absence and elects nothing from it, so a Tender whose
only figure is 0 has no known value rather than a value of zero
(24,647 Tenders; 11,793 of the zero rows sit on result_value and
1,408 on framework_maximum, fields where 0 cannot be a price). The derived
column also declines a conversion that ROUNDS to zero — a published CZK 0.10 or
HUF 1.48 is a real figure worth less than half a euro cent, and €0.00 is not what it
is worth — so a zero never appears in it for any reason.estimated_value and result_value; that is a fare, honestly
published into a field with no per-unit marker. 8,951 Tenders serve a value at or under
€10. These are NOT filtered, unlike the placeholder classes above:
a unit rate is a real figure, the source carries no field that distinguishes it from a
total, and the band has no cliff to cut at (2,892 at or under €0.10, 1,304 to
€1, 4,755 to €10, then 30,191 more to €1,000). Treat an implausibly small
value as unreliable rather than as the contract's size.0.01 or 1.00 is usually a token,
not a price — the largest placeholder class in the corpus. 59,000 Tenders published
exactly one minor unit and 53,000 exactly one major unit, and the next value down carries
36× fewer; ten currencies each spike at exactly one unit. Most of it sits on
result_value, on ordinary award notices for works and services. Kept in
amounts as published; the derived EUR column does not elect it, so these
Tenders have no known value. 0.10 and 2.00 are a tail
rather than a convention and ARE elected.tax_basis is incl, excl, or NULL — NULL
means the source did not say, and the incl/excl mix is era-biased; do not compare raw
sums across eras without checking it.OP_DATPRO); nothing this API returns is converted. A derived
EUR-at-publication-date column lives beside the published values
(eur_cents via /v1/sql; official ECB/ECU daily series
plus the irrevocable euro conversion rates; NULL where no official rate resolves)
and is what min_value/max_value compare against —
see the filter table and CHANGELOG.md in the repository.value is the figure its notice published, however
implausible — 257 trillion PLN on one row whose own lot results award
181.5 million.min_value/max_value do not compare
that figure. They compare a derived EUR column that skips five
classes: negative amounts (mostly the SDK's withheld marker, ~15,500 rows —
but see above, a few are revenue-side contracts and this bound loses them too),
exactly zero (an absence, not a price — see the zero bullet above), exactly
one minor or one major unit (a typed token, ~112,000 Tenders — see the bullet
above), a run of
nine or more identical digits, which is a form-width maximum rather than a
figure (€999,999,999.99 on street cleaning in a town of 47,000), and
anything above €100bn EUR-equivalent. Two consequences worth planning
for: a Tender whose only published amount falls in one of those classes has
no known value and is returned by NEITHER bound, and the
value in its payload can therefore be a figure the value filters
ignore (issue 366).published_at advances to the newest notice while a
deadline the newest notice is silent about is carried forward from an earlier
one. After an award notice, deadline < published_at is the
EXPECTED shape, not noise and not data loss (issue 370).submission_deadline is the lot's own if it published
one, otherwise the procedure's (issue 389). The legacy form generations
publish ONE procedure-level deadline for the whole notice — the r209 era's
DATE_RECEIPT_TENDERS is procedure-scoped by design — so most
pre-eForms lots have no date of their own, and that procedure deadline is the one a
bidder submits by. It is also the one ?status=open evaluates, for lots as
well as tenders, which is why the row shows it: a lot returned as open carries the
deadline that opened it. A lot that publishes its OWN deadline keeps it even when the
procedure's is later.
The row does not yet say which of the two it is. To tell them apart,
read dates on the tender detail: each entry names its
lot (null for a procedure-scoped date). A per-field provenance
marker on the rows themselves is issue 370's open unit, and will land on Tenders and
Lots together rather than in two shapes.GR
before the 2013 revision and EL after it, and a query for one does not
find the other.provisional flag on /v1/organizations tells you which
kind you are looking at.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.
| GET/v1 | Service info: version, revision, current cursor, endpoint list, source offer. |
| GET/v1/openapi.json | This API as an OpenAPI 3.0 document — machine-readable, CORS-enabled, for client generators and API tooling. |
| GET/health | Liveness probe (process up; does not query the DB — see /health/deep). |
| GET/metrics | Operational gauges in Prometheus text format (cursor, RSS, disk/WAL, per-job durations, quarantine counts). An operator surface, not part of the data API. |
| GET/_source | AGPL §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.