openapi: 3.1.0

# ---------------------------------------------------------------------------
# REQUIREMENTS §52.20 makes this document a process rule, not documentation:
# "The OpenAPI 3.1 document ... is authored before the controller. Documentation
# is generated from it and is never hand-written. CI gate 12 lints it, replays
# the feature suite against it asserting every request and response conforms, and
# fails the build on any divergence. A PR adding an endpoint without a spec entry
# fails."
#
# `tests/Feature/Api/OpenApiCoverageTest.php` is the shipped half of that gate:
# it enumerates `Route::getRoutes()` and fails if any `/api/v1` route is missing
# a matching path+method here, or if this file names a route that does not exist.
# Spectral linting and response replay are CI concerns and are not run locally.
# ---------------------------------------------------------------------------

info:
  title: VerifyPro Public API
  version: '1.0.0'
  description: |
    Email verification as an API.

    **Authentication.** `Authorization: Bearer evk_live_… ` or `evk_test_…`. The
    organization is derived from the key; no request carries a tenant identifier,
    which is why none of these endpoints accepts one.

    **Errors.** Every non-2xx body is the same envelope and `error.code` — not the
    HTTP status — is the contract (§52.20 A). Statuses may be added to an existing
    condition only in a new major version.

    **Pagination.** Keyset only. `?limit=` (default 100, max 1000) and `?cursor=`.
    `page`, `offset` and `skip` are rejected with `422 validation_failed`.

    **Idempotency.** `Idempotency-Key` is required on every credit-mutating POST.
    An identical retry replays the stored response with `Idempotency-Replayed: true`;
    the same key with a different body is `409 idempotency_conflict`.

    **Compatibility.** Additive changes ship without notice: clients must ignore
    unknown fields and tolerate unknown enum values (§52.20 E).

servers:
  - url: /api/v1
    description: This deployment

security:
  - bearerAuth: []

tags:
  - name: Verification
  - name: Bulk
  - name: Account
  - name: Keys
  - name: Webhooks
  - name: Meta

