openapi: 3.0.3
info:
  title: EUFundPortal API
  version: '0.1.0'
  description: |
    EU public capital allocation feed — opportunities, awards, and (where published)
    pre-award bidder data across 30+ European countries. Delivered via REST API,
    refreshed daily.

    **Data taxonomy:**
      - **Open calls** (`status=Open`) — tender notices and grant calls awaiting submissions
      - **Closed / awarded** (`status=Closed`) — tender awards with named winner; grant
        awards with named coordinator/lead institution
      - **Forthcoming** (`status=Forthcoming`) — published with future opening date
    Filter awarded records via `tag=Contract Award` or `tag=Grant Award`.

    **Delivery & freshness:**
      - REST API, refreshed daily at 05:00 UTC
      - Bulk dump (S3 / SFTP / Parquet) is on the roadmap for annual customers — not yet offered
      - Direct read-only Postgres connection on the roadmap for institutional pilots — not yet offered

    Issued under contract; do not redistribute as third-party feed without licence.
  contact:
    name: George Woodworth
    email: george@eufundportal.com
servers:
  - url: https://www.eufundportal.com
    description: Production (EU)
security:
  - apiKey: []
  - bearerJWT: []
paths:
  /api/healthz:
    get:
      summary: Health check (public)
      description: |
        Reports end-to-end health of the API + database. Returns 200 ok when DB
        round-trip < 4s; 503 otherwise. Cacheable for 30s. No auth required.
      security: []
      responses:
        '200':
          description: Healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '503':
          description: Degraded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'

  /api/grants:
    get:
      summary: List grants and awards (paginated)
      description: |
        Returns a paginated, filterable list of all records — open calls, awarded
        contracts, awarded grants. Filter by `status=Closed` and `tag=Contract Award`
        / `tag=Grant Award` to isolate winner data.

        **Common queries:**
          - All Italian contract awards over €1M in the last 7 days:
            `?country=IT&category=Tender&status=Closed&tag=Contract+Award&min_amount=1000000&since=2026-04-21T00:00:00Z`
          - Open defence-sector grants in CEE last 30 days:
            `?status=Open&category=Grant&country=EE&since=2026-03-29T00:00:00Z`
          - Daily delta:
            `?since={lastPollTimestamp}&pageSize=1000`
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [Open, Closed, Forthcoming, Closing Soon] } }
        - { in: query, name: country, schema: { type: string }, description: "ISO-2 e.g. DE, FR; or EU for pan-European" }
        - { in: query, name: category, schema: { type: string, enum: [Tender, Grant, Equity, Other] } }
        - { in: query, name: programme, schema: { type: string }, description: "e.g. 'Horizon Europe', 'PLACSP (Adjudicada)', 'NPOO'" }
        - { in: query, name: funder, schema: { type: string }, description: "Funder / buyer name" }
        - { in: query, name: sector, schema: { type: string } }
        - { in: query, name: type, schema: { type: string, enum: [Grant, Loan, Equity, Prize, Guarantee, Mixed] } }
        - { in: query, name: eligibility, schema: { type: string } }
        - { in: query, name: deadlineDays, schema: { type: integer }, description: "Records with deadline within N days from today" }
        - { in: query, name: search, schema: { type: string }, description: "Substring match on title" }
        - { in: query, name: page, schema: { type: integer, default: 0 } }
        - { in: query, name: pageSize, schema: { type: integer, default: 25, maximum: 1000 } }
        - { in: query, name: tag, schema: { type: string }, description: "Filter by tag membership. Common: 'Contract Award', 'Grant Award', 'Has Winner', 'CPV-XX', 'NPOO', 'RRF', 'Above-Threshold'" }
        - { in: query, name: min_amount, schema: { type: integer }, description: "Only return records where awarded_amount >= min_amount (EUR; non-EUR sources converted at static rates, original currency in tags as 'OriginalCurrency-XXX')" }
        - { in: query, name: since, schema: { type: string, format: date-time }, description: "ISO timestamp; records with last_seen_at > since (use for delta polling)" }
        - { in: query, name: from, schema: { type: string, format: date }, description: "Filter by published_at >= from. NOTE: this is the NOTICE publication date — for TED CAN this can be 1-3 years after the actual award. Use awarded_from for contract-conclusion filtering." }
        - { in: query, name: to, schema: { type: string, format: date }, description: "Filter by published_at <= to (notice publication date)" }
        - { in: query, name: awarded_from, schema: { type: string, format: date }, description: "Filter by award_date >= awarded_from (actual contract conclusion date — use this when you want awards by the date they were signed)" }
        - { in: query, name: awarded_to, schema: { type: string, format: date }, description: "Filter by award_date <= awarded_to" }
        - { in: query, name: cpv_code, schema: { type: string }, description: "CPV major group e.g. '45' (construction), '72' (IT services)" }
        - { in: query, name: has_bidder_list, schema: { type: boolean }, description: "Only records where pre-award invite-list / opening minutes available" }
        - { in: query, name: winner_name, schema: { type: string }, description: "Substring match (case-insensitive) on winner_name" }
      responses:
        '200':
          description: Paginated list
          content:
            application/json:
              schema:
                type: object
                properties:
                  grants:
                    type: array
                    items: { $ref: '#/components/schemas/Grant' }
                  total: { type: integer }
                  page: { type: integer }
                  pageSize: { type: integer }
        '401':
          description: Unauthorised — missing or invalid auth

  /api/grant/{id}:
    get:
      summary: Get single record by ID
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Record detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Grant' }
        '404':
          description: Not found

  /api/export/csv:
    get:
      summary: Bulk CSV export (Pro tier)
      description: |
        Same query parameters as `/api/grants`. Returns CSV attachment, max 5,000
        rows per call. For full snapshots, use S3 daily dump (contract tier).
      responses:
        '200':
          description: CSV file
          content:
            text/csv: {}

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        API key issued per-customer. Set `Authorization: Bearer <key>`.
        Each key carries a tier (`pilot` | `annual` | `exclusive`) and may
        be further restricted by:
          - `countries` — ISO-2 list. Records outside the list return empty.
          - `cpv_filter` — CPV major-group list. Records outside the list return empty.
          - `historical_from` — earliest `published_at` exposed; older records hidden.
          - `expires_at` — key auto-revokes at this timestamp.
        Scope restrictions are applied server-side; clients see a clean empty
        result rather than a 403 when querying outside scope.
    bearerJWT:
      type: http
      scheme: bearer
      description: Supabase user JWT (dashboard / web app sessions only).

  schemas:
    HealthResponse:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded] }
        timestamp: { type: string, format: date-time }
        total_ms: { type: integer }
        components:
          type: object
          properties:
            database:
              type: object
              properties:
                ok: { type: boolean }
                latency_ms: { type: integer }
                error: { type: string }
        version: { type: string }

    Grant:
      type: object
      description: |
        Unified record — covers open calls, awarded contracts, and awarded grants.
        Award-specific fields (winnerName, awardedAmount, etc.) are populated only
        when `status = "Closed"` and `tags` contains `Contract Award` or `Grant Award`.
      required: [id, title, country, status, deadline]
      properties:
        # Core identity
        id: { type: string, example: 'es-placsp-award-18527153' }
        title: { type: string, description: 'For awards: format <contract title> → <winner>' }
        originalTitle: { type: string }
        sourceUrl: { type: string, format: uri }

        # Geography & taxonomy
        country: { type: string, example: 'DE' }
        region: { type: string, nullable: true }
        sector:
          type: string
          enum: [Digital, Energy, Manufacturing, Research & Innovation, Agriculture, Transport, Healthcare, Construction, Education, Culture, Tourism, Finance, Food & Beverage, Retail, Other]
        category:
          type: string
          enum: [Tender, Grant, Equity, Other]
        type:
          type: string
          enum: [Grant, Loan, Equity, Prize, Guarantee, Mixed]
        topics:
          type: array
          items: { type: string }
        tags:
          type: array
          items: { type: string }
          description: |
            Free-form classification tags. Use these to filter via `?tag=`.
            Common: 'Contract Award', 'Grant Award', 'Has Winner', 'NPOO', 'RRF',
            'Public Procurement', 'CPV-XX', 'Above-Threshold'.

        # Lifecycle dates
        status: { type: string, enum: [Open, Closed, Forthcoming] }
        openDate: { type: string, format: date, nullable: true }
        deadline: { type: string, format: date }
        publishedAt: { type: string, format: date, nullable: true }
        lastSeenAt: { type: string, format: date-time, description: 'Most recent ingest run that confirmed this record. Use for delta polling.' }

        # Money
        budgetTotal: { type: string, description: 'Free-text formatted as published. Use awardMax / awardedAmount for arithmetic.' }
        awardMin: { type: integer, format: int32, nullable: true }
        awardMax: { type: integer, format: int32, nullable: true, description: 'For award records, holds the awarded amount in EUR.' }
        euContribution: { type: integer, format: int64, nullable: true }
        expectedProjects: { type: integer, nullable: true }

        # Funder / buyer / programme
        funder: { type: string, nullable: true, description: 'Funding body / contracting authority. For awards: the buyer (also exposed as buyerName).' }
        programme: { type: string }
        actionType: { type: string, nullable: true }
        eligibility:
          type: array
          items: { type: string }
        description: { type: string }

        # ──────────────────────────────────────────────────────────────────
        # Award structured fields (Apr 2026)
        # Populated for status='Closed' records tagged Contract Award / Grant Award.
        # ──────────────────────────────────────────────────────────────────
        winnerName:
          type: string
          nullable: true
          description: |
            Awarded supplier name (contract awards) / coordinator institution (grant awards).
            74% coverage on contract awards, 97% on grant awards.
        winnerCountry:
          type: string
          nullable: true
          description: 'ISO-2 country of the winner where source publishes.'
        winnerId:
          type: string
          nullable: true
          description: 'National company ID where published — e.g. CF/P.IVA (Italy), VAT, orgnr.'
        awardedAmount:
          type: integer
          format: int64
          nullable: true
          description: 'Awarded contract value in awardedCurrency (default EUR).'
        awardedCurrency:
          type: string
          default: EUR
          description: 'ISO 4217. UK records use GBP; everything else EUR.'
        awardDate:
          type: string
          format: date
          nullable: true
          description: 'Contract conclusion / signature / award decision date.'
        buyerName:
          type: string
          nullable: true
          description: 'Contracting authority for awarded contracts; programme funder for awarded grants.'
        buyerId:
          type: string
          nullable: true
          description: 'National ID of the buyer where published.'
        cpvCode:
          type: string
          nullable: true
          description: |
            CPV major group (2-digit). e.g. '45' (construction), '72' (IT services),
            '33' (medical), '38' (lab equipment).
        hasBidderList:
          type: boolean
          default: false
          description: |
            True if pre-award invite-list / opening minutes are available for this
            record. Currently false on all records — invite-list ingestion ships
            in weeks 5–8 for FR / IT / ES / PL.
        bidderCount:
          type: integer
          nullable: true
          description: |
            Number of bidders / offers received for this contract, where the
            source publishes it structurally (Italy ANAC partecipanti dataset,
            UK FTS OCDS tenderers, Slovak ÚVO). Different from hasBidderList,
            which requires named bidder data; bidderCount is a count even if
            names aren't published.

    Bidder:
      type: object
      description: |
        One pre-award bidder for a contract. New table introduced 28 Apr 2026
        in support of Pillar 2 (national-level invite-list ingestion). Today
        empty — pipeline shipping in trial weeks 1–4 for IT (ANAC structured),
        FR (BOAMP PDF OCR), ES (PLACSP PDF), PL (e-Zamówienia PDF). The other
        ~23 EU jurisdictions do not publish bidder identity pre-award and are
        out of scope.
      properties:
        grantId: { type: string, description: 'FK to Grant.id' }
        bidderName: { type: string }
        bidderCountry: { type: string, nullable: true }
        bidderId: { type: string, nullable: true, description: 'VAT / orgnr / national company ID where published' }
        bidAmount: { type: integer, format: int64, nullable: true }
        bidCurrency: { type: string, default: EUR }
        rank: { type: integer, nullable: true, description: '1 = winner where source ranks bidders' }
        isWinner: { type: boolean }
        sourceArtefactUrl: { type: string, nullable: true, description: 'URL of the procès-verbal / verbale / acta / protokół this row was extracted from' }
        sourceFormat: { type: string, enum: [pdf, xml, json, html], nullable: true }
        extractionMethod: { type: string, enum: [native, ocr-llm, manual], nullable: true }
        confidenceScore: { type: integer, minimum: 0, maximum: 100, nullable: true, description: '100 for native structured data; lower for OCR/LLM-derived rows' }
