openapi: "3.1.0"
info:
  title: Tender & Procurement Intelligence API
  version: "1.0.0"
  description: |
    Public tender notices as clean JSON — **normalised** across TED (EU), the SAM.gov daily
    contract-opportunities extract (US federal) and OCDS national portals, **deduplicated**
    across sources, with buyers and winners **resolved on national registration identifiers**,
    delivered by search, saved search and signed webhook.

    **This service is free.** No plan, no billing. Search, code expansion and every reference
    dataset are anonymous; saved searches, webhooks and organisation history need a free API key,
    because on a free service the delivery fan-out — not the query — is the scarce resource.

    ### Three things here are inferences, and say so
    | | |
    |---|---|
    | **Entity resolution** | Merges happen on identifiers. Two companies sharing a trading name under different registration numbers are never merged. Each organisation reports `identityStrength`. |
    | **Deduplication** | Explicit publisher links (OCDS `ocid`, `relatedProcesses`) are facts; anything inferred needs corroboration from several signals and carries a confidence. Every source notice is retained. |
    | **CPV ↔ NAICS crosswalk** | No official concordance exists. Ours is derived and confidence-scored — a search aid, never a legal classification. |

    ### Source licensing
    Every notice carries its source attribution: TED under EU reuse terms (Decision 2011/833/EU),
    SAM.gov as US public domain, UK Find a Tender under the Open Government Licence v3.0.

    > Not an official procurement system. Not legal advice. The authoritative text of a notice is
    > the one at its source link, and a deadline shown here is not a substitute for the deadline in
    > the source notice. Coverage is best effort per source — see `/v1/ingest/runs`.
  contact:
    name: Allan Niñal
    url: https://www.allanninal.dev/
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://apis.allanninal.dev/tenders
    description: Production (self-hosted)
  - url: http://localhost:8796
    description: Local development

tags:
  - name: search
    description: Find notices and tender clusters
  - name: organisations
    description: Resolved buyers and winners
  - name: classification
    description: CPV and NAICS expansion
  - name: watch
    description: Saved searches and webhook delivery
  - name: operations
    description: Coverage, freshness and usage
  - name: reference
    description: Free static reference data

security: []