paths:

  /health:
    get:
      tags: [Meta]
      operationId: health
      summary: Liveness probe
      description: |
        Unauthenticated. Touches no dependency: a liveness probe that queries the
        database turns a slow database into a total outage by killing every
        healthy node. Dependency health is a readiness concern and is not public.
      security: []
      responses:
        '200':
          description: The process is serving HTTP.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
          content:
            application/json:
              schema:
                type: object
                required: [data, request_id]
                properties:
                  data:
                    type: object
                    properties:
                      status: { type: string, examples: [ok] }
                      api_version: { type: string, examples: [v1] }
                      time: { type: string, format: date-time }
                  request_id: { $ref: '#/components/schemas/RequestId' }

  /status:
    get:
      tags: [Meta]
      operationId: status
      summary: The presented key's own view of itself
      description: |
        The first call an integrator makes: proves the key works, names its
        environment and lists its scopes. It reports nothing about the
        organization beyond its public id — the response to presenting a
        credential should not be a tenant profile.
      responses:
        '200':
          description: The key, as issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      api_version: { type: string }
                      key:
                        type: object
                        properties:
                          id: { type: string }
                          name: { type: string }
                          environment: { type: string, enum: [live, test] }
                          scopes: { type: array, items: { type: string } }
                          rate_limit_rpm: { type: integer }
                      organization:
                        type: object
                        properties:
                          id: { type: string }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /verify:
    post:
      tags: [Verification]
      operationId: verifySingle
      summary: Verify one address synchronously
      description: |
        Returns a verdict, priced through the §50.6 ladder and recorded.

        An `invalid` verdict is a **200**: the request succeeded, and `invalid` is
        the answer. Returning 4xx for it would put the product's most common
        useful result on every client's error path.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, minLength: 3, maxLength: 320 }
                intent: { type: string, maxLength: 40 }
      responses:
        '200':
          description: The verdict.
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/IdempotencyReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [data, request_id]
                properties:
                  data: { $ref: '#/components/schemas/VerificationResult' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /verify/batch:
    post:
      tags: [Verification]
      operationId: verifyBatch
      summary: Verify up to 200 addresses asynchronously
      description: |
        Returns **202** with a batch id. The batch id is a bulk job id: use
        `GET /bulk/{id}` for status, `GET /bulk/{id}/results` for results and
        `POST /bulk/{id}/cancel` to stop it.

        The 200-element cap is §52.20 F's. Larger lists go through `POST /bulk`
        with a file reference.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { type: string, minLength: 3, maxLength: 320 }
                name: { type: string, maxLength: 150 }
      responses:
        '202':
          description: Accepted. The work has not run yet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkJob' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /bulk/preview:
    post:
      tags: [Bulk]
      operationId: previewBulk
      summary: Hygiene, list risk and a credit estimate — before anything is charged
      description: |
        Creates nothing, reserves nothing, charges nothing and opens no socket.

        This is how an API client satisfies §15.16.11, which requires the customer
        see the List Risk Score and the selected mode before a job bills.

        The report is computed over a bounded prefix of the file; `sampled_rows`
        and `truncated` say how much was read, so a client does not extrapolate a
        sample as though it were a census. No DNS is resolved, which makes the
        estimate conservative — dead domains are priced at the full rung here and
        at a quarter when the job runs.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [upload_id]
              properties:
                upload_id: { type: string, minLength: 26, maxLength: 26 }
                email_column: { type: integer, minimum: 0 }
                has_header_row: { type: boolean }
                sample_rows: { type: integer, minimum: 100, maximum: 100000 }
      responses:
        '200':
          description: The pre-charge report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkPreview' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /bulk:
    post:
      tags: [Bulk]
      operationId: createBulkJob
      summary: Create a job from an uploaded file
      description: |
        The body carries a **reference**, never the addresses (§52.20 F).

        `start` defaults to `true`: for an API client the POST is the act of
        consent, and `POST /bulk/preview` is where the quote was shown. Send
        `"start": false` to hold the job in `pre_scan` with its score computed and
        no credits reserved.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [upload_id]
              properties:
                upload_id: { type: string, minLength: 26, maxLength: 26 }
                name: { type: string, maxLength: 150 }
                email_column: { type: integer, minimum: 0 }
                has_header_row: { type: boolean }
                start: { type: boolean, default: true }
      responses:
        '202':
          description: The job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkJob' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /bulk/{id}:
    get:
      tags: [Bulk]
      operationId: showBulkJob
      summary: Job status, mode, pause reason and progress
      description: |
        `progress` is computed from the job's rows, not from the denormalised
        counters on the job header — those are advisory (§57.11.4) and an ETA
        divided by a stale numerator is a confidently wrong completion time.

        `eta_seconds` is `null` rather than `0` when it cannot be measured.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkJob' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /bulk/{id}/results:
    get:
      tags: [Bulk]
      operationId: listBulkResults
      summary: The job's verdicts, keyset-paginated
      description: |
        Ordered by insertion, ascending, which makes the list stable under append:
        a client paging a running job sees every row exactly once and never has
        the window shift beneath it.
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [valid, invalid, catch_all, unknown, risky] }
      responses:
        '200':
          description: One page of verdicts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/KeysetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/VerificationResult' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /bulk/{id}/cancel:
    post:
      tags: [Bulk]
      operationId: cancelBulkJob
      summary: Stop a running job
      description: |
        Cooperative: workers stop when they next check, and a row mid-`RCPT TO`
        is allowed to finish rather than being killed with the socket open.

        Rows already verified were already charged; the remaining escrow is
        released. Cancelling an already-finished job is a **200**, not a 409 — the
        caller's question is "is it stopped", and the answer is yes.
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 255 }
      responses:
        '200':
          description: The cancelled job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkJob' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /account/credits:
    get:
      tags: [Account]
      operationId: showCredits
      summary: Balance, reserved, available and runway
      description: |
        `available` is `balance − reserved` and is the only number that can be
        spent. `runway.days_remaining` is `null` — never `0` — when no spend has
        been measured: no burn is infinite runway, not none.

        Every amount is a decimal **string**. JSON has one numeric type and it is
        a float; the fourth decimal place is contractual here.
      responses:
        '200':
          description: The wallet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CreditBalance' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /account/usage:
    get:
      tags: [Account]
      operationId: showUsage
      summary: Verifications and credits over a window
      description: |
        Counts and credits are reported separately because they are not
        proportional: hygiene prices a duplicate at zero and a dead domain at a
        quarter, so a month where volume doubled and spend held flat means a
        cleaner list, not a billing fault.

        `by_day` is dense — days with no activity appear with zeroes.
      parameters:
        - name: days
          in: query
          required: false
          description: Window length. Clamped to 92; larger reports are an export, not a GET.
          schema: { type: integer, minimum: 1, maximum: 92, default: 30 }
      responses:
        '200':
          description: The summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/UsageSummary' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /keys:
    get:
      tags: [Keys]
      operationId: listApiKeys
      summary: List this organization's API keys
      description: The secret is never returned. Only `POST /keys` ever emits one.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of keys.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/KeysetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Keys]
      operationId: createApiKey
      summary: Mint a key — the secret is returned once and never again
      description: |
        The platform stores a prefix and a SHA-256 digest, so the secret cannot be
        recovered afterwards by anyone, including us. `environment` defaults to
        `test`: a key minted from a copy-pasted example should not spend money.

        `rate_limit_rpm` can only tighten the plan's limit, never raise it.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                environment: { type: string, enum: [live, test], default: test }
                scopes: { type: array, items: { type: string, maxLength: 60 }, maxItems: 50 }
                rate_limit_rpm: { type: integer, minimum: 1 }
                expires_at: { type: string, format: date-time }
      responses:
        '201':
          description: The key and its one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [key, secret, warning]
                    properties:
                      key: { $ref: '#/components/schemas/ApiKey' }
                      secret: { type: string, description: Shown once. Store it now. }
                      warning: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /keys/{id}:
    delete:
      tags: [Keys]
      operationId: revokeApiKey
      summary: Revoke a key
      description: |
        A revoke, not a delete: the row survives because the audit trail and every
        verdict it produced reference it, and that is exactly the evidence wanted
        after a key is revoked in a hurry.

        A key cannot revoke itself.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The revoked key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ApiKey' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /billing/plans:
    get:
      tags: [Billing]
      operationId: listBillingPlans
      summary: The published price book — credit packs and subscription plans
      description: |
        Two arrays, because REQUIREMENTS 55.4.1 makes packs and plans two separate
        monotonic families and a single combined ladder is mathematically
        impossible: a Starter subscription grants 15,000 credits at $1.933/1k
        while the 25,000-credit PAYG pack is $3.00/1k, and a commitment product
        is supposed to beat a no-commitment one.

        `price_per_1k_minor` is the number the ladder rules are tested against
        and the number worth comparing between vendors. It is derived by integer
        half-up arithmetic from `price_minor` and `credits`; it is never stored,
        so it cannot drift out of step with the price it describes.

        Currency is the key organization's own and is not a parameter. Each
        currency is an INDEPENDENT price list (55.1.1), not a converted one, so
        quoting a currency the account cannot be charged in would be a contract
        that lies here and fails at checkout.

        Not paginated: a price book is a small fixed document whose value is that
        the whole ladder can be read in one response.
      responses:
        '200':
          description: The price book.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PriceBook' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /billing/wallet:
    get:
      tags: [Billing]
      operationId: showBillingWallet
      summary: Balance, lifetime totals, burn rate and runway
      description: |
        The billing view of the wallet. It overlaps `GET /account/credits`
        deliberately — 19.13 lists both — and adds what a billing reader needs
        and a verification client does not: lifetime purchased and used, the
        low-balance threshold, and the last settled purchase.

        `burn.runway_days` is null, never 0, when no spend has been measured.
        No burn is infinite runway, not none, and a 0 reads as "top up
        immediately" to exactly the automated integration most likely to act on
        it.

        Every credit amount is a decimal **string**. JSON has one numeric type
        and it is a float; the fourth decimal place is contractual here.
      responses:
        '200':
          description: The wallet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BillingWallet' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /billing/checkout:
    post:
      tags: [Billing]
      operationId: createCheckout
      summary: Create an order and open a gateway checkout session
      description: |
        19.11 steps 1 to 3. The body names WHAT is being bought and never what it
        costs: price, credit quantity, currency and tax are all derived server
        side (51.9 rule 3), because a client that could post an amount could buy
        a million credits for a cent.

        **Two idempotency layers, and both are needed.** `Idempotency-Key` is
        required (52.20 D) and replays the stored response byte for byte. Under
        it, `checkout_token` is claimed against a unique index and resolves a
        repeat to the order the first call created — which is what catches two
        requests carrying two DIFFERENT keys, such as a client that regenerates
        its key on retry. Omit `checkout_token` and the `Idempotency-Key` is used
        as the token, making a retry idempotent at both layers.

        A repeat that resolves to an existing order answers 200 with
        `replayed: true`; a new order answers 201.

        A coupon that fails 41.5's chain is a 422 `validation_failed` carrying
        the closed-list reason in `details.reason`. The order is NOT created at
        full price: a customer who typed a code is buying at the discounted price
        or not at all.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product, gateway]
              properties:
                product:
                  type: string
                  minLength: 26
                  maxLength: 26
                  description: The pack or plan public id from GET /billing/plans.
                gateway:
                  type: string
                  maxLength: 30
                  description: A gateway code that is enabled and configured, for example manual.
                coupon:
                  type: string
                  maxLength: 50
                  nullable: true
                checkout_token:
                  type: string
                  maxLength: 64
                  nullable: true
                  description: Defaults to the Idempotency-Key. See the description.
      responses:
        '201':
          description: The order was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CheckoutResult' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '200':
          description: An earlier call with this token already created the order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CheckoutResult' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /billing/orders/{id}:
    get:
      tags: [Billing]
      operationId: showOrder
      summary: Order and payment status
      description: |
        The endpoint a client polls after POST /billing/checkout. `message` is
        19.11's own "Checkout UX States" wording, so a client rendering a state
        to its own user does not have to invent phrasing that will differ from
        the portal's.

        `credits` is what the order PURCHASED, not the wallet balance. The two
        differ whenever a coupon bonus landed, whenever credits have since been
        spent, and always before settlement.

        `coupon` and `tax` are read from the order's FROZEN metadata (50.16 rule
        2) and are never re-evaluated: re-running the coupon chain here would
        report a code as invalid the day after it expired, on an order it had
        already legitimately discounted.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Order' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /billing/invoices:
    get:
      tags: [Billing]
      operationId: listInvoices
      summary: Issued tax documents, newest first
      description: |
        Keyset-paginated on `issued_at` descending with `id` as the tiebreaker.
        The tiebreaker is load-bearing rather than a formality: `issued_at` is
        written from a single timestamp inside the settlement transaction, so a
        batch of settlements in one second shares a value exactly, and without it
        a page boundary landing inside that batch would repeat or skip rows.

        Amounts are integer minor units. subtotal_minor + tax_minor =
        total_minor is stored rather than derived, and an issued invoice is
        immutable (50.14 rule 4) — a correction is a credit note, never an edit.

        No document link is returned on the list. Minting one signed expiring URL
        per row would produce a hundred live credentials per page, ninety-nine of
        which nobody will use.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, issued, paid, void, refunded] }
      responses:
        '200':
          description: A page of invoices.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/KeysetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # -------------------------------------------------------------------------
  # Webhooks — §56.6 A, §56.17 B and C
  #
  # Every delivery carries `X-Signature: t=<unix>,v1=<hex>[,v1=<hex>]` where
  # `v1 = HMAC-SHA256(secret, "<t>.<raw_body>")` in lowercase hex. A receiver
  # computes its own value once and accepts if ANY `v1` matches, compared in
  # constant time, and rejects anything where `abs(now - t) > 300` however good
  # the signature is. Two entries appear for 24 hours after a rotation, newest
  # first — that is what makes rotation zero-downtime in both directions.
  #
  # Every body carries `event_id` (a ULID). A repeated `event_id` is a duplicate
  # and must be treated as one: it is stable across all eight delivery attempts
  # AND across a replay, which is precisely what makes replay safe to offer.
  # -------------------------------------------------------------------------

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List this organization's endpoints
      description: |
        The signing secret is never returned here. `POST /webhooks` and
        `POST /webhooks/{id}/rotate` are the only two operations that ever emit
        one, and there is no reveal endpoint — a customer who loses a secret
        rotates, which is a visible act with an audit row.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of endpoints.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/KeysetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register an endpoint — the secret is returned once and never again
      description: |
        `events` is optional. An empty or absent list means **every** event,
        including ones added in future; naming events narrows the subscription.
        An unrecognised name is refused rather than dropped, because a silently
        narrowed subscription is discovered weeks later as "your webhooks never
        fire".

        The URL is checked against the SSRF policy at registration and again
        immediately before every single delivery attempt — DNS can be repointed
        at a private address long after a URL is approved.

        `Idempotency-Key` is required: a retried create mints a second endpoint
        with a second secret, and the customer cannot tell which one their
        consumer was configured against.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, maxLength: 500 }
                name: { type: string, maxLength: 100 }
                events:
                  type: array
                  items:
                    type: string
                    enum:
                      - verification.completed
                      - bulk.completed
                      - bulk.paused.protection
                      - credits.low
                      - credits.depleted
                      - export.ready
                      - child.created
      responses:
        '201':
          description: The endpoint and its one-time signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/WebhookEndpoint'
                      - type: object
                        required: [secret]
                        properties:
                          secret: { type: string, description: Shown once. Store it now. }
                          secret_note: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    get:
      tags: [Webhooks]
      operationId: showWebhookEndpoint
      summary: One endpoint
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Webhooks]
      operationId: updateWebhookEndpointPut
      summary: Update an endpoint (identical to PATCH)
      description: |
        Both verbs run the same handler with PATCH semantics. An ABSENT `events`
        key leaves the subscription untouched; `"events": null` or `[]` widens it
        to every event. The two are genuinely different instructions, and a body
        shape that could not distinguish them would leave a client unable to
        widen a filter at all.

        Changing the URL re-runs the SSRF policy and deliberately does NOT reset
        `failure_count` — repointing a dead endpoint at a fresh host is exactly
        how the auto-disable ladder would otherwise be evaded. Set
        `status: active` on a `failing` or `disabled` endpoint to reset it; that
        also queues any events recorded while it was down.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointUpdate' }
      responses:
        '200':
          description: The updated endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Update an endpoint
      description: See `PUT /webhooks/{id}` — the same handler and the same semantics.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointUpdate' }
      responses:
        '200':
          description: The updated endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete an endpoint
      description: |
        A soft delete. The delivery history survives its own retention schedule,
        and the URL becomes registrable again — the natural key covers only LIVE
        endpoints, so re-adding a URL you removed is not a duplicate.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The deleted endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/rotate:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Mint a new signing secret, keeping the old one live for 24 hours
      description: |
        During the overlap every delivery carries TWO `v1=` entries, the new
        secret first. A receiver that reads only the first succeeds as soon as it
        adopts the new secret; one that reads only the last succeeds until it
        does; one following the documented rule succeeds throughout. Deploy
        whenever you like — there is no flag day, which is the whole point,
        because a rotation that needs one is a rotation nobody performs.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '200':
          description: The endpoint and its new one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/WebhookEndpoint'
                      - type: object
                        required: [secret]
                        properties:
                          secret: { type: string }
                          secret_note: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhookEndpoint
      summary: Send a signed test delivery
      description: |
        Goes through the real delivery machinery — the same signature, the same
        pre-attempt SSRF re-check, the same ten-second timeout, the same retry
        ladder, and a row in the same delivery log. A test that took a different
        path would test the wrong thing.

        Its `event_type` is `endpoint.test`, deliberately OUTSIDE the seven-event
        catalogue: a correctly written consumer ignores unknown event types and
        answers 2xx, which is exactly the signal being looked for.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '202':
          description: |
            The ping is queued. It is 202 rather than 200 because there is no HTTP
            result yet — poll `/webhooks/{id}/deliveries` for the outcome.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      delivery_public_id: { type: string }
                      event_id: { type: string }
                      event_type: { type: string, examples: [endpoint.test] }
                      status: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: The delivery log for one endpoint
      description: |
        One entry per logical EVENT, newest first, with its full attempt history
        nested underneath. The underlying table stores one row per attempt — up
        to eight for a single event — and paginating that raw would fill a page
        with eight lines that all say the same thing.

        The retry ladder is: immediate, +30s, +2m, +10m, +1h, +4h, +12h, +24h.
        Eight attempts spanning about 41 hours, after which the event is
        `exhausted` and stays replayable for seven days.

        Your server's own response BODY is not returned. The status code and a
        normalised error message answer "why did it fail?" without carrying
        untrusted bytes into every consumer of this log.
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, delivered, failed, exhausted, endpoint_disabled]
        - name: event_type
          in: query
          required: false
          schema: { type: string, maxLength: 60 }
      responses:
        '200':
          description: One page of delivery log entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/KeysetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhookEndpoint
      summary: Replay this endpoint's undelivered backlog
      description: |
        Re-sends every UNDELIVERED event from the last seven days — `exhausted`
        (the ladder ran out) and `endpoint_disabled` (never attempted). Each
        EVENT is sent once however many attempts it originally made, so a fixed
        consumer catches up rather than being knocked over by the catch-up.

        `failed` events are excluded: they already have a successor attempt
        scheduled, and replaying one would put two attempts of the same event in
        flight with no way for the receiver to tell them apart.

        Every replay reuses the ORIGINAL `event_id`, so a consumer that
        deduplicates correctly is safe to replay a backlog it may already have
        processed. That is what makes this operation offerable at all.

        Optionally pass `event_ids` to replay a named subset.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                event_ids:
                  type: array
                  items: { type: string, minLength: 26, maxLength: 26 }
      responses:
        '202':
          description: The replay is queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      endpoint_public_id: { type: string }
                      window_days: { type: integer, examples: [7] }
                      max_events_per_run: { type: integer }
                      note: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhook-deliveries/{id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhookDelivery
      summary: Replay one recorded event
      description: |
        The surgical form of the endpoint-wide replay, for the case where you
        found the single event your consumer dropped and do not want the day's
        traffic with it. Takes a DELIVERY public id from the log, not an endpoint
        id — the two are separate routes rather than one route with a flag,
        because the blast radius differs by three orders of magnitude.

        The replay continues the event's attempt sequence rather than starting a
        fresh eight-rung ladder: you asserted the consumer is fixed, so the
        platform tries once and tells you the truth.
      parameters:
        - $ref: '#/components/parameters/PublicId'
      responses:
        '202':
          description: The replay is queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      delivery_public_id: { type: string }
                      event_id: { type: string }
                      event_type: { type: string }
                      attempt: { type: integer }
                      status: { type: string }
                  request_id: { $ref: '#/components/schemas/RequestId' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer evk_live_…` or `evk_test_…`.

        The key is read from this header and from nowhere else — never a query
        parameter, which would put a live credential into access logs, browser
        history and `Referer` headers.

        A request carrying both this header and a session cookie is refused with
        `400 ambiguous_credentials`.

  parameters:

    PublicId:
      name: id
      in: path
      required: true
      description: The resource's 26-character public id. Internal numeric ids are never exposed.
      schema: { type: string, minLength: 26, maxLength: 26 }

    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }

    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        The `next_cursor` from the previous page. Opaque — its contents are not
        part of the contract and a cursor minted for one ordering is refused
        against another.
      schema: { type: string, maxLength: 512 }

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Client-generated, ≤255 characters, UUIDv4 recommended. Retained 24 hours.

        An identical retry replays the stored response with
        `Idempotency-Replayed: true`. The same key with a different body is
        `409 idempotency_conflict`. A key whose first request is still in flight
        is `409` with `Retry-After: 1`.
      schema: { type: string, minLength: 1, maxLength: 255 }

  headers:

    XRequestId:
      description: The `req_<ULID>` correlation id. Present on every response, including 2xx.
      schema: { type: string }

    IdempotencyReplayed:
      description: Present and `true` when this response was replayed from a stored one.
      schema: { type: string, enum: ['true'] }

    RetryAfter:
      description: |
        Seconds. Present on 429, on 503, and on a 409 whose prior request is still
        in flight — and absent everywhere else (§52.20 C).
      schema: { type: integer, minimum: 1 }

    RateLimitLimit:
      description: Requests permitted in the current window.
      schema: { type: integer }

    RateLimitRemaining:
      description: Requests left in the window.
      schema: { type: integer }

    RateLimitReset:
      description: Unix seconds at which the window resets.
      schema: { type: integer }

  schemas:

    RequestId:
      type: string
      pattern: '^req_[0-9a-z]{26}$'

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id, doc_url]
          properties:
            code:
              type: string
              description: |
                The stable contract. Switch on this, not on the HTTP status — one
                status covers several codes and a status may be reused.
              enum:
                - validation_failed
                - unauthenticated
                - ambiguous_credentials
                - forbidden
                - not_found
                - insufficient_credits
                - rate_limited
                - concurrency_limit
                - idempotency_conflict
                - job_not_ready
                - job_cancelled
                - payload_too_large
                - unsupported_media_type
                - account_suspended
                - organization_suspended
                - internal_error
                - upstream_unavailable
                - rate_limiter_unavailable
                - csrf_token_invalid
                - csrf_origin_mismatch
                - blocked_during_impersonation
                - step_up_required
                - gone
                - server_assigned_field_rejected
                - oauth_state_invalid
                - dual_approval_same_actor
                - subject_erased
                - coupon_type_not_enabled
                - condition_too_complex
                - condition_field_not_allowed
                - action_invalid
                - negative_balance_hold
                - wallet_frozen_integrity
                - wallet_frozen_dispute
                - wallet_legal_hold
                - settlement_blocked
                - billing_retry_exhausted
                - reclaim_exceeds_available
                - invalid_idempotency_key
                - idempotency_key_reuse
                - request_in_progress
                - tax_location_unverified
            message: { type: string }
            details:
              type: object
              description: Present only when there is something to say. Absent, not empty.
              additionalProperties: true
            request_id: { $ref: '#/components/schemas/RequestId' }
            doc_url: { type: string, format: uri }

    KeysetPage:
      type: object
      required: [data, next_cursor, has_more, request_id]
      properties:
        data: { type: array, items: { type: object } }
        next_cursor:
          type: [string, 'null']
          description: Pass as `?cursor=` for the next page. `null` on the last page.
        has_more: { type: boolean }
        request_id: { $ref: '#/components/schemas/RequestId' }

    VerificationResult:
      type: object
      properties:
        id: { type: string }
        email: { type: string, description: The submitted address, or its mask once retention has purged it. }
        pii_purged: { type: boolean }
        domain: { type: string }
        status:
          type: string
          description: New members may be added; tolerate unknown values (§52.20 E).
          examples: [valid, invalid, catch_all, unknown, risky]
        sub_status: { type: string }
        confidence_score: { type: integer, minimum: 0, maximum: 100 }
        deliverability_score: { type: integer, minimum: 0, maximum: 100 }
        recommended_action: { type: string }
        verification_depth: { type: string }
        unknown_cause: { type: [string, 'null'] }
        flags:
          type: object
          properties:
            free_provider: { type: boolean }
            role_address: { type: boolean }
            disposable: { type: boolean }
            catch_all: { type: boolean }
            cache_hit: { type: boolean }
        mx_provider: { type: [string, 'null'] }
        smtp_code: { type: [integer, 'null'] }
        duration_ms: { type: integer }
        billable: { type: boolean }
        credit_charged: { type: string, description: A decimal string, never a JSON number. }
        source: { type: string }
        verified_at: { type: [string, 'null'], format: date-time }

    BulkJob:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string }
        mode: { type: string, examples: [standard, cautious, protected, safe] }
        terminal: { type: boolean }
        list_risk_score: { type: [integer, 'null'] }
        sample_required: { type: boolean }
        sample_completed: { type: boolean }
        pause_reason: { type: [string, 'null'], description: Why a stopped job stopped. }
        total_rows: { type: integer }
        credits_reserved: { type: string }
        credits_charged: { type: string }
        progress:
          type: [object, 'null']
          properties:
            total_rows: { type: integer }
            pending: { type: integer }
            claimed: { type: integer }
            done: { type: integer }
            failed: { type: integer }
            skipped: { type: integer }
            settled: { type: integer }
            outstanding: { type: integer }
            percent: { type: number }
            rows_per_second: { type: [number, 'null'] }
            eta_seconds: { type: [integer, 'null'], description: '`null` means not yet knowable — never `0`.' }
            estimated_completion_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }

    BulkPreview:
      type: object
      properties:
        hygiene:
          type: object
          properties:
            original_rows: { type: integer }
            duplicates_removed: { type: integer }
            exact_duplicates: { type: integer }
            subaddress_collapsed: { type: integer }
            syntax_invalid: { type: integer }
            dead_domains: { type: integer }
            disposable_tagged: { type: integer }
            role_tagged: { type: integer }
            trap_tagged: { type: integer }
            safe_for_smtp: { type: integer }
            estimated_credits: { type: string }
            naive_credits: { type: string }
            estimated_credit_saving: { type: string }
        list_risk:
          type: object
          properties:
            score: { type: integer, minimum: 0, maximum: 100 }
            label: { type: string }
            recommended_mode: { type: string }
            model_version: { type: string }
            floors_triggered: { type: array, items: { type: string } }
        credit_estimate:
          type: object
          properties:
            estimated: { type: string }
            naive: { type: string }
            saving: { type: string }
            per_disposition: { type: object, additionalProperties: true }
        sampled_rows: { type: integer }
        truncated: { type: boolean, description: True when the file is longer than the sample read. }

    CreditBalance:
      type: object
      properties:
        balance: { type: string }
        reserved: { type: string }
        available: { type: string, description: balance − reserved. The only spendable number. }
        overdraft_cap: { type: string }
        negative_balance_hold: { type: boolean }
        runway:
          type: object
          properties:
            window_days: { type: integer }
            average_daily_spend: { type: [string, 'null'] }
            days_remaining: { type: [integer, 'null'], description: '`null` when no spend has been measured.' }

    UsageSummary:
      type: object
      properties:
        window:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        totals:
          type: object
          properties:
            verifications: { type: integer }
            credits_spent: { type: string }
        by_status:
          type: object
          additionalProperties: { type: integer }
        by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              verifications: { type: integer }
              credits_spent: { type: string }

    PriceBook:
      type: object
      properties:
        currency:
          type: string
          nullable: true
          description: The account's billing currency, or null when nothing is published in it.
        packs:
          type: array
          items: { $ref: '#/components/schemas/PricedProduct' }
        plans:
          type: array
          items: { $ref: '#/components/schemas/PricedProduct' }

    PricedProduct:
      type: object
      properties:
        id: { type: string, description: Public id — pass this as product at checkout. }
        kind: { type: string, enum: [credit_pack, plan] }
        code: { type: string }
        name: { type: string }
        tier: { type: string }
        credits: { type: string, description: Decimal string, four places. }
        price_minor: { type: integer }
        price: { type: string, description: Major units, rendered by integer arithmetic. }
        currency: { type: string, minLength: 3, maxLength: 3 }
        price_per_1k_minor: { type: integer }
        price_per_1k: { type: string }
        overage_rate_minor: { type: integer }
        limits:
          type: object
          properties:
            max_rows_per_job: { type: integer }
            api_rpm: { type: integer }
            concurrent_jobs: { type: integer }
        features: { type: object, additionalProperties: true }

    BillingWallet:
      type: object
      properties:
        balance: { type: string }
        reserved: { type: string }
        available: { type: string, description: balance minus reserved; the only spendable figure. }
        overdraft_cap: { type: string }
        currency: { type: string }
        lifetime_purchased: { type: string }
        lifetime_used: { type: string }
        low_balance_threshold: { type: string, nullable: true }
        burn:
          type: object
          properties:
            window_days: { type: integer }
            daily_credits: { type: string, nullable: true }
            runway_days: { type: integer, nullable: true, description: Null when no spend was measured. }
        last_purchase:
          type: object
          nullable: true
          properties:
            id: { type: string }
            order_number: { type: string }
            credits: { type: string }
            amount_minor: { type: integer }
            currency: { type: string }
            paid_at: { type: string, format: date-time }

    CheckoutResult:
      type: object
      properties:
        order:
          type: object
          properties:
            id: { type: string }
            order_number: { type: string }
            status: { type: string, enum: [pending, processing, awaiting_approval, paid, failed, cancelled, refunded] }
            gateway: { type: string }
            credits: { type: string }
            amount_minor: { type: integer }
            currency: { type: string }
        quote: { $ref: '#/components/schemas/Quote' }
        session:
          type: object
          nullable: true
          description: Null on a replay, and on any gateway with no hosted page.
          properties:
            gateway: { type: string }
            order_id: { type: string }
            order_number: { type: string }
            session_id: { type: string }
            status: { type: string }
            message: { type: string }
            redirect_url: { type: string, nullable: true }
            instructions: { type: string, nullable: true }
            expires_at: { type: string, nullable: true }
        replayed:
          type: boolean
          description: True when an earlier call with this token created the order.

    Quote:
      type: object
      description: |
        The 41.5 preview joined to the 50.16 assessment. The coupon is evaluated
        against the GROSS total and tax is charged on the DISCOUNTED one — a
        min-purchase rule must not be able to disqualify its own coupon, and tax
        is owed on consideration actually given.
      properties:
        product: { $ref: '#/components/schemas/PricedProduct' }
        currency: { type: string }
        gross_minor: { type: integer }
        discount_minor: { type: integer }
        taxable_minor: { type: integer }
        tax_minor: { type: integer }
        total_minor: { type: integer }
        total: { type: string }
        credits: { type: string }
        bonus_credits: { type: string, description: Granted as a separate ledger entry, never merged into the purchase. }
        tax:
          type: object
          properties:
            treatment: { type: string }
            place_of_supply: { type: string }
            effective_rate_bp: { type: integer }
            legend: { type: string, nullable: true }
            lines:
              type: array
              items:
                type: object
                properties:
                  name: { type: string }
                  rate_bp: { type: integer }
                  taxable_minor: { type: integer }
                  tax_minor: { type: integer }
        coupon:
          type: object
          nullable: true
          properties:
            code: { type: string }
            valid: { type: boolean }
            reason: { type: string, nullable: true }
            message: { type: string, nullable: true }
            retryable: { type: boolean, nullable: true }

    Order:
      type: object
      properties:
        id: { type: string }
        order_number: { type: string }
        status: { type: string, enum: [pending, processing, awaiting_approval, paid, failed, cancelled, refunded] }
        message: { type: string, nullable: true }
        terminal: { type: boolean }
        gateway: { type: string }
        product_type: { type: string }
        credits: { type: string, description: What the order PURCHASED, not the wallet balance. }
        amount_minor: { type: integer }
        currency: { type: string }
        coupon:
          type: object
          nullable: true
          properties:
            code: { type: string }
            discount_minor: { type: integer }
            bonus_credits: { type: string }
        tax:
          type: object
          nullable: true
          properties:
            treatment: { type: string }
            place_of_supply: { type: string }
            subtotal_minor: { type: integer }
            tax_minor: { type: integer }
            effective_rate_bp: { type: integer }
            legend: { type: string, nullable: true }
            lines: { type: array, items: { type: object, additionalProperties: true } }
        invoice:
          type: object
          nullable: true
          properties:
            id: { type: string }
            number: { type: string }
            status: { type: string }
            subtotal_minor: { type: integer }
            tax_minor: { type: integer }
            total_minor: { type: integer }
            currency: { type: string }
            issued_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }

    Invoice:
      type: object
      properties:
        id: { type: string }
        number: { type: string }
        status: { type: string, enum: [draft, issued, paid, void, refunded] }
        subtotal_minor: { type: integer }
        tax_minor: { type: integer }
        total_minor: { type: integer }
        currency: { type: string }
        tax:
          type: object
          properties:
            place_of_supply: { type: string, nullable: true }
            treatment: { type: string }
            effective_rate_bp: { type: integer }
        issued_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }

    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        key_prefix: { type: string, description: The public half. Identifies a key; cannot authenticate. }
        environment: { type: string, enum: [live, test] }
        scopes: { type: array, items: { type: string } }
        rate_limit_rpm: { type: integer }
        status: { type: string, enum: [active, revoked, expired] }
        last_used_at: { type: [string, 'null'], format: date-time }
        expires_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }

    WebhookEndpoint:
      type: object
      description: |
        One registered callback destination. Neither the current nor the previous
        signing secret appears here under any circumstance — this schema has no
        field for one, which is why no list, read or update operation can leak it
        however it is later edited.
      properties:
        public_id: { type: string, minLength: 26, maxLength: 26 }
        name: { type: [string, 'null'], maxLength: 100 }
        url: { type: string, format: uri }
        events:
          type: [array, 'null']
          description: null means EVERY event, including ones added in future.
          items: { type: string }
        status:
          type: string
          enum: [active, paused, failing, disabled]
          description: |
            `paused` is yours; `failing` and `disabled` are ours. Keeping them
            apart is what lets an operator tell "the customer turned this off"
            from "we turned it off because it was broken". A `failing` endpoint is
            still attempted; a `disabled` one has its events recorded and not sent.
        failure_count:
          type: integer
          description: |
            EXHAUSTED EVENTS, not attempts. One dead consumer produces eight
            failures in 41 hours; counting attempts would disable a healthy
            endpoint after one bad afternoon. Reset to zero by any delivery.
        failing_threshold: { type: integer, examples: [10] }
        disable_threshold: { type: integer, examples: [20] }
        secret_rotated_at: { type: [string, 'null'], format: date-time }
        last_delivered_at: { type: [string, 'null'], format: date-time }
        last_failure_at: { type: [string, 'null'], format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }

    WebhookEndpointUpdate:
      type: object
      description: |
        Every field is optional and PATCH semantics apply throughout. Note the
        distinction between an absent `events` key (leave the subscription alone)
        and a present null or empty array (subscribe to everything).
      properties:
        url: { type: string, format: uri, maxLength: 500 }
        name: { type: [string, 'null'], maxLength: 100 }
        events: { type: [array, 'null'], items: { type: string, maxLength: 60 } }
        status:
          type: string
          enum: [active, paused]
          description: |
            Only the two states a customer owns. Setting `active` on an endpoint
            the platform disabled also resets `failure_count` and queues the
            events recorded while it was down.

    WebhookDelivery:
      type: object
      description: One logical event in the delivery log, with its attempt ladder.
      properties:
        public_id: { type: string, minLength: 26, maxLength: 26 }
        event_id:
          type: string
          description: |
            The ULID your consumer deduplicates on. Identical across all eight
            attempts AND across any replay — which is exactly what makes replay a
            safe operation to offer.
        event_type: { type: string }
        status:
          type: string
          enum: [pending, delivered, failed, exhausted, endpoint_disabled]
        attempt: { type: integer, description: The rung the latest attempt sat on. }
        attempt_count: { type: integer }
        response_code: { type: [integer, 'null'] }
        error: { type: [string, 'null'] }
        duration_ms: { type: [integer, 'null'] }
        delivered_at: { type: [string, 'null'], format: date-time }
        next_attempt_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }
        replayable:
          type: boolean
          description: |
            True only for `exhausted` and `endpoint_disabled`. A `failed` event
            already has a successor attempt scheduled and a `delivered` one landed.
        attempts:
          type: array
          items:
            type: object
            properties:
              attempt: { type: integer }
              status: { type: string }
              response_code: { type: [integer, 'null'] }
              duration_ms: { type: [integer, 'null'] }
              error: { type: [string, 'null'] }
              attempted_at: { type: [string, 'null'], format: date-time }
              next_attempt_at: { type: [string, 'null'], format: date-time }

  responses:

    Unauthenticated:
      description: |
        Missing, malformed, unknown, revoked or expired key — all indistinguishable
        by design. Telling a revoked key from an unknown one confirms it existed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Forbidden:
      description: Authenticated, but the key lacks the scope this endpoint requires.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    NotFound:
      description: |
        No such resource — including a resource that belongs to another
        organization. The two are deliberately indistinguishable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    ValidationFailed:
      description: The request was refused before any work was done. `details.fields` names what.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    InsufficientCredits:
      description: Nothing was verified and nothing was charged.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Conflict:
      description: |
        An idempotency conflict, or an operation the resource's current state does
        not permit.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Gone:
      description: The resource existed and has been purged. It is not coming back.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    RateLimited:
      description: Over the per-key limit for this window.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
