{
  "openapi": "3.0.3",
  "info": {
    "title": "tender-db API",
    "description": "Public procurement data — REST, live feeds (SSE), read-only SQL and webhooks. The historical backfill is ongoing, so collection counts grow over time. Money is integer minor units (`{\"cents\": 1234, \"currency\": \"EUR\"}`), never a float. Change cursors are opaque strings: compare for equality and pass back verbatim. Every endpoint that needs no token is CORS-open to any origin (browser JavaScript can call it directly, SSE resume included); the token-gated endpoints are not — call those server-side. The human-readable reference lives at /docs.",
    "version": "0.1.0",
    "license": {
      "name": "AGPL-3.0-or-later",
      "url": "https://www.gnu.org/licenses/agpl-3.0.html"
    },
    "contact": {
      "name": "tender-db",
      "url": "https://tenders.zebreus.click/_source"
    }
  },
  "servers": [
    { "url": "https://tenders.zebreus.click" }
  ],
  "tags": [
    { "name": "collections", "description": "Paged reads over the canonical layer. Send `Accept: text/event-stream` to any collection endpoint to turn the same query into a live subscription (snapshot, then diffs)." },
    { "name": "changes", "description": "The change feed — everything the canonical layer learned, in learn order, resumable by cursor." },
    { "name": "sql", "description": "One read-only SELECT per request against the public schema." },
    { "name": "webhooks", "description": "Account-scoped endpoints receiving signed change batches (Standard Webhooks)." },
    { "name": "account", "description": "Token identity." },
    { "name": "service", "description": "Service info, health and the AGPL source offer." }
  ],
  "paths": {
    "/v1": {
      "get": {
        "tags": ["service"],
        "summary": "Service info",
        "description": "Version, deployed revision, current change cursor, feed generation, endpoint list and the AGPL source offer.",
        "operationId": "serviceInfo",
        "responses": {
          "200": {
            "description": "Service description.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "service": { "type": "string", "example": "tender-db" },
                    "version": { "type": "string" },
                    "source": { "type": "string", "description": "Deployed git revision." },
                    "source_offer": { "type": "string" },
                    "license": { "type": "string", "example": "AGPL-3.0-or-later" },
                    "docs": { "type": "string", "example": "/docs" },
                    "openapi": { "type": "string", "example": "/v1/openapi.json" },
                    "cursor": { "$ref": "#/components/schemas/Cursor" },
                    "generation": { "type": "integer", "description": "Feed generation — moves only on an operator-initiated rebuild. Cursors and entity ids from different generations do not compose." },
                    "endpoints": { "type": "array", "items": { "type": "string" } },
                    "live": { "type": "string" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/tenders": {
      "get": {
        "tags": ["collections"],
        "summary": "List Tenders",
        "description": "The current version of each Tender. Default order is ascending id (a stable keyset order for pagination); `sort=published_at` switches to publication-date order (newest first by default) and `sort=deadline` to submission-deadline order (closing soonest first by default) — a bound on either date column implies its ordering. With `Accept: text/event-stream` this becomes a live subscription with the same filters (sort/order do not apply to streams).",
        "operationId": "listTenders",
        "parameters": [
          { "$ref": "#/components/parameters/source" },
          { "$ref": "#/components/parameters/country" },
          { "$ref": "#/components/parameters/cpv" },
          { "$ref": "#/components/parameters/buyer" },
          { "$ref": "#/components/parameters/winner" },
          { "$ref": "#/components/parameters/bidder" },
          { "$ref": "#/components/parameters/status" },
          { "$ref": "#/components/parameters/min_value" },
          { "$ref": "#/components/parameters/max_value" },
          { "$ref": "#/components/parameters/currency" },
          { "$ref": "#/components/parameters/lang" },
          { "$ref": "#/components/parameters/kind" },
          { "$ref": "#/components/parameters/publication_id" },
          { "$ref": "#/components/parameters/published_after" },
          { "$ref": "#/components/parameters/published_before" },
          { "$ref": "#/components/parameters/deadline_after" },
          { "$ref": "#/components/parameters/deadline_before" },
          { "$ref": "#/components/parameters/sort" },
          { "$ref": "#/components/parameters/order" },
          { "$ref": "#/components/parameters/limit" },
          { "$ref": "#/components/parameters/cursor" },
          { "$ref": "#/components/parameters/include_data" }
        ],
        "responses": {
          "200": {
            "description": "A page of Tenders, or a live event stream when `Accept: text/event-stream` was sent.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Page" },
                    { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Tender" } } } }
                  ]
                }
              },
              "text/event-stream": {
                "schema": { "$ref": "#/components/schemas/EventStream" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/tenders/{id}": {
      "get": {
        "tags": ["collections"],
        "summary": "One Tender in full",
        "description": "The current version plus its satellites: lot details, texts, amounts, dates, classifications, parties, lot results (award decisions), bids, contracts, and the version history — each version naming the `caused_by_notice_id` that produced it.",
        "operationId": "getTender",
        "parameters": [
          { "$ref": "#/components/parameters/id" },
          { "$ref": "#/components/parameters/lang" }
        ],
        "responses": {
          "200": {
            "description": "The Tender detail.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TenderDetail" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/lots": {
      "get": {
        "tags": ["collections"],
        "summary": "List Lots",
        "description": "Lots — subdivisions of Tenders. Same filters as every collection; `tender` restricts to one Tender's Lots. SSE-capable.",
        "operationId": "listLots",
        "parameters": [
          { "$ref": "#/components/parameters/source" },
          { "$ref": "#/components/parameters/country" },
          { "$ref": "#/components/parameters/cpv" },
          { "$ref": "#/components/parameters/buyer" },
          { "$ref": "#/components/parameters/winner" },
          { "$ref": "#/components/parameters/bidder" },
          { "$ref": "#/components/parameters/status" },
          { "$ref": "#/components/parameters/min_value" },
          { "$ref": "#/components/parameters/max_value" },
          { "$ref": "#/components/parameters/currency" },
          { "$ref": "#/components/parameters/lang" },
          { "$ref": "#/components/parameters/kind" },
          { "$ref": "#/components/parameters/tender" },
          { "$ref": "#/components/parameters/limit" },
          { "$ref": "#/components/parameters/cursor" },
          { "$ref": "#/components/parameters/include_data" }
        ],
        "responses": {
          "200": {
            "description": "A page of Lots, or a live event stream.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Page" },
                    { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Lot" } } } }
                  ]
                }
              },
              "text/event-stream": { "schema": { "$ref": "#/components/schemas/EventStream" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/organizations": {
      "get": {
        "tags": ["collections"],
        "summary": "List Organizations",
        "description": "Canonical Organizations — buyers, bidders, winners. SSE-capable.",
        "operationId": "listOrganizations",
        "parameters": [
          { "$ref": "#/components/parameters/country" },
          { "$ref": "#/components/parameters/kind" },
          { "$ref": "#/components/parameters/identifier" },
          { "$ref": "#/components/parameters/name_prefix" },
          { "$ref": "#/components/parameters/buyer" },
          { "$ref": "#/components/parameters/limit" },
          { "$ref": "#/components/parameters/cursor" },
          { "$ref": "#/components/parameters/include_data" }
        ],
        "responses": {
          "200": {
            "description": "A page of Organizations, or a live event stream.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Page" },
                    { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Organization" } } } }
                  ]
                }
              },
              "text/event-stream": { "schema": { "$ref": "#/components/schemas/EventStream" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/organizations/{id}": {
      "get": {
        "tags": ["collections"],
        "summary": "One Organization",
        "description": "The counterpart of a Tender detail's `parties[].organization_id`.",
        "operationId": "getOrganization",
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "200": {
            "description": "The Organization.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Organization" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/notices": {
      "get": {
        "tags": ["collections"],
        "summary": "List Notices",
        "description": "Raw import records. `tender` lists the Notices that caused that Tender's versions. Notices produce no canonical change rows, so an SSE subscription here is a snapshot then silence.",
        "operationId": "listNotices",
        "parameters": [
          { "$ref": "#/components/parameters/source" },
          { "$ref": "#/components/parameters/kind" },
          { "$ref": "#/components/parameters/tender" },
          { "$ref": "#/components/parameters/publication_id" },
          { "$ref": "#/components/parameters/limit" },
          { "$ref": "#/components/parameters/cursor" },
          { "$ref": "#/components/parameters/include_data" }
        ],
        "responses": {
          "200": {
            "description": "A page of Notices.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Page" },
                    { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Notice" } } } }
                  ]
                }
              },
              "text/event-stream": { "schema": { "$ref": "#/components/schemas/EventStream" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/notices/{id}": {
      "get": {
        "tags": ["collections"],
        "summary": "One Notice",
        "description": "The counterpart of a version's `caused_by_notice_id`.",
        "operationId": "getNotice",
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "200": {
            "description": "The Notice.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NoticeDetail" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/notices/{id}/content": {
      "get": {
        "tags": ["collections"],
        "summary": "A Notice's parsed content",
        "description": "The notice's whole parsed payload: the section tree (repeatable-node instances, `PROCEDURE` for the root) and every typed field value, verbatim from the parse layer — field ids in the source's own vocabulary (eForms BT/OPT ids, TED export field ids; the notice's `profile` names which). This is the pre-projection reading: every business term of the notice is here even before (or without) earning a canonical field. A quarantined notice has zero sections — its payload is held whole; see the notice detail's `quarantine` field for why.",
        "operationId": "getNoticeContent",
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "200": {
            "description": "The parsed content.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NoticeContent" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/changes": {
      "get": {
        "tags": ["changes"],
        "summary": "Change feed (poll)",
        "description": "Everything the canonical layer learned after `since` (start at 0). Same events as SSE without holding a connection. **Only `entity` narrows this feed** \u2014 the collection filters (`country`, `cpv`, `source`, `status`, `min_value`\u2026) parse but are not applied here, and every one you send comes back named in `ignored_filters`. 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. The cursor is learn order (ingestion), independent of publication date. Store `generation` beside your cursor: when it moves, drop local state, re-snapshot, and continue from that response's `last_cursor`.",
        "operationId": "pollChanges",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "description": "The cursor to read after — `0` or a previous response's `last_cursor`, verbatim. A cursor this feed never issued (past the head, or below a pruned log's horizon) is answered with a `reset` field — `cursor_ahead` or `cursor_expired` — an empty `events` array and `last_cursor: \"0\"`, the same verdict the SSE transport gives for the same cursor: drop your state, re-snapshot, resume from `0`. An unparseable `since` is lenient and serves the first page.",
            "schema": { "type": "string" }
          },
          {
            "name": "entity",
            "in": "query",
            "description": "Narrow to one entity kind.",
            "schema": { "type": "string", "enum": ["tender", "lot", "organization"] }
          },
          { "$ref": "#/components/parameters/limit" }
        ],
        "responses": {
          "200": {
            "description": "A batch of change events.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "events": { "type": "array", "items": { "$ref": "#/components/schemas/ChangeEvent" } },
                    "last_cursor": { "$ref": "#/components/schemas/Cursor" },
                    "more": { "type": "boolean" },
                    "generation": { "type": "integer" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/me": {
      "get": {
        "tags": ["account"],
        "summary": "Who the presented token belongs to",
        "operationId": "me",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The token's account.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user": {
                      "type": "object",
                      "properties": {
                        "id": { "type": "integer" },
                        "username": { "type": "string" },
                        "created_at": { "type": "integer", "description": "Epoch seconds." }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/sql": {
      "post": {
        "tags": ["sql"],
        "summary": "Run one read-only SELECT",
        "description": "The SQL travels as the raw request body (never in the URL). Exactly one statement, a bare SELECT — no writes, PRAGMA, ATTACH, EXPLAIN or multi-statement bodies. The queryable surface is a positive allow-list (see GET /v1/sql/schema). Time columns are epoch seconds here, unlike REST's ISO 8601. Caps: 10 000 rows / 10 MB per result (`truncated: true` when hit); per token 2 concurrent, 300/hour, 10 s per query (408 when exceeded).",
        "operationId": "runSql",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "text/plain": {
              "schema": { "type": "string", "example": "SELECT source, count(*) FROM v_tenders GROUP BY source" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The result set.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "columns": { "type": "array", "items": { "type": "string" } },
                    "rows": { "type": "array", "items": { "type": "array", "items": {} } },
                    "row_count": { "type": "integer" },
                    "truncated": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "408": { "description": "The QUERY exceeded the per-query time limit. The ANSWER is abandoned, but the server-side 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). Make the query cheaper (narrow the range, add a LIMIT) before retrying — never retry it unchanged.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "description": "The BACKEND could not get to the query, so it was never run — either no reader connection came free, or every SQL worker thread is pinned by an earlier query that cannot be interrupted. Distinct from 408 on purpose: retry the same query unchanged rather than rewriting it. The message names which of the two it was.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/v1/sql/schema": {
      "get": {
        "tags": ["sql"],
        "summary": "The queryable schema",
        "description": "Public, no token. Every allow-listed table and view with per-column notes, timestamp flags, enum vocabularies and worked examples. The main entry points are the current-state views v_tenders, v_lots, v_lot_results and v_organizations.",
        "operationId": "sqlSchema",
        "responses": {
          "200": {
            "description": "Tables and views with their columns and notes.",
            "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "tags": ["webhooks"],
        "summary": "List your endpoints",
        "description": "No secrets in the response.",
        "operationId": "listWebhooks",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Your registered endpoints.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "webhooks": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["webhooks"],
        "summary": "Register an endpoint",
        "description": "Only https URLs resolving to publicly-routable addresses are accepted (SSRF guard). The response carries the signing secret ONCE — it is never returned again. Deliveries are signed Standard Webhooks POSTs; delivery is at-least-once and advances only on a 2xx.",
        "operationId": "createWebhook",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url"],
                "properties": { "url": { "type": "string", "format": "uri", "example": "https://example.com/hooks/tenders" } }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered. Copy `secret` now.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "secret": { "type": "string", "description": "The whsec_… signing secret, shown once." },
                    "webhook": { "$ref": "#/components/schemas/Webhook" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "get": {
        "tags": ["webhooks"],
        "summary": "One endpoint with recent deliveries",
        "operationId": "getWebhook",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "200": {
            "description": "The endpoint and its recent delivery attempts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhook": { "$ref": "#/components/schemas/Webhook" },
                    "deliveries": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookDelivery" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["webhooks"],
        "summary": "Remove an endpoint",
        "operationId": "deleteWebhook",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "204": { "description": "Removed." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/webhooks/{id}/enable": {
      "post": {
        "tags": ["webhooks"],
        "summary": "Resume delivery",
        "description": "Default replays what was missed (the change log is the queue). Body `{\"from_now\": true}` drops the backlog and resumes at the head.",
        "operationId": "enableWebhook",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "from_now": { "type": "boolean", "default": false } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Enabled.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "enabled": { "type": "integer" } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/webhooks/{id}/disable": {
      "post": {
        "tags": ["webhooks"],
        "summary": "Pause delivery",
        "operationId": "disableWebhook",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "$ref": "#/components/parameters/id" }],
        "responses": {
          "200": {
            "description": "Disabled.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "disabled": { "type": "integer" } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "tags": ["service"],
        "summary": "This document",
        "description": "The machine-readable description of this API, served with CORS enabled so browser-based viewers (Swagger UI, Redoc) can load it directly.",
        "operationId": "openapi",
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } }
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": ["service"],
        "summary": "Liveness probe",
        "description": "Process is up and serving HTTP. Does not query the database — see /health/deep for a DB-backed readiness check. Outside the rate limiter.",
        "operationId": "health",
        "responses": {
          "200": {
            "description": "Healthy.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": { "type": "boolean" },
                    "rev": { "type": "string" },
                    "cursor": { "$ref": "#/components/schemas/Cursor" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/health/deep": {
      "get": {
        "tags": ["service"],
        "summary": "Deep operational probe",
        "description": "Liveness plus ingest freshness, job failures and disk — the endpoint an external pinger watches. Outside the rate limiter.",
        "operationId": "healthDeep",
        "responses": {
          "200": { "description": "All checks green.", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } },
          "503": { "description": "At least one check failing; the body names it.", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }
        }
      }
    },
    "/metrics": {
      "get": {
        "tags": ["service"],
        "summary": "Prometheus metrics scrape",
        "description": "Operational gauges in Prometheus text exposition format: change cursor, RSS, live SSE streams, disk and WAL size, per-job last-run duration and outcome, ingest freshness and lag, canonical row counts and quarantine totals. Every value is O(1) or read from the dashboard's 60-second cache, so a scrape never runs a table scan; a gauge not yet measured is absent rather than reported as zero. Outside the rate limiter, and — unlike the health probes — not CORS-open, being an operator surface.",
        "operationId": "metrics",
        "responses": {
          "200": { "description": "The current gauges.", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/_source": {
      "get": {
        "tags": ["service"],
        "summary": "AGPL §13 corresponding-source offer",
        "description": "Names the running revision and how to obtain its source.",
        "operationId": "sourceOffer",
        "responses": {
          "200": { "description": "The source offer.", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/docs": {
      "get": {
        "tags": ["service"],
        "summary": "Human-readable API reference",
        "operationId": "docs",
        "responses": {
          "200": { "description": "The reference page.", "content": { "text/html": { "schema": { "type": "string" } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tdb_… API token",
        "description": "Create tokens on the dashboard (/account). Shown once at creation."
      }
    },
    "parameters": {
      "id": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Entity id.",
        "schema": { "type": "integer", "format": "int64" }
      },
      "source": {
        "name": "source",
        "in": "query",
        "description": "Source key, e.g. `ted`.",
        "schema": { "type": "string" }
      },
      "country": {
        "name": "country",
        "in": "query",
        "description": "On Tenders and Lots, a NUTS place-code prefix matched against the notice's places: at the country level NUTS is ISO-3166 alpha-2, so Germany is `DE` (not `DEU`), and a longer prefix narrows to a region, e.g. `DE1` or `DEB35`. On Organizations, an exact match on the Organization's own (alpha-2) country. Not applied by Notices. Letters and digits only \u2014 a value containing anything else (including an empty one) is a 400, because the prefix is matched as a pattern and a metacharacter would change what the filter means rather than what it matches. At most five characters \u2014 a NUTS code is `DEB35` at its longest \u2014 so a longer value, which can prefix no code, is a 400 rather than a walk to the service deadline.",
        "schema": { "type": "string" }
      },
      "cpv": {
        "name": "cpv",
        "in": "query",
        "description": "CPV code prefix, e.g. `45` (construction). One to eight digits \u2014 the length of a full code \u2014 and nothing else: a letter, an empty value or a ninth digit is a 400.",
        "schema": { "type": "string" }
      },
      "buyer": {
        "name": "buyer",
        "in": "query",
        "description": "On Tenders and Lots, the Organization id in a buyer role. On Organizations, selects that one Organization by id. Not applied by Notices.",
        "schema": { "type": "integer", "format": "int64" }
      },
      "winner": {
        "name": "winner",
        "in": "query",
        "description": "Organization id that won at least one Lot.",
        "schema": { "type": "integer", "format": "int64" }
      },
      "bidder": {
        "name": "bidder",
        "in": "query",
        "description": "Organization id that submitted a bid (a tenderer) on the Tender, won or not — a superset of winner.",
        "schema": { "type": "integer", "format": "int64" }
      },
      "status": {
        "name": "status",
        "in": "query",
        "description": "By submission deadline.",
        "schema": { "type": "string", "enum": ["open", "closed"] }
      },
      "min_value": {
        "name": "min_value",
        "in": "query",
        "description": "Minimum value in EUR cents, compared against the tender's highest amount converted to EUR at its publication date (derived eur_cents; rows with no convertible amount never match).",
        "schema": { "type": "integer", "format": "int64" }
      },
      "max_value": {
        "name": "max_value",
        "in": "query",
        "description": "Maximum value in EUR cents — same comparison basis as min_value.",
        "schema": { "type": "integer", "format": "int64" }
      },
      "currency": {
        "name": "currency",
        "in": "query",
        "description": "Published currency: ISO 4217 code, case-insensitive (e.g. `EUR`). Matches Tenders/Lots whose current version publishes at least one amount in that currency, as published — no conversion.",
        "schema": { "type": "string", "pattern": "^[A-Za-z]{3}$", "example": "EUR" }
      },
      "lang": {
        "name": "lang",
        "in": "query",
        "description": "Preferred language for picked text values (titles): ISO 639 code, case-insensitive (`de`/`DEU`). Fallback: requested, then English, then any labelled, then unlabelled. A selector, not a filter — never narrows results and never appears in ignored_filters; the detail's texts array always carries every variant.",
        "schema": { "type": "string", "pattern": "^[A-Za-z]{2,3}$", "example": "de" }
      },
      "kind": {
        "name": "kind",
        "in": "query",
        "description": "A kind flag whose meaning is collection-specific: `procedure`/`registration` for Tenders, the lot kind (`Lot`/`LotsGroup`/`Part`) for Lots, the identifier kind for Organizations, and the mapping profile for Notices.",
        "schema": { "type": "string" }
      },
      "tender": {
        "name": "tender",
        "in": "query",
        "description": "Restrict Lots to one Tender id; on /v1/notices, list the Notices that caused that Tender's versions (a lookup, not a subscription: sending Accept: text/event-stream with this parameter is a 400).",
        "schema": { "type": "integer", "format": "int64" }
      },
      "publication_id": {
        "name": "publication_id",
        "in": "query",
        "description": "Exact-match an official publication number (the OJ S / TED notice id). On /v1/notices it matches the notice carrying that number; on /v1/tenders it resolves the tender whose versions that number caused — current or superseded. Index-served, so present and absent values are both fast; companions (source/kind, or any tender filter) narrow the same fast result.",
        "schema": { "type": "string" }
      },
      "published_after": {
        "name": "published_after",
        "in": "query",
        "description": "Only tenders whose current version was published at or after this instant (unix seconds, RFC 3339, or the literal `now`). Implies sort=published_at unless sort=id is explicit. /v1/tenders only.",
        "schema": { "type": "string" }
      },
      "published_before": {
        "name": "published_before",
        "in": "query",
        "description": "Only tenders whose current version was published before this instant (exclusive; unix seconds, RFC 3339, or the literal `now`). Implies sort=published_at unless sort=id is explicit. /v1/tenders only.",
        "schema": { "type": "string" }
      },
      "sort": {
        "name": "sort",
        "in": "query",
        "description": "List order for /v1/tenders: `id` (default, ascending), `published_at` (publication date of the current version) or `deadline` (submission deadline of the current version). Tenders without the chosen date are omitted from that ordering. Index-served in every case.",
        "schema": { "type": "string", "enum": ["id", "published_at", "deadline"] }
      },
      "order": {
        "name": "order",
        "in": "query",
        "description": "`asc` or `desc`. Defaults follow the question each ordering answers: ascending for sort=id (descending id is not supported), descending (newest first) for sort=published_at, ascending (closing soonest first) for sort=deadline. Keep sort/order stable while paginating and pass next_cursor verbatim.",
        "schema": { "type": "string", "enum": ["asc", "desc"] }
      },
      "deadline_after": {
        "name": "deadline_after",
        "in": "query",
        "description": "Only tenders whose current version's submission deadline is at or after this instant (unix seconds, RFC 3339, or the literal `now`). `deadline_after=now` with sort=deadline&order=asc is the \"closes soon\" query. Implies sort=deadline unless an explicit sort says otherwise. /v1/tenders only.",
        "schema": { "type": "string" }
      },
      "deadline_before": {
        "name": "deadline_before",
        "in": "query",
        "description": "Only tenders whose current version's submission deadline is before this instant (exclusive; unix seconds, RFC 3339, or the literal `now`). Implies sort=deadline unless an explicit sort says otherwise. /v1/tenders only.",
        "schema": { "type": "string" }
      },
      "name_prefix": {
        "name": "name_prefix",
        "in": "query",
        "description": "Case-insensitive organization name prefix (Unicode-aware, so umlauts match either case). Returns matches in name order with a keyset cursor; combine with country/kind to narrow. /v1/organizations only; must not be empty.",
        "schema": { "type": "string", "minLength": 1 }
      },
      "identifier": {
        "name": "identifier",
        "in": "query",
        "description": "Exact-match an Organization's official identifier value (e.g. a VAT number) on /v1/organizations. Pair with `kind` to pin the scheme when a value is reused across schemes. Index-served (organizations_identifier_id), so present and absent values are both fast.",
        "schema": { "type": "string" }
      },
      "limit": {
        "name": "limit",
        "in": "query",
        "description": "Page size. Must be between 1 and 1000 inclusive; anything outside that range is a 400, not silently clamped.",
        "schema": { "type": "integer", "default": 100, "maximum": 1000, "minimum": 1 }
      },
      "cursor": {
        "name": "cursor",
        "in": "query",
        "description": "Opaque page position — pass back the previous page's `next_cursor` verbatim. On an SSE request, an event id to resume from (equivalent to Last-Event-ID).",
        "schema": { "type": "string" }
      },
      "include_data": {
        "name": "include_data",
        "in": "query",
        "description": "SSE only: embed the entity's current JSON in each event.",
        "schema": { "type": "boolean" }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid input — an unknown or misspelled query parameter is rejected rather than silently ignored.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Missing or invalid bearer token.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "No such entity.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate limit exceeded (~10 req/s per client, burst 50, across /v1). Carries Retry-After.",
        "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "string" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The one error shape, matching the HTTP status.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "status": { "type": "integer", "example": 404 },
              "message": { "type": "string", "example": "no such tender" }
            }
          }
        }
      },
      "Cursor": {
        "type": "string",
        "nullable": true,
        "description": "Opaque change/page cursor. Compare for equality and pass back verbatim; never parse or do arithmetic on it."
      },
      "Money": {
        "type": "object",
        "description": "Integer minor units — never a float.",
        "properties": {
          "cents": { "type": "integer", "format": "int64", "example": 1234 },
          "currency": { "type": "string", "example": "EUR" }
        }
      },
      "Page": {
        "type": "object",
        "description": "The collection envelope. Paginate by following next_cursor until more is false.",
        "properties": {
          "items": { "type": "array", "items": {} },
          "next_cursor": { "$ref": "#/components/schemas/Cursor" },
          "more": { "type": "boolean" },
          "ignored_filters": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Filter parameters the client sent that this collection does not apply — every collection accepts the shared filter vocabulary but honours only the subset meaningful to it, so a dropped filter is named here rather than silently returning an unfiltered page. Empty when every filter applied."
          }
        }
      },
      "Tender": {
        "type": "object",
        "description": "A Tender row — the current version of one procedure. Rows echo the cpv and country codes they carry so a filter match is explainable. Fields beyond these evolve with the backfill; unknown fields must be tolerated.",
        "properties": {
          "id": { "type": "integer", "format": "int64" },
          "source": { "type": "string" },
          "title": { "type": "string", "nullable": true },
          "cpv": { "type": "array", "items": { "type": "string" } },
          "country": { "type": "array", "items": { "type": "string" }, "description": "NUTS place codes." },
          "published_at": { "type": "string", "nullable": true, "description": "ISO 8601; a source that published a date only yields a date only." },
          "kind": { "type": "string", "description": "What this row IS: `procedure` for a procurement, or a non-procedure publication kind." },
          "version": { "type": "integer", "description": "The current version's sequence number in this Tender's chain (ADR-0001)." },
          "publication_id": { "type": "string", "nullable": true, "description": "Publication number of the notice that caused the current version, e.g. `00710890-2023`." },
          "procedure_key": { "type": "string", "nullable": true, "description": "The grouping key binding this chain's notices into one Tender; null for an island." },
          "notice_subtype": { "type": "string", "nullable": true, "description": "The source's own notice-subtype code for the current version." },
          "original_lang": { "type": "string", "nullable": true, "description": "Language the current version was originally published in, uppercase ISO 639-2/T (ADR-0013)." },
          "dispatched_at": { "type": "string", "format": "date-time", "nullable": true, "description": "When the buyer dispatched the notice for publication — earlier than published_at." },
          "submission_deadline": { "type": "string", "format": "date-time", "nullable": true, "description": "The instant deadline_after/deadline_before filter on and sort=deadline orders by." },
          "lots": { "type": "integer", "description": "How many Lots the current version publishes; 0 is normal for a single-lot procurement." },
          "value": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Money" }], "description": "Headline value, or null when none was published." }
        },
        "additionalProperties": true
      },
      "TenderDetail": {
        "allOf": [
          { "$ref": "#/components/schemas/Tender" },
          {
            "type": "object",
            "description": "The Tender plus its satellites.",
            "properties": {
              "lots": { "type": "integer", "description": "Lot count." },
              "lot_details": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "texts": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "amounts": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "dates": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "classifications": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "parties": { "type": "array", "description": "The organizations this version names, and the role each plays. TWO vocabularies, deliberately (issue 393). The LEGACY TED eras fold onto canonical roles: `buyer`, `purchasing-body`, `winner`, `review-body`, `mediation-body`, `tender-receipt`, `further-information`, `specifications-provider`, `appeal-information`, `tax-legislation-information`, `environmental-legislation-information`, `employment-legislation-information`. eForms is served AS PUBLISHED — `Procedure-Buyer`, `Tenderer`, `Lot-ReviewOrg`, `Lot-TenderReceipt` — because the suffix carries the lot/procedure SCOPE that a canonical name would lose. So a buyer is `buyer` or `Procedure-Buyer`, and a winner is `winner` or `Tenderer`. Any other value is a source element name not yet folded, kept verbatim rather than guessed at: treat it as unknown, not as canonical. Rows written before the issue-393 re-projection may still carry the raw legacy element name (a winner as `ECONOMIC_OPERATOR_NAME_ADDRESS`).", "items": { "type": "object", "properties": { "organization_id": { "type": "integer", "format": "int64" }, "role": { "type": "string", "description": "The canonical party role — see the array description for the vocabulary." } }, "additionalProperties": true } },
              "lot_results": { "type": "array", "description": "Award decisions, accumulating across framework/DPS rounds.", "items": { "type": "object", "additionalProperties": true } },
              "bids": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "contracts": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
              "versions": { "type": "array", "items": { "type": "object", "properties": { "caused_by_notice_id": { "type": "integer", "format": "int64" } }, "additionalProperties": true } }
            }
          }
        ]
      },
      "Lot": {
        "type": "object",
        "description": "A subdivision of a Tender.",
        "properties": {
          "id": { "type": "integer", "format": "int64" },
          "tender_id": { "type": "integer", "format": "int64" },
          "kind": { "type": "string", "description": "The source's section kind for this lot (`Lot`, `LotGroup`, …)." },
          "lot_key": { "type": "string", "description": "The source's own lot identifier within its notice, e.g. `LOT-0001` — unique per tender, not globally." },
          "title": { "type": "string", "nullable": true, "description": "The lot's title, or the tender's when the lot publishes none." },
          "version": { "type": "integer", "description": "The parent Tender version this lot belongs to." },
          "submission_deadline": { "type": "string", "format": "date-time", "nullable": true, "description": "The lot's own deadline where the source states one per lot, otherwise the procedure's. 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), and that is the date a bidder submits by and the date `?status=open` evaluates — so a lot returned as open carries the deadline that opened it. A lot's own deadline wins even when the procedure's is later. The row does not say which of the two it is; `dates` on the tender detail names each date's `lot` (null for procedure-scoped)." },
          "value": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Money" }], "description": "The lot's value, or null when none was published — and null, too, when every published figure is one the derived value election refuses: a withheld marker, an exact zero, a negative, a repdigit field maximum, or a converted amount above the implausibility ceiling. The same rule the tender headline uses, so the two cannot disagree about one figure." }
        },
        "additionalProperties": true
      },
      "Organization": {
        "type": "object",
        "description": "A canonical Organization — buyer, bidder or winner.",
        "properties": {
          "id": { "type": "integer", "format": "int64" },
          "name": { "type": "string", "nullable": true },
          "country": { "type": "string", "nullable": true, "description": "ISO 3166-1 alpha-2; null where no publication stated one." },
          "identifier": { "type": "string", "nullable": true, "description": "Registration identifier, normalised (issue 300); null for a provisional row." },
          "identifier_kind": { "type": "string", "nullable": true, "description": "Which register the identifier belongs to — `national`, `vat`, `eu`, …. Matched case-insensitively by ?kind=." },
          "mentions": { "type": "integer", "description": "How many notice mentions resolve to this organization — a proxy for how well-attested the identity is." },
          "provisional": { "type": "boolean", "description": "True when minted from a name alone with no identifier to bind it: a placeholder a later publication may merge away." }
        },
        "additionalProperties": true
      },
      "Notice": {
        "type": "object",
        "description": "A raw import record — the traceability anchor of every canonical version (ADR-0001).",
        "properties": {
          "id": { "type": "integer", "format": "int64" },
          "source": { "type": "string" },
          "publication_id": { "type": "string", "description": "Official publication number, e.g. `00497150-2026`. Filter with ?publication_id=." },
          "published_at": { "type": "string", "format": "date-time", "nullable": true, "description": "When it was published; null for a notice held out of the canonical layer." },
          "dispatched_at": { "type": "string", "format": "date-time", "nullable": true, "description": "When the buyer dispatched it for publication." },
          "ingested_at": { "type": "string", "format": "date-time", "description": "When this system first stored it — learn order, not publication order." },
          "profile": { "type": "string", "description": "The mapping profile that parsed it, e.g. `eforms:eforms-sdk-1.13`, `text`, `ted-export-r209`." },
          "declared_version": { "type": "string", "nullable": true, "description": "The schema version the payload declared for itself, where it declares one." },
          "parse_state": { "type": "string", "description": "`pending`, `parsed` or `quarantined` (ADR-0004). THIS — not the presence of `quarantine` — says whether the notice is held today." },
          "content_hash": { "type": "string", "description": "Hash of the stored payload; the identity a re-fetch dedupes against." },
          "member_path": { "type": "string", "description": "Where the notice sits inside its source package — the provenance a /v1/sql join on v_fetches resolves." }
        },
        "additionalProperties": true
      },
      "NoticeContent": {
        "type": "object",
        "description": "A notice's parsed payload, grouped by section. Each value carries its source field_id, its ordinal (document-order repeat counter within the section) and a `type` tag naming which typed shape follows: text {lang, value}, code {list, code}, classification {scheme, code}, amount {cents, currency}, date {value: ISO 8601 in the published offset}, integer {value}, number {value, unit}, id {scheme, value, is_ref}.",
        "properties": {
          "notice_id": { "type": "integer", "format": "int64" },
          "sections": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "section_id": { "type": "string", "description": "The identifier the notice published (LOT-0001, ORG-0002, PROCEDURE for the root)." },
                "kind": { "type": "string", "description": "The repeatable node's kind (Lot, Organisation, LotResult, …)." },
                "parent_section_id": { "type": "string", "nullable": true },
                "values": {
                  "type": "array",
                  "items": { "type": "object", "additionalProperties": true }
                }
              }
            }
          }
        }
      },
      "NoticeDetail": {
        "description": "One Notice by id: the list-row identity plus `quarantine`, which is the notice's hold HISTORY and not a statement about today. Ask `parse_state` whether the notice is held now: `quarantined` means held, and only a held notice lacks parsed satellites and a canonical tender. Most notices carrying a `quarantine` object parsed long ago \u2014 the row is retained after a reclaim so the hold stays auditable.",
        "allOf": [
          { "$ref": "#/components/schemas/Notice" },
          {
            "type": "object",
            "properties": {
              "quarantine": {
                "nullable": true,
                "description": "Why the notice was ever held out of the canonical layer, or null if it never was. NOT a held-today flag: the row is retained after the member is reclaimed, so `reprocessed_at != null` means the member was reclaimed and its content IS served (`/v1/notices/{id}/content`, and it backs a tender version). `skipped_at != null` means resolved as a policy skip and still out of the corpus; both stamps null means outstanding. For \\\"is this notice held?\\\" read `parse_state`.",
                "allOf": [{ "$ref": "#/components/schemas/Quarantine" }]
              }
            }
          }
        ]
      },
      "Quarantine": {
        "type": "object",
        "description": "A held notice's quarantine record. `reason`/`detail` are the current hold cause; `first_reason`/`first_detail` the original when a re-attempt overwrote it. The terminal stamps say which outcome the member reached: outstanding (both null), reclaimed (`reprocessed_at` set), or skipped-by-policy (`skipped_at` set).",
        "properties": {
          "reason": { "type": "string" },
          "detail": { "type": "string", "nullable": true },
          "profile": { "type": "string", "nullable": true },
          "first_seen": { "type": "string", "description": "ISO 8601." },
          "attempts": { "type": "integer", "nullable": true },
          "last_attempt_at": { "type": "string", "nullable": true, "description": "ISO 8601." },
          "reprocessed_at": { "type": "string", "nullable": true, "description": "ISO 8601; set when the member was reclaimed into the corpus." },
          "skipped_at": { "type": "string", "nullable": true, "description": "ISO 8601; set when a dispatch policy declines the member (e.g. duplicate siblings)." },
          "skipped_reason": { "type": "string", "nullable": true },
          "first_reason": { "type": "string", "nullable": true },
          "first_detail": { "type": "string", "nullable": true }
        }
      },
      "ChangeEvent": {
        "type": "object",
        "description": "One shape across SSE change events, poll items and webhook batches.",
        "properties": {
          "cursor": { "$ref": "#/components/schemas/Cursor" },
          "op": { "type": "string", "enum": ["added", "changed", "removed"] },
          "entity": { "type": "string", "enum": ["tender", "lot", "organization"] },
          "id": { "type": "integer", "format": "int64" },
          "version": { "type": "integer", "nullable": true, "description": "Canonical version seq (null for notices)." },
          "changed_at": { "type": "string", "description": "ISO 8601. Poll only." },
          "data": { "type": "object", "additionalProperties": true, "description": "Only with ?include_data=true on SSE." }
        }
      },
      "EventStream": {
        "type": "string",
        "description": "Server-Sent Events: one `added` event per row currently matching the filter (the snapshot, read in a single consistent transaction), a `live` marker carrying the snapshot's cursor, then `change` events forever. Each event's SSE id is an opaque, generation-qualified resume token (e.g. `3:193055`); pass it back as Last-Event-ID or ?cursor= to skip the snapshot and receive exactly what was missed. A `reset` event ({\"reason\":\"cursor_expired\"|\"feed_rebuilt\"}) means the token cannot resume: drop local state and re-subscribe fresh. Streams are capped at 5 per client; a 15 s keep-alive comment holds the connection open."
      },
      "Webhook": {
        "type": "object",
        "description": "A registered endpoint (no secret). Deliveries are Standard Webhooks POSTs: webhook-id, webhook-timestamp and webhook-signature headers, the signature `v1,<base64 HMAC-SHA256>` over `{id}.{timestamp}.{body}` keyed by your secret. Retries back off 30 s → 2 m → 10 m → 1 h → 4 h → 12 h → daily; after ~3 days of continuous failure the endpoint auto-disables.",
        "properties": {
          "id": { "type": "integer", "format": "int64" },
          "url": { "type": "string", "format": "uri" },
          "created_at": { "type": "integer", "description": "Epoch seconds." },
          "disabled_at": { "type": "integer", "nullable": true, "description": "Set when disabled (by the owner or after sustained failure); null means active." },
          "last_delivered_cursor": { "type": "integer", "description": "Newest change cursor this endpoint has a 2xx for." },
          "failing_since": { "type": "integer", "nullable": true, "description": "First failure of the current streak, if currently failing." },
          "consecutive_failures": { "type": "integer" }
        }
      },
      "WebhookDelivery": {
        "type": "object",
        "description": "One recent delivery attempt.",
        "properties": {
          "attempted_at": { "type": "integer", "description": "Epoch seconds." },
          "cursor_from": { "type": "integer" },
          "cursor_to": { "type": "integer" },
          "events": { "type": "integer" },
          "status": { "type": "integer", "nullable": true, "description": "HTTP status, when the endpoint answered." },
          "duration_ms": { "type": "integer" },
          "ok": { "type": "boolean" },
          "error": { "type": "string", "nullable": true }
        }
      }
    }
  }
}
