openapi: 3.1.0
info:
  title: Affitor Tracking API
  version: 1.0.0
  description: |
    Server-to-server tracking API for Affitor advertisers.

    The integration is a three-step funnel:
    1. **Click** — captured automatically by the Affitor tracker script (`?aff=` links), or reported server-side.
    2. **Lead** — ties your internal customer ID (`customer_key`) to the click at signup.
    3. **Sale** — reports revenue; Affitor resolves the partner and computes commission.

    Refunds and chargebacks are reported via the Refund endpoint (or handled
    automatically when using Stripe webhook tracking).

    ## Authentication
    Server-to-server calls use your **program API key** as a Bearer token:
    `Authorization: Bearer <PROGRAM_API_KEY>`.
    Find it in your advertiser dashboard under Program Settings (shown masked;
    regenerating issues a new key and invalidates the old one immediately).

    ## Test mode
    Every endpoint accepts `additional_data: { "test_mode": true }` to create
    isolated test events (`is_test: true`) that never touch production
    attribution, commissions, or metrics.

    ## Attribution rules
    - Model: last-click. A new `?aff=` click overwrites the previous partner.
    - Attribution window: program-level `attribution_window_days` (default 60).
      Sales for customers whose first-click anchor is outside the window return
      `attributed: false` and create nothing.
    - Customer lifecycle: `click → lead → conversion` (status never regresses).
  contact:
    name: Affitor Support
    url: https://docs.affitor.com/support/contact
servers:
  - url: https://api.affitor.com
    description: Production
security: []
tags:
  - name: Tracking
    description: Click, lead, sale, and refund tracking
  - name: Agent
    description: Agent-facing verification surface — synthetic chain + readiness polling