paths:
  /v1/notices:
    get:
      operationId: searchNotices
      summary: Search notices, deduplicated by default
      tags: [search]
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Free text over title, description and buyer name. }
        - { name: cpv, in: query, schema: { type: string }, description: "CPV code; expanded through its own tree and (unless disabled) the crosswalk." }
        - { name: naics, in: query, schema: { type: string }, description: NAICS code, expanded the same way. }
        - { name: code, in: query, schema: { type: string }, description: Either vocabulary; detected from the code shape. }
        - { name: crossVocabulary, in: query, schema: { type: string, enum: ["true", "false"], default: "true" }, description: Set false to stay inside one vocabulary, which makes the expansion exact and official. }
        - { name: minConfidence, in: query, schema: { type: string, enum: [low, medium, high], default: low }, description: Drop crosswalk rows below this confidence. }
        - { name: country, in: query, schema: { type: string }, description: "Comma-separated country codes." }
        - { name: nuts, in: query, schema: { type: string }, description: "NUTS prefix; matches descendants (CZ0 covers CZ032)." }
        - { name: source, in: query, schema: { type: string }, description: Comma-separated source ids. }
        - { name: noticeType, in: query, schema: { type: string } }
        - { name: procedureType, in: query, schema: { type: string } }
        - { name: buyerOrgId, in: query, schema: { type: string } }
        - { name: valueMin, in: query, schema: { type: number } }
        - { name: valueMax, in: query, schema: { type: number } }
        - { name: currency, in: query, schema: { type: string }, description: Values are never converted; filter within one currency. }
        - { name: publishedFrom, in: query, schema: { type: string, format: date } }
        - { name: publishedTo, in: query, schema: { type: string, format: date } }
        - { name: deadlineFrom, in: query, schema: { type: string, format: date } }
        - { name: deadlineTo, in: query, schema: { type: string, format: date } }
        - { name: expand, in: query, schema: { type: string, enum: [notices] }, description: Return every source notice instead of one row per tender cluster. }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque keyset cursor from a previous response. }
      responses:
        "200":
          description: Matching notices
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResult" }
        "400":
          $ref: "#/components/responses/BadFilter"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/notices/{noticeId}:
    get:
      operationId: getNotice
      summary: One source notice with its provenance
      tags: [search]
      parameters:
        - { name: noticeId, in: path, required: true, schema: { type: string }, description: "e.g. ted:622732-2026" }
      responses:
        "200":
          description: The notice
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Notice" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tenders/{tenderId}:
    get:
      operationId: getTender
      summary: A tender cluster — its members, their roles and how they were linked
      tags: [search]
      parameters:
        - { name: tenderId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The cluster. Every source notice is retained; inferred links carry a confidence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tender" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/organisations:
    get:
      operationId: searchOrganisations
      summary: Search resolved organisations
      tags: [organisations]
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: country, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        "200":
          description: Organisations
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Organisation" } }

  /v1/organisations/{orgId}:
    get:
      operationId: getOrganisation
      summary: One organisation, its identifiers, name variants and merge candidates
      tags: [organisations]
      parameters:
        - { name: orgId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The organisation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organisation" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/organisations/{orgId}/history:
    get:
      operationId: getOrganisationHistory
      summary: Procurement history — notices bought, awards won
      tags: [organisations]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: orgId, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
      responses:
        "200":
          description: History
          content:
            application/json:
              schema:
                type: object
                properties:
                  orgId: { type: string }
                  identityStrength: { type: string, enum: [strong, weak] }
                  counts: { type: object, properties: { asBuyer: { type: integer }, asWinner: { type: integer } } }
                  asBuyer: { type: array, items: { $ref: "#/components/schemas/Notice" } }
                  asWinner: { type: array, items: { $ref: "#/components/schemas/Notice" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/codes/expand:
    get:
      operationId: expandCode
      summary: Expand a CPV or NAICS code through its tree and the crosswalk
      tags: [classification]
      parameters:
        - { name: code, in: query, required: true, schema: { type: string } }
        - { name: crossVocabulary, in: query, schema: { type: string, enum: ["true", "false"], default: "true" } }
        - { name: minConfidence, in: query, schema: { type: string, enum: [low, medium, high], default: low } }
      responses:
        "200":
          description: |
            The expansion. `exact` is the code's own tree and is authoritative; `crossed` comes from
            the derived crosswalk and is never official. `via` explains every jump.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodeExpansion" }
        "400": { description: code is required }
        "404": { description: unknown code }

  /v1/saved-searches:
    post:
      operationId: createSavedSearch
      summary: Save a search to watch
      tags: [watch]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                filter: { type: object, description: The same filter vocabulary as GET /v1/notices. }
      responses:
        "201":
          description: Created — the filter is stored already expanded
        "400": { $ref: "#/components/responses/BadFilter" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { description: saved-search limit for this key reached }
    get:
      operationId: listSavedSearches
      summary: List your saved searches
      tags: [watch]
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Saved searches }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/saved-searches/{id}:
    delete:
      operationId: deleteSavedSearch
      summary: Delete a saved search and its subscriptions
      tags: [watch]
      security: [{ bearerAuth: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/subscriptions:
    post:
      operationId: createSubscription
      summary: Subscribe a webhook endpoint to a saved search
      tags: [watch]
      security: [{ bearerAuth: [] }]
      description: |
        The endpoint is validated and then **verified** before it is activated: the API posts an
        `endpoint.verification` event and the endpoint must answer 2xx. Delivery targets must be
        public HTTPS — private, loopback and link-local addresses are rejected, DNS is re-resolved
        at delivery time and redirects are not followed.

        The signing secret is returned **once** and never again.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [savedSearchId, url]
              properties:
                savedSearchId: { type: string }
                url: { type: string, format: uri }
      responses:
        "201":
          description: Verified and active
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  url: { type: string }
                  status: { type: string, enum: [active] }
                  secret: { type: string, description: Shown once. Store it now. }
        "400": { description: the delivery URL was rejected (not https, private address, credentials embedded) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { description: the endpoint did not accept the verification challenge }
        "429": { description: subscription limit for this key reached }
    get:
      operationId: listSubscriptions
      summary: List your subscriptions
      tags: [watch]
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Subscriptions with status and failure counts }

  /v1/subscriptions/{id}/deliveries:
    get:
      operationId: listDeliveries
      summary: Recent delivery attempts
      tags: [watch]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        "200": { description: Attempts with status, HTTP code, timing and the next retry }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/subscriptions/{id}:
    delete:
      operationId: deleteSubscription
      summary: Remove a subscription
      tags: [watch]
      security: [{ bearerAuth: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/ingest/runs:
    get:
      operationId: getIngestRuns
      summary: Per-source coverage and freshness
      tags: [operations]
      description: |
        What each source is, when it last succeeded, how stale it is against its cadence, and the
        counts from recent runs. A stale source is reported as stale rather than served silently.
      parameters:
        - { name: source, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        "200": { description: Sources and runs }

  /v1/usage:
    get:
      operationId: getUsage
      summary: Your quota usage and the free-tier limits
      tags: [operations]
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Usage }

  /healthz:
    get:
      operationId: healthz
      summary: Liveness, dataset versions, corpus size, per-source staleness, webhook queue depth
      tags: [operations]
      responses:
        "200": { description: Health }

  /api/v1/index.json:
    get: { operationId: getIndex, summary: Endpoint catalogue, tags: [reference], responses: { "200": { description: Catalogue } } }
  /api/v1/meta.json:
    get: { operationId: getMeta, summary: Dataset versions, coverage, licensing and disclaimer, tags: [reference], responses: { "200": { description: Metadata } } }
  /api/v1/sources.json:
    get: { operationId: getSources, summary: "What is ingested, from where, under what licence — and what is NOT covered", tags: [reference], responses: { "200": { description: Source catalogue } } }
  /api/v1/cpv.json:
    get: { operationId: getCpv, summary: The full CPV tree (9,454 codes), tags: [reference], responses: { "200": { description: CPV } } }
  /api/v1/cpv-divisions.json:
    get: { operationId: getCpvDivisions, summary: The 45 CPV divisions only, tags: [reference], responses: { "200": { description: CPV divisions } } }
  /api/v1/naics.json:
    get: { operationId: getNaics, summary: The full NAICS 2022 tree, tags: [reference], responses: { "200": { description: NAICS } } }
  /api/v1/naics-sectors.json:
    get: { operationId: getNaicsSectors, summary: The 20 NAICS sectors only, tags: [reference], responses: { "200": { description: NAICS sectors } } }
  /api/v1/crosswalk.json:
    get: { operationId: getCrosswalk, summary: "CPV ↔ NAICS crosswalk — derived, unofficial, confidence-scored", tags: [reference], responses: { "200": { description: Crosswalk } } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A free API key. Search and reference data need none.

  responses:
    BadFilter:
      description: The filter was invalid; the reasons are listed rather than the query silently widened.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              details: { type: array, items: { type: string } }
    Unauthorized:
      description: This endpoint needs a free API key.
    NotFound:
      description: Not found. Another key's resource is reported as not found rather than forbidden.
    RateLimited:
      description: Rate limited. Carries Retry-After and RateLimit-* headers.

  schemas:
    SearchResult:
      type: object
      properties:
        total: { type: integer }
        deduplicated: { type: boolean, description: True when each row is one tender cluster. }
        items: { type: array, items: { $ref: "#/components/schemas/Notice" } }
        nextCursor: { type: [string, "null"] }
        codeExpansion:
          type: array
          description: What each supplied code expanded to, and whether that expansion was official.
          items: { type: object }
        freshness:
          type: object
          description: Sources currently stale, so a thin result set is explainable.
        disclaimer: { type: string }

    Notice:
      type: object
      properties:
        noticeId: { type: string }
        tenderId: { type: [string, "null"] }
        title: { type: [string, "null"] }
        titleLanguage: { type: [string, "null"] }
        description: { type: [string, "null"] }
        buyer:
          type: object
          properties:
            name: { type: [string, "null"] }
            orgId: { type: [string, "null"] }
            country: { type: [string, "null"] }
            identityStrength: { type: [string, "null"], enum: [strong, weak, null] }
        winners:
          type: array
          items:
            type: object
            properties:
              name: { type: [string, "null"] }
              identifiers: { type: array, items: { type: object } }
              identityStrength: { type: string, enum: [strong, weak] }
              pairing:
                type: string
                enum: [positional, identifier-only, name-only]
                description: |
                  How the winner was reconstructed. TED returns names and identifiers as parallel
                  arrays that do NOT always correspond, so they are paired only when every array
                  agrees on length. Otherwise the winner is anchored on its identifier and the
                  names are returned separately in `observedWinnerNames` rather than guessed onto it.
        observedWinnerNames:
          type: array
          items: { type: string }
          description: Winner names that could not be safely paired to an identifier.
        noticeType: { type: [string, "null"] }
        procedureType: { type: [string, "null"] }
        classifications: { type: object, properties: { cpv: { type: array, items: { type: string } }, naics: { type: array, items: { type: string } } } }
        places: { type: array, items: { type: string } }
        country: { type: [string, "null"] }
        value:
          type: [object, "null"]
          description: Source currency and amount, verbatim. No conversion is ever performed.
          properties:
            amount: { type: number }
            currency: { type: [string, "null"] }
            converted: { const: false }
        publishedAt: { type: [object, "null"] }
        deadlineAt: { type: [object, "null"] }
        provenance:
          type: object
          properties:
            source: { type: string }
            ref: { type: [string, "null"] }
            url: { type: [string, "null"] }
            attribution: { type: [string, "null"], description: Carry this if you redistribute the notice. }
            firstSeenAt: { type: [string, "null"] }
            lastUpdatedAt: { type: [string, "null"] }
            clusterLinkedBy: { type: [string, "null"] }
            clusterConfidence: { type: [number, "null"] }
        dataQuality:
          type: array
          description: Known problems with this record, surfaced rather than hidden.
          items: { type: object, properties: { code: { type: string }, detail: { type: string } } }

    Tender:
      type: object
      properties:
        tenderId: { type: string }
        inferred: { type: boolean }
        members:
          type: array
          items:
            type: object
            properties:
              noticeId: { type: string }
              source: { type: string }
              role: { type: string, enum: [notice, amendment, award] }
              linkedBy: { type: string, enum: [explicit-ocid, explicit-related-process, explicit-procedure-identifier, inferred-multi-signal, unlinked] }
              confidence: { type: number }
              notice: { $ref: "#/components/schemas/Notice" }
        note: { type: string }

    Organisation:
      type: object
      properties:
        orgId: { type: string }
        name: { type: [string, "null"] }
        country: { type: [string, "null"] }
        identityStrength:
          type: string
          enum: [strong, weak]
          description: |
            strong = backed by a registration identifier; weak = name and location only. SAM.gov's
            opportunities extract carries no UEI for awardees, so US winners are weak by
            construction while US buyers resolve strongly on agency codes.
        identifiers:
          type: array
          items: { type: object, properties: { scheme: { type: string }, value: { type: string }, country: { type: [string, "null"] } } }
        nameVariants: { type: array, items: { type: string } }
        mergeCandidates:
          type: array
          description: Near-misses, including matches blocked by an identifier conflict.
          items: { type: object }

    CodeExpansion:
      type: object
      properties:
        input: { type: string }
        vocabulary: { type: [string, "null"], enum: [cpv, naics, null] }
        exact: { type: array, items: { type: string } }
        crossed: { type: array, items: { type: string } }
        via:
          type: array
          items: { type: object, properties: { from: { type: string }, to: { type: string }, confidence: { type: string }, basis: { type: string } } }
        official: { type: boolean, description: False whenever the result crosses vocabularies. }
        notes: { type: array, items: { type: string } }