paths:
  /api/v1/track/click:
    post:
      operationId: trackClick
      tags: [Tracking]
      summary: Track a click
      description: |
        Records an affiliate click. Normally called by the Affitor tracker
        script when a visitor lands with `?aff=<partner-code>`; can also be
        called server-side. No authentication required — attribution is proven
        by the `aff` short code inside `affiliate_url`.

        On success, store the returned `click_id` in a first-party cookie named
        `affitor_click_id` with a lifetime of `cookie_window_days` days.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClickRequest'
            examples:
              production:
                summary: Real click
                value:
                  affiliate_url: "https://yoursite.com/pricing?aff=abc123"
                  page_url: "https://yoursite.com/pricing?aff=abc123"
                  page_title: "Pricing"
                  referrer_url: "https://google.com"
                  session_id: "sess_xyz_1234"
              test_mode:
                summary: Test mode
                value:
                  additional_data:
                    test_mode: true
                    program_id: "YOUR_PROGRAM_ID"
      responses:
        '200':
          description: |
            Click recorded. Also returned with `success: false` (HTTP 200) when
            the referral link exists but is inactive.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ClickResponse'
                  - $ref: '#/components/schemas/TestModeResponse'
              examples:
                recorded:
                  value:
                    success: true
                    click_id: "cust_42_1714000000000"
                    partner_code: "abc123"
                    cookie_window_days: 60
                inactive_link:
                  value:
                    success: false
                    message: "Referral link is inactive"
        '400':
          description: |
            Validation error. Causes: missing `affiliate_url`, URL not
            parseable, no `aff` parameter present, referral link not found, or
            invalid referral link configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/track/lead:
    post:
      operationId: trackLead
      tags: [Tracking]
      summary: Track a lead (signup)
      description: |
        Ties a signup to a prior click. Two modes:

        - **Server mode (recommended)** — send the Bearer program API key and
          your internal user ID as `customer_key`. Read the `affitor_click_id`
          cookie server-side and pass it as `click_id`.
        - **Browser mode** — called by `window.affitor.signup(customerKey, email)`
          with no Bearer token; the click cookie proves attribution. Programs
          configured with `lead_auth: server_required` record browser leads as
          **provisional** only — attribution settles when your backend sends
          the server-side lead (or on the first verified payment event).

        Either `click_id` or `customer_key` is required. The email, when
        provided, is hashed server-side before storage.
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LeadRequest'
            examples:
              server_mode:
                summary: Server mode (Bearer)
                value:
                  click_id: "cust_42_1714000000000"
                  customer_key: "user_123"
                  email: "user@example.com"
              test_mode:
                summary: Test mode
                value:
                  click_id: "test_lead_001"
                  customer_key: "test_customer"
                  additional_data:
                    test_mode: true
                    program_id: "YOUR_PROGRAM_ID"
      responses:
        '200':
          description: Lead recorded (or recorded as provisional).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LeadResponse'
                  - $ref: '#/components/schemas/LeadProvisionalResponse'
                  - $ref: '#/components/schemas/TestModeResponse'
              examples:
                tracked:
                  value:
                    success: true
                    message: "Lead tracked successfully"
                provisional:
                  value:
                    success: true
                    provisional: true
                    message: "Lead recorded as provisional. This program requires server-side (Bearer) lead tracking to settle attribution."
        '400':
          description: |
            Validation error. Causes: neither `click_id` nor `customer_key`
            provided; customer not found; customer belongs to a different
            program than the authenticated one; customer status cannot
            transition to lead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/track/sale:
    post:
      operationId: trackSale
      tags: [Tracking]
      summary: Track a sale
      description: |
        Reports a payment. Affitor resolves the partner from the customer's
        click→lead attribution and creates the commission — do not send a
        partner code or a pre-computed commission amount.

        `transaction_id` is the **idempotency key** (unique per platform): use
        your payment processor's invoice/charge ID. A duplicate returns HTTP
        409 — treat 409 as already-recorded, not as a retryable failure.

        If the customer's attribution window has expired, the call succeeds
        with `attributed: false` and creates no sale or commission (the sale is
        organic).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SaleRequest'
            examples:
              one_time:
                summary: One-time payment
                value:
                  transaction_id: "txn_abc123"
                  customer_key: "user_123"
                  amount_cents: 9999
                  currency: "USD"
                  sale_type: "payment"
              subscription:
                summary: Subscription renewal
                value:
                  transaction_id: "in_1QxYz..."
                  customer_key: "user_123"
                  amount_cents: 2490
                  currency: "USD"
                  sale_type: "subscription"
                  is_recurring: true
                  subscription_id: "sub_xyz"
                  subscription_interval: "monthly"
              test_mode:
                summary: Test mode
                value:
                  additional_data:
                    test_mode: true
                  amount_cents: 9999
                  currency: "USD"
                  sale_type: "payment"
      responses:
        '200':
          description: |
            Sale processed. Either attributed (sale + commission created) or
            explicitly not attributed (`attributed: false`, window expired).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SaleResponse'
                  - $ref: '#/components/schemas/SaleNotAttributedResponse'
                  - $ref: '#/components/schemas/TestModeResponse'
              examples:
                attributed:
                  value:
                    success: true
                    sale_id: 123
                    commission_id: 456
                    message: "Sale tracked successfully"
                window_expired:
                  value:
                    success: true
                    attributed: false
                    reason: "attribution_window_expired"
                    click_age_days: 75
                    window_days: 60
                    sale_id: null
                    commission_id: null
        '400':
          description: |
            Validation error. Causes: missing/invalid `transaction_id`;
            `amount_cents` not a positive number; customer not found; customer
            has no attribution in this program; no partner-program
            relationship.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Duplicate `transaction_id` — this sale was already recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Duplicate transaction_id. This sale has already been recorded."
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/track/refund:
    post:
      operationId: trackRefund
      tags: [Tracking]
      summary: Track a refund or chargeback
      description: |
        Reverses the commission for a previously tracked sale. Keyed by the
        **original sale's** `transaction_id` (not the refund event's ID).

        - Full refund (or `refund_amount_cents` omitted) — commission reversed.
        - Partial refund — commission marked refunded for the partial amount.
        - Reversal is idempotent: reporting the same refund twice does not
          double-reverse (atomic row-level claim).

        When using Stripe webhook tracking, `charge.refunded` events trigger
        the same reversal automatically — no API call needed.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefundRequest'
            example:
              transaction_id: "txn_abc123"
              refund_amount_cents: 5000
              refund_reason: "customer_request"
      responses:
        '200':
          description: |
            Refund processed, or sale found but had no commission to reverse
            (`status: no_commission`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefundResponse'
              examples:
                reversed:
                  value:
                    success: true
                    commission_id: 456
                no_commission:
                  value:
                    success: true
                    status: "no_commission"
                    message: "Sale has no commission to reverse"
        '400':
          description: '`transaction_id` is required.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No sale matches `transaction_id` for your program.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Sale not found for transaction_id"
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/cli/test-event:
    post:
      operationId: testEvent
      tags: [Agent]
      summary: Fire a synthetic verification chain
      description: |
        Runs a synthetic **click → lead → sale** through the real attribution +
        commission pipeline so an integration can be verified without a real
        customer or real money. Every row created is `is_test: true` and is
        excluded from earnings, analytics, payouts, and the readiness `live`
        gate's real-event signal.

        Send `{ "type": "chain" }`. The program is derived from the Bearer
        program API key. Rate limited to **10 chains / program / hour** (HTTP
        429 with `Retry-After`).

        After a successful chain, poll `GET /api/v1/programs/me/readiness` until
        `integration_verified: true`.

        A legacy single-event shape (`{ "event_type": "click" | "lead" |
        "sale" }`) also exists; it is a dumb insert (always `attributed:false`,
        no commission) and does **not** verify integration — prefer the chain.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestEventRequest'
            examples:
              chain:
                summary: Synthetic verification chain (recommended)
                value:
                  type: chain
              legacy_single:
                summary: Legacy single dumb-insert event
                value:
                  event_type: click
      responses:
        '200':
          description: Chain executed (or legacy single event recorded).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TestEventChainResponse'
              example:
                data:
                  type: chain
                  short_code: "afftest42"
                  customer_key: "aff_test_ck_42_1714000000000_ab12cd"
                  verdict:
                    click: attributed
                    lead: attributed
                    sale: attributed
                  attributed: true
        '400':
          description: Invalid `event_type` (legacy mode) or malformed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: |
            Synthetic-chain rate limit exceeded (max 10/program/hour). Respect
            the `Retry-After` header and `retry_after_seconds`.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentError'
              example:
                error:
                  status: 429
                  code: rate_limited
                  message: "Too many synthetic chains (max 10/program/hour)."
                  retry_after_seconds: 1800
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/programs/me/readiness:
    get:
      operationId: getMyReadiness
      tags: [Agent]
      summary: Program readiness (5-gate verdict)
      description: |
        Returns a machine-verifiable readiness verdict for the program behind
        the Bearer program API key (the program is derived from the key — there
        is no `:id` in the path, so no IDOR and the agent never needs to know
        its own program id).

        Five gates — `profile · economics · payout · tracking · live` — each
        `pass | fail | unknown`. `blocker` is the first failing gate; each
        failing gate carries a `next_action` string the agent can act on. Poll
        every `poll.retry_after_seconds` (default 5) until
        `integration_verified: true`.

        READ-ONLY: this endpoint writes nothing. Errors use the typed agent
        envelope `{ error: { code, message, retry_after_seconds? } }`.
      security:
        - bearerAuth: []
      parameters:
        - name: force_recheck
          in: query
          required: false
          schema: { type: boolean }
          description: Bypass the tracking-gate cache TTL (itself limited to 1 forced fetch / program / min).
      responses:
        '200':
          description: Readiness verdict.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadinessResponse'
              example:
                program_id: 42
                state: integration_verified
                integration_verified: true
                blocker: null
                gates:
                  profile: { status: pass }
                  economics: { status: pass }
                  payout: { status: pass, mode: stripe }
                  tracking: { status: pass, detail: receiving_events, checked_at: "2026-06-11T00:00:00.000Z" }
                  live:
                    status: pass
                    test_chain: { click: attributed, lead: attributed, sale: attributed }
                last_event_at: { click: null, lead: null, sale: null }
                counts_24h: { clicks: 0, leads: 0, sales: 0 }
                key_rotated_at: null
                poll: { retry_after_seconds: 5 }
        '401':
          description: Missing/malformed Authorization header, or invalid API token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentError'
              example:
                error:
                  code: invalid_key
                  message: "Invalid API token."
        '500':
          description: Failed to compute readiness — back off and retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentError'
              example:
                error:
                  code: internal_error
                  message: "Failed to compute readiness. Retry shortly."
                  retry_after_seconds: 10

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Program API key from your advertiser dashboard (Program Settings).

  responses:
    Unauthorized:
      description: Missing/malformed Authorization header, or invalid API token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Invalid API token"
    InternalError:
      description: Unexpected server error. Safe to retry with the same idempotency key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error message.
      required: [error]

    TestModeResponse:
      type: object
      description: Returned when `additional_data.test_mode` is true.
      properties:
        success: { type: boolean }
        message: { type: string }
        data:
          type: object
          properties:
            eventId: { type: integer }
            programId: { type: [integer, 'null'] }
            test_mode: { type: boolean, const: true }

    AdditionalData:
      type: object
      description: Extension object. Set `test_mode: true` to create an isolated test event.
      properties:
        test_mode:
          type: boolean
        program_id:
          type: [string, integer]
          description: Required with `test_mode` on unauthenticated calls so the test event can be linked to your program.
        event_name:
          type: string
          description: Optional label for the event (lead tracking).

    ClickRequest:
      type: object
      properties:
        affiliate_url:
          type: string
          format: uri
          description: The full landing URL containing `?aff=<partner-short-code>`. Required (except in test mode).
        page_url: { type: string, format: uri }
        page_title: { type: string }
        referrer_url: { type: string }
        session_id: { type: string }
        user_agent:
          type: string
          description: Falls back to the request's User-Agent header.
        screen_resolution: { type: string, example: "1920x1080" }
        viewport_size: { type: string, example: "1440x900" }
        language: { type: string, example: "en-US" }
        timezone: { type: string, example: "America/New_York" }
        existing_click_id:
          type: string
          description: Pass the stored click ID when the same visitor clicks again to reuse the customer record (last-click partner overwrite still applies).
        additional_data:
          $ref: '#/components/schemas/AdditionalData'

    ClickResponse:
      type: object
      properties:
        success: { type: boolean }
        click_id:
          type: string
          description: Store this in the `affitor_click_id` first-party cookie.
          example: "cust_42_1714000000000"
        partner_code: { type: string }
        cookie_window_days:
          type: integer
          description: Cookie lifetime in days. Follows the program's attribution window (default 60).
        message:
          type: string
          description: Present on non-recorded outcomes (e.g. inactive link).

    LeadRequest:
      type: object
      description: Either `click_id` or `customer_key` is required.
      properties:
        click_id:
          type: string
          description: Value of the `affitor_click_id` cookie (read server-side).
        customer_key:
          type: string
          description: Your internal, stable user ID. The join key for all future sales.
        email:
          type: string
          format: email
          description: Optional. Hashed server-side before storage; never stored in plain text.
        session_id: { type: string }
        page_url: { type: string }
        click_ids:
          type: array
          items: { type: string }
          description: Optional list of candidate click IDs from the tracker SDK (shadow attribution lane).
        additional_data:
          $ref: '#/components/schemas/AdditionalData'

    LeadResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, example: "Lead tracked successfully" }

    LeadProvisionalResponse:
      type: object
      description: Browser-mode lead on a program with `lead_auth: server_required`.
      properties:
        success: { type: boolean }
        provisional: { type: boolean, const: true }
        message: { type: string }

    SaleRequest:
      type: object
      required: [transaction_id, amount_cents]
      properties:
        transaction_id:
          type: string
          description: Idempotency key — unique per sale. Use your payment processor's invoice/charge ID. Duplicates return HTTP 409.
        customer_key:
          type: string
          description: Your internal user ID (preferred lookup). Provide this or `click_id`.
        click_id:
          type: string
          description: Alternative lookup via the `affitor_click_id` cookie value.
        amount_cents:
          type: integer
          minimum: 1
          description: Gross sale amount in cents.
        currency:
          type: string
          enum: [USD, EUR, VND]
          default: USD
        sale_type:
          type: string
          enum: [payment, subscription]
          default: payment
        is_recurring:
          type: boolean
          default: false
          description: True for subscription renewals.
        subscription_id: { type: string }
        subscription_interval:
          type: string
          enum: [monthly, quarterly, annual]
        product_id:
          type: string
          description: Optional product identifier, used for per-product commission policies.
        line_items:
          description: Optional structured line items (JSON).
        additional_data:
          $ref: '#/components/schemas/AdditionalData'

    SaleResponse:
      type: object
      properties:
        success: { type: boolean }
        sale_id: { type: integer }
        commission_id: { type: integer }
        message: { type: string, example: "Sale tracked successfully" }

    SaleNotAttributedResponse:
      type: object
      description: The customer's first-click anchor is outside the attribution window — the sale is organic. Nothing is created.
      properties:
        success: { type: boolean }
        attributed: { type: boolean, const: false }
        reason: { type: string, example: "attribution_window_expired" }
        click_age_days: { type: integer }
        window_days: { type: integer }
        sale_id: { type: 'null' }
        commission_id: { type: 'null' }

    RefundRequest:
      type: object
      required: [transaction_id]
      properties:
        transaction_id:
          type: string
          description: The ORIGINAL sale's `transaction_id` (not the refund event's ID).
        refund_amount_cents:
          type: integer
          minimum: 1
          description: Partial refund amount. Omit for a full refund.
        refund_reason:
          type: string
          description: Free-text reason, stored for audit.

    RefundResponse:
      type: object
      properties:
        success: { type: boolean }
        commission_id: { type: integer }
        status:
          type: string
          description: '`no_commission` when the sale had no commission to reverse.'
        message: { type: string }

    AgentError:
      type: object
      description: Typed agent error envelope so an agent can branch on `code`.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Stable machine code (e.g. invalid_key, rate_limited, internal_error).
            message: { type: string }
            status: { type: integer }
            retry_after_seconds:
              type: integer
              description: Present on retryable errors (e.g. rate limits, transient internal errors).
          required: [code, message]
      required: [error]

    TestEventRequest:
      type: object
      description: |
        Send `{ "type": "chain" }` to run the synthetic verification chain
        (recommended). The legacy single-event shape (`event_type`) is a dumb
        insert that does not verify integration.
      properties:
        type:
          type: string
          enum: [chain]
          description: Run a synthetic click→lead→sale chain through the real pipeline.
        event_type:
          type: string
          enum: [click, lead, sale]
          description: Legacy single dumb-insert event (always unattributed, no commission).

    TestEventChainResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            type: { type: string, const: chain }
            short_code:
              type: string
              description: The synthetic referral short code used for this program's fixture.
            customer_key:
              type: string
              description: The synthetic customer key threaded through all three steps.
            verdict:
              $ref: '#/components/schemas/TestChainVerdict'
            attributed:
              type: boolean
              description: True when the synthetic sale attributed (the chain's terminal success).

    TestChainVerdict:
      type: object
      description: Per-step verdict for the synthetic chain.
      properties:
        click: { $ref: '#/components/schemas/TestChainStepStatus' }
        lead: { $ref: '#/components/schemas/TestChainStepStatus' }
        sale: { $ref: '#/components/schemas/TestChainStepStatus' }

    TestChainStepStatus:
      type: string
      enum: [attributed, unattributed, wrong_partner, pending]

    GateStatus:
      type: string
      enum: [pass, fail, unknown]

    ReadinessResponse:
      type: object
      description: |
        Machine-verifiable readiness verdict. Poll until
        `integration_verified: true`. READ-ONLY.
      properties:
        program_id: { type: integer }
        state:
          type: string
          enum: [draft, configuring, ready, integration_verified, live_verified]
          description: |
            State machine. `integration_verified` = all gates pass via the
            synthetic chain (no real event yet) — the agent's goal.
            `live_verified` = a real (non-test) event has flowed.
        integration_verified:
          type: boolean
          description: True when `state` is `integration_verified` or `live_verified`.
        blocker:
          type: [string, 'null']
          enum: [profile, economics, payout, tracking, live, null]
          description: First failing gate in canonical order; null when all pass.
        gates:
          type: object
          properties:
            profile:
              type: object
              properties:
                status: { $ref: '#/components/schemas/GateStatus' }
                next_action: { type: string, description: "e.g. complete_program_profile" }
            economics:
              type: object
              properties:
                status: { $ref: '#/components/schemas/GateStatus' }
                next_action: { type: string, description: "e.g. set_commission_policy" }
            payout:
              type: object
              properties:
                status: { $ref: '#/components/schemas/GateStatus' }
                mode: { type: string, enum: [stripe, manual] }
                next_action: { type: string, description: "e.g. surface_stripe_connect_url" }
                endpoint: { type: string, description: "Stripe-connect URL endpoint when payout fails." }
            tracking:
              type: object
              properties:
                status: { $ref: '#/components/schemas/GateStatus' }
                detail:
                  type: string
                  description: "e.g. tracker_installed | receiving_events"
                checked_at: { type: [string, 'null'], format: date-time }
                next_action: { type: string, description: "e.g. install_tracker_or_send_events" }
            live:
              type: object
              properties:
                status: { $ref: '#/components/schemas/GateStatus' }
                test_chain: { $ref: '#/components/schemas/TestChainVerdict' }
                next_action: { type: string, description: "e.g. run_synthetic_sale_chain" }
        last_event_at:
          type: object
          description: Most recent REAL (non-test) event timestamp per type, or null.
          properties:
            click: { type: [string, 'null'], format: date-time }
            lead: { type: [string, 'null'], format: date-time }
            sale: { type: [string, 'null'], format: date-time }
        counts_24h:
          type: object
          properties:
            clicks: { type: integer }
            leads: { type: integer }
            sales: { type: integer }
        key_rotated_at: { type: [string, 'null'], format: date-time }
        poll:
          type: object
          properties:
            retry_after_seconds: { type: integer, description: Seconds to wait between polls. }
