openapi: 3.0.3
info:
  title: Hardcount Billing API
  description: |
    Open-source usage-based billing engine. Handles pricing, subscriptions,
    usage metering, invoice generation, Stripe payments, dunning, and credits.
  version: "2026-04-07"
  license:
    name: MIT
    url: https://github.com/gethardcount/hardcount/blob/main/LICENSE
  contact:
    name: Hardcount
    url: https://github.com/gethardcount/hardcount

servers:
  - url: http://localhost:8080
    description: Local development

security:
  - BearerAuth: []

tags:
  - name: Customers
    description: Customers, billing profiles, payment methods and per-customer views.
  - name: Pricing
    description: Meters, plans, prices and price overrides.
  - name: Subscriptions
    description: Subscriptions, their items, trials, pauses and cancellations.
  - name: Usage
    description: Sending usage events and reading usage totals.
  - name: Invoices
    description: Invoices, their lines, payments and refunds.
  - name: CreditNotes
    description: Credit notes against issued invoices.
  - name: Credits
    description: Prepaid customer credit, kept as a ledger of grant, usage, expiry and adjustment entries.
  - name: Billing
    description: Billing runs, previews and analytics.
  - name: Dunning
    description: Retrying failed payments. Policies and runs.
  - name: Webhooks
    description: Outbound webhook endpoints, events and deliveries. Also the inbound webhooks Stripe and Postmark call.
  - name: Auth
    description: Dashboard sign-in, password reset and API keys.
  - name: Members
    description: Workspace members and invitations. Dashboard session only.
  - name: ProviderCosts
    description: What upstream providers charge you, for margin reports.
  - name: TestClocks
    description: Simulated time for test-mode subscriptions.
  - name: Recipes
    description: Ready-made pricing setups you can preview and apply.
  - name: Public
    description: Pages a customer opens with a token, without an API key.
  - name: System
    description: Health, settings, tenants, the audit log and setup.

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key authentication. Use `Authorization: Bearer hc_secret_...`
        Key types: hc_platform_ (tenant mgmt), hc_secret_ (full access)
    SessionCookie:
      type: apiKey
      in: cookie
      name: hardcount_session
      description: |
        Dashboard session cookie, set by `POST /v1/auth/login`. Routes
        under `/v1` (except `/v1/tenants`) accept it in place of a Bearer
        key. Operations that list only this scheme (members, mode
        switch, logout) do not accept an API key.
    MetricsToken:
      type: http
      scheme: bearer
      description: |
        The value of the `METRICS_TOKEN` setting, sent as
        `Authorization: Bearer <token>`. Used only by `GET /metrics`. It is
        not an API key.
    PostmarkBasicAuth:
      type: http
      scheme: basic
      description: |
        HTTP Basic auth with `POSTMARK_WEBHOOK_USER` and
        `POSTMARK_WEBHOOK_PASS`. You put both in the webhook URL you give
        Postmark. Used only by `POST /v1/webhooks/postmark`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Makes the call safe to retry. Send a unique value (a UUID works) and
        the same value on every retry. For 24 hours, a repeat with the same
        key, method, path and body gets the first response again, with an
        `Idempotent-Replayed: true` header, instead of running again. The
        error cases are in docs/api-surface.md. The usage routes do not take
        it: each usage event carries its own `idempotency_key`.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: |
        Rows per page, from 1 to 100. Default 50. Any other value, or one
        that is not a whole number, is a 422 with `param: limit`.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    Offset:
      name: offset
      in: query
      required: false
      description: |
        Rows to skip, 0 or more. Default 0. A negative value, or one that is
        not a whole number, is a 422 with `param: offset`.
      schema:
        type: integer
        minimum: 0
        default: 0

  responses:
    InvalidPage:
      description: "`limit` or `offset` is out of range or not a whole number, or the request sends the removed `after` cursor (`param` names it)"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    ListTotal:
      type: integer
      description: |
        How many rows match the request's filters, on every page, not only
        this one. The audit log and the customer list's search and name or
        email sort say where they stop counting.

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              description: |
                One of `invalid_request_error`, `authentication_error`,
                `rate_limit_error` or `api_error`. Every error the API
                answers has this shape, including an unknown path (404,
                code `route_not_found`) and a method the path does not take
                (405, code `method_not_allowed`, with an `Allow` header).
                The few exceptions are listed in docs/api-surface.md.
              example: invalid_request_error
            code:
              type: string
            message:
              type: string
            request_id:
              type: string
            param:
              type: string
              description: |
                The request field the error is about. Sent only when the error
                names one. A request body may hold only the fields its
                operation lists, spelled and cased as listed: any other
                field, or one the server sets (such as `id`), is refused
                with 422 and `param` naming it. An operation with no request body lists no fields: send
                no body, `{}` or `null`. The Stripe and Postmark webhooks and
                the LiteLLM callback take their sender's body as it comes.

    MemberInvitation:
      type: object
      required: [id, email, role, status, expires_at, created_at]
      properties:
        id:
          type: string
          example: hc_minv_1a2b3c4d5e6f7890
        email:
          type: string
        role:
          type: string
          description: Recorded for the future role split; not enforced (every member holds the full permission set).
        status:
          type: string
          enum: [pending, accepted, revoked, expired]
          description: Server-derived; treat as authoritative.
        invited_by_email:
          type: string
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        accepted_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time

    Customer:
      type: object
      required: [id, external_id, display_name, status, created_at, updated_at]
      properties:
        id:
          type: string
          example: hc_cus_42014438d82ddf33
        external_id:
          type: string
        display_name:
          type: string
        email:
          type: string
        additional_emails:
          type: array
          maxItems: 10
          items:
            type: string
          description: |
            CC'd on billing documents and billing-state emails (invoice,
            receipt, dunning, payment failed, credit note) — never on
            credential-bearing emails (setup links, resets, invites).
            Normalized to lowercase bare addresses, deduped, capped at 10,
            never equal to the primary email. Stored encrypted at rest.
        status:
          type: string
          enum: [active, archived]
        created_at:
          type: string
          format: date-time
        test_clock_id:
          type: string
          description: |
            Test clock this customer is pinned to (test mode only). When set,
            the customer — and any invoices/subscriptions created for it —
            run on the clock's simulated time. Empty for live customers. The
            dashboard reads it to show the "test clock" badge + simulated-time
            banner on the customer and its invoices.
        tenant_id:
          type: string
        email_status:
          type: string
          enum: [unknown, ok, bounced, complained]
          description: Deliverability of `email`, learned from bounce and complaint reports.
        email_last_bounced_at:
          type: string
          format: date-time
        email_bounce_reason:
          type: string
        dunning_policy_id:
          type: string
          description: Assigned dunning policy; absent means the tenant default.
        stripe_customer_id:
          type: string
          description: The Stripe customer. Absent until the first payment-method action.
        livemode:
          type: boolean
        updated_at:
          type: string
          format: date-time

    Meter:
      type: object
      required: [id, event_name, name, unit, aggregation, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        event_name:
          type: string
          description: The `event_name` usage events send for this meter.
        name:
          type: string
        unit:
          type: string
        aggregation:
          type: string
          enum: [sum, count, max, last]
          description: |
            How the meter rolls its events into one quantity per billing
            period. Every price of the meter bills under it. `last`
            is the latest event by timestamp, then by id. Fixed once the
            meter has a usage event (see `has_usage`).
        has_usage:
          type: boolean
          description: |
            Whether the meter has usage events. Once it has, its
            `aggregation` cannot change. Held usage for its `event_name`
            does not count until it is released. Returned when you create,
            get or update one meter; the meter list leaves it out. A create
            or update that succeeded leaves it out if it could not be read.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    Plan:
      type: object
      required: [id, code, name, currency, billing_interval, status, base_amount_cents, base_bill_timing, prices, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        code:
          type: string
        name:
          type: string
        currency:
          type: string
        billing_interval:
          type: string
          enum: [monthly, yearly]
        status:
          type: string
          enum: [active, archived]
        base_amount_cents:
          type: integer
          format: int64
        base_bill_timing:
          type: string
          enum: [in_advance, in_arrears]
          description: |
            When the recurring base fee is invoiced relative to the period it covers.
            in_arrears: base + usage billed at period end.
            in_advance: base billed at period start (day 1 on create; cycle-close invoice
            carries upcoming period's base + elapsed period's usage). Usage lines are
            always arrears regardless of this field. A plan with no base fee is
            in_arrears. See ADR-031.
        prices:
          type: array
          description: |
            The plan's usage prices from now on. A plan's meters are the
            meters it has a price for.
          items:
            $ref: "#/components/schemas/PlanPrice"
        description:
          type: string
        tax_code:
          type: string
          description: Stripe tax code for this plan. Absent means the tenant's `default_product_tax_code`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PlanDetail:
      description: A plan as Get, Create and Update plan answer it.
      allOf:
        - $ref: "#/components/schemas/Plan"
        - type: object
          required: [prices, used, live_subscription_count]
          properties:
            used:
              type: boolean
              description: |
                Whether a subscription has ever used the plan (canceled ones
                and scheduled plan changes included). On a plan no one has
                used, a price change applies at once; on a used plan, from
                each subscriber's next billing period, and from the join for
                a subscriber who first joins the plan in a period after it.
            live_subscription_count:
              type: integer
              description: |
                Subscriptions on this plan that are not canceled. While it
                is above 0, `base_amount_cents` cannot change (see Update
                plan).

    PriceShape:
      type: object
      description: |
        How a price bills a quantity. Send only the mode's fields: a field the
        mode does not use must be 0 or left out (`tiers`: `[]` or left out),
        or the request is refused with 422 and `param` naming it.
      required: [mode]
      properties:
        mode:
          type: string
          enum: [per_unit, graduated, package]
          description: >-
            `per_unit` bills quantity × `unit_amount_cents`. `graduated`
            bills each tier's part of the quantity at that tier's rate.
            `package` bills ceil(quantity ÷ `package_size`) ×
            `package_amount_cents`: a part package bills as a whole one.
        unit_amount_cents:
          type: string
          description: >-
            Price per unit in cents, as a decimal string (e.g. "0.0003" =
            $3.00 per 1,000,000 units). Used when `mode` is `per_unit`. 0 or
            more, under 10^18, at most 12 decimal places (trailing zeros do
            not count), and at most 100 characters. A JSON number is also
            read. A value outside them is refused with 422 and `param`
            naming the field. Invoice line amounts round to whole cents.
        tiers:
          type: array
          description: >-
            Used when `mode` is `graduated`. Each `up_to` is a whole number
            above the one before. The last tier has `up_to: null` (no upper
            bound), and only the last. Otherwise 422 with `param` `tiers`.
            A tier's rate takes the limits of `unit_amount_cents`; a refusal
            names `tiers[i].unit_amount_cents` (i counts from 0).
          items:
            $ref: "#/components/schemas/PriceTier"
        package_size:
          type: integer
          format: int64
          description: Used when `mode` is `package`. Must be above 0.
        package_amount_cents:
          type: string
          description: >-
            Price of one package in cents, as a decimal string (e.g. "0.2"
            with `package_size` 1000 = $0.002 per 1,000 units), with the
            limits of `unit_amount_cents`. Used when `mode` is `package`.

    PlanPrice:
      type: object
      description: |
        One usage price of a plan: a meter, the events it takes
        (`dimension_match`) and its rate. The price takes the plan's
        currency. Its id stays the same when its rate changes; its meter
        and `dimension_match` are its identity and never change.
      required: [id, plan_id, meter_id, dimension_match, mode, created_at, updated_at, unit_amount_cents, tiers, package_size, package_amount_cents]
      properties:
        id:
          type: string
          example: hc_prc_1a2b3c4d5e6f7890abcdef12
        tenant_id:
          type: string
        plan_id:
          type: string
        meter_id:
          type: string
        dimension_match:
          type: object
          additionalProperties:
            type: string
          description: |
            The labels an event must carry for this price to take it, for
            example `{"model": "claude-opus-4.5", "token_type": "input"}`.
            `{}` takes every event of the meter. Of the prices whose labels
            an event carries, the one with the most labels takes it.
        mode:
          type: string
          enum: [per_unit, graduated, package]
          description: As on PriceShape.
        unit_amount_cents:
          type: string
          description: Price per unit in cents, as a decimal string.
        tiers:
          type: array
          description: Empty (`[]`) unless `mode` is `graduated`.
          items:
            $ref: "#/components/schemas/PriceTier"
        package_size:
          type: integer
          format: int64
        package_amount_cents:
          type: string
          description: Price of one package in cents, as a decimal string.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
          description: When the content shown was saved.

    PriceWrite:
      description: |
        The answer to a price write: the price, and when the write applies.
        `now`: no subscription has used the plan, so it applies at once.
        `next_period`: each subscriber pays the new content from their next
        billing period; the current period keeps the prices it started with.
        A subscriber who first joins the plan in a period after the write
        gets it from the join: a subscription that starts after it (after a
        trial, from the trial's end), an immediate plan change onto the
        plan, or an item added on it. Each is priced from that moment by the
        plan's prices then, so a subscription that starts later on the day
        of the write bills a price added that day and not one removed that
        day. A subscription that was on the plan earlier in the period and
        joins it again keeps the prices of its first join until its next
        period. On a test clock, the write takes effect just after the
        clock's time: a subscriber who joins at that same clock time gets
        the earlier prices.
      allOf:
        - $ref: "#/components/schemas/PlanPrice"
        - type: object
          required: [effective]
          properties:
            effective:
              type: string
              enum: [now, next_period]
            removed:
              type: boolean
              description: True on the answer to a removal.
            ended_overrides:
              type: array
              description: |
                On a removal, the customer overrides of the price it ended.
                Each ends when the removal takes effect for its customer.
              items:
                type: object
                required: [id, customer_id]
                properties:
                  id:
                    type: string
                  customer_id:
                    type: string

    PriceInput:
      description: One price to add to a plan.
      allOf:
        - type: object
          required: [meter_id]
          properties:
            meter_id:
              type: string
            dimension_match:
              type: object
              maxProperties: 16
              additionalProperties:
                type: string
              description: |
                The labels an event must carry for the price to take it.
                Values are strings of 1 to 100 characters, keys too; at
                most 16 labels. Omitted or `{}` takes every event of the
                meter. Two prices of one plan and meter that could both take
                one event with the same number of labels are refused (409
                `price_conflict`).
        - $ref: "#/components/schemas/PriceShape"

    PriceTier:
      type: object
      required: [up_to, unit_amount_cents]
      properties:
        up_to:
          type: integer
          format: int64
          nullable: true
          description: >-
            The tier's upper bound in whole units: the tier prices the
            quantity above the previous tier's bound, up to this one. `null`
            on the last tier, which has no upper bound. Only the last tier
            is `null`, and it must be.
        unit_amount_cents:
          type: string
          description: Price per unit in cents in this tier, as a decimal string.

    Subscription:
      type: object
      required: [id, code, display_name, customer_id, status, billing_time, cancel_at_period_end, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        code:
          type: string
        display_name:
          type: string
        customer_id:
          type: string
        items:
          type: array
          description: The subscription's priced lines. Each item names its plan.
          items:
            $ref: "#/components/schemas/SubscriptionItem"
        status:
          type: string
          enum: [draft, trialing, active, canceled]
        billing_time:
          type: string
          enum: [calendar, anniversary]
        trial_start_at:
          type: string
          format: date-time
        trial_end_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        activated_at:
          type: string
          format: date-time
        canceled_at:
          type: string
          format: date-time
          description: When the subscription was canceled. Absent until then.
        cancel_at:
          type: string
          format: date-time
          description: A scheduled cancel time, if one is set.
        cancel_at_period_end:
          type: boolean
          description: True when the subscription cancels at the end of the current period.
        cancel_effective_at:
          type: string
          format: date-time
          description: When a scheduled cancel takes effect. Absent when no cancel is scheduled.
        pause_collection:
          $ref: "#/components/schemas/SubscriptionPauseCollection"
        billing_thresholds:
          $ref: "#/components/schemas/SubscriptionBillingThresholds"
        current_billing_period_start:
          type: string
          format: date-time
        current_billing_period_end:
          type: string
          format: date-time
          description: End of the current period (exclusive).
        current_billing_period_display:
          type: string
          description: The current period as text, with the last day included, in the tenant's billing time zone.
        next_billing_at:
          type: string
          format: date-time
        billing_anchor_day:
          type: integer
          description: Day of the month the subscription bills on (1-31). Absent for calendar-monthly subscriptions.
        test_clock_id:
          type: string
        billing_failure:
          $ref: "#/components/schemas/BillingFailure"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    BillingFailure:
      type: object
      required: [attempts, max_attempts, last_error, failed_at, parked]
      description: |
        Present on a subscription while the billing run is failing on the
        work that subscription has due now (a period close, or a scheduled
        mid-period cancel). Absent otherwise: a subscription that bills
        cleanly, or whose due work changed, carries no `billing_failure`.

        After each failed attempt the billing run waits before the next one:
        15 minutes, 1 hour, 4 hours, 12 hours, then 24 hours three times.
        The eighth failure parks the subscription (`parked: true`, no
        `next_attempt_at`). A parked subscription is not attempted again
        until `POST /v1/subscriptions/{id}/retry-billing` is called.

        Never set on a subscription attached to a test clock.
      properties:
        attempts:
          type: integer
          description: Failed attempts on the work that is due now. At least 1.
        max_attempts:
          type: integer
          description: The attempt at which the subscription parks (8).
        last_error:
          type: string
          description: |
            Why the latest attempt failed: a sentence an operator can act
            on, or a generic line carrying the subscription id as the
            reference to search the server log for. Never raw error text.
        failed_at:
          type: string
          format: date-time
          description: When the latest attempt failed.
        next_attempt_at:
          type: string
          format: date-time
          description: |
            The billing run does not attempt the subscription before this
            instant. The attempt happens on the first run at or after it,
            so on an hourly schedule it can be up to an hour later. Absent
            when parked.
        parked:
          type: boolean
          description: True when automatic attempts have stopped.

    UsageEvent:
      type: object
      required: [id, customer_id, meter_id, quantity, timestamp]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        customer_id:
          type: string
        meter_id:
          type: string
        quantity:
          type: string
          description: Exact quantity as a decimal string (e.g. "1.5").
        dimensions:
          type: object
          additionalProperties: true
          description: The dimension values sent with the event. Absent when none were sent.
        idempotency_key:
          type: string
        timestamp:
          type: string
          format: date-time
        origin:
          type: string
          enum: [api, backfill]
          description: How the event arrived. `backfill` means `POST /v1/usage-events/backfill`.
        provider_cost_cents:
          type: string
          description: |
            What serving this event cost you at your provider, in US cents as
            a decimal string (e.g. "0.45"), set when the event arrived: from
            your provider cost rates, or the cost the sender reported (see
            `provider_cost_source`). Absent when the cost is unknown or the
            event has no costed dimensions.
        provider_cost_source:
          type: string
          enum: [table, not_applicable, observed]
          description: |
            Where `provider_cost_cents` came from: `table` (your provider
            cost rates), `observed` (the cost the sender reported), or
            `not_applicable` (the meter has no costed dimensions). Absent when
            the cost is unknown.
        billing_status:
          $ref: "#/components/schemas/UsageBillingStatus"

    UsageBillingStatus:
      type: string
      enum: [after_close, no_price, not_on_plan, outside_subscription]
      description: |
        What Hardcount found when the event arrived. It checked the event
        against the customer's subscriptions, the plans and prices in force,
        and the invoices already built at that moment. Sent only on the
        answer that wrote the event; the event is stored all the same.

        A status that is present was true at that moment:

        - `after_close`: the event's time is already invoiced. A
          spend-threshold invoice counts only once it is paid. Bill the
          event with a one-off invoice if it should be charged.
        - `not_on_plan`: a billing period not invoiced yet holds the event's
          time, but its plan has no price for this meter.
        - `outside_subscription`: no active or trialing subscription of the
          customer bills the event's time. For example, it has none, or the
          time is before one starts, in a free trial, or after one ends or
          its scheduled cancel.
        - `no_price`: the plan that bills the event's time prices this
          meter, but no price of that plan takes the event's `dimensions`.
          A price takes an event whose dimensions include all of its
          `dimension_match`, compared as strings: the number `1` is not the
          string `"1"`.

        No `billing_status` means Hardcount found no reason then. It is not a
        promise that the event will be billed. Hardcount checks only the
        customer's current billing periods: time after a period end whose
        invoice is not built yet is not checked, nor is time after a trial's
        end before Hardcount ends the trial. A later change can change the
        outcome either way: a subscription created, activated or changed, a
        trial extended, a scheduled cancel undone, or a price added. The
        invoice is the final answer.

        Two cases can make a present status wrong: a price change whose
        save began before a billing period started and finished after the
        request read the prices, and a subscription change saved while the
        request is in flight. Absent on a replay and on a read.

    UsageEventBatchResult:
      type: object
      required: [ingested, deduplicated, total, after_close, no_price, not_on_plan, outside_subscription, results]
      properties:
        ingested:
          type: integer
          description: Newly written events.
        deduplicated:
          type: integer
          description: Idempotency replays — already ingested, counted as success.
        total:
          type: integer
          description: Events in the request.
        after_close:
          type: integer
          description: Of `ingested`, the events answered `billing_status` `after_close`.
        no_price:
          type: integer
          description: Of `ingested`, the events answered `billing_status` `no_price`.
        not_on_plan:
          type: integer
          description: Of `ingested`, the events answered `billing_status` `not_on_plan`.
        outside_subscription:
          type: integer
          description: Of `ingested`, the events answered `billing_status` `outside_subscription`.
        results:
          type: array
          description: |
            One entry per event, in the order sent: `results[i]` is the
            request's event `i`.
          items:
            $ref: "#/components/schemas/UsageEventBatchEventResult"

    UsageEventBatchEventResult:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ingested, deduplicated]
          description: |
            `ingested`: the batch wrote the event. `deduplicated`: an event
            with its `idempotency_key` was already stored, earlier or in
            this batch, so it was not stored again.
        billing_status:
          $ref: "#/components/schemas/UsageBillingStatus"

    DocumentBillTo:
      type: object
      description: |
        What an issued invoice or credit note prints about its buyer, copied
        when the document was issued (ADR-109). The printed name is
        `legal_name` when set, otherwise `display_name`.
      required: [display_name]
      properties:
        display_name:
          type: string
        legal_name:
          type: string
        email:
          type: string
        address_line1:
          type: string
        address_line2:
          type: string
        city:
          type: string
        state:
          type: string
        postal_code:
          type: string
        country:
          type: string
        tax_id:
          type: string
          description: |
            On an invoice, the tax ID its tax was calculated with when set,
            otherwise the billing profile's tax ID at issue.
        tax_id_type:
          type: string

    DocumentSupplier:
      type: object
      description: |
        What an issued invoice or credit note prints about its seller (the
        tenant's company), copied when the document was issued (ADR-109).
      properties:
        name:
          type: string
        email:
          type: string
        phone:
          type: string
        address_line1:
          type: string
        address_line2:
          type: string
        city:
          type: string
        state:
          type: string
        postal_code:
          type: string
        country:
          type: string
        tax_id:
          type: string

    InvoiceStatus:
      type: string
      enum: [draft, finalized, paid, voided, uncollectible]
      description: |
        Lifecycle state of the invoice. `uncollectible` marks an invoice
        a dunning policy has given up collecting (no further retries) while
        keeping it on the books — distinct from `voided` (cancelled, owes
        nothing).

    InvoicePaymentStatus:
      type: string
      enum: [pending, processing, succeeded, failed, unknown]
      description: |
        Payment-attempt status. `unknown` marks a Stripe charge attempt that
        returned an ambiguous error (5xx, timeout, connection reset) where
        we cannot tell whether Stripe processed the charge. With a payment
        intent recorded, Hardcount asks Stripe for it after a short wait. With
        none (a parked invoice), Hardcount lists the customer's payments at
        Stripe every billing tick: it records a payment for the invoice that
        appears, and if none has appeared 24 hours after the attempt the
        invoice goes back to `pending` (`failed` after a dunning retry) and
        is charged again as usual.

    InvoiceTaxStatus:
      type: string
      enum: [ok, pending]
      description: |
        Whether tax has been calculated. `ok`: it has. `pending`: the
        calculation failed, and `tax_error_code` says why and whether
        Hardcount retries it. A pending invoice cannot be finalized, so a wrong
        tax amount is never sent.

    AttentionSeverity:
      type: string
      enum: [info, warning, critical]
      description: |
        Urgency of an Attention surface. Operators sort/filter on this
        and the dashboard renders one colour per level. Priority
        descending: Critical > Warning > Info.

    AttentionReason:
      type: string
      enum:
        - tax_calculation_failed
        - tax_location_required
        - payment_failed
        - payment_unconfirmed
        - payment_anomaly
        - payment_processing
        - payment_scheduled
        - awaiting_payment
        - no_payment_method
        - dunning_exhausted
        - collection_paused
        - commit_exposure
        - dispute_lost
        - amount_below_minimum
      description: |
        Closed, typed code naming why an invoice needs operator
        attention. Stable public-API contract — codes are never
        repurposed; deprecations keep the code reserved and add a
        successor. See `Attention.code` for the open, provider-specific
        sub-code. `payment_anomaly` (ADR-068) reports money that does
        not reconcile — double charge, captured-vs-booked amount
        mismatch, or a payment on a voided invoice — and is surfaced
        even on paid/voided invoices. `dunning_exhausted` reports a
        no-payment-method invoice whose automatic recovery has ended
        without collecting (2026-07-22). `collection_paused` reports an
        invoice that is queued for auto-charge but whose subscription
        has `pause_collection` set, so the charge sweeps deliberately
        skip it (2026-07-28). A dunning run already open on the invoice
        keeps retrying it through the pause, and `message` then says so;
        with no open run it is not charged automatically until
        collection resumes. `commit_exposure` reports an
        unpaid invoice that funded a prepaid commit whose credit is
        already spendable (ADR-078 funds commits at issue, not at
        payment) — note this reason is only returned when no other
        reason applies; when one does, the exposure is folded into that
        reason's `message` and its `actions` instead, so clients must
        not treat the reason code as the presence test for exposure
        (2026-08-04). `dispute_lost` reports a payment on the invoice that
        the customer disputed with their bank and that the dispute took
        back (the dispute closed as `lost`). Hardcount leaves the invoice paid
        and moves no money; the operator records the loss. Surfaced on paid
        invoices. On an invoice that bought prepaid credit, the message
        also says how much of the credit is still spendable and names
        commit relief, because an ordinary credit note is refused there.
        When the invoice also has `payment_anomaly`, that reason stays and
        its message asks the operator to check Stripe before any refund.
        The disputes themselves are at `GET /v1/invoices/{id}/disputes`
        (2026-10-19). `amount_below_minimum` reports an invoice Stripe
        refused to charge, or refused a payment page for, because the amount
        is below Stripe's minimum charge. The minimum depends on the
        currency the Stripe account settles in; Stripe's reply, in
        `provider_response`, names it. Nothing was charged, the invoice
        stays `finalized` with `payment_status` `pending`, it is not
        charged again automatically, dunning does not start (a run already
        open ends), and the customer is not emailed. The operator voids it
        or records a payment; Collect payment applies the customer's credit
        balance and pays it if that covers the amount, and otherwise asks
        Stripe again. Stripe's answer can change (for a converted charge the
        minimum is the settlement currency's), and a different answer ends
        this reason (2026-10-23).

    RecoveryWarning:
      type: object
      required: [blocked]
      properties:
        blocked:
          type: boolean
          description: Always false — this shape only ever warns.
        code:
          type: string
          enum:
            - tax_reversed_unrecoverable
            - relief_not_reissued
        message:
          type: string
          description: Operator-facing sentence naming the consequence that
            will go unreconciled if the payment is recorded.
      description: |
        What an operator must know BEFORE recording an OFFLINE payment on a
        written-off invoice (`status = uncollectible`) — a consequence that
        will go unreconciled, never a refusal. The money already arrived, and
        refusing to record it would only make the books wrong too.

        A written-off invoice is settled by RECORDING writers only — an
        offline payment, or Hardcount recording a card charge that Stripe shows
        succeeded (its webhook, or the check of an unresolved charge).
        Nothing initiates a card
        charge against one (ADR-113): recovery of a returned customer runs on
        normal rails via a fresh recovery invoice.

        `tax_reversed_unrecoverable` — the invoice's tax was reversed with the
        tax provider and cannot be re-reported automatically; the recorded
        payment collects tax already reported as not collected.
        `relief_not_reissued` — a credit the invoice was owed was never
        applied, so `amount_due_cents` is higher than what is owed and the
        customer may have overpaid.

    AttentionAction:
      type: string
      enum:
        - edit_billing_profile
        - retry_tax
        - retry_payment
        - wait_provider
        - rotate_api_key
        - reconcile_payment
        - review_registration
        - charge_now
        - send_reminder
        - add_payment_method
        - update_payment_method
        - connect_tax_provider
        - void_invoice
        - mark_uncollectible
      description: |
        Operator's recommended next step. Closed enum because every
        code maps to a concrete server endpoint or frontend route,
        and audit logs key off the code. `reconcile_payment` opens the
        payment in Stripe; it is offered on `payment_anomaly`, where the
        refund or correction is made there. `mark_uncollectible` opens
        the write-off and is offered only on a parked invoice (payment
        status unknown with no payment intent recorded), for a customer
        who paid another way: Hardcount records a payment that Stripe shows,
        and charges the invoice again if none appears within 24 hours.
        Dashboards render it as a secondary action. `void_invoice` is offered
        on prepaid-commit exposure, only while some of the granted
        credit is still unspent: voiding retires the unspent balance
        (ADR-078) and is the sole recovery for an unpaid invoice whose
        credit is already live. Credit the customer has already spent
        is never clawed back (2026-08-04). It is also offered on
        `amount_below_minimum`, an amount Stripe will not charge
        (2026-10-23).

    AttentionActionItem:
      type: object
      required: [code]
      properties:
        code:
          $ref: "#/components/schemas/AttentionAction"
        label:
          type: string
          description: |
            Default rendering label. UIs are free to substitute
            localized copy.

    Attention:
      type: object
      description: |
        Unified "this invoice needs operator attention" surface.
        Computed server-side from durable invoice fields
        (`tax_status`, `tax_error_code`, `payment_status`,
        `last_payment_error`). Never persisted —
        always derived. Omitted entirely when the invoice is healthy
        (matches Stripe's `last_finalization_error: null` ergonomic).
        See ADR-009.
      required: [severity, reason, message]
      properties:
        severity:
          $ref: "#/components/schemas/AttentionSeverity"
        reason:
          $ref: "#/components/schemas/AttentionReason"
        message:
          type: string
          description: |
            Human-readable copy, safe to render verbatim: one plain
            sentence saying what happens and what to do. Facts that must
            show up front follow on more lines, separated by a newline
            (`\n`), one sentence each and up to three lines in all: the
            setup-link email on `no_payment_method` (and on
            `dunning_exhausted` when no link can be emailed), and the
            prepaid credit at risk on an unpaid commit invoice. A no-card
            commit invoice carries all three. Wording may change between
            releases; switch on `reason` and `code`, not on this text.
        actions:
          type: array
          items:
            $ref: "#/components/schemas/AttentionActionItem"
          description: |
            Prescribed next steps. First item is the primary CTA; the
            rest are secondary.
        code:
          type: string
          description: |
            Open, dotted, provider-specific code (e.g.
            `tax.customer_data_invalid`, `payment.declined`).
            New codes ship without a contract
            bump. Stripe parity.
        doc_url:
          type: string
          description: |
            Deep link to operator-facing documentation for this
            reason. Stripe parity.
        param:
          type: string
          description: |
            Dotted-path pointer at the entity field the operator must
            edit to resolve the issue (e.g.
            `customer.address.postal_code`). Stripe parity. Empty
            when the resolution isn't field-scoped.
        detail:
          type: string
          description: |
            Hardcount's own explanation behind `message`: why the state
            holds, what Hardcount does on its own, and ids support may ask
            for (for example the payment intent ids of a payment
            anomaly). Plain text; may contain blank-line paragraph
            breaks. Never a provider payload — that is
            `provider_response`.
        provider_response:
          type: string
          description: |
            The provider's own response (Stripe Tax error envelope,
            Stripe's last payment error), exactly as received. Present
            only when Hardcount called the provider.
        since:
          type: string
          format: date-time
          description: |
            When the attention condition started — operators triage
            by age. tax_deferred_at for tax reasons; due_at for
            overdue; updated_at as a proxy for payment failures.
        next_attempt_at:
          type: string
          format: date-time
          description: |
            When Hardcount will next retry on its own. Today only a tax
            retry sets it. Absent when no automatic retry is scheduled.
        due_by:
          type: string
          format: date-time
          description: |
            The invoice's payment deadline (its `due_at`). Hardcount does not
            act on this date; it is shown so the operator knows it.
            Absent when the invoice has no due date.

    InvoiceBillingReason:
      type: string
      enum: [subscription_cycle, subscription_create, subscription_cancel, subscription_update, manual, threshold]
      description: |
        Mirrors Stripe's `invoice.billing_reason`. Classifies the trigger
        that produced the invoice. Stamped at create time and never mutated.

    InvoiceLineType:
      type: string
      enum: [base_fee, usage, add_on]

    ItemChangeType:
      type: string
      enum: [plan, quantity, add, remove]
      description: |
        Classifies per-item proration artifacts so the dedup index can
        distinguish a plan-change and a quantity-change that happen to
        share the same `(subscription, item, timestamp)`.

    Invoice:
      type: object
      description: |
        A Hardcount invoice. Returned from create, finalize, void, list, get,
        rotate-public-token, and collect-payment endpoints.
      required:
        - id
        - customer_id
        - subscription_id
        - invoice_number
        - status
        - payment_status
        - currency
        - subtotal_cents
        - discount_cents
        - tax_amount_cents
        - tax_rate
        - total_amount_cents
        - amount_due_cents
        - amount_paid_cents
        - credits_applied_cents
        - billing_period_start
        - billing_period_end
        - net_payment_term_days
        - created_at
        - updated_at
        - paid_out_of_band
        - is_simulated
      properties:
        id:
          type: string
          example: hc_inv_42014438d82ddf33
        tenant_id:
          type: string
          description: |
            Owning tenant. Returned for diagnostic clarity but redundant
            with the API key (which already determines the tenant).
            Omitted when empty.
        customer_id:
          type: string
        subscription_id:
          type: string
          description: Empty for one-off invoices not tied to a subscription.
        invoice_number:
          type: string
          description: Set when the invoice is created, including drafts.
        status:
          $ref: "#/components/schemas/InvoiceStatus"
        payment_status:
          $ref: "#/components/schemas/InvoicePaymentStatus"
        currency:
          type: string
          example: USD
        subtotal_cents:
          type: integer
          format: int64
        discount_cents:
          type: integer
          format: int64
        tax_amount_cents:
          type: integer
          format: int64
        tax_rate:
          type: number
          format: double
          description: Tax rate as a percent (18.50 = 18.50%), up to 4-decimal precision. ADR-042/043.
        tax_name:
          type: string
        tax_country:
          type: string
        tax_id:
          type: string
        tax_provider:
          type: string
          description: |
            Durable audit snapshot of the upstream tax engine that ran the
            calculation (e.g. `stripe`, `none`, `manual`).
        tax_calculation_id:
          type: string
          description: |
            Provider-side calculation reference captured at build time.
            For Stripe Tax this expires after 24 h; persisted so finalized
            invoices remain reconstructable past expiry.
        tax_transaction_id:
          type: string
          description: |
            Provider-side committed transaction reference, captured at
            finalize. For Stripe Tax this is `tx_xxx` and is the handle
            the provider needs for a later reversal when a credit note is
            issued.
        tax_reverse_charge:
          type: boolean
        tax_exempt_reason:
          type: string
        tax_status:
          $ref: "#/components/schemas/InvoiceTaxStatus"
        tax_deferred_at:
          type: string
          format: date-time
          nullable: true
        tax_retry_count:
          type: integer
        tax_pending_reason:
          type: string
        tax_error_code:
          type: string
          enum:
            - customer_data_invalid
            - jurisdiction_unsupported
            - provider_outage
            - provider_auth
            - provider_not_configured
            - unknown
          description: |
            Why tax could not be calculated, as a code. Set while
            `tax_status` is `pending`; `tax_pending_reason` has the
            detail.
        tax_next_retry_at:
          type: string
          format: date-time
          description: |
            The earliest time the background retry may calculate tax again,
            after a failed attempt. Absent when a retry may run now.
        tax_reversed_at:
          type: string
          format: date-time
          description: |
            When the provider's tax transaction was reversed after a void or
            write-off, or found to need no reversal. Absent while the
            reversal is not confirmed.
        payment_anomaly_kind:
          type: string
          description: |
            Set when a payment arrived that the invoice did not expect. The
            webhook event name of the cause: `payment.duplicate_charge`,
            `payment.amount_mismatch`, `payment.currency_mismatch` or
            `payment.received_on_voided_invoice`.
            Only the latest one is kept.
        payment_anomaly_payment_intent_id:
          type: string
          description: The Stripe PaymentIntent of that unexpected payment.
        payment_anomaly_captured_cents:
          type: integer
          format: int64
          description: The amount Stripe took in that unexpected payment.
        payment_card_brand:
          type: string
          description: Brand of the saved card that paid the invoice. Absent when the card is unknown.
        payment_card_last4:
          type: string
          description: Last four digits of the saved card that paid the invoice.
        attention:
          $ref: "#/components/schemas/Attention"
        recovery_warning:
          allOf:
            - $ref: "#/components/schemas/RecoveryWarning"
          description: |
            What an operator must know BEFORE recording an OFFLINE payment on a
            written-off invoice — a consequence that will go unreconciled, NOT
            a refusal. Absent when there is nothing to say.
        total_amount_cents:
          type: integer
          format: int64
        amount_due_cents:
          type: integer
          format: int64
        amount_paid_cents:
          type: integer
          format: int64
        credits_applied_cents:
          type: integer
          format: int64
        billing_period_start:
          type: string
          format: date-time
        billing_period_end:
          type: string
          format: date-time
        billing_period_display:
          type: string
          description: >-
            Human period string with the inclusive last covered day
            ("Jun 1, 2028 – Jun 30, 2028"), date-only in the invoice's billing
            timezone (ADR-058 / ADR-077). Computed on read; the raw
            billing_period_start/end stay half-open. Empty/omitted for one-off
            invoices with no period.
        billing_timezone:
          type: string
          description: >-
            IANA timezone the period boundaries are anchored in — the org
            billing timezone resolved and denormalized onto the invoice at issue
            (ADR-077). billing_period_display is rendered in this zone, so an
            issued invoice's dates stay fixed even if the org later changes its
            timezone. Empty only on legacy invoices created before the zone was
            stamped (cycle invoices before migration 0134, manual invoices
            before 2026-10-16).
        bill_to_snapshot:
          allOf:
            - $ref: "#/components/schemas/DocumentBillTo"
          description: |
            The buyer details this invoice was issued with (ADR-109), copied
            when it left draft. The PDF, the hosted page and the dashboard
            render an issued invoice from this copy, so a later edit to the
            customer or billing profile does not change it. Absent on drafts
            and on invoices issued before this field existed; those render
            from the current customer details.
        supplier_snapshot:
          allOf:
            - $ref: "#/components/schemas/DocumentSupplier"
          description: |
            The seller (company) details this invoice was issued with
            (ADR-109), copied when it left draft. Absent on drafts and on
            invoices issued before this field existed. Branding (logo, brand
            color) is not part of it.
        issued_at:
          type: string
          format: date-time
          nullable: true
          description: Set when the invoice transitions out of draft.
        due_at:
          type: string
          format: date-time
          nullable: true
        paid_at:
          type: string
          format: date-time
          nullable: true
        voided_at:
          type: string
          format: date-time
          nullable: true
        uncollectible_at:
          type: string
          format: date-time
          description: When the invoice was marked uncollectible.
        stripe_payment_intent_id:
          type: string
          description: |
            The Stripe PaymentIntent of the card charge that paid the
            invoice, or of its last charge that is in progress or failed. It
            can be absent after a charge was sent: for example when the
            invoice was then paid without a card (by credits, with nothing
            due, or outside Hardcount: `paid_out_of_band`), or when Stripe
            refused the last charge as below its minimum.
        paid_out_of_band:
          type: boolean
          description: |
            True when the payment was recorded as made outside Hardcount, such as
            a bank transfer or cheque (`POST /v1/invoices/{id}/record-payment`,
            or a payment recovery run resolved as `payment_recovered`). Such
            an invoice has no `stripe_payment_intent_id`, so there is no card
            to refund.
        last_payment_error:
          type: string
        auto_charge_pending:
          type: boolean
        no_pm_notified_at:
          type: string
          format: date-time
          description: |
            When the customer was emailed a link to add a payment method for
            this invoice. The email is sent once.
        provider_payment_status:
          type: string
          description: |
            Stripe's PaymentIntent status the last time Hardcount checked it,
            word for word. For information only: `payment_status` is the
            invoice's payment state. Absent until Hardcount has checked. An
            invoice checked by the Stripe Search sweep of releases before
            this one may still show `search_not_found`,
            `search_terminal_only` or `search_error`.
        provider_synced_at:
          type: string
          format: date-time
          description: |
            When Hardcount last asked Stripe about this invoice's payment, whether
            or not Stripe answered: a read of `provider_payment_status`, or a
            check of a charge whose result was lost.
        public_token:
          type: string
          description: |
            Hosted-invoice-URL credential (Stripe-parity
            `hosted_invoice_url`). Generated at finalize; drafts have an
            empty token. Rotatable via `POST
            /v1/invoices/{id}/rotate-public-token`.
        net_payment_term_days:
          type: integer
        memo:
          type: string
        footer:
          type: string
          description: |
            The tenant's `invoice_footer` as it was when this invoice was
            issued. Printed on the PDF and the hosted invoice page. Empty on
            a draft.
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        source_plan_changed_at:
          type: string
          format: date-time
          nullable: true
        source_subscription_item_id:
          type: string
        source_change_type:
          $ref: "#/components/schemas/ItemChangeType"
        billing_reason:
          $ref: "#/components/schemas/InvoiceBillingReason"
        is_simulated:
          type: boolean
          description: |
            Whether this invoice's domain timestamps (created/issued/due/paid)
            were stamped on a test clock's simulated time rather than
            wall-clock. Captured at write time (engine: the subscription is
            clock-pinned; manual composer: the customer is clock-pinned). The
            dashboard renders a "simulated" badge from this authoritative flag
            instead of inferring it. Always false in live mode.
        test_clock_id:
          type: string
          description: |
            The test clock this invoice's dates follow: the clock its customer
            is pinned to, for cycle and one-off invoices alike. Read from the
            customer on `GET /v1/invoices` and `GET /v1/invoices/{id}`; other
            responses may leave it out. Left out for a customer on wall-clock
            time, and always in live mode.

    InvoiceLineItem:
      type: object
      description: A single line on an invoice.
      required:
        - id
        - invoice_id
        - line_type
        - description
        - quantity
        - unit_amount_decimal
        - amount_cents
        - tax_rate
        - tax_amount_cents
        - total_amount_cents
        - currency
        - created_at
      properties:
        id:
          type: string
        invoice_id:
          type: string
        tenant_id:
          type: string
          description: Owning tenant. Omitted when empty.
        line_type:
          $ref: "#/components/schemas/InvoiceLineType"
        meter_id:
          type: string
        meter_unit:
          type: string
          description: >
            The meter's unit ("tokens", "seconds", …), stamped on usage lines
            at write time so invoice surfaces can apply the display-scale
            convention (token rates read "$3.00 / 1M tokens"). Empty on
            non-usage lines and on lines written before the 0173 backfill.
        description:
          type: string
        quantity:
          type: string
          description: >
            The line's exact quantity as a decimal string, for example "3"
            or "1.5". Line amounts stay whole cents.
        unit_amount_decimal:
          type: string
          description: >
            The unit price in cents at full precision, for example "0.3"
            for $0.003. On a usage line priced `per_unit` it is that
            rate; on any other line it is amount_cents ÷ quantity. Line
            amounts and totals stay whole cents.
        amount_cents:
          type: integer
          format: int64
        tax_rate:
          type: number
          format: double
          description: Tax rate as a percent (18.50 = 18.50%), up to 4-decimal precision. ADR-042/043.
        tax_amount_cents:
          type: integer
          format: int64
        tax_jurisdiction:
          type: string
        tax_code:
          type: string
        tax_reason:
          type: string
          description: |
            Stripe-canonical structured `taxability_reason`
            (`standard_rated`, `reverse_charge`, `not_collecting`,
            `customer_exempt`, `product_exempt`, `zero_rated`, etc.).
            `prepaid_credit_purchase` is Hardcount's own value, on a prepaid
            commit purchase line under the manual or Stripe Tax provider:
            the line is not taxed, and the usage the credit pays for is
            taxed when invoiced. Otherwise empty for non-Stripe providers.
        total_amount_cents:
          type: integer
          format: int64
        currency:
          type: string
        pricing_mode:
          type: string
          description: "On a usage line, the shape of its price: `per_unit`, `graduated` or `package`. Absent on other lines."
        price_id:
          type: string
          description: On a usage line, the plan price that billed it. Absent on other lines.
        dimension_match:
          type: object
          additionalProperties:
            type: string
          description: On a usage line, the labels its price takes. Absent for a price that takes every event, and on other lines.
        billing_period_start:
          type: string
          format: date-time
          nullable: true
        billing_period_end:
          type: string
          format: date-time
          nullable: true
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        commit_granted_cents:
          type: integer
          format: int64
          nullable: true
          description: |
            Marks the line as a prepaid-commit purchase (ADR-078): when the
            invoice finalizes, a credit block of this many cents is granted
            to the customer. May differ from the line price — discounted
            commits sell $10k of credits for $9k. add_on lines on manual
            invoices only, at most one per invoice. Absent on normal lines.
        commit_expires_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Expiry of the granted commit block. The credit can be drawn up
            to and including this instant; a period invoice draws it when
            its period ends at or before it. The dashboard sends the local
            midnight after the picked day. Absent/null = the credits never
            expire (the default).

    ProviderCostRate:
      type: object
      required: [id, provider, model, token_type, unit_cost_cents, currency, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        provider:
          type: string
        model:
          type: string
        token_type:
          type: string
        unit_cost_cents:
          type: string
          description: US cents per token, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 tokens).
        currency:
          type: string
          enum: [USD]
          description: Always USD. Cost and margin count every rate in US cents.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    MarginReport:
      type: object
      required: [customer_id, from, to, revenue_cents, cost_cents, cost_currency, by_model, unattributed_revenue_cents, unresolved_events, costed_events, cost_rates_configured]
      properties:
        customer_id:
          type: string
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        revenue_cents:
          type: integer
          format: int64
          description: Total rated usage revenue in the window, an estimate at each subscription's current prices. Only active and trialing subscriptions are counted.
        revenue_currency:
          type: string
          description: Currency of revenue_cents (the customer's price rules). Omitted when the window's revenue spans more than one currency or the customer has no priced meter.
        cost_cents:
          type: string
          description: Total stamped provider cost in US cents, as a decimal string (e.g. "275.75").
        cost_currency:
          type: string
          description: Currency of cost_cents. Always USD.
        margin_bps:
          type: integer
          format: int64
          description: (revenue − cost) / revenue in basis points. Omitted when revenue is 0, when revenue_currency is not cost_currency, when costed_events is 0 (no provider cost known), or when unresolved_events is above 0 (part of the cost unknown, so the margin would read too high).
        by_model:
          type: array
          items:
            type: object
            required: [model, cost_cents, attributed]
            properties:
              model:
                type: string
              cost_cents:
                type: string
                description: This model's stamped provider cost in US cents, as a decimal string.
              revenue_cents:
                type: integer
                format: int64
              attributed:
                type: boolean
                description: True only when the price that billed the usage names this model — margin is never heuristically allocated.
              margin_bps:
                type: integer
                format: int64
                description: This model's (revenue − cost) / revenue in basis points, by the same formula as the report's margin_bps. Omitted whenever the report's margin_bps is omitted for currency or unresolved_events, and when the row is not attributed or has no provider cost of its own.
        unattributed_revenue_cents:
          type: integer
          format: int64
        unresolved_events:
          type: integer
          format: int64
          description: Token events with costable dims but no cost — no rate matched (add a rate to cost new events), or the cost, quantity × `unit_cost_cents`, was too large to record (about $9.2 trillion or more, either sign).
        costed_events:
          type: integer
          format: int64
          description: Events in the window that carry a stamped provider cost (zero cost included). 0 means the cost side is unknown.
        cost_rates_configured:
          type: boolean
          description: Whether the tenant has any provider cost rate. False with costed_events 0 means no rates are set up.

    InvoiceWithLineItems:
      type: object
      description: |
        Response shape for `GET /v1/invoices/{id}`. The handler returns the
        invoice and its line items as separate top-level keys (rather than
        nesting `line_items` inside the invoice) so the client can render
        the two arrays independently.
      required: [invoice, line_items]
      properties:
        invoice:
          $ref: "#/components/schemas/Invoice"
        line_items:
          type: array
          items:
            $ref: "#/components/schemas/InvoiceLineItem"

    CreditBalance:
      type: object
      description: >
        A customer's credit balance in one currency and where that credit
        went. Every cent granted is either still in the balance or in one of
        the totals: total_granted = total_used + total_expired +
        total_retired + total_deducted + balance_cents.
      required: [customer_id, balance_cents, total_granted, total_used, total_expired, total_retired, total_deducted, currency]
      properties:
        customer_id:
          type: string
        currency:
          type: string
          description: >
            The currency of this balance, uppercase. Each credit ledger entry
            stores its own currency, and credit pays only invoices in that
            currency. A customer with credit in two currencies has two
            balances; they are never added together or converted.
        balance_cents:
          type: integer
          format: int64
        total_granted:
          type: integer
          format: int64
          description: >
            Every credit added: grants, positive adjustments, and credit given
            back when an invoice it was applied to was voided.
        total_used:
          type: integer
          format: int64
          description: Credit applied to invoices.
        total_expired:
          type: integer
          format: int64
          description: Credit written off by expiry.
        total_retired:
          type: integer
          format: int64
          description: >
            Prepaid commit credit taken back because the invoice that funded
            it was voided or refunded by a credit note.
        total_deducted:
          type: integer
          format: int64
          description: Credit an operator deducted (a negative adjustment).

    CreditLiveBalance:
      type: object
      description: >
        A customer's credit position right now, one entry per currency: every
        currency the customer holds credit in, and every currency a live
        subscription will invoice usage in. Amounts in two currencies are
        never added together.
      required: [customer_id, balances, computed_at]
      properties:
        customer_id:
          type: string
        balances:
          type: array
          description: Ordered by currency.
          items:
            $ref: "#/components/schemas/CreditLiveCurrency"
        computed_at:
          type: string
          format: date-time
          description: When the server computed these figures.

    CreditLiveCurrency:
      type: object
      description: >
        The live position in one currency. `balance_cents` is the credit the
        customer can spend in it: the settled ledger balance, which moves only
        when an invoice, grant, adjustment or expiry is written, less any
        grant that is past its expiry date and not yet written off.
        `unbilled_usage_cents` is the usage of the current period that no
        invoice has billed yet and that will be invoiced in this currency.
        `estimated_remaining_cents` is the first minus the second, never
        below zero.
      required: [currency, balance_cents, unbilled_usage_cents, estimated_remaining_cents]
      properties:
        currency:
          type: string
        balance_cents:
          type: integer
          format: int64
          description: >
            Settled ledger balance in this currency, less what is left of any
            grant past its expiry date that the hourly expiry run has not
            written off yet (an invoice cannot draw on such a grant). Can be
            negative.
        unbilled_usage_cents:
          type: integer
          format: int64
          description: >
            Metered usage that the next invoice of each subscription invoiced
            in this currency will bill, priced as the period close prices it.
            Usage that a mid-period threshold invoice already billed is not
            counted again. After a mid-period plan change each meter is
            counted for the time a plan carrying it was on the subscription.
            For a trialing subscription only usage inside its first paid
            period counts; that period starts at the beginning of the day the
            trial ends.
        estimated_remaining_cents:
          type: integer
          format: int64
          description: >
            `balance_cents` minus `unbilled_usage_cents`, floored at zero. To
            see how far past zero a customer is, subtract the two parts
            yourself.

    RecipeOverride:
      type: object
      description: >-
        One override key declared in a recipe's `overridable` list. Drives
        validation at instantiate time and the override form in the
        dashboard's preview dialog.
      required: [key, type, default]
      properties:
        key:
          type: string
        type:
          type: string
          enum: [string, int]
        default:
          description: Default value applied when the override is not supplied.
        enum:
          type: array
          items:
            type: string
        max_length:
          type: integer
        pattern:
          type: string

    RecipeMeter:
      type: object
      required: [event_name, name, unit, aggregation]
      properties:
        event_name:
          type: string
        name:
          type: string
        unit:
          type: string
        aggregation:
          type: string

    RecipePlan:
      type: object
      required: [code, name, currency, billing_interval, base_amount_cents, prices]
      properties:
        code:
          type: string
        name:
          type: string
        currency:
          type: string
        billing_interval:
          type: string
          enum: [monthly, yearly]
        base_amount_cents:
          type: integer
          format: int64
        base_bill_timing:
          type: string
          enum: [in_advance, in_arrears]
          description: Optional when the base fee is 0; the plan is then in_arrears (ADR-031).
        prices:
          type: array
          description: The plan's usage prices. Each takes the plan's currency.
          items:
            $ref: "#/components/schemas/RecipePrice"

    RecipeCreates:
      type: object
      description: >-
        Per-role count of objects a recipe will produce when instantiated.
        Recipes create no product entity — there is no `products` count.
      required: [meters, plans, prices]
      properties:
        meters:
          type: integer
        plans:
          type: integer
        prices:
          type: integer
          description: The prices the install puts on its plans.

    RecipeCreatedObjects:
      type: object
      description: >-
        Per-role map of entity IDs a recipe apply produced (including
        ADOPTED meters — apply reconnects to an existing meter rather than
        duplicating it, ADR-085). Every field is omitted when empty; clients
        must guard reads.
      properties:
        meter_ids:
          type: array
          items:
            type: string
        plan_ids:
          type: array
          items:
            type: string
        price_ids:
          type: array
          items:
            type: string

    RecipeInstance:
      type: object
      description: >-
        The install badge: the record of a recipe being applied for a tenant
        (scoped per livemode since m0157). Field names are `recipe_key` /
        `recipe_version` — NOT `key`/`version`, which the recipe template
        itself uses.
      required: [id, recipe_key, recipe_version, overrides, created_objects, created_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
          description: Omitted when empty.
        recipe_key:
          type: string
        recipe_version:
          type: string
        overrides:
          type: object
          additionalProperties: true
        created_objects:
          $ref: "#/components/schemas/RecipeCreatedObjects"
        created_at:
          type: string
          format: date-time
        created_by:
          type: string
          description: Operator email or API key ID. Omitted when empty.

    Recipe:
      type: object
      description: >-
        A recipe template as served by list/detail: the full parsed YAML,
        its meters and its plans with their prices, not just display
        metadata. A recipe creates nothing else.
      required: [key, version, name, summary, overridable, meters, plans]
      properties:
        key:
          type: string
        version:
          type: string
        name:
          type: string
        summary:
          type: string
        description:
          type: string
        overridable:
          type: array
          items:
            $ref: "#/components/schemas/RecipeOverride"
        meters:
          type: array
          items:
            $ref: "#/components/schemas/RecipeMeter"
        plans:
          type: array
          items:
            $ref: "#/components/schemas/RecipePlan"

    RecipeListItem:
      allOf:
        - $ref: "#/components/schemas/Recipe"
        - type: object
          required: [creates]
          properties:
            creates:
              $ref: "#/components/schemas/RecipeCreates"
            instantiated:
              $ref: "#/components/schemas/RecipeInstance"
              description: >-
                Present when this tenant (in the session's livemode) has
                installed the recipe; absent otherwise. Drives the
                "Installed" badge — re-apply is an idempotent no-op and
                there is no uninstall (ADR-085).

    RecipeDetail:
      allOf:
        - $ref: "#/components/schemas/Recipe"
        - type: object
          required: [creates]
          properties:
            creates:
              $ref: "#/components/schemas/RecipeCreates"

    RecipePreviewObjects:
      type: object
      description: >-
        What an install creates: the meters, and each plan with its prices.
        Both arrays are always present. An install reuses a meter that
        already exists with the same `event_name` and aggregation.
      required: [meters, plans]
      properties:
        meters:
          type: array
          items:
            $ref: "#/components/schemas/RecipeMeter"
        plans:
          type: array
          items:
            $ref: "#/components/schemas/RecipePlan"

    RecipePrice:
      description: One usage price of a recipe plan. It takes the plan's currency.
      required: [meter, dimension_match, mode, unit_amount_cents, tiers, package_size, package_amount_cents]
      allOf:
        - type: object
          required: [meter, dimension_match]
          properties:
            meter:
              type: string
              description: The `event_name` of the recipe meter the price is on.
            dimension_match:
              type: object
              additionalProperties:
                type: string
        - $ref: "#/components/schemas/PriceShape"

    RecipePreviewResult:
      type: object
      required: [key, version, objects, warnings]
      properties:
        key:
          type: string
        version:
          type: string
        objects:
          $ref: "#/components/schemas/RecipePreviewObjects"
        warnings:
          type: array
          items:
            type: string

    CustomerBillingProfileInput:
      type: object
      description: |
        The billing details printed on invoices and used for tax. The
        whole profile is replaced on every write.
      properties:
        legal_name:
          type: string
        phone:
          type: string
          description: 7 to 20 characters of digits, spaces, `+`, `-`, `(`, `)`.
        address_line1:
          type: string
        address_line2:
          type: string
        city:
          type: string
        state:
          type: string
        postal_code:
          type: string
        country:
          type: string
          description: ISO 3166 alpha-2 code, such as `US` or `IN`.
        currency:
          type: string
          description: |
            ISO 4217 code, such as `USD`. When set, invoices for this
            customer use it instead of the plan currency.
        tax_status:
          type: string
          enum: [standard, exempt, reverse_charge]
          description: Defaults to `standard`.
        tax_exempt_reason:
          type: string
          description: Required when `tax_status` is `exempt`.
        tax_id:
          type: string
          description: Buyer tax ID. Required when `tax_status` is `reverse_charge`.
        tax_id_type:
          type: string
          description: Tax ID kind, stored as its Stripe code (for example `eu_vat`, `in_gst`).

    CustomerBillingProfile:
      allOf:
        - $ref: "#/components/schemas/CustomerBillingProfileInput"
        - type: object
          required: [customer_id, tax_status, tax_id, tax_id_type, profile_status, created_at, updated_at]
          properties:
            customer_id:
              type: string
            tenant_id:
              type: string
            profile_status:
              type: string
              enum: [incomplete, ready]
              description: |
                Set by the server. `ready` when legal name, address line
                1, city and country are all set.
            created_at:
              type: string
              format: date-time
            updated_at:
              type: string
              format: date-time

    SentEmail:
      type: object
      required: [id, email_type, recipient, status, delivery_state, created_at]
      properties:
        id:
          type: string
        email_type:
          type: string
          enum: [invoice, payment_receipt, payment_failed, payment_setup_request, dunning_warning, dunning_escalation, credit_note]
        recipient:
          type: string
        status:
          type: string
          enum: [pending, dispatched, failed, skipped]
          description: Hardcount's send state. `dispatched` means the mail server accepted it.
        delivery_state:
          type: string
          enum: [unknown, delivered, bounced, complained]
          description: What the email provider reported back, if anything.
        invoice_number:
          type: string
        last_error:
          type: string
        created_at:
          type: string
          format: date-time
        dispatched_at:
          type: string
          format: date-time
        line:
          description: |
            The email in the words of the activity line standard (ADR-128),
            the same as on the invoice's payment timeline: its name and
            what happened to it ("Receipt email delivered"), why it was not
            sent, the invoice it was about, and who it went to. Dated at the
            time on the customer's test clock that caused it, with the real
            send time as `recorded_at`. Present for every email type this
            list returns.
          allOf:
            - $ref: "#/components/schemas/ActivityLine"

    CustomerUsage:
      type: object
      required: [customer_id, period, subscriptions, meters, totals, warnings, buckets]
      properties:
        customer_id:
          type: string
        period:
          type: object
          description: |
            The window the response covers. With no `from` and `to`
            (`current_billing_cycle`), each subscription is rated over its
            own current billing period, and the period runs from the
            earliest start to the latest end of those periods. A meter's
            totals and daily `buckets` cover its own subscription's whole
            period, including usage a threshold invoice already billed.
          required: [from, to, source]
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
            source:
              type: string
              enum: [current_billing_cycle, explicit]
              description: |
                `current_billing_cycle`: no `from` and `to` were sent, and
                the amounts are what the period's invoices bill.
                `explicit`: the window you sent. Its amounts are an
                estimate, so a past window can differ from its invoices.
                Only active and trialing subscriptions are counted: the
                usage of a canceled subscription is left out. Each
                subscription's usage in the window is priced at the
                prices of its current billing period.
        subscriptions:
          type: array
          description: |
            One entry per item of each active or trialing subscription. A
            subscription with several plans appears once per plan, with the
            same `id`.
          items:
            type: object
            required: [id, plan_id, plan_name, currency, current_period_start, current_period_end]
            properties:
              id:
                type: string
              plan_id:
                type: string
              plan_name:
                type: string
              currency:
                type: string
              current_period_start:
                type: string
                format: date-time
              current_period_end:
                type: string
                format: date-time
        meters:
          type: array
          items:
            $ref: "#/components/schemas/CustomerUsageMeter"
        totals:
          type: array
          description: One entry per currency.
          items:
            type: object
            required: [currency, amount_cents]
            properties:
              currency:
                type: string
              amount_cents:
                type: integer
                format: int64
        warnings:
          type: array
          description: |
            Problems found working out this usage, in words, such as usage
            that no price takes or a meter whose usage went below zero.
          items:
            type: string
        buckets:
          type: array
          description: One entry per UTC day in the period, including days with no usage.
          items:
            type: object
            required: [bucket_start, per_meter]
            properties:
              bucket_start:
                type: string
                format: date-time
              per_meter:
                type: object
                description: Quantity per meter ID for that day, as a decimal string.
                additionalProperties:
                  type: string

    CustomerUsageMeter:
      type: object
      required: [meter_id, event_name, meter_name, unit, currency, total_quantity, total_amount_cents, prices]
      properties:
        meter_id:
          type: string
        event_name:
          type: string
        meter_name:
          type: string
        unit:
          type: string
        currency:
          type: string
        total_quantity:
          type: string
          description: Decimal string.
        total_amount_cents:
          type: integer
          format: int64
        prices:
          type: array
          description: |
            One row per (plan, price) that took usage, and a `no_price` row
            per plan for the usage no price of that plan took. Two plans can
            price one meter differently.
          items:
            type: object
            required: [plan_id, quantity, amount_cents]
            properties:
              price_id:
                type: string
                description: Absent on a `no_price` row.
              plan_id:
                type: string
              dimension_match:
                type: object
                additionalProperties:
                  type: string
                description: The labels the price takes. Absent for a price that takes every event.
              quantity:
                type: string
                description: Decimal string.
              amount_cents:
                type: integer
                format: int64
              unit_amount_decimal:
                type: string
                description: Price per unit in cents, as a decimal string; the same figure the invoice line shows.
              no_price:
                type: boolean
                description: |
                  True for usage no price of the plan took. Hardcount bills
                  nothing for it, and it is left out of the meter's totals.

    CustomerOverview:
      type: object
      required: [customer_id, active_subscriptions, recent_invoices, outstanding_balance]
      properties:
        customer_id:
          type: string
        active_subscriptions:
          type: array
          items:
            $ref: "#/components/schemas/Subscription"
        recent_invoices:
          type: array
          description: The 5 newest invoices.
          items:
            $ref: "#/components/schemas/Invoice"
        outstanding_balance:
          type: object
          required: [total_cents, unpaid_count, by_currency]
          description: |
            Invoices that are not draft, voided or uncollectible and
            whose payment is pending, failed or unknown.
          properties:
            total_cents:
              type: integer
              format: int64
              description: Sum of `amount_due_cents`. Amounts in different currencies are added together, so use `by_currency` to show an amount.
            unpaid_count:
              type: integer
            by_currency:
              type: array
              description: Amount due on the same invoices, one entry per currency, largest first. Empty when nothing is owed.
              items:
                type: object
                required: [currency, amount_cents]
                properties:
                  currency:
                    type: string
                  amount_cents:
                    type: integer
                    format: int64

    PaymentMethod:
      type: object
      required: [id, customer_id, type, is_default, created_at]
      properties:
        id:
          type: string
        customer_id:
          type: string
        type:
          type: string
          example: card
        card_brand:
          type: string
        card_last4:
          type: string
        card_exp_month:
          type: integer
        card_exp_year:
          type: integer
        is_default:
          type: boolean
        created_at:
          type: string
          format: date-time

    CreditLedgerEntry:
      type: object
      required: [id, customer_id, entry_type, currency, amount_cents, balance_after, description, created_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        customer_id:
          type: string
        entry_type:
          type: string
          enum: [grant, usage, expiry, adjustment]
        currency:
          type: string
          description: >
            The entry's currency, uppercase. Credit pays only invoices in its
            own currency.
        amount_cents:
          type: integer
          format: int64
          description: Positive adds credit, negative removes it.
        balance_after:
          type: integer
          format: int64
          description: The customer's credit balance in this entry's currency after it, in cents.
        description:
          type: string
        invoice_id:
          type: string
        expires_at:
          type: string
          format: date-time
          description: |
            On a grant, its expiry. The credit can be drawn up to and
            including this instant; a period invoice draws it when its
            period ends at or before it. The dashboard sends the local
            midnight after the picked day.
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        consumed_cents:
          type: integer
          format: int64
          description: For entries that add credit, how much has been used or expired.
        grant_kind:
          type: string
          enum: [commit, promotional]
        source_invoice_id:
          type: string
          description: For a `commit` grant, the invoice that paid for it.
        source_subscription_id:
          type: string
          description: |
            With `source_subscription_item_id`, `source_plan_changed_at` and
            `source_change_type`, set on a proration credit: the subscription
            change that produced it.
        source_subscription_item_id:
          type: string
        source_plan_changed_at:
          type: string
          format: date-time
        source_change_type:
          $ref: "#/components/schemas/ItemChangeType"
        source_credit_note_id:
          type: string
          description: The credit note that granted this credit to the balance.
        source_invoice_reversal_id:
          type: string
          description: The voided invoice whose applied credit this entry gave back.

    SubscriptionItem:
      type: object
      required: [id, subscription_id, plan_id, quantity, created_at, updated_at]
      description: One priced line of a subscription. It pairs a plan with a quantity.
      properties:
        id:
          type: string
        tenant_id:
          type: string
        subscription_id:
          type: string
        plan_id:
          type: string
        quantity:
          type: integer
          format: int64
        metadata:
          type: object
          additionalProperties: true
          description: The item's stored metadata. Absent when empty.
        pending_plan_id:
          type: string
          description: Plan the item moves to at `pending_plan_effective_at`. Absent when no change is scheduled.
        pending_plan_effective_at:
          type: string
          format: date-time
        plan_changed_at:
          type: string
          format: date-time
          description: When the plan was last changed immediately. Absent until the first such change.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    SubscriptionPauseCollection:
      type: object
      required: [behavior, set_by]
      description: |
        Present on a subscription while collection is paused. New invoices
        are still created, but stay drafts and are not charged.
      properties:
        behavior:
          type: string
          enum: [keep_as_draft]
        resumes_at:
          type: string
          format: date-time
          description: |
            When collection resumes. Absent when the pause lasts until it is
            cleared. On a pause set by dunning, it is when Hardcount recorded the
            payment of the invoice the pause was set for.
        set_by:
          type: string
          enum: [operator, dunning]
          readOnly: true
          description: |
            Who paused collection. `operator`: you, through the API or the
            dashboard; the pause lasts until you resume it or until
            `resumes_at`. `dunning`: a dunning policy's `pause` final action.
            A dunning pause lifts by itself once the invoice whose dunning
            set it is paid, even if another invoice is still owed. Periods
            that ended during the pause keep their draft invoices.

    SubscriptionBillingThresholds:
      type: object
      required: [item_thresholds]
      description: |
        Limits that make Hardcount invoice before the period ends. A threshold
        invoice bills the period's usage so far, and no base fee. The period
        keeps its dates, the base fee is billed as it would be with no
        threshold, and the period's next invoice bills the rest of the usage.
      properties:
        amount_gte:
          type: integer
          format: int64
          description: Usage threshold in cents, compared with the period's usage so far (the base fee is not counted). Absent when not set.
        item_thresholds:
          type: array
          items:
            $ref: "#/components/schemas/SubscriptionItemThreshold"

    SubscriptionItemThreshold:
      type: object
      required: [subscription_item_id, usage_gte]
      properties:
        subscription_item_id:
          type: string
        usage_gte:
          type: string
          description: Usage quantity threshold, as a decimal string.

    SubscriptionItemProration:
      type: object
      required: [old_plan_id, new_plan_id, proration_factor, amount_cents, type, credited_to_balance_cents, reduced_open_invoices_cents, held_on_draft_invoices_cents]
      description: What Hardcount did to settle the rest of the period for an item change.
      properties:
        old_plan_id:
          type: string
        new_plan_id:
          type: string
        proration_factor:
          type: number
          format: double
          description: Share of the full billing cycle that was left, from 0 to 1.
        amount_cents:
          type: integer
          format: int64
          description: |
            Gross amount, tax included: the invoice total (`invoice`), or
            the whole credit for unused time (`credit` and `adjustment`).
            `credited_to_balance_cents`, `reduced_open_invoices_cents` and
            `held_on_draft_invoices_cents` say where the credit went.
        type:
          type: string
          enum: [invoice, credit, adjustment]
          description: |
            `invoice`: a proration invoice was created. `credit`: some of
            the credit went to the customer's balance, or no invoice had
            room for any of it (`amount_cents` is then 0). `adjustment`:
            none of it went to the balance.
        invoice_id:
          type: string
          description: The proration invoice. Present when `type` is `invoice`.
        credited_to_balance_cents:
          type: integer
          format: int64
          description: |
            The part of the credit added to the customer's balance, tax
            included. On a paid invoice, all of its part. On an open invoice
            paid partly with credits, the part above what it still asks for:
            those credits go back to the balance. A part on an invoice whose
            payment is still processing counts here; if that payment fails,
            it reduces the invoice instead. 0 when `type` is `invoice`.
        reduced_open_invoices_cents:
          type: integer
          format: int64
          description: |
            The part of the credit taken off the amount due on open
            invoices, tax included. 0 when `type` is `invoice`.
        held_on_draft_invoices_cents:
          type: integer
          format: int64
          description: |
            The part of the credit on draft invoices, tax included. It is
            held until the draft is finalized, then applied by how the
            invoice stands: if paid, to the customer's balance; if still
            open, off what it asks for first, as above. 0 when `type` is
            `invoice`.

    SubscriptionNewPeriodInvoice:
      type: object
      required: [id, invoice_number, status, total_amount_cents, currency]
      description: |
        The invoice an immediate change to a plan with another billing
        interval created for the new period it starts.
      properties:
        id:
          type: string
        invoice_number:
          type: string
        status:
          type: string
          enum: [draft, finalized]
          description: |
            `draft` when the invoice was not issued yet: collection is
            paused, or its tax could not be calculated yet. Its total may
            then change before it is finalized.
        total_amount_cents:
          type: integer
          format: int64
          description: |
            The invoice total when the change created it. On a `draft`
            whose tax could not be calculated yet, it has no tax in it.
        currency:
          type: string

    SubscriptionItemChangeResult:
      type: object
      required: [item, effective_at]
      properties:
        item:
          $ref: "#/components/schemas/SubscriptionItem"
        effective_at:
          type: string
          format: date-time
          description: When the change takes effect. For a scheduled plan change, the end of the current period.
        proration:
          $ref: "#/components/schemas/SubscriptionItemProration"
        new_period_invoice:
          $ref: "#/components/schemas/SubscriptionNewPeriodInvoice"

    SubscriptionTimelineEvent:
      type: object
      required: [timestamp, source, event_type, status, description, line]
      properties:
        timestamp:
          type: string
          format: date-time
          description: When the action was recorded (wall-clock time).
        source:
          type: string
          enum: [audit]
        event_type:
          type: string
          description: The audit action, e.g. `create`, `cancel`, `subscription.item_updated`.
        status:
          type: string
          description: The line's tone in older words, read by the dashboard's dot.
          enum: [neutral, succeeded, warning, failed]
        description:
          type: string
          description: The line's title, for example "Subscription canceled".
        detail:
          type: string
          description: |
            The line's meta line as text, without the actor (see
            `actor_name`) and without a date at its end, which is in
            `detail_timestamp`. For example "Starter → Pro".
        detail_timestamp:
          type: string
          format: date-time
          description: The date the end of `detail` refers to, for the client to format, as in "Trial now ends" followed by this date.
        error:
          type: string
          description: The line's problem.
        actor_type:
          type: string
        actor_name:
          type: string
        actor_id:
          type: string
        is_simulated:
          type: boolean
          description: True when the action affected a subscription on a test clock.
        sim_effective_at:
          type: string
          format: date-time
          description: The test clock's simulated time when the action took effect.
        test_clock_id:
          type: string
        line:
          $ref: "#/components/schemas/ActivityLine"

    AnalyticsPeriod:
      type: string
      enum: ["7d", "30d", "90d", "12m"]
      default: "30d"

    AnalyticsMrrMovementTotals:
      type: object
      required: [new, expansion, contraction, churned, net]
      description: |
        MRR change in cents. `churned` and `contraction` are positive
        amounts. `net` = `new` + `expansion` - `contraction` - `churned`.
      properties:
        new:
          type: integer
          format: int64
        expansion:
          type: integer
          format: int64
        contraction:
          type: integer
          format: int64
        churned:
          type: integer
          format: int64
        net:
          type: integer
          format: int64

    AnalyticsMrrMovementPoint:
      type: object
      required: [date, new, expansion, contraction, churned, net]
      properties:
        date:
          type: string
          description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC.
        new:
          type: integer
          format: int64
        expansion:
          type: integer
          format: int64
        contraction:
          type: integer
          format: int64
        churned:
          type: integer
          format: int64
        net:
          type: integer
          format: int64

    AnalyticsRevenuePoint:
      type: object
      required: [date, revenue_cents, invoice_count]
      properties:
        date:
          type: string
          description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC.
        revenue_cents:
          type: integer
          format: int64
          description: What paid the invoices paid in the bucket (payment plus customer credit applied). A credit note that lowered an invoice before it was paid is not counted.
        invoice_count:
          type: integer

    AnalyticsUsagePoint:
      type: object
      required: [date, events, quantity]
      properties:
        date:
          type: string
          description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC.
        events:
          type: integer
          format: int64
        quantity:
          type: string
          description: Summed quantity, as a decimal string.

    AnalyticsTopMeter:
      type: object
      required: [meter_id, meter_name, event_name, events, quantity]
      properties:
        meter_id:
          type: string
        meter_name:
          type: string
        event_name:
          type: string
        events:
          type: integer
          format: int64
        quantity:
          type: string
          description: Summed quantity, as a decimal string.

    AnalyticsOverview:
      type: object
      required:
        - period
        - currency
        - mrr
        - mrr_prev
        - arr
        - arr_prev
        - revenue
        - revenue_prev
        - outstanding_ar
        - outstanding_by_currency
        - avg_invoice_value
        - credit_balance_by_currency
        - active_customers
        - new_customers
        - live_customers
        - live_customers_prev
        - active_subscriptions
        - trialing_subscriptions
        - paid_invoices
        - failed_payments
        - failed_payments_prev
        - open_invoices
        - dunning_active
        - refunds_needing_attention
        - usage_events
        - logo_churn_rate
        - revenue_churn_rate
        - nrr
        - dunning_recovery_rate
        - mrr_movement
      description: |
        "The window" is the requested period, ending now. "The previous
        window" is the one of equal length before it. Money is in cents of
        `currency`.
      properties:
        period:
          $ref: "#/components/schemas/AnalyticsPeriod"
        currency:
          type: string
          description: The tenant's default currency. Every money field uses it, except `credit_balance_by_currency` and `outstanding_by_currency`, which carry their own.
        mrr:
          type: integer
          format: int64
          description: MRR of active subscriptions now.
        mrr_prev:
          type: integer
          format: int64
          description: MRR at the start of the window, rebuilt from item change history.
        arr:
          type: integer
          format: int64
          description: "`mrr` times 12."
        arr_prev:
          type: integer
          format: int64
          description: "`mrr_prev` times 12."
        revenue:
          type: integer
          format: int64
          description: What paid the invoices paid in the window (payment plus customer credit applied). A credit note that lowered an invoice before it was paid is not counted.
        revenue_prev:
          type: integer
          format: int64
          description: "`revenue` for the previous window."
        outstanding_ar:
          type: integer
          format: int64
          description: Amount due on finalized invoices whose payment has not succeeded, in `currency` only.
        outstanding_by_currency:
          type: array
          description: |
            Amount due on the invoices `open_invoices` counts, one entry per
            currency. `currency` comes first, then the largest amount. Empty
            when nothing is owed.
          items:
            type: object
            required:
              - currency
              - amount_cents
            properties:
              currency:
                type: string
              amount_cents:
                type: integer
                format: int64
        avg_invoice_value:
          type: integer
          format: int64
          description: Average total of invoices paid in the window.
        credit_balance_by_currency:
          type: array
          description: >
            Customers' credit balances added up per currency, `currency`
            first, then largest. A currency whose balance is zero is left
            out. Customers on a test clock are not counted.
          items:
            type: object
            required: [currency, amount_cents]
            properties:
              currency: { type: string }
              amount_cents: { type: integer, format: int64 }
        active_customers:
          type: integer
          description: |
            Customer records with status `active`. Includes customers with
            no subscription or only canceled ones; `live_customers` counts
            the customers you bill.
        new_customers:
          type: integer
          description: Customers created in the window.
        live_customers:
          type: integer
          description: Customers with at least one `active` or `trialing` subscription now.
        live_customers_prev:
          type: integer
          description: |
            `live_customers` at the start of the window, rebuilt from
            subscription start and cancel times.
        active_subscriptions:
          type: integer
          description: Subscriptions with status `active`.
        trialing_subscriptions:
          type: integer
          description: Subscriptions with status `trialing`.
        paid_invoices:
          type: integer
          description: Invoices paid in the window.
        failed_payments:
          type: integer
          description: Open invoices created in the window whose payment failed. A voided or written-off invoice is not counted. All currencies.
        failed_payments_prev:
          type: integer
          description: "`failed_payments` for the previous window."
        open_invoices:
          type: integer
          description: Finalized invoices whose payment has not succeeded. All currencies.
        dunning_active:
          type: integer
          description: Dunning runs (automatic payment retries) still in progress.
        refunds_needing_attention:
          type: integer
          description: |
            Issued credit notes whose refund failed, has an unknown result,
            has been pending for more than 72 hours, or has not started an
            hour after issue.
        usage_events:
          type: integer
          format: int64
          description: Usage events with a timestamp in the window.
        logo_churn_rate:
          type: number
          format: double
          description: Subscriptions canceled in the window, divided by subscriptions active at its start. 0 when none were active.
        revenue_churn_rate:
          type: number
          format: double
          description: Churned MRR divided by `mrr_prev`. 0 when `mrr_prev` is 0.
        nrr:
          type: number
          format: double
          description: |
            Net revenue retention: (`mrr_prev` + expansion - contraction -
            churned) divided by `mrr_prev`. Can be above 1. 0 when
            `mrr_prev` is 0.
        dunning_recovery_rate:
          type: number
          format: double
          description: Dunning runs opened in the window that a payment closed (`resolution` is `payment_recovered` and the invoice has `amount_paid_cents` above 0), divided by all runs opened in the window. A run closed by a void, a write-off, or a credit note or customer credit that left nothing due is not recovered. 0 when none were opened.
        mrr_movement:
          $ref: "#/components/schemas/AnalyticsMrrMovementTotals"

    InvoiceLineItemInput:
      type: object
      description: A line to add to a draft invoice.
      required: [description, quantity, unit_amount_cents]
      properties:
        description:
          type: string
        line_type:
          type: string
          enum: [base_fee, usage, add_on]
          description: Defaults to `add_on`.
        quantity:
          type: integer
          format: int64
          minimum: 1
        unit_amount_cents:
          type: integer
          format: int64
          minimum: 1
        commit_granted_cents:
          type: integer
          format: int64
          minimum: 1
          maximum: 100000000
          description: |
            Makes this line a prepaid commit purchase: when the invoice is
            finalized, the customer gets this many cents of credit. Only on
            `add_on` lines of manual invoices, at most one per invoice.
        commit_expires_at:
          type: string
          format: date-time
          description: |
            When the granted credit expires. The credit can be drawn up to
            and including this instant; a period invoice draws it when its
            period ends at or before it. The dashboard sends the local
            midnight after the picked day. Must be in the future. Needs
            `commit_granted_cents`. Absent means it never expires.

    InvoiceCreateRequest:
      type: object
      required: [customer_id]
      properties:
        customer_id:
          type: string
        subscription_id:
          type: string
          description: Optional. Must belong to `customer_id`. Leave out for a one-off invoice. The subscription's own cycle invoices are still billed, also for the same period.
        currency:
          type: string
          description: ISO 4217 code. Must be the customer's currency (see 409). Defaults to the billing-profile currency, then the live subscriptions' plan currency, then the tenant default.
          example: USD
        billing_period_start:
          type: string
          format: date-time
        billing_period_end:
          type: string
          format: date-time
        net_payment_term_days:
          type: integer
          description: Days until the invoice is due. 0 means due on receipt. Defaults to the tenant's net payment terms.
        memo:
          type: string
        line_items:
          type: array
          description: Lines created in the same transaction as the invoice.
          items:
            $ref: "#/components/schemas/InvoiceLineItemInput"

    CreditNoteStats:
      type: object
      required: [draft_count, issued_count, voided_count, by_currency]
      properties:
        draft_count:
          type: integer
        issued_count:
          type: integer
        voided_count:
          type: integer
        by_currency:
          type: array
          description: |
            Money on issued notes, one entry per currency, the tenant's
            default currency first. Empty when no note is issued.
          items:
            type: object
            required: [currency, total_cents, refunded_cents, refund_pending_cents, refund_failed_cents, credited_cents, out_of_band_cents]
            properties:
              currency:
                type: string
              total_cents:
                type: integer
                format: int64
                description: Total of the issued notes.
              refunded_cents:
                type: integer
                format: int64
                description: Refunds that succeeded. A refund still pending or one that failed is not counted here.
              refund_pending_cents:
                type: integer
                format: int64
                description: Refunds not confirmed yet. Neither succeeded nor failed, the refund is waiting on Stripe, Stripe's answer was unclear, or its result was never recorded.
              refund_failed_cents:
                type: integer
                format: int64
                description: Refunds that failed. No money went back to the card.
              credited_cents:
                type: integer
                format: int64
                description: Added to customers' credit balances.
              out_of_band_cents:
                type: integer
                format: int64
                description: Recorded as paid back outside Stripe.

    CreditNote:
      type: object
      description: |
        A credit note reduces what a customer owes on an invoice, or gives
        money back after it was paid. Its total is split three ways: refund
        to the card (`refund_amount_cents`), credit to the customer's
        balance (`credit_amount_cents`), and money returned outside Hardcount
        (`out_of_band_amount_cents`).
      required:
        - id
        - invoice_id
        - customer_id
        - credit_note_number
        - status
        - reason
        - subtotal_cents
        - tax_amount_cents
        - total_cents
        - refund_amount_cents
        - credit_amount_cents
        - out_of_band_amount_cents
        - currency
        - refund_status
        - is_simulated
        - issue_pending
        - tax_reversal_pending
        - created_at
        - updated_at
      properties:
        id:
          type: string
        tenant_id:
          type: string
        invoice_id:
          type: string
        customer_id:
          type: string
        credit_note_number:
          type: string
        status:
          type: string
          enum: [draft, issued, voided]
        reason:
          type: string
        subtotal_cents:
          type: integer
          format: int64
        tax_amount_cents:
          type: integer
          format: int64
        total_cents:
          type: integer
          format: int64
        refund_amount_cents:
          type: integer
          format: int64
        credit_amount_cents:
          type: integer
          format: int64
        out_of_band_amount_cents:
          type: integer
          format: int64
        commit_retired_cents:
          type: integer
          format: int64
          description: Prepaid commit credits this note retired. Absent on other notes.
        currency:
          type: string
        issued_at:
          type: string
          format: date-time
        voided_at:
          type: string
          format: date-time
        refund_status:
          type: string
          enum: [none, pending, succeeded, failed, unknown]
          description: |
            The refund part's state at Stripe. `unknown`: Stripe gave no
            clear answer (a timeout, a network error, a Stripe 5xx, a 409 or
            a 429), so the customer may already be refunded. Don't refund it
            again in the Stripe Dashboard. Retry it with
            `POST /v1/credit-notes/{id}/retry-refund`, which looks for the
            refund at Stripe first and then resends the same request with
            the same idempotency key.
        stripe_refund_id:
          type: string
        bill_to_snapshot:
          $ref: "#/components/schemas/DocumentBillTo"
        supplier_snapshot:
          $ref: "#/components/schemas/DocumentSupplier"
        tax_transaction_id:
          type: string
          description: Tax provider's reversal transaction, set when the note is issued.
        is_simulated:
          type: boolean
          description: True when `issued_at` is test-clock time.
        issue_pending:
          type: boolean
          description: True on a draft that Hardcount will issue automatically.
        tax_reversal_pending:
          type: boolean
          description: True when the tax reversal failed and will be retried.
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        recorded_at:
          type: string
          format: date-time
          description: Real-world time the row was written, when `created_at` is test-clock time.
        updated_at:
          type: string
          format: date-time

    ActivityLine:
      type: object
      description: |
        One activity row in the words of the activity line standard
        (ADR-128): a title with no values, the amount when the event moves
        money, asks for it or cancels money owed, a problem line on failure
        and warning rows, the meta line's parts and the actor, a tone, and
        the event's time on the entity's calendar. `recorded_at` is the
        real-world time, set only for an entity on a test clock.
      required: [id, kind, title, detail, tone, occurred_at]
      properties:
        id:
          type: string
          description: |
            The row's stable key: the event's `id` on an invoice timeline
            row, the audit entry's id on a subscription timeline row,
            `dunning:` and the event's id on a dunning run event (the same
            key the invoice timeline gives that event), and `email:` and
            the email's id on a sent email (likewise).
        kind:
          type: string
          description: Machine name of the event.
          example: invoice.voided
        title:
          type: string
          example: Payment failed
        amount:
          $ref: "#/components/schemas/ActivityMoney"
        problem:
          type: string
          example: "Card declined: insufficient funds"
        detail:
          type: array
          description: |
            The meta line before the actor. Each item is one segment, shown
            joined by " · "; a segment's parts read as one phrase, for
            example a money part followed by the text " refunded to card".
          items:
            type: array
            items:
              $ref: "#/components/schemas/ActivityPart"
        actor:
          $ref: "#/components/schemas/ActivityActor"
        tone:
          type: string
          enum: [neutral, success, warning, failure]
          description: |
            neutral: something happened. success: money arrived, or was
            returned as intended. warning: needs a look, or money will not
            be collected. failure: an attempt failed.
        occurred_at:
          type: string
          format: date-time
        recorded_at:
          type: string
          format: date-time

    ActivityMoney:
      type: object
      required: [cents, currency]
      properties:
        cents:
          type: integer
          format: int64
        currency:
          type: string
          example: USD

    ActivityPart:
      type: object
      description: One piece of a meta segment. Exactly one field is set.
      properties:
        text:
          type: string
        money:
          $ref: "#/components/schemas/ActivityMoney"
        time:
          type: string
          format: date-time
        link:
          $ref: "#/components/schemas/ActivityLink"

    ActivityLink:
      type: object
      description: An object the row's own data names, which still exists.
      required: [kind, id, label]
      properties:
        kind:
          type: string
          enum: [invoice, credit_note, subscription]
        id:
          type: string
        label:
          type: string

    ActivityActor:
      type: object
      description: |
        Who did it. `person` and `api_key` carry the name (a person's email
        when they have no name), `customer` says where through `via`, and
        `automatic` is anything Hardcount did on its own. Absent when no fact
        written with the row names one.
      required: [kind]
      properties:
        kind:
          type: string
          enum: [person, api_key, customer, automatic]
        name:
          type: string
        via:
          type: string
          enum: [payment_page, portal]

    InvoiceTimelineEvent:
      type: object
      required: [id, timestamp, source, event_type, status, description, line]
      properties:
        id:
          type: string
          description: Stable key for the row.
        timestamp:
          type: string
          format: date-time
        source:
          type: string
          enum: [lifecycle, payment, dunning, email, credit_note, dispute]
        event_type:
          type: string
          example: invoice.finalized
        status:
          type: string
          description: The line's tone in older words, read by the dashboard's dot.
          enum: [neutral, succeeded, warning, failed]
        description:
          type: string
          description: The line's title.
        detail:
          type: string
          description: The line's meta line as text, its actor last, for example "Visa •••• 4242 · After 2 retries".
        error:
          type: string
          description: The line's problem.
        amount_cents:
          type: integer
          format: int64
        currency:
          type: string
        payment_intent_id:
          type: string
        attempt_count:
          type: integer
        is_simulated:
          type: boolean
          description: True when `timestamp` is test-clock time.
        recorded_at:
          type: string
          format: date-time
          description: Real-world time of a row whose `timestamp` is test-clock time.
        line:
          $ref: "#/components/schemas/ActivityLine"

    InvoiceDispute:
      type: object
      description: |
        A card dispute (chargeback) Stripe reported against one of the
        invoice's payments, recorded from Stripe's `charge.dispute.created`,
        `.updated` and `.closed` webhook events. It holds the dispute as the
        newest of those events showed it, whatever order they arrived in.
        Hardcount records and reports a dispute, and nothing else: the invoice
        stays paid and no money moves.
      required:
        - id
        - invoice_id
        - stripe_dispute_id
        - stripe_payment_intent_id
        - status
        - amount_cents
        - currency
        - opened_at
        - livemode
        - created_at
        - updated_at
      properties:
        id:
          type: string
          example: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f
        invoice_id:
          type: string
        stripe_dispute_id:
          type: string
          example: du_1MtJUT2eZvKYlo2CNaw2HvEv
        stripe_charge_id:
          type: string
        stripe_payment_intent_id:
          type: string
          description: The disputed payment.
        status:
          type: string
          enum:
            - warning_needs_response
            - warning_under_review
            - warning_closed
            - needs_response
            - under_review
            - won
            - lost
            - prevented
          description: |
            Stripe's dispute status, as Stripe sends it. The `warning_`
            statuses are an inquiry, which comes before a chargeback or
            instead of one. `won`, `lost`, `warning_closed` and `prevented`
            are closed.
        reason:
          type: string
          description: The reason the cardholder gave, for example `fraudulent`.
        amount_cents:
          type: integer
          format: int64
          description: The disputed amount. It can differ from the payment.
        currency:
          type: string
          example: USD
        evidence_due_by:
          type: string
          format: date-time
          description: The deadline to respond in Stripe. Absent when Stripe gave none.
        opened_at:
          type: string
          format: date-time
          description: When Stripe opened the dispute.
        closed_at:
          type: string
          format: date-time
          description: When Stripe closed it. Absent while it is open.
        livemode:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    InvoiceDisputeList:
      type: object
      required: [data, total]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/InvoiceDispute"
        total:
          $ref: "#/components/schemas/ListTotal"

    DisputeWebhookEventData:
      type: object
      description: |
        The `data` of the outbound webhook events `payment.dispute_opened`
        and `payment.dispute_closed` (docs/webhooks.md): the invoice the
        disputed payment paid and the dispute, each as the API returns it.
        Both are saved in the same transaction that records the dispute, so
        a crash cannot drop them. `payment.dispute_opened` is sent once per
        dispute, when Hardcount first records it; if the first event Hardcount
        receives is already the close, both are sent, and both carry the
        closed status and `closed_at`. Their delivery order is not
        guaranteed. `payment.dispute_closed` is sent when the dispute
        reaches a closed status it did not have: `won`, `lost`,
        `warning_closed` or `prevented`. A lost dispute that Stripe later
        marks won sends it again.
      required: [invoice, dispute]
      properties:
        invoice:
          $ref: "#/components/schemas/Invoice"
        dispute:
          $ref: "#/components/schemas/InvoiceDispute"

    InvoicePaymentTimeline:
      type: object
      required: [events, degraded, truncated]
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/InvoiceTimelineEvent"
        degraded:
          type: array
          description: Sources that could not be read, so their rows are missing.
          items:
            type: string
            enum: [audit, credit_notes, emails, dunning, charge_attempts, disputes]
        truncated:
          type: boolean
          description: True when the credit-note source hit its limit of 100 rows.

    DunningRun:
      type: object
      required: [id, invoice_id, policy_id, state, attempt_count, paused, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        invoice_id:
          type: string
        customer_id:
          type: string
        policy_id:
          type: string
        state:
          type: string
          enum: [active, escalated, resolved]
          description: |
            `active`: retries are running. `escalated`: retries ran out
            (`resolution` is `retries_exhausted`). `resolved`: the run ended
            another way; see `resolution`.
        reason:
          type: string
        attempt_count:
          type: integer
        last_attempt_at:
          type: string
          format: date-time
        next_action_at:
          type: string
          format: date-time
        paused:
          type: boolean
        resolved_at:
          type: string
          format: date-time
        resolution:
          type: string
          enum:
            - payment_recovered
            - invoice_voided
            - invoice_not_collectible
            - retries_exhausted
            - action_failed
            - amount_below_minimum
          description: |
            How the run ended, set when it is resolved. Exception:
            `action_failed` is set on a run that is still `active`. The
            final action (pause, cancel or mark uncollectible) failed and
            will be retried at `next_action_at`. `amount_below_minimum`: a
            charge on the invoice was refused by Stripe because the amount is
            below its minimum charge, so the run ended without its final
            action.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        invoice_number:
          type: string
          description: Filled in on list responses.
        invoice_amount_due_cents:
          type: integer
          format: int64
          description: Filled in on list responses.
        invoice_currency:
          type: string
          description: Filled in on list responses.
        effective_now:
          type: string
          format: date-time
          description: The test clock's current time, when the customer is on a test clock. Filled in on list responses.
        test_clock_id:
          type: string
          description: Filled in on list responses.
        max_retry_attempts:
          type: integer
          description: |
            How many retries the run's policy makes: the length of its
            `retry_days`. Filled in on list responses.
            When `attempt_count` has reached it, no charge is retried:
            `next_action_at` is when the final action (pause, cancel or
            mark uncollectible) runs.

    DunningRunEvent:
      type: object
      required: [id, run_id, invoice_id, event_type, state, attempt_count, created_at]
      properties:
        id:
          type: string
        run_id:
          type: string
        tenant_id:
          type: string
        invoice_id:
          type: string
        event_type:
          type: string
          enum:
            - dunning_started
            - retry_attempted
            - escalated
            - resolved
        state:
          type: string
          enum: [active, escalated, resolved]
        reason:
          type: string
        attempt_count:
          type: integer
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        recorded_at:
          type: string
          format: date-time
        line:
          description: |
            The event in the words of the activity line standard (ADR-128):
            the line the invoice's payment timeline shows for the same
            event, without that timeline's folds. Absent when the run's
            invoice could not be read (`degraded` names `invoice`).
          allOf:
            - $ref: "#/components/schemas/ActivityLine"

    DunningRunDetail:
      type: object
      required: [run, events, degraded]
      properties:
        run:
          $ref: "#/components/schemas/DunningRun"
        events:
          type: array
          items:
            $ref: "#/components/schemas/DunningRunEvent"
        degraded:
          type: array
          description: |
            Sources that could not be read. `events`: the event history, so
            `events` is empty. `invoice`: the run's invoice, so no event has
            a `line`. The others leave the lines without what they would
            add: `emails` the reminder and final-notice clauses,
            `charge_attempts` the decline reason and amount, `audit` who
            resolved the run.
          items:
            type: string
            enum: [events, invoice, emails, charge_attempts, audit]

    DunningStats:
      type: object
      required: [active_count, escalated_count, resolved_count, recovered_count, at_risk_cents, currency, at_risk_by_currency]
      properties:
        active_count:
          type: integer
        escalated_count:
          type: integer
        resolved_count:
          type: integer
          description: Resolved runs, whatever closed them (a payment, a void or a write-off).
        recovered_count:
          type: integer
          description: Resolved runs that a payment closed (`resolution` is `payment_recovered` and the invoice has `amount_paid_cents` above 0). A run closed by a credit note or customer credit that left nothing due is resolved, not recovered.
        at_risk_cents:
          type: integer
          format: int64
          description: Amount due on active and escalated runs whose invoice is still open, in `currency` only. A voided or written-off invoice is not at risk.
        currency:
          type: string
          description: The tenant's default currency.
        at_risk_by_currency:
          type: array
          description: |
            Amount due on active and escalated runs whose invoice is still
            open, one entry per currency. `currency` comes first, then the
            largest amount. Empty when no run is at risk.
          items:
            type: object
            required: [currency, amount_cents]
            properties:
              currency:
                type: string
              amount_cents:
                type: integer
                format: int64

    CustomerPriceOverride:
      type: object
      description: |
        A customer's negotiated rate for one plan price. It keeps applying
        when the price's rate changes, and ends when the price is removed.
        It takes the plan's currency.
      required: [id, customer_id, price_id, mode, active, created_at, updated_at, unit_amount_cents, tiers, package_size, package_amount_cents]
      properties:
        id:
          type: string
          example: hc_cpo_1a2b3c4d5e6f7890
        tenant_id:
          type: string
        customer_id:
          type: string
        price_id:
          type: string
          description: The plan price this override replaces the rate of, for this customer.
        mode:
          type: string
          enum: [per_unit, graduated, package]
          description: As on PriceShape.
        unit_amount_cents:
          type: string
          description: Price per unit in cents, as a decimal string.
        tiers:
          type: array
          description: Empty (`[]`) unless `mode` is `graduated`.
          items:
            $ref: "#/components/schemas/PriceTier"
        package_size:
          type: integer
          format: int64
        package_amount_cents:
          type: string
          description: Price of one package in cents, as a decimal string.
        reason:
          type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
          description: |
            When the override starts, in the customer's time: wall time, or
            its test clock's time. A subscription already on the plan gets
            it from its next period; one that first joins the plan in a
            period after this instant gets it from the join.
        deactivated_at:
          type: string
          format: date-time
          description: When the override ended, in the customer's time. Absent while it is active.
        updated_at:
          type: string
          format: date-time

    DunningPolicyInput:
      type: object
      required: [retry_days]
      properties:
        name:
          type: string
          maxLength: 255
        enabled:
          type: boolean
          default: false
          description: A disabled policy starts no dunning.
        retry_days:
          type: array
          minItems: 1
          maxItems: 15
          items:
            type: integer
            minimum: 1
            maximum: 30
          example: [3, 4, 7]
          description: |
            Days to wait before each retry. The first counts from the failed
            payment, or, with no card on file, from the collection that found
            none. Each later one counts from the retry before it: `[3, 4, 7]`
            retries on day 3, 7 and 14. The number of retries is the length
            of the list. A day is 24 hours. `retry_schedule`,
            `max_retry_attempts` and `grace_period_days` are refused.
        final_subscription_action:
          type: string
          enum: [none, pause, cancel]
          default: pause
          description: |
            What happens to the subscription when retries run out. `pause`
            pauses collection and sends `subscription.collection_paused`
            with `triggered_by: dunning`. It does nothing if collection is
            already paused, the subscription is canceled, or the invoice
            was paid first. `cancel` cancels the subscription and sends
            `subscription.canceled` with `canceled_by: dunning`. It does
            nothing if the subscription is already canceled. If a scheduled
            cancel was already due, that cancel happens instead, and its
            event says `canceled_by: schedule`.
        final_invoice_action:
          type: string
          enum: [none, mark_uncollectible]
          default: none
          description: What happens to the invoice when retries run out.

    DunningPolicy:
      type: object
      required: [id, name, enabled, is_default, seeded, retry_days, final_subscription_action, final_invoice_action, created_at, updated_at]
      properties:
        id:
          type: string
          example: hc_dpol_1a2b3c4d5e6f7890
        tenant_id:
          type: string
        name:
          type: string
        enabled:
          type: boolean
        is_default:
          type: boolean
          description: Customers without an assigned policy use the default.
        seeded:
          type: boolean
          description: |
            True for the "Default retries" policy the account was created
            with, until you edit it or make it the default. While it is
            true, the next enabled policy you create becomes the default
            instead. Set by the server: a request body that sends it is
            refused with 422.
        retry_days:
          type: array
          items:
            type: integer
          example: [3, 4, 7]
          description: Days to wait before each retry; the number of retries is its length.
        final_subscription_action:
          type: string
          enum: [none, pause, cancel]
        final_invoice_action:
          type: string
          enum: [none, mark_uncollectible]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DunningPolicyListItem:
      allOf:
        - $ref: "#/components/schemas/DunningPolicy"
        - type: object
          required: [assigned_customers]
          properties:
            assigned_customers:
              type: integer
              description: Customers explicitly assigned to this policy. Customers using it only as the default are not counted.

    UsageAggregate:
      type: object
      required: [total_events, total_units, active_meters, active_customers, by_meter]
      properties:
        total_events:
          type: integer
        total_units:
          type: string
          description: |
            Sum of all matching quantities across meters, as a decimal
            string. It adds different units and ignores each meter's
            aggregation; use `by_meter[].value` instead.
        active_meters:
          type: integer
          description: Distinct meters among the matching events.
        active_customers:
          type: integer
          description: Distinct customers among the matching events.
        by_meter:
          type: array
          description: One row per meter, largest total first.
          items:
            type: object
            required: [meter_id, total, aggregation, value, events, customers, peak, daily]
            properties:
              meter_id:
                type: string
              total:
                type: string
                description: Plain sum of reported quantities, as a decimal string.
              aggregation:
                type: string
                description: |
                  The meter's aggregation (`sum`, `count`, `max`, `last`),
                  which every price of the meter bills under. Empty when
                  unknown.
              value:
                type: string
                description: |
                  Usage under the meter's aggregation, as a decimal string.
                  `max` and `last` take each customer's peak or latest
                  reading in the window and add them across customers.
                  Billing applies them per billing period.
              events:
                type: integer
              customers:
                type: integer
                description: Distinct customers with events on this meter.
              peak:
                type: string
                description: Highest single reported quantity, as a decimal string.
              daily:
                type: array
                description: One entry per calendar day (in `tz`) with events, oldest first.
                items:
                  type: object
                  required: [date, value]
                  properties:
                    date:
                      type: string
                      format: date
                    value:
                      type: string
                      description: The day's usage under the meter's aggregation.

    LitellmSpendLog:
      type: object
      description: |
        The fields of a LiteLLM `StandardLoggingPayload` that Hardcount reads.
        Other fields are accepted and ignored.
      properties:
        id:
          type: string
          description: LiteLLM's call id. Used to build the events' idempotency keys.
        status:
          type: string
          description: A `failure` record (a call the provider refused or that timed out) is skipped, whatever token counts it carries.
        cache_hit:
          type: boolean
          nullable: true
          description: |
            `true` when LiteLLM answered from its own response cache. The
            record is skipped and counted in `cache_hits`: the provider
            charged nothing.
        call_type:
          type: string
          description: |
            A record bills on its token counts unless its call type is one
            Hardcount does not bill (images, audio, realtime, moderation,
            batches, files, A2A, response retrieval, ...); those are
            skipped, and a skipped record that carries tokens is logged.
        model:
          type: string
        custom_llm_provider:
          type: string
        user:
          type: string
          description: The customer's external_id.
        end_user:
          type: string
          description: Where LiteLLM Proxy puts the caller's `user`. Used when `user` is empty.
        usage:
          type: object
          properties:
            prompt_tokens:
              type: integer
              format: int64
              description: Includes cache-read tokens.
            completion_tokens:
              type: integer
              format: int64
            total_tokens:
              type: integer
              format: int64
            prompt_tokens_details:
              type: object
              properties:
                cached_tokens:
                  type: integer
                  format: int64
                cache_creation_tokens:
                  type: integer
                  format: int64
                cache_creation_token_details:
                  type: object
                  description: |
                    Anthropic's cache writes by cache lifetime. They become
                    `cache_write_5m` and `cache_write_1h` events, taken out
                    of `input`. Without this split, or when it does not add
                    up to the cache-write count, the writes stay in `input`.
                  properties:
                    ephemeral_5m_input_tokens:
                      type: integer
                      format: int64
                      nullable: true
                    ephemeral_1h_input_tokens:
                      type: integer
                      format: int64
                      nullable: true
            cache_read_input_tokens:
              type: integer
              format: int64
            cache_creation_input_tokens:
              type: integer
              format: int64
              description: Cache-write tokens, included in prompt_tokens. Split into `cache_write_5m` and `cache_write_1h` by `prompt_tokens_details.cache_creation_token_details`.
        prompt_tokens:
          type: integer
          format: int64
          description: Used when there is no `usage` object.
        completion_tokens:
          type: integer
          format: int64
        total_tokens:
          type: integer
          format: int64
        response_cost:
          type: number
          description: Accepted but not stored.
        cost_breakdown:
          type: object
          description: |
            The provider's own cost, in dollars. When present, each event's
            provider cost comes from its part (input, output, cache read or
            cache write) instead of the provider-costs table. A part that is
            missing or negative falls back to the table. `input_cost`
            includes cache reads and cache writes, so the input event gets
            `input_cost` minus `cache_read_cost` and `cache_creation_cost`.
            If there were cache reads but no `cache_read_cost`, or split-off
            cache writes but no `cache_creation_cost`, the input-side events
            use the provider-costs table. With 1-hour cache writes they
            always do: LiteLLM prices `cache_creation_cost` at the 5-minute
            rate.
          properties:
            input_cost:
              type: number
            output_cost:
              type: number
            cache_read_cost:
              type: number
            cache_creation_cost:
              type: number
            tool_usage_cost:
              type: number
            total_cost:
              type: number
        startTime:
          type: number
          description: Unix seconds. Event time when `endTime` is absent.
        endTime:
          type: number
          description: Unix seconds. The event time. With neither, the customer's current time is used.
        metadata:
          type: object
          additionalProperties: true
          description: |
            `team_id` and `request_tags` become event dimensions;
            `usage_object` is read for token counts; other keys are ignored.

    HeldUsageGroup:
      type: object
      description: The usage rows still held for one external customer ID, event name and reason.
      required: [external_customer_id, event_name, reason, rows, total_quantity, first_event_at, last_event_at, last_received_at, customer_id, meter_exists]
      properties:
        external_customer_id:
          type: string
          description: The LiteLLM `user` value the rows carried.
        event_name:
          type: string
          description: The `event_name` the rows carried.
        reason:
          type: string
          enum: [customer_not_found, meter_not_found]
          description: What was missing when the rows arrived.
        rows:
          type: integer
        total_quantity:
          type: string
          description: Sum of the held quantities, as a decimal string.
        first_event_at:
          type: string
          format: date-time
        last_event_at:
          type: string
          format: date-time
        last_received_at:
          type: string
          format: date-time
          description: When Hardcount last received a row for this group.
        customer_id:
          type: string
          description: The customer whose external ID now matches, or empty when none does yet.
        meter_exists:
          type: boolean
          description: Whether a meter with this `event_name` exists now.

    HeldUsageReleaseResult:
      type: object
      description: |
        The counts are what Hardcount found when the release wrote the rows, as
        for `billing_status` (see `UsageBillingStatus`). The invoice is the
        final answer.
      required: [customer_id, released, deduplicated, still_held, more_to_release, in_open_period, no_price, not_on_plan, pending_threshold_invoice, outside_open_period]
      properties:
        customer_id:
          type: string
          description: The customer the rows were ingested for.
        released:
          type: integer
          description: Rows newly written as usage events.
        deduplicated:
          type: integer
          description: |
            Rows that were already usage events under the same idempotency
            key (LiteLLM resent the call after the customer existed), and
            LiteLLM cache-write rows whose call is already billed with the
            writes inside its input row. Marked released, not written again.
        still_held:
          type: integer
          description: |
            Rows for this external ID still held that a release cannot take
            yet: their meter does not exist yet, or their time is after the
            customer's current time (a customer on a test clock).
        more_to_release:
          type: integer
          description: |
            Rows for this external ID still held only because one release
            takes at most 5,000 rows. Release again to take them.
        in_open_period:
          type: integer
          description: |
            Released rows in a billing period not invoiced yet that a price
            of its plan takes. Rows Hardcount does not check count here too:
            rows after the current period's end, while the subscription
            continues, and rows after a trial's end while the subscription
            still trials. Rows of a sum or count meter stamped before the end
            of this period's spend-threshold invoice are not counted here:
            see `pending_threshold_invoice` and `outside_open_period`.
        no_price:
          type: integer
          description: |
            Released rows in a billing period not invoiced yet whose plan
            prices the meter, but no price of that plan takes them (their
            dimensions do not include any price's `dimension_match`).
        not_on_plan:
          type: integer
          description: |
            Released rows in a billing period not invoiced yet whose plan
            does not price the meter.
        pending_threshold_invoice:
          type: integer
          description: |
            Released rows of a sum or count meter stamped before the end of
            this period's spend-threshold invoice, while that invoice is not
            paid. The period's invoice counts them only if that invoice is
            voided before the period closes. A written-off invoice can still
            be voided.
        outside_open_period:
          type: integer
          description: |
            Released rows outside every subscription's time (before it
            started, in a free trial, after it ended or after its scheduled
            cancel, or in another, ended subscription's time), and rows in a
            period already invoiced. Also rows of a sum or count meter
            stamped before the end of this period's paid spend-threshold
            invoice. Bill them with a one-off invoice if they should be
            charged.

    LitellmSpendResult:
      type: object
      required: [accepted, deduplicated, held, skipped, cache_hits, after_close, no_price, not_on_plan, outside_subscription]
      properties:
        accepted:
          type: integer
          description: Usage events newly recorded.
        deduplicated:
          type: integer
          description: Usage events already recorded, or already held, by an earlier delivery.
        held:
          type: integer
          description: |
            Usage events kept because their customer or meter does not
            exist yet. Not billed until released with
            `POST /v1/usage-events/held/release`.
        skipped:
          type: integer
          description: Records that bill nothing - a call type with no tokens, zero tokens, a failed call (`status` `failure`) or a LiteLLM response-cache hit.
        cache_hits:
          type: integer
          description: The LiteLLM response-cache hits among `skipped`. LiteLLM's own spend counts them as $0.
        after_close:
          type: integer
          description: |
            Of `accepted`, the events answered `billing_status:
            after_close`: their time was already invoiced. A replay never
            counts here.
        no_price:
          type: integer
          description: |
            Of `accepted`, the events no price of the subscription's plan
            takes (`billing_status: no_price`), such as a model no price
            names. A replay never counts here.
        not_on_plan:
          type: integer
          description: |
            Of `accepted`, the events whose meter the subscription's plan
            does not price (`billing_status: not_on_plan`). A replay never
            counts here.
        outside_subscription:
          type: integer
          description: |
            Of `accepted`, the events whose time no subscription of the
            customer bills (`billing_status: outside_subscription`). A replay
            never counts here.
        errors:
          type: array
          description: One entry per record that was not recorded. A record's events are recorded together or not at all.
          items:
            type: object
            required: [id, error]
            properties:
              id:
                type: string
                description: The LiteLLM record id.
              error:
                type: string

    APIKey:
      type: object
      required: [id, key_prefix, key_type, livemode, name, tenant_id, created_at]
      properties:
        id:
          type: string
          example: hc_key_1a2b3c4d5e6f7890
        key_prefix:
          type: string
          description: The start of the key (type, mode and a few characters), enough to tell keys apart. Never the full key.
          example: hc_secret_test_1a2b3c4d
        key_type:
          type: string
          enum: [platform, secret]
        livemode:
          type: boolean
        name:
          type: string
        tenant_id:
          type: string
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: When the key stops working. Also set on the old key during a rotation grace period.
        revoked_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          description: Updated at most about once per interval, so it can lag a little.

    APIKeyCreated:
      type: object
      required: [key, raw_key]
      properties:
        key:
          $ref: "#/components/schemas/APIKey"
        raw_key:
          type: string
          description: The full key. Shown only once; store it now.
          example: hc_secret_test_4f9c...

    APIKeyRotation:
      type: object
      required: [old_key, new_key, raw_key]
      properties:
        old_key:
          $ref: "#/components/schemas/APIKey"
        new_key:
          $ref: "#/components/schemas/APIKey"
        raw_key:
          type: string
          description: The new key's full value. Shown only once.

    TenantSettingsInput:
      type: object
      description: Every field is optional. Fields you leave out keep their stored value.
      properties:
        default_currency:
          type: string
          description: ISO 4217 code. Zero-decimal currencies (such as JPY) are not supported yet.
          example: USD
        timezone:
          type: string
          description: IANA zone name, such as `America/New_York`. `Local` is rejected. All billing date math uses this zone.
          example: UTC
        invoice_prefix:
          type: string
          pattern: "^[A-Za-z0-9-]{1,20}$"
          description: Starts every invoice number, such as `HC-000001`.
        credit_note_prefix:
          type: string
          pattern: "^[A-Za-z0-9-]{1,20}$"
          description: |
            Starts every credit note number, such as `CN-000001`. A change
            applies to new credit notes only. Issued notes keep their
            numbers. It must differ from `invoice_prefix`, or an invoice and
            a credit note would get the same number.
        net_payment_terms:
          type: integer
          minimum: 0
          maximum: 365
          description: Days until an invoice is due. 0 means due on issue.
        credit_balance_low_threshold_cents:
          type: integer
          format: int64
          nullable: true
          minimum: 1
          description: |
            When a customer's credit in one currency drops below this
            amount, in that currency's minor units, the `credit.balance_low`
            webhook fires: 1000 means $10.00 of USD credit and €10.00 of EUR
            credit. null turns it off.
        tax_provider:
          type: string
          enum: [none, manual, stripe_tax]
          description: "`none` = no tax, `manual` = the flat `tax_rate`, `stripe_tax` = Stripe Tax."
        tax_rate:
          type: number
          minimum: 0
          maximum: 100
          description: Percent, up to 4 decimal places (18.5 = 18.5%). Used by the `manual` provider.
        tax_name:
          type: string
          description: Label for the tax line, such as `VAT`.
        tax_inclusive:
          type: boolean
          description: Whether prices already include tax.
        tax_on_failure:
          type: string
          enum: [block, fallback_manual]
          description: What to do when the tax provider fails. `block` holds the invoice until tax succeeds.
        default_product_tax_code:
          type: string
          pattern: "^(txcd_[0-9]{8})?$"
          description: |
            Stripe product tax code used when a plan has none, such as
            `txcd_10103001`. Empty is allowed. Surrounding whitespace is
            trimmed before the check.
        company_name:
          type: string
          maxLength: 255
          description: Shown on invoices and as the sender name on emails. No line breaks.
        company_address_line1:
          type: string
          maxLength: 200
        company_address_line2:
          type: string
          maxLength: 200
        company_city:
          type: string
          maxLength: 100
        company_state:
          type: string
          maxLength: 100
        company_postal_code:
          type: string
          maxLength: 20
        company_country:
          type: string
          maxLength: 2
          pattern: "^([A-Za-z]{2})?$"
          description: |
            ISO 3166 alpha-2 code, such as `US`. Empty is allowed.
            Whitespace is trimmed and the code is upper-cased before it is
            checked and stored.
        company_email:
          type: string
        company_phone:
          type: string
          description: 7 to 20 characters of digits, spaces, `+`, `-`, `(` and `)`.
        logo_url:
          type: string
          description: Shown in emails and on the hosted invoice page. PDFs do not print it.
        brand_color:
          type: string
          pattern: "^#[0-9a-fA-F]{6}$"
          description: Accent color on invoice PDFs, as `#rrggbb`.
        tax_id:
          type: string
          maxLength: 50
          description: Your own tax number (VAT, EIN, GSTIN), printed on invoices.
        support_url:
          type: string
          maxLength: 500
          description: |
            Must start with `http://` or `https://`. Linked in emails and on
            the hosted invoice page, and printed at the foot of invoice and
            credit-note PDFs.
        invoice_footer:
          type: string
          maxLength: 1000
          description: |
            Printed under the totals on invoice PDFs and on the hosted
            invoice page. Each invoice keeps the footer it was issued with,
            so a change applies to invoices issued after it. A draft shows
            the current text.

    TenantSettings:
      allOf:
        - $ref: "#/components/schemas/TenantSettingsInput"
        - type: object
          required: [tenant_id, default_currency, timezone, invoice_prefix, credit_note_prefix, net_payment_terms, tax_provider, tax_rate, tax_inclusive, next_invoice_sequence, created_at, updated_at]
          properties:
            tenant_id:
              type: string
            next_invoice_sequence:
              type: integer
              description: |
                The number the next invoice in this mode (test or live) gets,
                before the prefix: 43 means the next invoice is
                `<invoice_prefix>-000043`. Test and live count separately, and
                changing `invoice_prefix` keeps the count. Not a setting:
                sending it on `PUT` does nothing.
            created_at:
              type: string
              format: date-time
              description: Zero time when the tenant has never saved settings.
            updated_at:
              type: string
              format: date-time

    StripeConnection:
      type: object
      required: [id, tenant_id, livemode, secret_key_last4, publishable_key, has_webhook_secret, created_at, updated_at]
      properties:
        id:
          type: string
          description: Also the `{id}` in the Stripe webhook URL `/v1/webhooks/stripe/{id}`.
        tenant_id:
          type: string
        livemode:
          type: boolean
        stripe_account_id:
          type: string
          description: Set after Stripe accepted the key.
        stripe_account_name:
          type: string
        secret_key_prefix:
          type: string
          description: First 12 characters of the secret key.
        secret_key_last4:
          type: string
        publishable_key:
          type: string
        webhook_secret_last4:
          type: string
        has_webhook_secret:
          type: boolean
        verified_at:
          type: string
          format: date-time
          description: Last time Stripe accepted the key.
        last_verified_error:
          type: string
          description: |
            Why the last Stripe check failed, in Stripe's own words (or the
            network error when Stripe was not reached). Empty when it passed.
        last_verified_error_kind:
          type: string
          enum: [rejected, unavailable]
          description: |
            What kind of failure `last_verified_error` is. `rejected`: Stripe
            answered and refused the key (invalid, revoked, or without access).
            `unavailable`: Stripe was not reached, or answered 429/5xx, so the
            key was not checked; connecting again may work. Absent when the
            check passed, and on keys that failed before this field existed.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        retries_queued:
          type: integer
          description: Connect response only. Number of stuck invoices being retried in the background.

    Tenant:
      type: object
      required: [id, name, status, created_at, updated_at]
      properties:
        id:
          type: string
          example: hc_ten_1a2b3c4d5e6f7890
        name:
          type: string
        status:
          type: string
          enum: [active, suspended, deleted]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    BootstrapResult:
      type: object
      required: [tenant, owner_email, owner_password, password_generated, secret_key_test, secret_key_live, message]
      properties:
        tenant:
          $ref: "#/components/schemas/Tenant"
        owner_email:
          type: string
          description: Dashboard login email.
        owner_password:
          type: string
          description: Dashboard login password (the one you sent, or the generated one).
        password_generated:
          type: boolean
        secret_key_test:
          type: string
          description: Test-mode secret key (`hc_secret_test_...`).
        secret_key_live:
          type: string
          description: Live-mode secret key (`hc_secret_live_...`).
        message:
          type: string

    AuditLogEntry:
      type: object
      required: [id, actor_type, actor_id, action, resource_type, resource_id, created_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        actor_type:
          type: string
          enum: [user, api_key, system]
        actor_id:
          type: string
          description: User id, API key id, or `system`.
        actor_name:
          type: string
          description: Name of the API key, when the actor is a key.
        action:
          type: string
          description: For example `create`, `update`, `revoke`, `finalize`.
        resource_type:
          type: string
        resource_id:
          type: string
          description: Can be empty for tenant-wide resources such as settings.
        resource_label:
          type: string
        metadata:
          type: object
          additionalProperties: true
          description: Action details. A settings change lists each changed field with `from` and `to`.
        ip_address:
          type: string
        request_id:
          type: string
          description: Matches the `Hardcount-Request-Id` header of the request that made the change.
        created_at:
          type: string
          format: date-time
        sim_effective_at:
          type: string
          format: date-time
          description: Simulated time, for entries made inside a test clock.
        test_clock_id:
          type: string
        subject_deleted:
          type: boolean
          description: The resource no longer exists because its test clock was deleted. The entry is kept.

    AuditLogFilters:
      type: object
      required: [actions, resource_types, test_clocks]
      properties:
        actions:
          type: array
          items:
            type: string
        resource_types:
          type: array
          items:
            type: string
        test_clocks:
          type: array
          description: Test clocks seen in the log, most recent activity first.
          items:
            type: object
            required: [id, name]
            properties:
              id:
                type: string
              name:
                type: string

    WebhookEndpoint:
      type: object
      required: [id, livemode, url, secret_last4, events, active, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        livemode:
          type: boolean
        url:
          type: string
        description:
          type: string
        secret_last4:
          type: string
        events:
          type: array
          items:
            type: string
          description: Subscribed event types. `*` means all.
        active:
          type: boolean
          description: false = paused.
        secondary_secret_last4:
          type: string
          description: The previous signing secret, during a rotation grace period.
        secondary_secret_expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookEndpointStats:
      type: object
      required: [endpoint_id, total_deliveries, succeeded, failed, success_rate]
      properties:
        endpoint_id:
          type: string
        total_deliveries:
          type: integer
          description: All deliveries, including pending ones.
        succeeded:
          type: integer
        failed:
          type: integer
        success_rate:
          type: number
          description: Percent (0-100) of finished deliveries that succeeded. Pending deliveries are not counted. 0 when none finished.

    WebhookEndpointDelivery:
      type: object
      required: [id, event_id, event_type, is_replay, status, status_code, attempt_count, response_body, error, created_at, completed_at, next_retry_at]
      properties:
        id:
          type: string
        event_id:
          type: string
        event_type:
          type: string
        is_replay:
          type: boolean
          description: The delivery came from a replay.
        replay_of_event_id:
          type: string
        status:
          type: string
          enum: [pending, succeeded, failed]
        status_code:
          type: integer
          description: HTTP status from the receiver. 0 when there was no response.
        attempt_count:
          type: integer
        response_body:
          type: string
          description: First 4 KB of the receiver's response.
        error:
          type: string
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        next_retry_at:
          type: string
          format: date-time
          nullable: true

    WebhookEventDeliveryAttempt:
      type: object
      required: [id, event_id, endpoint_id, endpoint_url, endpoint_description, attempt_no, status, status_code, response_body, error, request_payload_sha256, attempted_at, completed_at, next_retry_at, is_replay, replay_event_id]
      properties:
        id:
          type: string
        event_id:
          type: string
          description: The event this delivery belongs to. When `is_replay` is true, the replay's own id; the message it sent carries the original event's `id` (`root_event_id`).
        endpoint_id:
          type: string
        endpoint_url:
          type: string
        endpoint_description:
          type: string
        attempt_no:
          type: integer
          description: Position in the timeline, starting at 1.
        status:
          type: string
          enum: [pending, succeeded, failed]
        status_code:
          type: integer
        response_body:
          type: string
          description: First 4 KB of the receiver's response.
        error:
          type: string
        request_payload_sha256:
          type: string
          description: SHA-256 of the event payload. Equal values mean the same payload was sent.
        attempted_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        next_retry_at:
          type: string
          format: date-time
          nullable: true
        is_replay:
          type: boolean
        replay_event_id:
          type: string
          description: The replay's event id when `is_replay` is true; otherwise empty.

    WebhookReplayResult:
      type: object
      required: [event_id, replay_of, status]
      properties:
        event_id:
          type: string
          description: The new event created by the replay.
        replay_of:
          type: string
          description: The original event.
        status:
          type: string
          enum: [queued, no_matching_endpoints]

    WebhookDeliveryReplayResult:
      type: object
      required: [event_id, replay_of, delivery_id, status]
      properties:
        event_id:
          type: string
          description: The new event created by the replay.
        replay_of:
          type: string
          description: The original event.
        delivery_id:
          type: string
          description: The new delivery to this endpoint.
        status:
          type: string
          enum: [queued]

    WebhookStreamFrame:
      type: object
      description: The JSON in each `data:` line of `/v1/webhook_events/stream`.
      required: [event_id, event_type, customer_id, status, last_attempt_at, created_at, livemode, replay_of_event_id]
      properties:
        event_id:
          type: string
        event_type:
          type: string
        customer_id:
          type: string
          description: |
            The customer the event is about: the `customer_id` of the
            resource in `data` (its `id` for a `customer.*` event), or empty.
            For an event queued before `data` held the resource, its
            `data.customer_id`.
        status:
          type: string
          description: |
            Delivery state. Live frames send `pending`, `succeeded` or
            `failed`. Frames in the first batch send `pending`, `failed`,
            `delivered` (all deliveries succeeded) or `no_endpoints`.
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        livemode:
          type: boolean
        replay_of_event_id:
          type: string
          nullable: true

    HostedInvoice:
      type: object
      description: |
        What the hosted invoice page shows. It leaves out internal ids and
        Stripe details. `bill_to` and the company details in `branding`
        come from the same place as the invoice PDF's.
      required: [invoice, line_items, bill_to, branding, pay_enabled, livemode]
      properties:
        invoice:
          type: object
          required: [invoice_number, status, payment_status, currency, subtotal_cents, discount_cents, tax_amount_cents, tax_rate, total_amount_cents, amount_due_cents, amount_paid_cents, credits_applied_cents]
          properties:
            invoice_number:
              type: string
            status:
              $ref: "#/components/schemas/InvoiceStatus"
            payment_status:
              $ref: "#/components/schemas/InvoicePaymentStatus"
            currency:
              type: string
            subtotal_cents:
              type: integer
              format: int64
            discount_cents:
              type: integer
              format: int64
            tax_amount_cents:
              type: integer
              format: int64
            tax_rate:
              type: number
              description: Tax rate in percent, for example `18` for 18%.
            tax_name:
              type: string
            tax_reverse_charge:
              type: boolean
            tax_exempt_reason:
              type: string
            total_amount_cents:
              type: integer
              format: int64
            amount_due_cents:
              type: integer
              format: int64
            amount_paid_cents:
              type: integer
              format: int64
            credits_applied_cents:
              type: integer
              format: int64
            issued_at:
              type: string
              format: date-time
            due_at:
              type: string
              format: date-time
            paid_at:
              type: string
              format: date-time
            voided_at:
              type: string
              format: date-time
            billing_timezone:
              type: string
              description: The time zone to show the dates in, so they match the PDF.
              example: Asia/Kolkata
            memo:
              type: string
            footer:
              type: string
              description: The footer this invoice was issued with.
        line_items:
          type: array
          items:
            type: object
            required: [description, quantity, unit_amount_decimal, amount_cents, total_amount_cents, currency]
            properties:
              description:
                type: string
              quantity:
                type: string
                description: The exact quantity as a decimal string, for example `3` or `1.5`.
              unit_amount_decimal:
                type: string
                description: The unit price in cents at full precision, for example `0.3` for $0.003.
              meter_unit:
                type: string
              amount_cents:
                type: integer
                format: int64
              tax_amount_cents:
                type: integer
                format: int64
              total_amount_cents:
                type: integer
                format: int64
              currency:
                type: string
        bill_to:
          type: object
          properties:
            name: { type: string }
            email: { type: string }
            address_line1: { type: string }
            address_line2: { type: string }
            city: { type: string }
            state: { type: string }
            postal_code: { type: string }
            country: { type: string }
        branding:
          type: object
          properties:
            company_name: { type: string }
            company_email: { type: string }
            company_phone: { type: string }
            company_address_line1: { type: string }
            company_address_line2: { type: string }
            company_city: { type: string }
            company_state: { type: string }
            company_postal_code: { type: string }
            company_country: { type: string }
            logo_url: { type: string }
            brand_color: { type: string }
            support_url: { type: string }
        pay_enabled:
          type: boolean
          description: |
            True when the customer can pay online now: the invoice is
            finalized, something is owed, no payment is in progress, the
            seller has Stripe connected for the invoice's mode, and Stripe's
            latest answer for the amount was not that it is below its
            minimum charge.
        pay_unavailable_reason:
          type: string
          enum: [online_payment_unavailable, amount_below_minimum]
          description: |
            Sent only when money is owed but can't be paid online:
            `online_payment_unavailable` when the seller has no payment
            provider connected for this mode, `amount_below_minimum` when
            Stripe's latest answer refused the amount as below its minimum
            charge.
        livemode:
          type: boolean
          description: False for a test-mode invoice. The page shows a test-mode banner.

    PublicPaymentUpdate:
      type: object
      description: What the payment-update page shows before the customer adds a card.
      required: [customer_name, invoice_number, invoice_status, amount_due_cents, currency, branding]
      properties:
        customer_name:
          type: string
        invoice_number:
          type: string
        invoice_status:
          $ref: "#/components/schemas/InvoiceStatus"
        amount_due_cents:
          type: integer
          format: int64
          description: Not cleared when the invoice is voided. Check `invoice_status` before asking for payment.
        currency:
          type: string
        branding:
          type: object
          properties:
            company_name: { type: string }
            company_email: { type: string }
            logo_url: { type: string }
            brand_color: { type: string }
            support_url: { type: string }

    ReadinessReport:
      type: object
      required: [status, checks]
      properties:
        status:
          type: string
          enum: [ok, degraded, draining]
        checks:
          type: object
          description: |
            One entry per check. `api` is `ok` or `draining`. `database` is
            `ok` or `error`. `scheduler` starts with `ok` or `degraded`,
            followed by detail such as `ok: last run 12s ago`. While
            draining, only `api` is sent.
          additionalProperties:
            type: string

    CreditGrantBlock:
      type: object
      description: |
        One block of credit on a customer's balance (a grant or a positive
        adjustment) and how much of it is left.
      required: [id, entry_type, currency, description, amount_cents, consumed_cents, remaining_cents, expired_cents, retired_cents, deducted_cents, created_at]
      properties:
        id:
          type: string
          description: The ledger entry that added the credit.
        entry_type:
          type: string
          description: "`grant` or `adjustment`."
        currency:
          type: string
          description: The block's currency, uppercase.
        grant_kind:
          type: string
          enum: [commit, promotional]
          description: Absent for unclassified credit.
        description:
          type: string
        amount_cents:
          type: integer
          format: int64
          description: The credit the block started with.
        consumed_cents:
          type: integer
          format: int64
          description: |
            Everything drawn from the block: used by invoices, expired,
            retired and deducted.
        remaining_cents:
          type: integer
          format: int64
        expired_cents:
          type: integer
          format: int64
          description: The part that expired unused.
        retired_cents:
          type: integer
          format: int64
          description: |
            Commit credit taken back when the invoice that paid for it was
            voided, or refunded by a credit note.
        deducted_cents:
          type: integer
          format: int64
          description: The part an operator removed with a negative adjustment.
        expires_at:
          type: string
          format: date-time
          description: |
            The credit can be drawn up to and including this instant; a
            period invoice draws it when its period ends at or before it.
            The dashboard sends the local midnight after the picked day.
        source_invoice_id:
          type: string
          description: For a `commit` block, the invoice that paid for it.
        created_at:
          type: string
          format: date-time

    CreditGrants:
      type: object
      description: |
        A customer's credit blocks, newest first, with the remaining credit
        added up by currency and kind.
      required: [grants, remaining_by_currency]
      properties:
        grants:
          type: array
          items:
            $ref: "#/components/schemas/CreditGrantBlock"
        remaining_by_currency:
          type: array
          description: One entry per currency with credit left, ordered by currency.
          items:
            type: object
            required: [currency, commit_remaining_cents, promotional_remaining_cents, other_remaining_cents]
            properties:
              currency:
                type: string
              commit_remaining_cents:
                type: integer
                format: int64
                description: Remaining credit the customer paid for (`commit`).
              promotional_remaining_cents:
                type: integer
                format: int64
                description: Remaining free credit (`promotional`).
              other_remaining_cents:
                type: integer
                format: int64
                description: Remaining credit with no kind, such as adjustments and credit-note credit.

    WebhookEndpointCreated:
      type: object
      description: A new webhook endpoint and its signing secret. The secret is shown only here.
      required: [endpoint, secret]
      properties:
        endpoint:
          $ref: "#/components/schemas/WebhookEndpoint"
        secret:
          type: string
          description: The signing secret (`whsec_…`). Store it now; Hardcount does not show it again.

    WebhookSecretRotation:
      type: object
      description: The endpoint's new signing secret. It is shown only here.
      required: [secret]
      properties:
        secret:
          type: string
        secondary_valid_until:
          type: string
          format: date-time
          description: Until this time, deliveries are also signed with the old secret.

    WebhookEvent:
      type: object
      description: One outbound webhook event, as Hardcount created it.
      required: [id, livemode, event_type, payload, created_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        livemode:
          type: boolean
        event_type:
          type: string
          example: invoice.finalized
        payload:
          type: object
          additionalProperties: true
          description: |
            The event's data: the resource the event is about, under the
            resource's name (`invoice`, `subscription`, `customer`,
            `payment_method`, `dunning_run` or `credit_balance`), as the
            API returns it, and the few keys the event adds beside it.
            docs/webhooks.md lists what each event carries;
            `payment.dispute_opened` and `payment.dispute_closed` carry a
            `DisputeWebhookEventData`.
        created_at:
          type: string
          format: date-time
        replay_of_event_id:
          type: string
          description: Set on a replay. The event the replay was made from.

    TestClock:
      type: object
      description: |
        A clock for test mode. Customers and subscriptions attached to it
        are billed on its time, not the real time.
      required: [id, name, frozen_time, status, created_at, updated_at]
      properties:
        id:
          type: string
        tenant_id:
          type: string
        name:
          type: string
        frozen_time:
          type: string
          format: date-time
          description: The clock's current time.
        status:
          type: string
          enum: [ready, advancing, internal_failure]
          description: |
            `ready`: it can be advanced or deleted. `advancing`: billing is
            catching up to the new time. `internal_failure`: the last advance
            failed partway; resume it with `POST /v1/test-clocks/{id}/retry-advance`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        last_failure_reason:
          type: string
          description: Why the last advance failed. Cleared by the next successful advance.
        last_advance_summary:
          $ref: "#/components/schemas/TestClockAdvanceSummary"

    TestClockAdvanceSummary:
      type: object
      description: |
        What the latest advance did: the time span it covered and how many
        of each billing step it ran. Absent until the clock is first
        advanced.
      required: [advanced_from, advanced_to, invoices_generated, trials_activated, pauses_resumed, thresholds_fired, tax_retried, charges_retried, clawbacks_issued, credits_expired, dunning_advanced, had_errors]
      properties:
        advanced_from:
          type: string
          format: date-time
        advanced_to:
          type: string
          format: date-time
        invoices_generated:
          type: integer
        trials_activated:
          type: integer
        pauses_resumed:
          type: integer
        thresholds_fired:
          type: integer
        tax_retried:
          type: integer
        charges_retried:
          type: integer
        clawbacks_issued:
          type: integer
        credits_expired:
          type: integer
        dunning_advanced:
          type: integer
        had_errors:
          type: boolean
          description: True when a step failed. The counts then cover only the work done before the failure.

    InvoicePreview:
      type: object
      description: |
        A dry-run invoice built from the usage recorded so far. Nothing is
        saved.
      required: [customer_id, subscription_id, plan_name, billing_period_start, billing_period_end, lines, totals, warnings, generated_at]
      properties:
        customer_id:
          type: string
        subscription_id:
          type: string
        plan_name:
          type: string
          description: The names of the subscription's plans, comma-separated.
        billing_period_start:
          type: string
          format: date-time
        billing_period_end:
          type: string
          format: date-time
        lines:
          type: array
          items:
            $ref: "#/components/schemas/InvoicePreviewLine"
        totals:
          type: array
          description: One total per currency on the lines.
          items:
            $ref: "#/components/schemas/InvoicePreviewTotal"
        warnings:
          type: array
          description: Ways the real invoice can differ from this preview.
          items:
            type: string
        generated_at:
          type: string
          format: date-time

    InvoicePreviewLine:
      type: object
      required: [line_type, description, currency, quantity, unit_amount_cents, amount_cents]
      properties:
        line_type:
          type: string
          enum: [base_fee, usage]
        description:
          type: string
        meter_id:
          type: string
        meter_unit:
          type: string
        price_id:
          type: string
          description: On a usage line, the plan price that priced it.
        dimension_match:
          type: object
          additionalProperties:
            type: string
          description: On a usage line, the labels its price takes. Absent for a price that takes every event.
        currency:
          type: string
        quantity:
          type: string
          description: Exact quantity as a decimal string.
        unit_amount_cents:
          type: integer
          format: int64
        amount_cents:
          type: integer
          format: int64
        pricing_mode:
          type: string
          enum: [per_unit, graduated, package]

    InvoicePreviewTotal:
      type: object
      required: [currency, amount_cents]
      properties:
        currency:
          type: string
        amount_cents:
          type: integer
          format: int64

paths:
  /health:
    get:
      tags: [System]
      summary: Health check
      security: []
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [ok]

  /health/ready:
    get:
      tags: [System]
      summary: Readiness check for load balancers
      operationId: getHealthReady
      security: []
      description: |
        Tells a load balancer whether this server can take requests. It
        returns 503 when the server is shutting down (`draining`) or cannot
        reach the database. A late billing scheduler shows as `degraded` in
        the body but keeps the status at 200: taking the server out of the
        balancer would not restart the scheduler. Alert on the scheduler
        metrics instead (see docs/ops/runbook/monitoring.md).
      responses:
        "200":
          description: Ready. `status` is `ok`, or `degraded` when only the scheduler is late.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadinessReport"
        "503":
          description: Not ready. `status` is `draining`, or `degraded` when the database cannot be reached.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadinessReport"

  /metrics:
    get:
      tags: [System]
      summary: Prometheus metrics
      operationId: getMetrics
      security:
        - MetricsToken: []
        - {}
      description: |
        Metrics in the Prometheus text format, for a Prometheus scrape.
        When `METRICS_TOKEN` is set, send it as a Bearer token. When it is
        not set, the route is open, except with `APP_ENV=production`, where
        it answers 401. This is not an API key route.
      responses:
        "200":
          description: Metrics in the Prometheus text format.
          content:
            text/plain:
              schema:
                type: string
        "401":
          description: The token is missing or wrong, or `METRICS_TOKEN` is not set in production. The body is the plain text `unauthorized`.
          content:
            text/plain:
              schema:
                type: string

  /v1/customers:
    post:
      tags: [Customers]
      summary: Create customer
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_id, display_name]
              properties:
                external_id:
                  type: string
                display_name:
                  type: string
                email:
                  type: string
                additional_emails:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                test_clock_id:
                  type: string
                  description: Test mode only. Attaches the customer to a test clock.
      responses:
        "201":
          description: Customer created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Customer"
        "409":
          description: Customer already exists
        "422":
          description: |
            A field failed validation, or the body has an unknown field (`param` names it; the body must use only the fields listed here).
            `dunning_policy_id` is not a create field: assign a policy with
            `PATCH /v1/customers/{id}`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Customers]
      summary: List customers
      parameters:
        - name: status
          in: query
          schema:
            type: string
        - name: external_id
          in: query
          description: Exact-match filter on the caller-assigned external ID.
          schema:
            type: string
        - name: ids
          in: query
          description: Comma-separated customer IDs to fetch exactly.
          schema:
            type: string
        - name: search
          in: query
          description: |
            Case-insensitive substring match on display name, email,
            external ID, or ID. Matching runs post-decryption (PII
            columns are encrypted at rest), bounded to the first 5000
            rows of the tenant's filtered set; the rows and `total` stop
            there.
          schema:
            type: string
        - name: sort
          in: query
          description: |
            One of display_name, email, external_id, status, created_at
            (default). A sort by display_name or email looks at 5,000
            customers at most; the rows and `total` stop there.
          schema:
            type: string
        - name: dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Customer list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Customer"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/customers/{id}/rotate-cost-dashboard-token:
    post:
      tags: [Customers]
      summary: Rotate the public cost-dashboard token (ADR-032)
      description: |
        Mints a fresh `hc_pcd_…` token and writes it to the customer
        row. The previous token is invalidated immediately (read-only
        public surface; no grace window). The plaintext token is the
        sole credential for `GET /v1/public/cost-dashboard/{token}`
        and is shown ONCE — Hardcount never returns it again after this
        response. Audit log records the rotation without the token.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Rotated
          content:
            application/json:
              schema:
                type: object
                required: [token, public_url]
                properties:
                  token:
                    type: string
                    example: hc_pcd_a3f9c4...
                  public_url:
                    type: string
                    example: https://api.example.com/v1/public/cost-dashboard/hc_pcd_a3f9c4...
        "404":
          description: Customer not found

  /v1/customers/{id}:
    get:
      tags: [Customers]
      summary: Get customer
      operationId: getCustomer
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Customer"
        "404":
          description: No customer with this ID in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      tags: [Customers]
      summary: Update customer
      description: |
        Changes only the fields you send. An empty `display_name` or
        `email` is ignored, so you cannot clear the email here.

        - `status: archived` is refused with 409 `invalid_state` while
          the customer has a subscription that still bills. Cancel those
          subscriptions first. Archiving does not stop dunning on unpaid
          invoices.
        - `dunning_policy_id` set to `""` removes the assignment, so the
          tenant's default policy applies. A set ID must be a policy in
          this tenant and mode, or the call returns 404. The change
          applies to the next dunning run, not to runs already started.
        - `additional_emails` replaces the whole CC list; `[]` clears
          it. Entries are lowercased, deduplicated, capped at 10 and may
          not equal the primary email.
        - A new `email` that is already on the CC list is refused with
          422 unless you send a new `additional_emails` too. Changing the
          email resets its bounce state.

        Name and email changes are copied to the linked Stripe customer
        when there is one. A Stripe failure is logged and does not fail
        the call. The change is written to the audit log (it records that
        the email changed, not the address).
      operationId: updateCustomer
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: string
                email:
                  type: string
                status:
                  type: string
                  enum: [active, archived]
                dunning_policy_id:
                  type: string
                  description: Policy ID, or `""` to use the tenant default.
                additional_emails:
                  type: array
                  maxItems: 10
                  items:
                    type: string
      responses:
        "200":
          description: The updated customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Customer"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer, or the named dunning policy, not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Archive refused because a subscription still bills (`invalid_state`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: An email or CC entry is invalid (`param` names the field).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{id}/billing-profile:
    put:
      tags: [Customers]
      summary: Create or replace the customer's billing profile
      description: |
        Writes the whole profile. Fields you leave out are stored empty,
        so send every field you want to keep. `profile_status` is worked
        out by the server: `ready` when legal name, address line 1, city
        and country are all set, otherwise `incomplete`.

        Checks, each answered with 422 and `param` set to the field:
        `country` must be an ISO 3166 alpha-2 code; `currency` must be a
        supported ISO 4217 code (zero-decimal currencies such as JPY are
        refused); `phone` is 7 to 20 digits, spaces or `+-()`; `tax_id`
        must match `tax_id_type` when the type is a known one;
        `tax_status: exempt` needs `tax_exempt_reason`;
        `tax_status: reverse_charge` needs `tax_id`. Country and currency
        are stored in upper case.

        A currency that differs from the plan currency of a subscription
        that still bills is refused with 409 `invalid_state`, because
        invoices would show plan prices in the new currency without
        conversion.

        After saving, the profile is copied to the linked Stripe customer
        (best effort), and draft invoices whose tax failed on bad
        customer data are retried. The change is written to the audit log.
      operationId: upsertCustomerBillingProfile
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CustomerBillingProfileInput"
      responses:
        "200":
          description: The stored profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerBillingProfile"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Currency conflicts with a billing subscription's plan currency (`invalid_state`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            A field failed validation, or the body has a key the profile does
            not have, such as `tax_exempt` (set `tax_status` instead), or one
            the server sets, such as `customer_id` or `created_at`. `param`
            names the field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Customers]
      summary: Get the customer's billing profile
      operationId: getCustomerBillingProfile
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The billing profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerBillingProfile"
        "404":
          description: |
            The customer has no billing profile yet, or the customer does
            not exist. Both return `billing profile not found`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{id}/sent-emails:
    get:
      tags: [Customers]
      summary: List emails sent to the customer
      description: |
        Billing emails tied to this customer's invoices from the last 30
        days, newest first: invoice, payment receipt, payment failed,
        payment setup request, dunning, and credit note emails. Emails
        not tied to an invoice (such as a setup link sent from the
        payment-methods page) are not listed.
      operationId: listCustomerSentEmails
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Sent emails.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SentEmail"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: No customer with this ID in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/customers/{id}/usage:
    get:
      tags: [Customers]
      summary: Get the customer's usage and cost for a period
      description: |
        Usage per meter on the customer's active and trialing
        subscriptions, priced the same way the invoice prices it. With no
        `from` and `to`, the period is the current billing cycle of the
        subscription whose cycle started last, as of the customer's current
        time (the test clock's time for a customer on a test clock). A
        trialing subscription's cycle starts when its trial ends, so it is
        not picked before then, unless no subscription's cycle has started.
        Send both or neither.

        Errors: 404 if the customer does not exist. 422 when only one of
        `from`/`to` is sent, when `from` is not before `to`, when the
        window is longer than one year, or (code
        `customer_has_no_subscription`) when no period is sent and the
        customer has no subscription with a current cycle.

        Decimal quantities are returned as strings. If the daily series
        fails to load, `buckets` is empty and `warnings` says so.
      operationId: getCustomerUsage
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: from
          in: query
          description: Start of the window. RFC 3339 instant or `YYYY-MM-DD` (start of that day, UTC).
          schema:
            type: string
        - name: to
          in: query
          description: End of the window, exclusive. RFC 3339 instant or `YYYY-MM-DD` (23:59:59.999999 UTC that day, so the day is included).
          schema:
            type: string
      responses:
        "200":
          description: Usage for the period.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerUsage"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Bad or missing period (`param` is `from`, `to` or `period`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{customer_id}/overview:
    get:
      tags: [Customers]
      summary: Get a customer overview
      description: |
        The customer's active subscriptions (up to 50), the 5 newest
        invoices, and the amount still owed on unpaid invoices. A failed
        read inside the overview returns an empty part or a zero balance
        rather than an error.
      operationId: getCustomerOverview
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Customer overview.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerOverview"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{customer_id}/payment-methods:
    get:
      tags: [Customers]
      summary: List the customer's payment methods
      description: |
        Saved payment methods that are not detached, default first, then
        newest. At most 100. `total` is the number returned.
      operationId: listCustomerPaymentMethods
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Payment methods.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PaymentMethod"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/customers/{customer_id}/payment-methods/send-setup-email:
    post:
      tags: [Customers]
      summary: Email the customer a link to add a payment method
      description: |
        Creates a Stripe Checkout setup link (as `setup-session` does)
        and queues an email with it to the customer's primary email. The
        link is never sent to the CC list. The card details go from the
        customer's browser to Stripe; Hardcount does not see them. The card
        is saved when Stripe reports the setup succeeded.

        At most one call per customer per minute when the server has Redis
        configured. The minute starts at any call whose body is accepted,
        including one that then fails, so a second call within it returns
        429 `cooldown_active` with a `Retry-After` header. Without Redis
        there is no cooldown.

        The send is written to the audit log.
      operationId: sendCustomerPaymentSetupEmail
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: |
                    Optional text shown at the top of the email. At most
                    2000 bytes (UTF-8) after trimming whitespace.
      responses:
        "202":
          description: Email queued.
          content:
            application/json:
              schema:
                type: object
                required: [to, subject]
                properties:
                  to:
                    type: string
                    description: The address the email was sent to.
                  subject:
                    type: string
        "400":
          description: The body is not valid JSON. Nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Nothing was sent. `email_not_configured`: this server has no SMTP
            set up, and no setup link was created. `stripe_not_connected`:
            Stripe is not connected for this mode. Like any other call that
            gets this far, it starts the one-minute cooldown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `note_too_long` (more than 2000 bytes),
            `missing_email` (the customer has no email), or an unknown
            field (`param` names it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: This endpoint was called for this customer less than a minute ago (`cooldown_active`).
          headers:
            Retry-After:
              description: Seconds to wait.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: |
            The setup session could not be created. No email was queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: |
            The email could not be queued (`email_enqueue_failed`), or
            Stripe refused to create the setup session with a message it
            is safe to show (`provider_error`). Retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Email sending is not set up on this server (`email_unavailable`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{customer_id}/payment-methods/setup-session:
    post:
      tags: [Customers]
      summary: Create a link for the customer to add a payment method
      description: |
        Returns a Stripe Checkout URL in setup mode (card only, no
        charge) for you to give to the customer. Creates the Stripe
        customer on first use. When the customer finishes, Stripe sends
        them to `return_url` with `?status=success` or `?status=cancel`
        added. The card is saved when Stripe reports the setup succeeded.
        The link is written to the audit log; if the audit row cannot be
        written, no link is returned.
      operationId: createCustomerPaymentSetupSession
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                return_url:
                  type: string
                  description: |
                    Where Stripe sends the customer afterwards. Defaults to
                    the dashboard's setup-complete page.
      responses:
        "201":
          description: Setup link created.
          content:
            application/json:
              schema:
                type: object
                required: [url, session_id]
                properties:
                  url:
                    type: string
                    description: Stripe Checkout URL. Expires on Stripe's schedule (about 24 hours).
                  session_id:
                    type: string
                    description: Stripe Checkout Session ID.
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: "`stripe_not_connected`: Stripe is not connected for this mode. Connect it under Settings."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: The body has an unknown field (`param` names it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{customer_id}/payment-methods/{pmID}:
    delete:
      tags: [Customers]
      summary: Detach a payment method
      description: |
        Detaches the card in Stripe and marks it detached in Hardcount. If it
        was the default, the newest remaining card becomes the default in
        the same step, and that change is copied to Stripe (best effort).
        Both changes are written to the audit log. A Stripe error on the
        detach returns 500 and leaves the card attached.
      operationId: detachCustomerPaymentMethod
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
        - name: pmID
          in: path
          required: true
          description: Hardcount payment method ID.
          schema:
            type: string
      responses:
        "200":
          description: The detached payment method.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentMethod"
        "404":
          description: No such payment method for this customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/customers/{customer_id}/payment-methods/{pmID}/default:
    post:
      tags: [Customers]
      summary: Make a payment method the default
      description: |
        Makes this card the one Hardcount charges and clears the flag on the
        others. The change is copied to the Stripe customer (best effort;
        a Stripe failure is recorded on the audit row and does not fail
        the call).
      operationId: setCustomerDefaultPaymentMethod
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
        - name: pmID
          in: path
          required: true
          description: Hardcount payment method ID.
          schema:
            type: string
      responses:
        "200":
          description: The new default payment method.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentMethod"
        "404":
          description: No such active payment method for this customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/public/cost-dashboard/{token}:
    get:
      tags: [Public]
      security: []
      summary: Public cost-dashboard projection (ADR-032)
      description: |
        Unauthenticated. The `hc_pcd_…` token in the URL is the sole
        credential — partners embed via iframe / fetch from their own
        app without an API key. Sanitized projection: customer_id,
        tenant_id, livemode, billing_period, subscriptions, usage[],
        totals, projected_total_cents, credits. PII (email, display_name, external_id,
        metadata, billing_profile) is NEVER on this response. Wrong
        prefix or unknown token → 401 (anti-enumeration). No active
        sub → 200 with `billing_period.source = "no_subscription"`:
        `subscriptions`, `usage` and `totals` are empty, and `credits`
        still lists the customer's credit balance.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Projection
          content:
            application/json:
              schema:
                type: object
                required: [customer_id, tenant_id, livemode, billing_period, subscriptions, usage, totals, projected_total_cents, credits]
                properties:
                  customer_id: { type: string }
                  tenant_id: { type: string }
                  livemode: { type: boolean }
                  billing_period:
                    type: object
                    required: [start, end, source]
                    properties:
                      start: { type: string, format: date-time }
                      end: { type: string, format: date-time }
                      source:
                        type: string
                        enum: [subscription, no_subscription]
                        description: >
                          Branch on this before rendering the period. When it
                          is `no_subscription` the customer has no active
                          subscription, `subscriptions`, `usage` and `totals`
                          are empty (`credits` is not), and `start`/`end`
                          carry the zero time (`0001-01-01T00:00:00Z`) rather
                          than a real range — display them and the page reads
                          "January 1, year 1".
                  subscriptions:
                    type: array
                    items:
                      type: object
                      required: [id, plan_name, currency, current_period_start, current_period_end]
                      properties:
                        id: { type: string }
                        plan_name: { type: string }
                        currency: { type: string }
                        current_period_start: { type: string, format: date-time }
                        current_period_end: { type: string, format: date-time }
                  usage:
                    type: array
                    items:
                      type: object
                      required: [event_name, meter_name, unit, currency, total_quantity, total_amount_cents, prices]
                      properties:
                        event_name: { type: string }
                        meter_name: { type: string }
                        unit: { type: string }
                        currency: { type: string }
                        total_quantity: { type: string }
                        total_amount_cents: { type: integer, format: int64 }
                        prices:
                          type: array
                          description: One row per price that took usage this period.
                          items:
                            type: object
                            required: [quantity, amount_cents]
                            properties:
                              dimension_match:
                                type: object
                                additionalProperties: { type: string }
                                description: The labels the price takes. Absent for a price that takes every event.
                              quantity: { type: string }
                              amount_cents: { type: integer, format: int64 }
                              unit_amount_decimal:
                                type: string
                                description: >
                                  Per-unit price in DECIMAL CENTS (ADR-045,
                                  the Stripe unit_amount_decimal model) — NOT
                                  dollars. "0.0003" is 0.0003 CENTS per unit
                                  ($3.00 per 1M units); multiplying it by
                                  `quantity` yields CENTS, matching the
                                  sibling `amount_cents`. Reading it as dollars
                                  overstates the cost 100x.
                  totals:
                    type: array
                    description: One entry per currency present in `usage`.
                    items:
                      type: object
                      required: [currency, amount_cents]
                      properties:
                        currency: { type: string }
                        amount_cents: { type: integer, format: int64 }
                  projected_total_cents:
                    type: integer
                    format: int64
                    description: >
                      The metered cost of the current billing period so far,
                      in cents, including usage a threshold invoice already
                      billed. Recurring base fees are not included — an
                      in_advance base is already invoiced at period open.
                  credits:
                    type: array
                    description: >
                      The customer's credit position, one entry per currency,
                      ordered by currency. `balance_cents` is the ledger
                      balance in that currency, less any grant past its expiry
                      date that is not yet written off: credit is drawn down
                      when an invoice is issued, so it does not move with
                      usage. `unbilled_usage_cents` is the usage no invoice has
                      billed yet that will be invoiced in that currency, and
                      `estimated_remaining_cents` is the balance minus that,
                      never below zero. These three are the same figures
                      `GET /v1/credits/balance/{customer_id}/live` returns.
                      `unbilled_usage_cents` differs from
                      `projected_total_cents` after a threshold invoice: it
                      leaves out the usage that invoice billed. It is an
                      estimate: tax, base fees billed at period end and unpaid
                      invoices are not subtracted.
                    items:
                      type: object
                      required: [currency, balance_cents, unbilled_usage_cents, commit_remaining_cents, promotional_remaining_cents, estimated_remaining_cents]
                      properties:
                        currency: { type: string }
                        balance_cents: { type: integer, format: int64 }
                        unbilled_usage_cents: { type: integer, format: int64 }
                        commit_remaining_cents:
                          type: integer
                          format: int64
                          description: What is left of the customer's prepaid commit.
                        promotional_remaining_cents:
                          type: integer
                          format: int64
                          description: What is left of promotional credit.
                        estimated_remaining_cents: { type: integer, format: int64 }
        "401":
          description: Invalid token
        "429":
          description: Rate limited (60/min/IP)

  /v1/public/invoices/{token}:
    get:
      tags: [Public]
      security: []
      summary: Data behind the hosted invoice page
      operationId: getHostedInvoice
      description: |
        Unauthenticated. The invoice's public token in the URL is the only
        credential. Paid and voided invoices stay viewable. A draft is
        never shown: it returns 404 like an unknown token. Share the page
        link with customers instead of calling this route.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The invoice as the page shows it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HostedInvoice"
        "404":
          description: Unknown token, or the invoice is a draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (60/min/IP)

  /v1/public/invoices/{token}/pdf:
    get:
      tags: [Public]
      security: []
      summary: Download the hosted invoice PDF
      operationId: getHostedInvoicePdf
      description: |
        Unauthenticated, like the hosted invoice page. Returns the same PDF
        as `GET /v1/invoices/{id}/pdf`, shown inline and named after the
        invoice number.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: PDF file.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "404":
          description: Unknown token, or the invoice is a draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (60/min/IP)

  /v1/public/invoices/{token}/checkout:
    post:
      tags: [Public]
      security: []
      summary: Start paying the invoice in Stripe Checkout
      operationId: createHostedInvoiceCheckout
      description: |
        The hosted page's Pay button. Returns a Stripe Checkout URL for the
        amount due. Hardcount has already worked out the tax, so Stripe adds
        none. After paying, Stripe sends the customer back to the hosted
        page with `?paid=1`. The Stripe webhook, not this redirect, marks
        the invoice paid.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Checkout session created.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    description: Stripe Checkout URL.
        "404":
          description: Unknown token, or the invoice is a draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            The invoice cannot be paid now. The code says why:
            `not_payable` when it is not finalized, nothing is owed, or its
            payment status is `processing` or `unknown`.
            `payment_in_progress` when another payment for it has started.
            `online_payment_unavailable` when the seller has no Stripe keys
            for the invoice's mode. `amount_below_minimum` when Stripe's
            latest answer for the amount was that it is below Stripe's
            minimum charge, so no payment page is made for it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (60/min/IP)
        "502":
          description: Stripe could not create the session. Code `stripe_error`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: The server runs without a Stripe adapter. Code `stripe_unavailable`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/public/payment-updates/{token}:
    get:
      tags: [Public]
      security: []
      summary: Data behind the payment-update page
      operationId: getPublicPaymentUpdate
      description: |
        Unauthenticated. The token from the payment-update email is the only
        credential. It is valid for 24 hours, and starting checkout uses it
        up. Share the page link with customers instead of calling this
        route.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: What the page shows.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicPaymentUpdate"
        "401":
          description: The token is unknown, expired or already used. Code `invalid_token`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: The invoice or customer the token names is gone. Code `invoice_not_found` or `customer_not_found`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (60/min/IP)

  /v1/public/payment-updates/{token}/checkout:
    post:
      tags: [Public]
      security: []
      summary: Start adding a card in Stripe Checkout
      operationId: createPublicPaymentUpdateCheckout
      description: |
        Returns a Stripe Checkout URL in setup mode (card only, no charge).
        Creates the Stripe customer if there is none. This call uses up the
        token. If Stripe fails to create the session, the token works again.
        Stripe sends the customer to the payment-method-added page with
        `?status=success` or `?status=cancel`. Writes an audit row.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "201":
          description: Checkout session created.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    description: Stripe Checkout URL.
        "401":
          description: The token is unknown, expired or already used. Code `invalid_token`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (60/min/IP)
        "409":
          description: The seller has not connected Stripe for this mode, so a card cannot be added online. Code `online_payment_unavailable`. The token still works.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: Starting the session failed, at Stripe or on the server. Code `stripe_error`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/meters:
    post:
      tags: [Pricing]
      summary: Create meter
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_name, name]
              properties:
                event_name:
                  type: string
                  description: The `event_name` usage events will send for this meter. Letters, digits, `-` and `_`; at most 100 characters.
                name:
                  type: string
                unit:
                  type: string
                  default: unit
                aggregation:
                  type: string
                  enum: [sum, count, max, last]
                  default: sum
                  description: How the meter rolls its events into one quantity per billing period. Fixed once the meter has a usage event.
      responses:
        "201":
          description: Meter created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Meter"
    get:
      tags: [Pricing]
      summary: List meters
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Meter list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Meter"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/meters/{id}:
    patch:
      tags: [Pricing]
      summary: Update meter
      description: |
        Fields omitted are left unchanged. A meter only counts: its prices
        are on the plans (`/v1/plans/{id}/prices`).

        `aggregation` can change until the meter has a usage event, then
        it is fixed: a change is refused with 409 `meter_has_usage`. Usage
        events are counted under the aggregation the meter has; to count
        differently, make a new meter with another event name. Held usage
        for its `event_name` does not fix it: it is counted under the
        meter's aggregation once released. `name` and `unit` can always
        change, at once.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                unit:
                  type: string
                aggregation:
                  type: string
                  enum: [sum, count, max, last]
      responses:
        "200":
          description: Updated meter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Meter"
        "404":
          description: Meter not found
        "409":
          description: |
            `meter_has_usage`: the meter has usage events, so its
            `aggregation` cannot change. `invalid_state`: another write
            changed the meter during this one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Validation failure (bad aggregation, an empty name, or an unknown field)

    get:
      tags: [Pricing]
      summary: Get meter
      operationId: getMeter
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The meter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Meter"
        "404":
          description: "`not_found`: no meter with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credit-notes:
    post:
      tags: [CreditNotes]
      summary: Create a credit note (line-based, or commit relief)
      description: |
        Two shapes. LINE-BASED (regular invoices): lines[] +
        refund/credit/out_of_band allocation; capped by the invoice's
        remaining creditable gross. COMMIT RELIEF (ADR-080, paid commit
        invoices only): pass commit_relief and NO lines — the server
        retires commit credits and derives the refundable cash from the
        telescoping price-ratio anchor (discounted commits refund at the
        purchase rate, never at face). The relief credit note is created
        AND issued in one transaction; the credit-balance channel is
        forbidden on relief. Unpaid commit invoices: void the invoice
        instead.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_id]
              properties:
                invoice_id:
                  type: string
                reason:
                  type: string
                lines:
                  type: array
                  items:
                    type: object
                    properties:
                      invoice_line_item_id:
                        type: string
                        description: Optional. The invoice line this credit line reverses.
                      description:
                        type: string
                      quantity:
                        type: integer
                      unit_amount_cents:
                        type: integer
                refund_amount_cents:
                  type: integer
                  description: Not with commit_relief; put the relief's split inside commit_relief.
                credit_amount_cents:
                  type: integer
                out_of_band_amount_cents:
                  type: integer
                  description: Not with commit_relief; put the relief's split inside commit_relief.
                refund_type:
                  type: string
                  enum: [refund, credit]
                  description: |
                    Older single-channel form. On a paid invoice whose three
                    `*_amount_cents` fields are all 0, `refund` or `credit`
                    puts the whole note in that channel. Not with commit_relief.
                auto_issue:
                  type: boolean
                commit_relief:
                  type: object
                  description: |
                    Mutually exclusive with lines and credit_amount_cents. The
                    relief's split goes in here, not at the top level: a top-level
                    `refund_amount_cents`, `out_of_band_amount_cents` or
                    `refund_type` is a 422. Example after a lost dispute:
                    `{"invoice_id": "inv_…", "commit_relief": {"retire_all": true, "out_of_band_amount_cents": 5000}}`.
                  properties:
                    invoice_id:
                      type: string
                      description: Optional. Defaults to the top-level invoice_id.
                    reason:
                      type: string
                      description: Optional. Defaults to the top-level reason.
                    retire_cents:
                      type: integer
                      description: Commit credits to retire (1..remaining).
                    retire_all:
                      type: boolean
                      description: Retire the LIVE remaining (absorbs racing drawdowns).
                    refund_amount_cents:
                      type: integer
                      description: Optional allocation; must sum with out_of_band to the derived cash.
                    out_of_band_amount_cents:
                      type: integer
      responses:
        "201":
          description: Credit note (issued immediately for commit relief)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "409":
          description: State gate (unpaid commit → void; fully-consumed commit; in-flight charge)
        "422":
          description: Validation (over-retire with live remaining; allocation mismatch; zero-cash slice), or an unknown field (`param` names it; the body must use only the fields listed here)

    get:
      tags: [CreditNotes]
      summary: List credit notes
      operationId: listCreditNotes
      description: |
        Lists credit notes, newest first by default. The response has no
        `total`; page with `limit` and `offset` until a page comes back
        shorter than `limit`.
      parameters:
        - name: invoice_id
          in: query
          schema:
            type: string
        - name: customer_id
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, issued, voided]
        - name: refund_status
          in: query
          schema:
            type: string
            enum: [none, pending, succeeded, failed, unknown, needs_attention]
          description: |
            `needs_attention` returns issued, non-test-clock notes whose
            refund failed or has an unknown result, has been pending more
            than 72 hours, or has a refund amount still at `none` after 1
            hour.
        - name: sort
          in: query
          description: One of credit_note_number, total_cents (alias amount), status, issued_at, created_at (default).
          schema:
            type: string
        - name: dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Credit note list.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreditNote"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/credits/grants/{customer_id}:
    get:
      tags: [Credits]
      summary: Per-grant burndown + kind subtotals
      description: |
        The customer's credit blocks with drawdown state — granted /
        drawn / remaining per grant, expiry, and remaining per currency
        split by cost-basis class (commit = money-backed, promotional =
        free, other = legacy/adjustments). include_exhausted=true adds
        fully-drawn blocks for history. Each grant's `consumed_cents`
        also counts credit no invoice used: `expired_cents`, the part that
        expired unused; `retired_cents`, commit credit taken back when the
        invoice that funded it was voided or refunded by a credit note; and
        `deducted_cents`, the part an operator deduction (a negative
        adjustment) drew from this grant. The credit invoices actually used
        is `consumed_cents - expired_cents - retired_cents - deducted_cents`.
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
        - name: include_exhausted
          in: query
          schema:
            type: boolean
      responses:
        "200":
          description: Grants with remaining + kind subtotals
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditGrants"
        "404":
          description: No customer with this ID in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credits/adjust:
    post:
      tags: [Credits]
      summary: Add or remove credit
      description: |
        Writes one `adjustment` entry to the customer's credit ledger in
        `currency`. A positive amount adds credit in that currency; a
        negative amount removes credit in that currency only. Audit action
        is `credit.adjustment` or `credit.deduction`.

        Errors (422, `param` names the field): `customer_id`, `currency` or
        `description` missing; `currency` not supported (an unknown code or
        a zero-decimal currency such as JPY); `amount_cents` is zero, above
        100000000 (1,000,000.00 in that currency), or a removal larger than
        the credit in that currency that can be drawn (expired grants not
        yet written off do not count).
      operationId: adjustCredits
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, currency, amount_cents, description]
              properties:
                customer_id:
                  type: string
                currency:
                  type: string
                  description: ISO 4217 code of the balance to change. Case-insensitive; stored uppercase.
                  example: USD
                amount_cents:
                  type: integer
                  format: int64
                  description: Positive adds credit, negative removes it. Not zero.
                description:
                  type: string
      responses:
        "201":
          description: The ledger entry written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditLedgerEntry"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Validation failed (see description), or an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credits/balances:
    get:
      tags: [Credits]
      summary: List credit balances for all customers
      description: |
        One row per customer and currency that has any credit ledger
        entry, highest balance first.
        `total_granted` is the sum of every credit added in that currency
        (grants, positive adjustments and reversals).
      operationId: listCreditBalances
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Balances.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreditBalance"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/credits/ledger/{customer_id}:
    get:
      tags: [Credits]
      summary: List a customer's credit ledger
      description: |
        Ledger entries for one customer. `balance_after` is the running
        balance in the entry's currency, in time order over the whole
        ledger, so it stays right when you filter by `entry_type`.
      operationId: listCreditLedgerEntries
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
        - name: entry_type
          in: query
          schema:
            type: string
            enum: [grant, usage, expiry, adjustment]
        - name: sort
          in: query
          description: One of created_at (default), amount_cents, entry_type, expires_at.
          schema:
            type: string
        - name: dir
          in: query
          description: Default desc.
          schema:
            type: string
            enum: [asc, desc]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Ledger entries. `total` counts entries matching the filter.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreditLedgerEntry"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: No customer with this ID in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/webhook-endpoints/endpoints:
    post:
      tags: [Webhooks]
      summary: Create webhook endpoint
      description: |
        Returns the endpoint plus its signing secret (shown once). Event
        names are validated against the emit catalog — subscribing to an
        event Hardcount never emits is a 422, not silence. Omitted/empty
        events = all events ("*"); "invoice.*"-style prefix wildcards are
        supported.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                description:
                  type: string
                events:
                  type: array
                  items:
                    type: string
      responses:
        "201":
          description: Endpoint created (response carries the one-time signing secret)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointCreated"
        "422":
          description: Invalid URL or unknown event type
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Endpoint list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpoint"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/webhook-endpoints/endpoints/{id}:
    get:
      tags: [Webhooks]
      summary: Get a webhook endpoint
      operationId: getWebhookEndpoint
      description: One endpoint, without its signing secret.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "404":
          description: No such endpoint in the caller's mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      tags: [Webhooks]
      summary: Update webhook endpoint (no secret rotation)
      description: |
        Mutates url / description / events / active WITHOUT rotating the
        signing secret — the receiver keeps verifying while the target or
        event set changes. Fields omitted are left unchanged; events are
        validated against the emit catalog.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                description:
                  type: string
                events:
                  type: array
                  items:
                    type: string
                active:
                  type: boolean
      responses:
        "200":
          description: Updated endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "404":
          description: Endpoint not found
        "422":
          description: Invalid URL or unknown event type
    delete:
      tags: [Webhooks]
      summary: Delete (disable) webhook endpoint
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Endpoint disabled
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [deleted]

  /v1/webhook-endpoints/endpoints/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate signing secret (72h dual-signing grace)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: New secret (old secret keeps verifying during the grace window)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSecretRotation"

  /v1/webhook-endpoints/endpoints/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List an endpoint's recent deliveries
      operationId: listWebhookEndpointDeliveries
      description: |
        The endpoint's deliveries, newest first. Each row names the event
        it carried. A deleted or unknown endpoint returns 404. The endpoint
        itself is `GET /v1/webhook-endpoints/endpoints/{id}`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Endpoint and its deliveries
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpointDelivery"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: No such endpoint, or it was deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/webhook-endpoints/endpoints/{id}/deliveries/{deliveryID}/replay:
    post:
      tags: [Webhooks]
      summary: Resend one delivery to its endpoint
      operationId: replayWebhookDelivery
      description: |
        Sends the delivery's event again to this one endpoint only. Other
        endpoints are not contacted. Hardcount copies the event to a new event
        (`replay_of` points to the original) and queues a new delivery.
        The message sent carries the original event's `id` and
        `created_at`. The original delivery is not changed. Sending happens in the
        background, so `status` is always `queued`.
        Writes an audit row.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Endpoint id.
        - name: deliveryID
          in: path
          required: true
          schema:
            type: string
          description: Delivery id. It must belong to this endpoint.
      responses:
        "200":
          description: Replay queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryReplayResult"
        "404":
          description: No such delivery, the delivery belongs to another endpoint, or the endpoint was deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: "The endpoint is paused (`active` is false). Resume it first. Code `invalid_state`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/webhook-endpoints/endpoints/stats:
    get:
      tags: [Webhooks]
      summary: Delivery counts per webhook endpoint
      operationId: getWebhookEndpointStats
      description: |
        All-time delivery counts for each endpoint that has deliveries, in
        the caller's mode. Endpoints with no deliveries are not listed.
        Deleted endpoints can appear.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: One row per endpoint
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpointStats"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/webhook-endpoints/events:
    get:
      tags: [Webhooks]
      summary: List outbound events, newest first
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Event list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEvent"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/webhook_events/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List all delivery attempts for an event
      operationId: listWebhookEventDeliveries
      description: |
        Returns one timeline, oldest first, of every delivery for the
        original event and for all of its replays. If `id` is a replay,
        the timeline starts from its original event (`root_event_id`).
        Rows keep the endpoint's URL and description even after the
        endpoint is deleted.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: An event id, either the original or a replay.
      responses:
        "200":
          description: Delivery timeline
          content:
            application/json:
              schema:
                type: object
                required: [root_event_id, deliveries]
                properties:
                  root_event_id:
                    type: string
                    description: The original event the timeline is built from.
                  deliveries:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEventDeliveryAttempt"
        "403":
          description: Caller lacks the `apikey:read` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such event in this mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/webhook_events/{id}/replay:
    post:
      tags: [Webhooks]
      summary: Replay an event to all matching endpoints
      operationId: replayWebhookEvent
      description: |
        Copies the event to a new event and queues it for every active
        endpoint in the same mode that subscribes to its type. Endpoints
        that already received it get it again, with the original event's
        `id` and `created_at`. The original event and its deliveries are
        not changed. Each call makes a new copy.

        `replay_of` is always the original event, even when `id` is a
        replay. `status` is `queued`, or `no_matching_endpoints` when no
        endpoint will receive it. Sending happens in the background.
        Writes an audit row. Needs only the `apikey:read` permission.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Replay created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookReplayResult"
        "403":
          description: Caller lacks the `apikey:read` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such event in this mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/webhook_events/stream:
    get:
      tags: [Webhooks]
      summary: Stream webhook events live (Server-Sent Events)
      operationId: streamWebhookEvents
      description: |
        A long-lived Server-Sent Events stream of the tenant's outbound
        webhook events. Accepts the dashboard session cookie or a Bearer
        key with `apikey:read`. It has no 30-second request timeout.

        On connect, the server sends the 50 newest events, newest first.
        It then sends a frame for each new event, replay, and delivery
        result. Every frame is `event: webhook_event` with a JSON
        `data:` line. A comment line `: heartbeat` is sent every 15
        seconds to keep proxies from closing the connection. The same
        event can arrive more than once; de-duplicate by `event_id`.

        Live frames are not filtered by mode: check `livemode` on each
        frame. The first batch holds only the caller's mode. Live frames
        come from the server instance that handled the event, so with
        several replicas a client sees only that replica's events.
      responses:
        "200":
          description: |
            Event stream. Each frame's `data` is a WebhookStreamFrame
            object in JSON.
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE frames: `event: webhook_event\ndata: <WebhookStreamFrame JSON>\n\n`,
                  plus `: heartbeat\n\n` comments.
        "401":
          description: Missing or invalid credential
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Caller lacks the `apikey:read` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/webhooks/stripe/{endpoint_id}:
    post:
      tags: [Webhooks]
      security: []
      summary: Receive a Stripe webhook
      operationId: receiveStripeWebhook
      description: |
        Stripe calls this route; your code does not. Point a webhook in your
        Stripe dashboard at it for each connected mode. `endpoint_id` is the
        `id` of that mode's Stripe connection (`GET /v1/settings/stripe`).
        The request is checked against the `Stripe-Signature` header and
        that mode's signing secret. The body can be at most 64 KB.

        Events for objects Hardcount did not create are answered 200 with
        `status: skipped`. A 500 means a temporary failure, and Stripe
        sends the event again.
      parameters:
        - name: endpoint_id
          in: path
          required: true
          schema:
            type: string
        - name: Stripe-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A Stripe event object.
              additionalProperties: true
      responses:
        "200":
          description: Processed, or skipped.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [processed, skipped]
                  reason:
                    type: string
                    description: Why the event was skipped.
        "400":
          description: Bad signature, an unreadable body or invalid JSON, or the event's mode or Hardcount account does not match the endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No Stripe connection has this `endpoint_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: The body is over 64 KB. Code `payload_too_large`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Temporary failure. Stripe retries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/webhooks/postmark:
    post:
      tags: [Webhooks]
      security:
        - PostmarkBasicAuth: []
      summary: Receive a Postmark delivery webhook
      operationId: receivePostmarkWebhook
      description: |
        Postmark calls this route; your code does not. Hardcount reads
        `Delivery`, `Bounce` and `SpamComplaint` records and ignores other
        record types and soft bounces. Without `POSTMARK_WEBHOOK_USER` and
        `POSTMARK_WEBHOOK_PASS` set, every request gets 401. See
        docs/ops/email-setup.md.

        Error bodies are `{"error": "<reason>"}`, not the usual error
        object. A body that a retry cannot fix is answered 200, so Postmark
        stops retrying: over 64 KB, invalid JSON, or no known Hardcount email
        id in its metadata.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A Postmark webhook payload.
              additionalProperties: true
      responses:
        "200":
          description: Processed, skipped or ignored.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [processed, skipped, ignored]
                  reason:
                    type: string
        "401":
          description: Wrong or missing Basic auth, or the credentials are not set on the server.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
        "500":
          description: Temporary failure. Postmark retries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string

  /v1/plans:
    post:
      tags: [Pricing]
      summary: Create plan
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, name, currency, billing_interval]
              properties:
                code:
                  type: string
                name:
                  type: string
                description:
                  type: string
                currency:
                  type: string
                billing_interval:
                  type: string
                  enum: [monthly, yearly]
                base_amount_cents:
                  type: integer
                  format: int64
                base_bill_timing:
                  type: string
                  enum: [in_advance, in_arrears]
                  description: |
                    Required when `base_amount_cents` is above 0. With a base
                    of 0 it may be left out, and the plan is in_arrears;
                    `in_advance` with a base of 0 is refused. See
                    Plan.base_bill_timing (ADR-031).
                prices:
                  type: array
                  description: |
                    The plan's usage prices, created with it in one request.
                    Each takes the plan's currency.
                  items:
                    $ref: "#/components/schemas/PriceInput"
                status:
                  type: string
                  enum: [active]
                  description: Optional. A plan is created active; archive it with PATCH.
                tax_code:
                  type: string
                  description: |
                    Stripe product tax code for the base fee, such as
                    `txcd_10103001`. Empty or omitted means the tenant's
                    `default_product_tax_code`.
      responses:
        "201":
          description: Plan created, with its prices
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanDetail"
        "409":
          description: |
            `price_exists`: two prices name the same meter and
            `dimension_match`. `price_conflict`: two prices on one meter
            could both take one event with the same number of labels, so
            neither would win; the message names the other price, and
            `param` names the second one. Nothing is created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            A field failed validation (a price names an unknown meter, a
            rate is outside its limits, `base_bill_timing` is missing with a
            base fee above 0 or is `in_advance` with a base of 0), `status`
            is set to anything but `active` (a plan is created active;
            archive it with PATCH), or the body has an unknown field
            (`param` names it, `prices[i].` first for a price; the body must
            use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Pricing]
      summary: List plans
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Plan list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Plan"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/plans/{id}:
    get:
      tags: [Pricing]
      summary: Get plan
      operationId: getPlan
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The plan, with its prices
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanDetail"
        "404":
          description: "`not_found`: no plan with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      tags: [Pricing]
      summary: Update plan
      description: |
        A field left out keeps its current value. To clear `description`
        or `tax_code`, send an empty string. `code`, `currency` and
        `billing_interval` cannot be changed: sending one is a 422 naming
        it. The plan's
        usage prices change through `/v1/plans/{id}/prices`.

        `base_amount_cents` cannot change while a subscription that is not
        canceled uses the plan (`live_subscription_count` on Get plan):
        create a new plan instead. It can be set to 0. `base_bill_timing`
        cannot change once a subscription has used the plan (`used`). A
        plan with no base fee must be `in_arrears`. `name`, `description`,
        `tax_code` and `status` change at once.
      operationId: updatePlan
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Whitespace is trimmed. Empty or omitted keeps the current name.
                description:
                  type: string
                  description: Whitespace is trimmed. Omit to keep the current description; an empty string clears it.
                base_amount_cents:
                  type: integer
                  format: int64
                  description: 0 or more. Omit to keep the current base fee.
                base_bill_timing:
                  type: string
                  enum: [in_advance, in_arrears]
                status:
                  type: string
                  enum: [active, archived]
                tax_code:
                  type: string
                  description: Stripe product tax code. Omit to keep the current code; an empty string clears it, and the base fee then uses the tenant's `default_product_tax_code`.
      responses:
        "200":
          description: The updated plan, with its prices
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanDetail"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`not_found`: no plan with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `invalid_state`: another write changed the plan's base fee
            during this one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: the base fee changed while live
            subscriptions use the plan (param `plan`), `base_bill_timing`
            changed on a used plan or is `in_advance` with a base of 0, a
            negative base fee, a `status` other than `active` or
            `archived`, an invalid `tax_code`, or an unknown field such as
            `meter_ids` (a plan's meters are its prices' meters).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: |
            `internal_error`. Nothing is changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/plans/{id}/prices:
    get:
      tags: [Pricing]
      summary: List a plan's prices
      description: The plan's usage prices from now on, in the order they were added.
      operationId: listPlanPrices
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Prices
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PlanPrice"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: "`not_found`: no plan with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"
    post:
      tags: [Pricing]
      summary: Add a price to a plan
      description: |
        On a plan no subscription has used, the price applies at once
        (`effective: now`). On a used plan it applies from each
        subscriber's next billing period (`effective: next_period`); until
        then the plan's other prices bill the meter's usage as before. A
        subscriber who first joins the plan in a period after the add pays
        it from the join (see `PriceWrite`).

        A price that names a removed price's meter and `dimension_match`
        adds that price back under its id. Its old customer overrides do
        not come back.
      operationId: addPlanPrice
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PriceInput"
      responses:
        "201":
          description: The price, and when it applies
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PriceWrite"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`not_found`: no plan with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `price_exists`: the plan already has a price on this meter with
            this `dimension_match`; edit it instead. `price_conflict`: a
            price on the plan and meter could take the same events with the
            same number of labels, so neither would win; the message names
            it. Add a label to make one more specific, or give a shared
            label another value. `meter_overlap`: the meter is new to a used
            plan, and a customer on the plan already has it on another item,
            of the same subscription or another one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: an unknown meter, a label that is not a
            string or breaks the limits, a rate outside its limits, a price
            shape that cannot be billed, or an unknown field (`param` names
            it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/plans/{id}/prices/{price_id}:
    patch:
      tags: [Pricing]
      summary: Change a price's rate
      description: |
        Changes the price's shape. Its meter and `dimension_match` are its
        identity and cannot change: add a price for other events instead.
        On a plan no subscription has used, the change applies at once; on
        a used plan, from each subscriber's next billing period. The current
        period keeps the price it started with. A subscriber who first joins
        the plan in a period after the change pays it from the join (see
        `PriceWrite`). Customer overrides of the price keep applying.
      operationId: updatePlanPrice
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: price_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PriceShape"
      responses:
        "200":
          description: The price, and when the change applies
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PriceWrite"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`not_found`: no such plan, or no such price on it (a removed price included)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: a rate outside its limits, a price shape
            that cannot be billed, `meter_id` or `dimension_match` (the
            price's identity), or another unknown field (`param` names it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Pricing]
      summary: Remove a price from a plan
      description: |
        On a plan no subscription has used, the price is gone at once. On a
        used plan it takes no part from each subscriber's next billing
        period; the current period keeps it. A subscriber who first joins the
        plan in a period after the removal does not pay it (see
        `PriceWrite`). The removal
        ends every customer override of the price, each when the removal
        takes effect for its customer, and the answer names them
        (`ended_overrides`). Adding the price back later keeps its id but not
        the overrides.
      operationId: removePlanPrice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: price_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The removed price, when the removal applies, and the overrides it ended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PriceWrite"
        "404":
          description: "`not_found`: no such plan, or no such price on it (a removed price included)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions:
    post:
      tags: [Subscriptions]
      summary: Create subscription
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, display_name, customer_id, items]
              properties:
                code:
                  type: string
                display_name:
                  type: string
                customer_id:
                  type: string
                items:
                  type: array
                  description: Plan items — a subscription bills one or more plans.
                  items:
                    type: object
                    required: [plan_id]
                    properties:
                      plan_id:
                        type: string
                      quantity:
                        type: integer
                        default: 1
                billing_time:
                  type: string
                  enum: [calendar, anniversary]
                  default: calendar
                trial_days:
                  type: integer
                  minimum: 0
                  maximum: 730
                  description: |
                    Days of free trial; 0 means no trial. Above 0, the
                    subscription is `trialing` from now and its first paid
                    period starts on the day the trial ends.
                status:
                  type: string
                  enum: [draft]
                  description: |
                    Leave it out and the subscription starts now. Without
                    `trial_days` it is `active`, its first period starts
                    today in the account's billing time zone (the clock's
                    day for a customer on a test clock), and a non-zero base
                    fee billed in advance is invoiced now. With `trial_days`
                    above 0 it is `trialing` from now. Send `draft` to create it
                    without starting it: it bills nothing until you activate
                    it with `POST /v1/subscriptions/{id}/activate`. A draft
                    cannot have a trial (`trial_days` above 0). An empty or
                    null `status` is refused.
      responses:
        "201":
          description: Subscription created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "422":
          description: |
            A field failed validation (`param` names it), for example
            `trial_days` outside 0 to 730, `status` other than `draft`
            (empty and null included), or `trial_days` above 0 with
            `status: draft`. Or the body has an unknown
            field (`param` names it; the body must use only the fields
            listed here). `start_now` is such a field since 2026-10-23: a
            subscription starts now unless `status` is `draft`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Subscriptions]
      summary: List subscriptions
      parameters:
        - name: customer_id
          in: query
          schema:
            type: string
        - name: plan_id
          in: query
          description: Filter to subscriptions with a live item on this plan.
          schema:
            type: string
        - name: status
          in: query
          description: |
            Exact status. Any other value is a 400. Paused collection is not
            a status: use `collection=paused`.
          schema:
            type: string
            enum: [draft, trialing, active, canceled]
        - name: billing
          in: query
          description: |
            `failing` returns only subscriptions that carry a
            `billing_failure`: those waiting for their next billing attempt
            and those that are parked. Any other value is a 400.
          schema:
            type: string
            enum: [failing]
        - name: collection
          in: query
          description: |
            `paused` returns only active and trialing subscriptions whose
            `pause_collection` is in force, whether an operator or dunning set
            it. A timed pause ends at `resumes_at` (on the subscription's test
            clock, if it has one), even while the response still carries it.
            Any other value is a 400.
          schema:
            type: string
            enum: [paused]
        - name: search
          in: query
          description: Case-insensitive substring match on display name or code.
          schema:
            type: string
        - name: sort
          in: query
          description: One of display_name, status, next_billing_at, trial_end_at, created_at (default).
          schema:
            type: string
        - name: dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Subscription list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Subscription"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "400":
          description: A `status`, `billing` or `collection` value the API does not know.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/subscriptions/{id}/activate:
    post:
      tags: [Subscriptions]
      summary: Activate draft subscription
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Subscription activated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"

  /v1/subscriptions/{id}/retry-billing:
    post:
      tags: [Subscriptions]
      summary: Retry billing on a subscription whose billing is failing
      description: |
        Clears the subscription's `billing_failure` and bills it now, with
        the same attempt the billing run makes. Use it after fixing the
        cause of a failure, to skip the wait, or to restart a parked
        subscription. An audit log entry records the retry.

        Once the failure is cleared, the outcome of the attempt is never
        an HTTP error. A 200 has three outcomes:

        - **Billed.** No `billing_failure`, and `still_due` is false. A
          subscription that was overdue for several periods is billed for
          each of them, one invoice per period.
        - **Failed again.** The subscription carries a new
          `billing_failure` with `attempts: 1`, and the billing run then
          retries on its own schedule. This includes an attempt that
          finished without an error but left the subscription due.
        - **Not completed.** No `billing_failure`, and `still_due` is
          true. The attempt was cut short by a lost database connection
          or a deadlock, and nothing was saved. The subscription is still
          unbilled, and the next billing run attempts it. Calling this
          endpoint again answers 409, because the failure is already
          cleared.

        The call bills inline. If it times out part-way through a long
        catch-up, the response is an error, the invoices already created
        stay, and the next billing run finishes the rest. The failure may
        already be cleared at that point, so a repeat call can answer 409.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: |
            The retry ran. Read the outcome from two fields.
            `subscription.billing_failure` is present when the attempt
            failed again. When it is absent, `still_due` false means the
            subscription billed or had nothing left to bill, and
            `still_due` true means the attempt did not complete and the
            subscription is still unbilled.
          content:
            application/json:
              schema:
                type: object
                required: [subscription, invoices_generated, still_due]
                properties:
                  subscription:
                    $ref: "#/components/schemas/Subscription"
                  invoices_generated:
                    type: integer
                    description: |
                      Period-close invoices this call created. The final
                      invoice of a scheduled cancel and the first invoice
                      at a trial end are not counted, so 0 does not mean
                      the retry failed. Read success from
                      `subscription.billing_failure` and `still_due`.
                  still_due:
                    type: boolean
                    description: |
                      True when, after the attempt, the subscription has
                      no `billing_failure` and is still due for billing.
                      The attempt did not complete and nothing was saved;
                      the next billing run attempts the subscription.
        "404":
          description: Subscription not found.
        "409":
          description: |
            The retry was refused. Code `billing_not_failing`: the
            subscription has no `billing_failure`, so there is nothing to
            retry. Code `invalid_state`: the subscription is attached to
            a test clock; use the clock's retry advance instead.

  /v1/subscriptions/{id}:
    get:
      tags: [Subscriptions]
      summary: Get subscription
      operationId: getSubscription
      description: Returns one subscription with its items.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found (code `not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/cancel:
    post:
      tags: [Subscriptions]
      summary: Cancel subscription now
      operationId: cancelSubscription
      description: |
        Cancels the subscription immediately. Works on `draft`, `trialing`
        and `active` subscriptions.

        In the same transaction, Hardcount writes the final invoice for the
        part of the current period that has been used, and credits the
        unused part of any base fee that was billed in advance. If a
        billing period has already ended, it is billed first. If a
        scheduled cancel was already due, that cancel takes effect at its
        scheduled time and the canceled subscription is returned.

        Sends the `subscription.canceled` webhook event.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The canceled subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is already `canceled`.
            Code `unbilled_period`: a billing period ended and
            could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/schedule-cancel:
    post:
      tags: [Subscriptions]
      summary: Schedule a cancel
      operationId: scheduleSubscriptionCancel
      description: |
        Schedules the subscription to cancel later. Set exactly one of
        `at_period_end` or `cancel_at`. A new call replaces any earlier
        schedule.

        - **Active subscription.** `at_period_end` cancels at the end of
          the current period. `cancel_at` must be on or after the end of
          the current period. A date inside a later period is allowed, and
          that period is billed up to the date.
        - **Trialing subscription.** `at_period_end` cancels at the end of
          the trial, with no charge. `cancel_at` must be exactly
          `trial_end_at` (free cancel) or on or after
          `current_billing_period_end`.

        The response's `cancel_effective_at` says when the cancel takes
        effect. Sends the `subscription.cancel_scheduled` webhook event.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                at_period_end:
                  type: boolean
                  description: Cancel at the end of the current period (or of the trial, while trialing).
                cancel_at:
                  type: string
                  format: date-time
                  description: Cancel at this instant. Must be in the future.
      responses:
        "200":
          description: The subscription with the schedule set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is `canceled`, or its
            status changed while the request ran (read
            it again and retry). Code `unbilled_period`: a billing period
            ended and could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Code `validation_error`: neither or both of `at_period_end` and
            `cancel_at` are set, or `cancel_at` is in the past or breaks the
            date rules above.
            Also: the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/scheduled-cancel:
    delete:
      tags: [Subscriptions]
      summary: Clear a scheduled cancel
      operationId: clearSubscriptionScheduledCancel
      description: |
        Removes a scheduled cancel and sends the
        `subscription.cancel_cleared` webhook event. Calling it on a
        subscription with no schedule returns the subscription unchanged
        and sends no event.

        If the scheduled date has already passed, the cancel has taken
        effect and the request answers 409.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The subscription with no scheduled cancel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the scheduled cancel already took effect
            when the billing period ended. Code `unbilled_period`: a billing
            period ended and could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/pause-collection:
    put:
      tags: [Subscriptions]
      summary: Pause collection
      operationId: pauseSubscriptionCollection
      description: |
        Pauses payment collection. The subscription keeps running: each
        period still closes and its invoice is created, but the invoice
        stays a draft. Hardcount does not finalize it, charge it or start
        dunning (automatic payment retries).

        Invoices finalized before the pause are not drafts. Payment retries
        already under way for them continue, as in Stripe. To stop them,
        void the invoice or mark it uncollectible.

        Only `behavior: keep_as_draft` is supported. With `resumes_at`,
        periods that end before that instant bill as drafts, and later
        periods bill normally. Without it, the pause lasts until you call
        `DELETE /v1/subscriptions/{id}/pause-collection`. A new call
        replaces the current pause, including one dunning set; the pause is
        then yours (`set_by: operator`), and a payment no longer lifts it.

        Sends the `subscription.collection_paused` webhook event, with
        `triggered_by: operator`. If the current pause had already ended
        (its `resumes_at` has passed), its `subscription.collection_resumed`
        is sent first.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [behavior]
              properties:
                behavior:
                  type: string
                  enum: [keep_as_draft]
                resumes_at:
                  type: string
                  format: date-time
                  description: When collection resumes. Must be in the future.
      responses:
        "200":
          description: The subscription with `pause_collection` set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is `canceled`. Code
            `unbilled_period`: a billing period ended and
            could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Code `validation_error`: `behavior` is missing or not
            `keep_as_draft`, or `resumes_at` is not in the future.
            Also: the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Subscriptions]
      summary: Resume collection
      operationId: resumeSubscriptionCollection
      description: |
        Ends a collection pause. The period that is running bills
        normally; a period that ended during the pause keeps its draft
        invoice. Sends the `subscription.collection_resumed` webhook
        event. If the pause had already ended (its `resumes_at` has
        passed), the event says what ended it: `triggered_by` is `payment`
        or `schedule`, not `operator`. Calling it on a subscription that is
        not paused returns the subscription unchanged and sends no event.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The subscription with no `pause_collection`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `unbilled_period`: a billing period ended and could not be
            invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/end-trial:
    post:
      tags: [Subscriptions]
      summary: End trial now
      operationId: endSubscriptionTrial
      description: |
        Ends the trial now and makes the subscription `active`. The first
        paid period starts now. Items whose base fee is billed in advance
        get their first invoice at once.

        Sends the `subscription.trial_ended` webhook event.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The active subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is not `trialing`, or it
            has a scheduled cancel. Clear the scheduled cancel first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/extend-trial:
    post:
      tags: [Subscriptions]
      summary: Extend trial
      operationId: extendSubscriptionTrial
      description: |
        Moves the end of the trial later. The first paid period is moved
        to start at the new trial end. A cancel scheduled with
        `at_period_end` moves with the trial. To shorten a trial, use
        `POST /v1/subscriptions/{id}/end-trial`.

        Sends the `subscription.trial_extended` webhook event.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [trial_end]
              properties:
                trial_end:
                  type: string
                  format: date-time
                  description: The new trial end. Must be in the future, after the current `trial_end_at`, and at most 730 days after `trial_start_at`.
      responses:
        "200":
          description: The subscription with the new `trial_end_at`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "400":
          description: The body is not valid JSON, or `trial_end` is not an RFC 3339 date-time.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is not `trialing`, or it
            has a scheduled cancel with an explicit `cancel_at`. Clear the
            scheduled cancel first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `trial_end` is missing, in the past, not after the current
            `trial_end_at`, or more than 730 days after `trial_start_at`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/billing-thresholds:
    put:
      tags: [Subscriptions]
      summary: Set billing thresholds
      operationId: setSubscriptionBillingThresholds
      description: |
        Sets the billing thresholds: limits that make Hardcount invoice before
        the period ends. A threshold bills sooner; it does not cap or
        block usage. Set `amount_gte`, `item_thresholds`, or both. The
        first threshold reached triggers the invoice.

        The threshold invoice bills the period's usage so far, and no base
        fee. The period keeps its dates, the base fee is billed as it would
        be with no threshold, and the period's next invoice bills the rest
        of the usage.
        `reset_billing_cycle`, which started a new period at the threshold
        invoice, was removed; a body that sends it is refused with 422.

        Each call replaces the whole configuration. Item thresholds left
        out of the body are removed.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount_gte:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Invoice early when the period's usage so far reaches this amount, in cents of the subscription's currency. The base fee is not counted.
                item_thresholds:
                  type: array
                  items:
                    type: object
                    required: [subscription_item_id, usage_gte]
                    properties:
                      subscription_item_id:
                        type: string
                        description: An item of this subscription. Each item can appear once.
                      usage_gte:
                        type: string
                        description: >-
                          Invoice early when the item's usage in the period
                          reaches this quantity. A decimal string (e.g. "1000"
                          or "3.14"): more than 0, under 10^26, at most 12
                          decimal places (trailing zeros do not count), and at
                          most 100 characters.
      responses:
        "200":
          description: The subscription with `billing_thresholds` set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: "Code `invalid_state`: the subscription is `canceled`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Neither `amount_gte` nor `item_thresholds` is set;
            `amount_gte` is negative; an item id is missing, repeated, or
            not on this subscription; a `usage_gte` is not a number or is
            outside its limits (`param` is `item_thresholds[i].usage_gte`);
            or the subscription's plans use more than one currency.
            Also: the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Subscriptions]
      summary: Clear billing thresholds
      operationId: clearSubscriptionBillingThresholds
      description: |
        Removes all billing thresholds. Calling it on a subscription with
        no thresholds returns the subscription unchanged.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The subscription with no `billing_thresholds`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/items:
    post:
      tags: [Subscriptions]
      summary: Add subscription item
      operationId: addSubscriptionItem
      description: |
        Adds a plan to the subscription as a new item. The plan must use
        the same billing interval and currency as the other items, and
        must not share meters with the customer's other live
        subscriptions.

        On an active subscription, a plan whose base fee is billed in
        advance is charged for the rest of the current period, in the
        same transaction as the add. An item added on the first day of the
        period (in the tenant's timezone) pays for the whole period. A
        plan billed in arrears is billed at the end of the period for the
        days it was on the subscription.

        Sends the `subscription.item.added` webhook event.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_id]
              properties:
                plan_id:
                  type: string
                quantity:
                  type: integer
                  format: int64
                  minimum: 1
                  default: 1
      responses:
        "201":
          description: The new item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionItem"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `already_exists`: the subscription already has an item for
            this plan. Code `invalid_state`: the subscription is `canceled`;
            the plan is archived; the plan's meters (the meters it has a
            price for) overlap another live subscription of the customer; or the plan's
            currency differs from the subscription's. Code
            `unpaid_invoice_blocks_change`: the invoice for the current
            period is unpaid (a written-off invoice is unpaid); settle or
            void it first. Code
            `unbilled_period`: a billing period ended and could not be
            invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Code `validation_error`: `plan_id` is missing or not found,
            `quantity` is less than 1, or the plan's billing interval
            differs from the other items'.
            Also: the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/items/{itemID}:
    patch:
      tags: [Subscriptions]
      summary: Change subscription item
      operationId: updateSubscriptionItem
      description: |
        Changes the quantity or the plan of one item. Send exactly one of
        `quantity` or `new_plan_id`. The subscription must be `active`.

        - **Quantity.** Takes effect now.
        - **Plan, `immediate: true`.** Takes effect now. If the new plan
          has a different billing interval, the current period ends now
          and a new period starts on the new plan.
        - **Plan, `immediate: false` (default).** Scheduled for the end of
          the current period. The item shows it in `pending_plan_id` and
          `pending_plan_effective_at`. Cancel it with
          `DELETE /v1/subscriptions/{id}/items/{itemID}/pending-change`.

        For a change that takes effect now on a plan billed in advance,
        Hardcount settles the rest of the period at once. An increase is
        invoiced. A decrease is credited to the customer's balance with a
        credit note, or reduces the amount due when the period's invoice
        is still unpaid. `proration` in the response describes the result.
        A plan billed in arrears is settled at the end of the period.

        Changes to one subscription's items run one at a time, and each is
        settled from the item as the change before it left it. A request
        that repeats a change that already finished finds nothing to change
        and gets 422.

        Sends `subscription.item.updated`, or
        `subscription.pending_change.scheduled` for a scheduled plan
        change.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: itemID
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                quantity:
                  type: integer
                  format: int64
                  minimum: 1
                  description: The new quantity. Must differ from the current one.
                new_plan_id:
                  type: string
                  description: The plan to move the item to. Must differ from the current one and use the same base-fee billing timing (in advance or in arrears).
                immediate:
                  type: boolean
                  default: false
                  description: Apply a plan change now instead of at the end of the period. Ignored for quantity changes.
      responses:
        "200":
          description: The changed item and when the change takes effect.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionItemChangeResult"
        "400":
          description: The body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Subscription not found, or the item is not on this subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the subscription is not `active`, the new
            plan is archived, its meters (the meters it has a price for)
            overlap another live subscription of the customer, or its
            currency differs. Code
            `unpaid_invoice_blocks_change`: an increase would charge against
            an unpaid invoice for the current period (a written-off invoice
            is unpaid); settle or void it first. Code `subscription_period_moved`: the billing period
            changed while the request ran; read the subscription again and
            retry. Code `item_changed`: a plan change found the
            subscription's items changed by another request that finished
            first; read the subscription again and retry. Code
            `unbilled_period`: a billing period ended and could not be
            invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Code `validation_error`: neither or both of `quantity` and
            `new_plan_id` are set; the value equals the current one;
            `quantity` is less than 1; the new plan is not found; or the new
            plan's billing timing or interval does not fit the subscription.
            Also: the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Subscriptions]
      summary: Remove subscription item
      operationId: removeSubscriptionItem
      description: |
        Removes an item from the subscription. The last item of an active
        subscription cannot be removed; cancel the subscription instead.

        On an active subscription, the unused part of a base fee billed in
        advance is credited in the same transaction. A plan billed in
        arrears is billed at the end of the period for the days it was on
        the subscription.

        Sends the `subscription.item.removed` webhook event.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: itemID
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Item removed.
        "404":
          description: Subscription not found, or the item is not on this subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: this is the last item of an active
            subscription. Code `unbilled_period`: a billing period ended and
            could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/items/{itemID}/pending-change:
    delete:
      tags: [Subscriptions]
      summary: Cancel a scheduled plan change
      operationId: cancelSubscriptionItemPendingChange
      description: |
        Cancels the plan change scheduled on an item. Calling it on an item
        with no scheduled change returns the item unchanged. Sends the
        `subscription.pending_change.canceled` webhook event.

        If the period has already ended, the change has taken effect and
        the request answers 409.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: itemID
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The item with no pending plan change.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionItem"
        "404":
          description: The item is not found or is not on this subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`: the change already took effect when the
            billing period ended. Code `unbilled_period`: a billing period
            ended and could not be invoiced; retry billing first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/subscriptions/{id}/timeline:
    get:
      tags: [Subscriptions]
      summary: Get subscription activity timeline
      operationId: getSubscriptionTimeline
      description: |
        Returns the subscription's history from the audit log as
        display-ready rows, in the order the actions took effect: by
        `sim_effective_at` for actions on a test-clock subscription,
        otherwise by `timestamp`. On a test-clock subscription `timestamp`
        (wall-clock) may therefore not be in ascending order. It holds at most the 100 most recent
        entries. `truncated` is true when older entries were left out.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Timeline events.
          content:
            application/json:
              schema:
                type: object
                required: [events, truncated]
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/SubscriptionTimelineEvent"
                  truncated:
                    type: boolean
                    description: True when the subscription has more than 100 audit entries, so the earliest events are missing.
        "404":
          description: Subscription not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events:
    post:
      tags: [Usage]
      summary: Ingest usage event
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_customer_id, event_name, quantity, idempotency_key]
              properties:
                external_customer_id:
                  type: string
                  description: The customer's external_id (your identifier), not the internal hc_cus_ id.
                event_name:
                  type: string
                  description: The meter's `event_name` (e.g. "tokens").
                quantity:
                  oneOf:
                    - type: number
                    - type: string
                  description: |
                    A JSON number (`5`) or a string holding one (`"5.5"`,
                    for exact decimals). At most 12 decimal places; trailing
                    zeros do not count. Its size must be under 10^26, and it
                    is at most 100 characters. A negative value is a
                    correction. A missing quantity, or any other value, is
                    refused with 422 and `param: quantity`.
                dimensions:
                  type: object
                  additionalProperties: true
                  description: Scalar labels of the event (e.g. {"model", "token_type"}). A price takes an event whose labels include the price's `dimension_match`.
                idempotency_key:
                  type: string
                  maxLength: 255
                  description: |
                    Your unique ID for this event. Pick it once, before the
                    first send, and send the same key on every retry of the
                    event. Keys are unique per tenant and mode (live or
                    test), forever. An event whose key was already used is
                    not stored again: the first event wins and the new body
                    is discarded. So never reuse a key for a different
                    event, such as a batch index or a timestamp that can
                    repeat, or that usage is lost. Empty or whitespace-only
                    keys are refused with 422, and so are keys longer than
                    255 characters. This is the only dedup key: the usage
                    routes ignore the `Idempotency-Key` header.
                timestamp:
                  type: string
                  format: date-time
      responses:
        "201":
          description: |
            Event ingested. A `billing_status` is what Hardcount found when the
            event arrived (see `UsageBillingStatus`); the event is stored
            all the same.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageEvent"
        "200":
          description: |
            Idempotency replay — the original event is returned unchanged
            with an `Idempotent-Replayed: true` header. No duplicate row
            is written. The new body is not compared with the original.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageEvent"
        "422":
          description: |
            Validation failed and nothing was stored. `param` names the
            field: `idempotency_key` when it is missing, blank or longer
            than 255 characters, `quantity` when it is missing or not as
            described above, `external_customer_id` or `event_name` for an
            unknown customer or meter, `timestamp` for a malformed one, and
            `properties` when it has a key (it was renamed `dimensions`;
            `null` or `{}` is ignored).
        "503":
          description: |
            `ingest_unavailable`: a customer or meter lookup, or the read of
            the customer's billing periods, could not be answered. Nothing
            was recorded; retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Usage]
      summary: List usage events
      parameters:
        - name: customer_id
          in: query
          schema:
            type: string
        - name: meter_id
          in: query
          schema:
            type: string
        - name: dimensions
          in: query
          description: |
            Comma-separated key=value pairs matched against the event's
            dimensions via JSONB containment (multiple pairs AND).
            Values that parse as JSON booleans/numbers are matched
            typed ("cached=true", "attempt=2"); everything else as a
            string. Malformed pairs → 422. Example:
            `model=gpt-4o,token_type=input`.
          schema:
            type: string
        - name: from
          in: query
          description: Only events at or after this time. RFC 3339, or a date (YYYY-MM-DD) meaning 00:00:00 UTC.
          schema:
            type: string
        - name: to
          in: query
          description: Only events before this time, exclusive. RFC 3339, or a date (YYYY-MM-DD) meaning 23:59:59.999999 UTC that day, so the day is included.
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Event list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/UsageEvent"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          description: "`validation_error`: a malformed `dimensions` pair, a `from` or `to` that cannot be parsed, `to` before `from`, or a `limit` or `offset` out of range (param names the field)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events/batch:
    post:
      tags: [Usage]
      summary: Batch ingest usage events (max 1000, all-or-nothing)
      description: |
        The batch is atomic: every event validates, then all of them
        commit in one transaction. On any validation failure nothing is
        ingested. Every event must carry its own `idempotency_key`; an
        event without one fails validation, so the whole batch is refused.
        Retrying the whole batch is safe because of those keys: events
        already stored are counted in `deduplicated`, not stored again,
        and are not errors. A `deduplicated` count above zero on a batch
        you never sent before means some keys were already used, by an
        earlier event or by another event in the batch. Those events
        were not stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [external_customer_id, event_name, quantity, idempotency_key]
                properties:
                  external_customer_id:
                    type: string
                    description: The customer's external_id (your identifier), not the internal hc_cus_ id.
                  event_name:
                    type: string
                    description: The meter's `event_name` (e.g. "tokens").
                  quantity:
                    oneOf:
                      - type: number
                      - type: string
                    description: |
                      A JSON number (`5`) or a string holding one (`"5.5"`,
                      for exact decimals). At most 12 decimal places;
                      trailing zeros do not count. Its size must be under
                      10^26, and it is at most 100 characters. A negative
                      value is a correction. A missing quantity, or any
                      other value, fails validation.
                  dimensions:
                    type: object
                    additionalProperties: true
                  idempotency_key:
                    type: string
                    maxLength: 255
                    description: |
                      Your unique ID for this event. Pick it once, before the
                      first send, and send the same key on every retry of the
                      event. Keys are unique per tenant and mode (live or
                      test), forever. An event whose key was already used is
                      not stored again: the first event wins and the new body
                      is discarded. So never reuse a key for a different
                      event, such as a batch index or a timestamp that can
                      repeat, or that usage is lost. Empty or whitespace-only
                      keys are refused with 422, and so are keys longer than
                      255 characters. This is the only dedup key: the usage
                      routes ignore the `Idempotency-Key` header.
                  timestamp:
                    type: string
                    format: date-time
      responses:
        "201":
          description: Batch committed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageEventBatchResult"
        "422":
          description: |
            Batch rejected — one or more events failed validation and
            NOTHING was ingested. The body is the error envelope (code
            `batch_rejected`) plus `errors[]`, which names every failing
            index (`event[i]: reason`), `ingested: 0` and `total`. Fix them
            and retry the whole batch.
        "413":
          description: Request body over the 1MB cap — send smaller batches.
        "503":
          description: |
            `ingest_unavailable`: a customer or meter lookup, or the read of
            the customer's billing periods, could not be answered. Nothing
            was recorded; retry the whole batch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events/backfill:
    post:
      tags: [Usage]
      summary: Backfill a past usage event
      description: |
        Records one usage event with a past timestamp. Same body as
        `POST /v1/usage-events`, but `timestamp` is required and must not be
        after the customer's current time (a test clock's time when the
        customer is on one). The event is marked `origin: backfill` and an
        audit log entry is written.

        The event counts toward the billing period its timestamp falls in
        only if that period's invoice has not been built yet. An event for
        an already-invoiced period is stored, but that invoice does not
        bill it; bill it with a one-off invoice if needed. Its 201 carries
        the `billing_status` Hardcount found (see `UsageBillingStatus`):
        `after_close` while the subscription that invoiced the period is
        active or trialing, `outside_subscription` once it has ended. A
        billing period of another subscription that is not invoiced yet and
        holds the event's time decides the status instead.

        Shares idempotency keys with live ingest: a repeated
        `idempotency_key` returns 200 with the original event and an
        `Idempotent-Replayed: true` header, and writes nothing.
        Requires usage write permission.
      operationId: backfillUsageEvent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_customer_id, event_name, quantity, timestamp, idempotency_key]
              properties:
                external_customer_id:
                  type: string
                  description: The customer's external_id (your identifier), not the internal hc_cus_ id.
                event_name:
                  type: string
                  description: The meter's `event_name` (e.g. "tokens").
                quantity:
                  oneOf:
                    - type: number
                    - type: string
                  description: |
                    A JSON number (`5`) or a string holding one (`"5.5"`,
                    for exact decimals). At most 12 decimal places; trailing
                    zeros do not count. Its size must be under 10^26, and it
                    is at most 100 characters. A negative value is a
                    correction. A missing quantity, or any other value, is
                    refused with 422 and `param: quantity`.
                dimensions:
                  type: object
                  additionalProperties: true
                  maxProperties: 16
                  description: Scalar labels of the event. A price takes an event whose labels include the price's `dimension_match`.
                idempotency_key:
                  type: string
                  maxLength: 255
                  description: |
                    Your unique ID for this event. Pick it once, before the
                    first send, and send the same key on every retry of the
                    event. Keys are unique per tenant and mode (live or
                    test), forever. An event whose key was already used is
                    not stored again: the first event wins and the new body
                    is discarded. So never reuse a key for a different
                    event, such as a batch index or a timestamp that can
                    repeat, or that usage is lost. Empty or whitespace-only
                    keys are refused with 422, and so are keys longer than
                    255 characters. This is the only dedup key: the usage
                    routes ignore the `Idempotency-Key` header.
                timestamp:
                  type: string
                  format: date-time
                  description: RFC 3339. Must not be in the future.
      responses:
        "201":
          description: |
            Event recorded. A `billing_status` is what Hardcount found when the
            event arrived (see `UsageBillingStatus`); the event is stored
            all the same.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageEvent"
        "200":
          description: Idempotency replay. The original event, unchanged, with an `Idempotent-Replayed` header.
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum: ["true"]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageEvent"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: a missing, empty or whitespace-only
            `idempotency_key`, or one longer than 255 characters (param
            `idempotency_key`); missing `external_customer_id`, `event_name`
            or `timestamp`; unknown customer or meter; a future or badly
            formatted `timestamp`; more than 16 dimensions or a non-scalar
            dimension value; a missing or malformed `quantity`, one with
            more than 12 decimal places, one of 10^26 or more, or one
            longer than 100 characters; a
            `properties` field with a key (it was renamed `dimensions`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            `ingest_unavailable`: the customer or meter lookup, or the read
            of the customer's billing periods, could not be answered.
            Nothing was recorded; retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events/held:
    get:
      tags: [Usage]
      summary: List held usage
      description: |
        Usage rows the LiteLLM integration received for a customer or meter
        that did not exist yet. They are kept with their idempotency keys,
        not billed, until you release or discard them. Grouped by external
        customer ID, event name and reason, most recent arrival first. Lists
        the 200 external customer IDs with the most recent arrivals, with
        every group for each; `total_customers` and `total_rows` count all
        held rows, listed or not. Requires usage read permission.
      operationId: listHeldUsage
      responses:
        "200":
          description: Held groups
          content:
            application/json:
              schema:
                type: object
                required: [data, total_customers, total_rows]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/HeldUsageGroup"
                  total_customers:
                    type: integer
                    description: External customer IDs with rows held, including those not listed in `data`.
                  total_rows:
                    type: integer
                    description: Rows held, including those not listed in `data`.

  /v1/usage-events/held/release:
    post:
      tags: [Usage]
      summary: Release held usage
      description: |
        Writes the held rows for one external customer ID as usage events
        for a customer, each under its original idempotency key and time,
        marked `origin: backfill`, in one transaction. A row that is already
        a usage event under the same key is not written again. Releasing
        twice writes nothing the second time.

        Rows whose meter still does not exist, or whose time is after the
        customer's current time, stay held. One call releases at most 5,000
        rows; `more_to_release` counts the rest, and another call takes
        them. A released row counts toward
        the billing period its time falls in only if that period's invoice
        has not been built yet; Hardcount does not move it onto a later
        invoice. Writes an audit log entry. Requires usage write permission.
      operationId: releaseHeldUsage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_customer_id]
              properties:
                external_customer_id:
                  type: string
                  description: The external ID the rows were held under.
                customer_id:
                  type: string
                  description: |
                    Release into this customer (an `hc_cus_` id) instead of
                    the one whose external ID matches, for a `user` value
                    that had a typo.
      responses:
        "200":
          description: Release result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HeldUsageReleaseResult"
        "422":
          description: "`validation_error`: no customer has that external ID yet and no `customer_id` was given, or `customer_id` was not found."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events/held/discard:
    post:
      tags: [Usage]
      summary: Discard held usage
      description: |
        Marks the held rows for one external customer ID (and one event
        name, when given) as discarded. They are never billed. The rows stay
        stored as a record. Writes an audit log entry. Requires usage write
        permission.
      operationId: discardHeldUsage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_customer_id]
              properties:
                external_customer_id:
                  type: string
                event_name:
                  type: string
                  description: Discard only the rows with this `event_name`. Omit for all.
      responses:
        "200":
          description: Rows discarded
          content:
            application/json:
              schema:
                type: object
                required: [discarded]
                properties:
                  discarded:
                    type: integer
        "422":
          description: "`validation_error`: `external_customer_id` is missing, or the body has an unknown field (`param` names it; the body must use only the fields listed here)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/usage-events/aggregate:
    get:
      tags: [Usage]
      summary: Usage totals
      description: |
        Event count, quantity totals, and a per-meter breakdown over every
        usage event matching the filters (no paging). Each `by_meter` row
        carries `value`, the meter's usage under its own aggregation: a
        sum, an event count, each customer's peak (Maximum) or each
        customer's latest reading (Latest value), added across customers.
        `total` stays the plain sum of reported quantities. `total_units`
        adds quantities across all meters whatever their units, and is kept
        for compatibility only.
      operationId: aggregateUsageEvents
      parameters:
        - name: customer_id
          in: query
          schema:
            type: string
        - name: meter_id
          in: query
          schema:
            type: string
        - name: from
          in: query
          description: Events at or after this time. RFC 3339, or a date (YYYY-MM-DD) meaning 00:00:00 UTC.
          schema:
            type: string
        - name: to
          in: query
          description: Events before this time, exclusive. RFC 3339, or a date (YYYY-MM-DD) meaning 23:59:59.999999 UTC that day, so the day is included.
          schema:
            type: string
        - name: dimensions
          in: query
          description: |
            Comma-separated key=value pairs matched against event dimensions,
            same syntax as `GET /v1/usage-events`. Example:
            `model=gpt-4o,token_type=input`.
          schema:
            type: string
        - name: tz
          in: query
          description: |
            IANA time zone for the calendar days of `by_meter[].daily`
            (default UTC). An unknown name returns 422.
          schema:
            type: string
      responses:
        "200":
          description: Totals
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageAggregate"
        "422":
          description: "`validation_error`: a malformed `dimensions` pair, an unknown `tz`, a `from` or `to` that cannot be parsed, or `to` before `from`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      parameters:
        - name: customer_id
          in: query
          schema:
            type: string
        - name: subscription_id
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
        - name: payment_status
          in: query
          schema:
            type: string
        - name: search
          in: query
          description: Case-insensitive substring match on invoice number.
          schema:
            type: string
        - name: from
          in: query
          description: |
            Lower bound on created_at, inclusive. RFC3339 instant or
            bare YYYY-MM-DD (anchored at 00:00:00Z).
          schema:
            type: string
        - name: to
          in: query
          description: |
            Upper bound on created_at, inclusive. RFC3339 instant or
            bare YYYY-MM-DD (anchored at 23:59:59.999999Z, the end of that day).
          schema:
            type: string
        - name: overdue
          in: query
          description: |
            When true, restrict to open invoices past their due date
            that haven't settled (finalized + due_at < now +
            payment_status not succeeded/processing). Backs the
            dashboard's "Past due" segment.
          schema:
            type: boolean
        - name: ids
          in: query
          description: Comma-separated invoice IDs to fetch exactly.
          schema:
            type: string
        - name: sort
          in: query
          description: One of invoice_number, amount_due_cents, billing_period_start, due_at, issued_at, status, payment_status, created_at (default). amount_due_cents sorts a paid invoice by what paid it (`amount_paid_cents` plus `credits_applied_cents`, since paying zeroes `amount_due_cents`) and every other invoice by `amount_due_cents`, which a voided or written-off invoice keeps.
          schema:
            type: string
        - name: dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Invoice list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invoice"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

    post:
      tags: [Invoices]
      summary: Create a draft invoice
      operationId: createInvoice
      description: |
        Creates a one-off invoice in `draft` status with billing reason
        `manual`. Send `line_items` to create the invoice and its lines in
        one transaction, or omit them and add lines later with
        `POST /v1/invoices/{id}/line-items`.

        A manual invoice uses the customer's currency. A `currency` that
        differs from the customer's billing-profile currency, or from the
        plan currency of any of the customer's live (draft, trialing or
        active) subscriptions, is refused with 409. A customer with neither
        may be invoiced in any supported currency.

        Defaults when a field is left out:
        - `currency`: the customer's billing-profile currency, then the plan
          currency of the customer's live subscriptions, then the tenant
          default.
        - `net_payment_term_days`: the tenant's net payment terms. A
          negative value is stored as 0 (due on receipt).
        - `billing_period_start` / `billing_period_end`: now.

        Tax is not calculated here. It is calculated when the invoice is
        finalized, so totals equal the subtotal until then.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceCreateRequest"
      responses:
        "201":
          description: Draft invoice created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: Body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer or subscription not found (code `not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Validation error: missing `customer_id`, unsupported currency,
            an invalid line item (the message names the line number), line
            items whose total does not fit 64-bit cents (`param` is
            `line_items`), or an unknown field (`param` names it; the body
            must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `currency` is not the customer's (code
            `customer_currency_conflict`). The message names the billing
            profile or subscription plan currency it conflicts with.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            The customer's currency could not be read (code
            `currency_unavailable`). Retry the request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/create_preview:
    post:
      tags: [Invoices]
      summary: Preview the next invoice for a customer (Stripe-equivalent)
      description: |
        Answers "what is my next bill going to look like?" from the usage
        recorded so far. Without `period`, the preview mirrors the cycle
        close for the subscription's current period: base fees prorate for
        a short first period and per segment after a mid-period item add,
        quantity change, plan swap or removal, and each meter's usage counts
        only while a plan carrying it was on the subscription. For a
        trialing subscription it is the first invoice after the trial: the
        trial-end invoice when a plan bills in advance, otherwise the close
        of the first paid period, prorated the same way. With an
        explicit `period`, it is a flat estimate over that window and warns
        when a mid-period change makes the invoice differ. Composes across customer / subscription / pricing;
        registered as a sibling of `/v1/invoices` (chi picks the more
        specific pattern, otherwise `/{id}` would capture
        `create_preview` as an invoice ID).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id]
              properties:
                customer_id:
                  type: string
                  description: Customer to preview the invoice for.
                subscription_id:
                  type: string
                  description: |
                    Optional. Defaults to the customer's primary active
                    subscription if not supplied.
                period:
                  type: object
                  description: |
                    Optional explicit window. Both bounds must be
                    supplied together; partial windows are rejected.
                    Defaults to the resolved subscription's current
                    cycle.
                  required: [from, to]
                  properties:
                    from:
                      type: string
                      format: date-time
                    to:
                      type: string
                      format: date-time
      responses:
        "200":
          description: Preview result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoicePreview"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer or subscription not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Validation error (missing customer_id, partial period, no active sub, ...)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}:
    get:
      tags: [Invoices]
      summary: Get invoice with line items
      operationId: getInvoice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice with its line items.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceWithLineItems"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/pdf:
    get:
      tags: [Invoices]
      summary: Download invoice PDF
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: PDF file
          content:
            application/pdf:
              schema:
                type: string
                format: binary

  /v1/invoices/{id}/send:
    post:
      tags: [Invoices]
      summary: Email the invoice (PDF attached) to a recipient
      description: |
        Sends the invoice document email. `additional_emails` is
        tri-state (ADR-082): absent → the customer's stored
        additional_emails are CC'd (so legacy `{email}`-only bodies now
        CC by default); explicit `[]` → primary only for this send;
        explicit list → validated exact override (same rules as the
        customer field — normalize, dedupe, not-the-recipient, cap 10).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                additional_emails:
                  type: array
                  maxItems: 10
                  items:
                    type: string
      responses:
        "200":
          description: Email enqueued
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [sent]
        "404":
          description: Invoice not found
        "409":
          description: |
            Nothing was sent. Codes: `email_not_configured` (this server
            has no SMTP set up), or `payment_in_flight` /
            `payment_unidentifiable` (a charge on the invoice is unresolved).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`email` is missing or invalid, a CC entry is invalid, or the body has an unknown field (`param` names it)"

  /v1/invoices/{id}/line-items:
    post:
      tags: [Invoices]
      summary: Add a line item to a draft invoice
      operationId: addInvoiceLineItem
      description: |
        Adds one line to a `draft` invoice and recomputes its totals.
        `amount_cents` is `quantity × unit_amount_cents`. Only draft invoices
        accept new lines.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceLineItemInput"
      responses:
        "201":
          description: Line item created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceLineItem"
        "400":
          description: Body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Validation error: the invoice is not a draft, a line field is
            invalid (for example a quantity of 0, or a second commit line), or
            the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/void:
    post:
      tags: [Invoices]
      summary: Void an invoice
      operationId: voidInvoice
      description: |
        Cancels a `draft`, `finalized` or `uncollectible` invoice. Voiding
        is final. In the same transaction, credits that were applied to the
        invoice go back to the customer's credit balance.

        After the void, Hardcount stops collection at Stripe (expires open
        Checkout sessions and cancels the PaymentIntent where Stripe allows
        it) and resolves any active dunning run with `invoice_voided`.
        Fires the `invoice.voided` webhook event.

        A spend-threshold invoice (`billing_reason = threshold`) that is
        voided before its period's invoice is built gives its charges back:
        that invoice bills them too. Voided after that, its charges are
        billed on no invoice; bill them with a one-off invoice if they
        should be charged.

        A paid invoice cannot be voided: issue a credit note instead.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice voided.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            The invoice cannot be voided in its current state. Codes:
            `invalid_state` (already paid or already voided),
            `payment_in_flight` (a charge is still running), or
            `payment_unidentifiable` (Stripe's answer to a charge was unclear
            and named no payment; Hardcount finds out from Stripe within a day).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/resend-setup-link:
    post:
      tags: [Invoices]
      summary: Resend the payment-method setup link
      operationId: resendInvoiceSetupLink
      description: |
        Emails the customer the link to add a payment method again. It is
        the same email Hardcount sends when an invoice is finalized and the
        customer has no card on file. Once a card is added, Hardcount charges
        the invoice automatically.

        Works on unpaid invoices that are `finalized` or `uncollectible`.
        This is different from `POST /v1/invoices/{id}/send`, which emails
        the invoice itself with a link to pay it.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Email queued.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [sent]
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Nothing was sent. Codes: `invoice_not_collectible` (the invoice
            is draft, voided or paid), `payment_in_flight` or
            `payment_unidentifiable` (a charge is unresolved),
            `email_not_configured` (this server has no SMTP set up), or
            `no_email_on_file` (the customer has no email address).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/mark-uncollectible:
    post:
      tags: [Invoices]
      summary: Write off an invoice as uncollectible
      operationId: markInvoiceUncollectible
      description: |
        Moves a `finalized` invoice to `uncollectible` (a bad-debt write-off).
        The invoice can still be paid later, for example with
        `POST /v1/invoices/{id}/record-payment`. Tax is not reversed.

        Resolves any active dunning run with `invoice_not_collectible`, so
        payment retries stop. Fires the `invoice.marked_uncollectible`
        webhook event.

        A written-off spend-threshold invoice keeps its charges billed: no
        later invoice bills them again unless the invoice is then voided.
        `POST /v1/invoices/{id}/void` says when a void gives them back.

        Asks Stripe to expire any Checkout session still open for the
        invoice. If Stripe refuses or cannot be reached, the session stays
        payable until it expires, at most an hour after it opened.

        If the invoice billed its subscription's current period in advance,
        it still counts as unpaid for that subscription. While it has an
        amount due, a plan change, added item or seat increase that would
        charge for the rest of the period is refused with
        `unpaid_invoice_blocks_change`, unless a charge on the invoice is
        still being confirmed with Stripe. A cancel, downgrade, removed item
        or plan swap lowers what it asks for by the unused time; a cancel or
        a change of billing interval on the period's first day voids it
        instead, if nothing is paid on it, no charge on it is being
        confirmed with Stripe and it bills nothing else. A written-off invoice from an earlier period, or one not
        tied to a subscription's period, does not affect subscription
        changes.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice written off.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            The invoice cannot be written off in its current state. Codes:
            `invalid_state` (draft, paid, voided, or already uncollectible)
            or `payment_in_flight` (a charge is still running).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/record-payment:
    post:
      tags: [Invoices]
      summary: Record a payment made outside Stripe
      operationId: recordInvoicePayment
      description: |
        Marks a `finalized` or `uncollectible` invoice as paid for money
        received another way, such as a bank transfer, cheque or cash. The
        full amount due is recorded; partial payments are not supported.
        The invoice gets `paid_out_of_band: true` and no
        `stripe_payment_intent_id`.

        Resolves any active dunning run with `payment_recovered`. Fires the
        `invoice.payment_recorded` webhook event. The optional `note` is
        stored in the audit log, cut to 500 characters.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: 'Free text, for example "Cheque 1234". Do not put personal data here: the audit log cannot be edited.'
      responses:
        "200":
          description: Invoice marked paid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: Body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            The payment cannot be recorded. Codes: `invalid_state` (draft,
            voided or already paid), `payment_in_flight` or
            `payment_unidentifiable` (a card charge is unresolved).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/rotate-public-token:
    post:
      tags: [Invoices]
      summary: Replace the hosted invoice link
      operationId: rotateInvoicePublicToken
      description: |
        Creates a new `public_token` for the hosted invoice page. The old
        link stops working at once. Use it when a link was shared with the
        wrong people. Draft invoices have no public link.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice with its new `public_token`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: The invoice is a draft (code `invalid_state`). Finalize it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/collect:
    post:
      tags: [Invoices]
      summary: Charge a finalized invoice now
      operationId: collectInvoicePayment
      description: |
        Collects a `finalized`, unpaid invoice right away instead of waiting
        for the automatic charge.

        1. The customer's credit balance is applied first. If it covers the
           whole amount, the invoice is marked paid and no card is charged.
        2. Otherwise Hardcount charges the remaining amount to the customer's
           saved payment method with a Stripe PaymentIntent.

        After a successful card charge, any active dunning run is resolved
        with `payment_recovered`. Check `payment_status` on the returned
        invoice.

        A written-off (`uncollectible`) invoice cannot be charged here.
        Record an offline payment or issue a new invoice instead.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice after the credit and charge steps.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            An earlier charge must be resolved first. Code
            `payment_in_flight`: a charge on the invoice is running, or its
            result was lost and Hardcount is confirming it with Stripe (within
            about an hour). Code `payment_unidentifiable`: Stripe's answer
            to the earlier charge was unclear and named no payment; Hardcount
            records a payment that appears, and the invoice is charged again
            as usual if none has appeared 24 hours after that charge. Code
            `amount_below_minimum`: the amount is below Stripe's minimum
            charge, so nothing was charged; the invoice stays open with
            `payment_status` `pending`, and no dunning starts. Collect
            applies the credit balance first and asks Stripe each time
            anything is still owed: for a converted charge, Stripe's minimum
            follows the account's settlement currency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Code `validation_error`. The invoice is not finalized, is already
            paid, or has no amount due; the customer is paying on the hosted
            page; payments are not configured; or the customer has no payment
            method set up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: |
            Stripe declined or refused the charge (code `provider_error`),
            so nothing was charged. The message gives the reason in plain
            words. Code `stripe_key_unusable`: Stripe refused this account's
            API key, or none is connected for this mode; nothing was charged
            and the invoice is unchanged. Update the key under Settings →
            Payments, then retry. Code `payment_outcome_unknown`: Stripe never
            answered, so the card may have been charged; Hardcount checks with
            Stripe and updates the invoice. Do not retry until it does.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/refund:
    post:
      tags: [Invoices]
      summary: Refund a paid invoice
      operationId: refundInvoice
      description: |
        Refunds a `paid` invoice to the customer's card. Hardcount creates and
        issues a credit note (a document that reduces what the customer
        owes) for the refund, and sends the refund to Stripe.

        Leave out `amount_cents`, or send 0, to refund what was paid by card
        less the refunds on the invoice's credit notes that are not voided. A
        refund that failed or is not finished counts too: retry it with
        `POST /v1/credit-notes/{id}/retry-refund`. If other credit notes
        already cover part of the invoice, the refund stops at what is left of
        the invoice total. The response is the credit note. Check its
        `refund_status`: if Stripe refused the refund, it is `failed`. If
        Stripe gave no clear answer, it is `unknown`: the customer may
        already be refunded, so don't refund again in the Stripe Dashboard.
        Retry either with `POST /v1/credit-notes/{id}/retry-refund`.

        Only invoices paid by card through Stripe can be refunded here. For
        an invoice paid outside Stripe, create a credit note with
        `out_of_band_amount_cents`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                amount_cents:
                  type: integer
                  format: int64
                  minimum: 0
                  description: Amount to refund. 0 or absent refunds what was paid by card less the refunds on the invoice's credit notes that are not voided, up to what is left of the invoice total.
                reason:
                  type: string
                  enum: [duplicate, fraudulent, requested_by_customer, other]
                description:
                  type: string
                  description: 'Text for the credit note line. Defaults to "Refund: <reason>".'
      responses:
        "200":
          description: The issued refund credit note.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "400":
          description: Body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`. The invoice is not paid, was paid outside
            Stripe, has no payment to refund, or is already fully refunded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Validation error: missing or unknown `reason`, a negative
            amount, an amount above what is left to refund, refunds are
            not configured, or an unknown field (`param` names it; the body
            must use only the fields listed here). A misspelled amount such
            as `amount` is refused rather than read as a full refund.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/payment-timeline:
    get:
      tags: [Invoices]
      summary: Get the invoice activity timeline
      operationId: getInvoicePaymentTimeline
      description: |
        Returns everything that happened to an invoice, oldest first:
        lifecycle steps (created, finalized, paid, voided, marked
        uncollectible), charge attempts (source `payment`, event_type
        `charge_attempt.succeeded|failed|unknown|below_minimum`), dunning retries, credit
        notes, customer emails, and card disputes (source `dispute`, a
        `payment.dispute_opened` row and, once Stripe closes the dispute, a
        `payment.dispute_closed` row; both at Stripe's own time).
        A draft invoice returns an empty list.

        Each row carries `line`, the row in the activity line standard's
        words (ADR-128). `description`, `detail`, `error`, `status` and
        `amount_cents` repeat it for clients that read the older fields.

        If one source cannot be read, the rest is still returned and the
        source is named in `degraded`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Timeline events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoicePaymentTimeline"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/disputes:
    get:
      tags: [Invoices]
      summary: List the card disputes on an invoice
      operationId: listInvoiceDisputes
      description: |
        Returns the card disputes (chargebacks) Stripe reported against the
        invoice's payments, oldest first. An invoice without any returns an
        empty list. Hardcount records them from Stripe's `charge.dispute.*`
        webhook events, which must be turned on for the Stripe webhook
        endpoint. Hardcount never changes the invoice because of a dispute:
        respond to it in Stripe, and if it is lost, record the loss with a
        credit note or a write-off in your books.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: The invoice's disputes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceDisputeList"
        "404":
          description: Invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/credit-notes/stats:
    get:
      tags: [CreditNotes]
      summary: Get credit note totals
      operationId: getCreditNoteStats
      description: |
        Counts of every credit note by status, and the money on issued
        notes, one entry per currency. Covers every note in this mode, not a
        page of them. Draft and voided notes are counted but move no money.
      responses:
        "200":
          description: Credit note totals.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNoteStats"

  /v1/credit-notes/{id}/send:
    post:
      tags: [CreditNotes]
      summary: Email the credit-note document (PDF attached)
      description: |
        Sends the credit-note PDF to the customer (ADR-082 rider — CNs
        previously had no send surface at all). Issued credit notes
        only: drafts and voided CNs return 422. Recipient contract
        matches the invoice send (`email` + tri-state
        `additional_emails`). The send is recorded on the applied
        invoice's timeline and the customer's Sent-emails section.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                additional_emails:
                  type: array
                  maxItems: 10
                  items:
                    type: string
      responses:
        "200":
          description: Email enqueued
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [sent]
        "404":
          description: Credit note not found
        "409":
          description: |
            Nothing was sent. `email_not_configured`: this server has no
            SMTP set up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "Not issued (draft/voided), `email` is missing or invalid, or the body has an unknown field (`param` names it)"

  /v1/credit-notes/{id}:
    get:
      tags: [CreditNotes]
      summary: Get a credit note
      operationId: getCreditNote
      description: Returns the credit note without its line items. The PDF has the lines.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credit note.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "404":
          description: Credit note not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credit-notes/{id}/issue:
    post:
      tags: [CreditNotes]
      summary: Issue a draft credit note
      operationId: issueCreditNote
      description: |
        Moves a `draft` credit note to `issued` and applies it.
        - Unpaid invoice: the invoice's amount due goes down. If nothing is
          left due, the invoice is marked `paid` and `invoice.paid` is sent,
          unless a charge is running on it.
        - Paid invoice: the refund part is sent to Stripe, and the credit
          part is added to the customer's credit balance.

        Check the returned `status` and `refund_status`:
        - A refund Stripe refused still returns 200, with `refund_status`
          `failed`. A refund with no clear answer from Stripe returns 200
          with `unknown`: the customer may already be refunded. Retry either
          with `POST /v1/credit-notes/{id}/retry-refund`.
        - If the invoice's charge is still running, or the invoice is still
          a draft, nothing changes and the note stays `draft`. Hardcount issues
          it later.
        - If the invoice was voided or written off, the draft is voided
          instead and returned with `status` `voided`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credit note after the issue attempt.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "404":
          description: Credit note not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The credit note is not a draft (code `invalid_state`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credit-notes/{id}/retry-refund:
    post:
      tags: [CreditNotes]
      summary: Retry a failed credit-note refund
      operationId: retryCreditNoteRefund
      description: |
        Sends the refund part of an `issued` credit note to Stripe again,
        when its `refund_status` is `failed`, `unknown`, `pending` or
        `none`. Before creating a refund, Hardcount asks Stripe for one already
        made for this note and records it if found. For `unknown` and
        `none`, Hardcount then resends the earlier request whose result it
        never recorded, with the same idempotency key. While Stripe keeps
        that key (at least 24 hours), Stripe returns the earlier request's
        result instead of making a second refund.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credit note with its updated `refund_status`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "404":
          description: Credit note not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`. The note is not issued, has no refund part,
            the refund already succeeded or is still processing at Stripe,
            Stripe is not connected, or the invoice was paid outside Stripe
            (void the note and issue a new one with
            `out_of_band_amount_cents`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: |
            Stripe refused the refund (code `provider_error`): the note's
            `refund_status` is `failed`. Or Stripe gave no clear answer (code
            `payment_outcome_unknown`): it is `unknown`, and the customer may
            already be refunded. Retry later in both cases.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credit-notes/{id}/void:
    post:
      tags: [CreditNotes]
      summary: Void a draft credit note
      operationId: voidCreditNote
      description: |
        Cancels a `draft` credit note. Issued credit notes are final and
        cannot be voided.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credit note voided.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditNote"
        "404":
          description: Credit note not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Code `invalid_state`. The note is already voided, is issued, or
            its refund was already sent to Stripe.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credit-notes/{id}/pdf:
    get:
      tags: [CreditNotes]
      summary: Download credit note PDF
      operationId: getCreditNotePdf
      description: |
        Returns the credit note as a PDF, shown inline and named after the
        credit note number. An issued note prints the customer and company
        details it was issued with.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: PDF file.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "404":
          description: Credit note not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invoices/{id}/finalize:
    post:
      tags: [Invoices]
      summary: Finalize draft invoice
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice finalized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"

  /v1/invoices/{id}/retry-tax:
    post:
      tags: [Invoices]
      summary: Retry tax calculation on a deferred invoice
      description: |
        Re-runs tax calculation against a draft invoice whose
        `tax_status` is `pending`. Backs the operator-
        facing "Retry tax" action surfaced by the unified
        `Attention` shape.

        Idempotent: each call increments `tax_retry_count` and
        rewrites the per-line and invoice-level tax fields. A retry
        that succeeds clears `tax_pending_reason` / `tax_error_code`
        and unblocks finalize. A retry that still fails writes the
        new typed code so the dashboard banner refreshes — operators
        get an immediate signal of whether their fix worked.

        Returns the updated invoice (carrying the re-derived
        `attention`) on success or post-retry failure. A retry that
        is still failing is **not** an HTTP error — the caller
        renders the new attention.

        When the tax resolves on an invoice the billing engine made,
        the call also finalizes it, unless collection is paused on its
        subscription. If that finalize fails, the call returns 500 or
        409 and the invoice stays a draft with its tax saved: finalize
        it.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invoice updated; check `attention` for the new state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          description: Invoice not found.
        "409":
          description: |
            Invoice is not in a retryable state (status != draft, or
            tax_status is not pending). Also returned when the
            tax was saved but the invoice's state then stopped the
            finalize.

  /v1/auth/login:
    post:
      tags: [Auth]
      security: []
      summary: Sign in with email and password; mint an httpOnly session cookie
      description: |
        Validates email + password, mints a server-side session row, and
        sets `Set-Cookie: hardcount_session=...; HttpOnly; SameSite=Lax; Path=/`.
        Subsequent dashboard requests authenticate via the cookie; sessions
        are bound to `users.id` and are independent of API key lifecycle.
        See ADR-011.

        A successful sign-in also sets `hardcount_device` (HttpOnly,
        `Path=/v1/auth`, 180 days). It marks the browser as one that signed
        in to this email, so the sign-in throttle counts its attempts on
        their own. It never replaces the password.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
      responses:
        "200":
          description: Session minted; cookie set on response
          content:
            application/json:
              schema:
                type: object
                required: [user_id, tenant_id, email, livemode, expires_at]
                properties:
                  user_id:
                    type: string
                  tenant_id:
                    type: string
                  email:
                    type: string
                  livemode:
                    type: boolean
                  expires_at:
                    type: string
                    format: date-time
        "401":
          description: Invalid email or password
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: |
            Too many attempts. Two limits answer 429, and neither locks
            the account:

            - Code `login_throttled`: the sign-in throttle. Repeated wrong
              passwords for one email, from one address or from many,
              start a wait of 1 to 15 minutes. The message names the wait
              in minutes and `Retry-After` gives it in seconds. The answer
              is the same for an email with no account. A browser that
              signed in to the email before is counted on its own.
            - Code `rate_limited`: the shared per-address request limit on
              `/v1/auth`.
          headers:
            Retry-After:
              description: Seconds until an attempt is counted again.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            Code `authentication_unavailable`: the sign-in throttle could not
            read the database, so the attempt was refused before the password
            check. Try again shortly.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/auth/logout:
    post:
      tags: [Auth]
      security:
        - SessionCookie: []
        - {}
      summary: Revoke the current session and clear the cookie
      description: |
        Marks the session row as revoked server-side and clears the
        cookie on the response. Idempotent — a missing or stale cookie
        is treated as already-logged-out.
      responses:
        "204":
          description: Session revoked

  /v1/auth/mode:
    post:
      tags: [Auth]
      security:
        - SessionCookie: []
      summary: Switch the dashboard session between test and live mode
      description: |
        Updates `dashboard_sessions.livemode` for the cookie session.
        All subsequent requests inherit the new mode via session middleware,
        so customers / invoices / subscriptions / API keys reads filter to
        the active mode without per-call mode parameters. The same operator
        flips back and forth without re-authenticating.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [livemode]
              properties:
                livemode:
                  type: boolean
      responses:
        "200":
          description: Mode updated
          content:
            application/json:
              schema:
                type: object
                required: [livemode]
                properties:
                  livemode:
                    type: boolean
        "401":
          description: Missing or expired session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/auth/password-reset/request:
    post:
      tags: [Auth]
      security: []
      summary: Request a password reset link
      description: |
        Always returns the same 200 body regardless of whether the email
        exists, to avoid account enumeration. If a user matches, a
        single-use 1-hour token is generated and the reset link is
        emailed via SMTP (requires `SMTP_HOST` and `DASHBOARD_BASE_URL`;
        there is no stdout fallback). `email_delivery` reports whether
        this deployment can deliver reset emails at all — deployment
        posture, computed independently of any match, so it leaks no
        account existence.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: Request accepted (identical body regardless of email existence)
          content:
            application/json:
              schema:
                type: object
                required: [message, email_delivery]
                properties:
                  message:
                    type: string
                  email_delivery:
                    type: string
                    enum: [ok, not_configured]
                    description: |
                      Whether this deployment can deliver reset emails
                      (SMTP configured AND `DASHBOARD_BASE_URL` set).
                      Deployment posture, never account state.

  /v1/auth/password-reset/confirm:
    post:
      tags: [Auth]
      security: []
      summary: Set a new password using a reset token
      description: |
        Consumes a single-use reset token and updates the user's password.
        The token is invalidated on use; reuse or expired tokens return 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                password:
                  type: string
                  minLength: 12
      responses:
        "200":
          description: Password updated. Every session the user had is signed out.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message:
                    type: string
                    example: password updated — sign in with your new password
        "422":
          description: Token invalid/expired or password does not meet policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/auth/password-reset/check:
    get:
      tags: [Auth]
      summary: Check a password reset token
      operationId: checkPasswordResetToken
      description: |
        Tells whether a reset token can still be used, without using it up.
        The reset-password page calls it on load, so it can show "this link
        is no longer valid" before the user types a new password. An
        unknown, expired or already-used token returns 422 with code
        `invalid_or_expired`.
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: The token from the reset link.
      responses:
        "200":
          description: Token can be used
          content:
            application/json:
              schema:
                type: object
                required: [valid]
                properties:
                  valid:
                    type: boolean
                    enum: [true]
        "422":
          description: "`invalid_or_expired` (param `token`): the token is unknown, expired or already used"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited (shared per-IP limit on `/v1/auth`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/auth/invite/{token}:
    get:
      tags: [Members]
      security: []
      summary: Preview a team invitation (accept-page context)
      description: |
        Unauthenticated — the token IS the credential (mirrors password
        reset). Returns the invited email, workspace name, and whether
        the invitee needs to create an account. Invalid, expired,
        revoked, and already-used tokens all return the same generic
        422 (no state oracle for token guessing). ADR-081.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Invitation preview
          content:
            application/json:
              schema:
                type: object
                required: [email, tenant_id, tenant_name, needs_new_account, expires_at, invited_by_email]
                properties:
                  email:
                    type: string
                  tenant_id:
                    type: string
                  tenant_name:
                    type: string
                  needs_new_account:
                    type: boolean
                  invited_by_email:
                    type: string
                  expires_at:
                    type: string
                    format: date-time
        "422":
          description: Token invalid, expired, revoked, or already used
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/auth/accept-invite:
    post:
      tags: [Members]
      security: []
      summary: Accept a team invitation
      description: |
        Consumes the single-use invite token (CAS — a replay or a race
        loses) and attaches the membership. If no account exists for the
        invited email, `password` is required (min 12 chars), the account
        is created, and a session cookie is minted (`session_minted:
        true`). An EXISTING account is attached WITHOUT a session —
        possession of the email alone must not log into an
        already-privileged account; the user signs in with their own
        password afterwards. ADR-081.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                password:
                  type: string
                  minLength: 12
                  description: Required (and validated) only when no account exists for the invited email; ignored otherwise.
      responses:
        "200":
          description: Membership attached; cookie set when session_minted is true
          content:
            application/json:
              schema:
                type: object
                required: [user_id, email, tenant_id, session_minted, livemode, expires_at]
                properties:
                  user_id:
                    type: string
                  email:
                    type: string
                  tenant_id:
                    type: string
                  session_minted:
                    type: boolean
                  livemode:
                    type: boolean
                  expires_at:
                    type: string
                    description: Session expiry (RFC 3339) when session_minted; empty string otherwise.
        "422":
          description: Token invalid/expired/revoked/used, or password does not meet policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/members:
    get:
      tags: [Members]
      operationId: listMembers
      security:
        - SessionCookie: []
      summary: List workspace members and invitations
      description: |
        Dashboard-session only (403 for API keys — membership is a human
        act with a human actor). Members are every user attached to the
        tenant; invitations carry a server-derived status
        (pending/accepted/revoked/expired). No RBAC yet: `role` is
        recorded but every member holds the full permission set. ADR-081.
      responses:
        "200":
          description: Members + invitations
          content:
            application/json:
              schema:
                type: object
                required: [members, invitations]
                properties:
                  members:
                    type: array
                    items:
                      type: object
                      required: [user_id, email, role, joined_at]
                      properties:
                        user_id:
                          type: string
                        email:
                          type: string
                        role:
                          type: string
                        joined_at:
                          type: string
                          format: date-time
                  invitations:
                    type: array
                    items:
                      $ref: "#/components/schemas/MemberInvitation"
        "403":
          description: Caller is not a dashboard session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/members/invite:
    post:
      tags: [Members]
      operationId: inviteMember
      security:
        - SessionCookie: []
      summary: Invite a teammate by email
      description: |
        Creates a pending invitation (7-day expiry, one pending per
        email) and sends the accept link via the transactional email
        outbox. Requires `DASHBOARD_BASE_URL` — accept links are never
        derived from request headers. 409 when the email is already a
        member or already has a pending invite. ADR-081.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "201":
          description: Invitation created and email enqueued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MemberInvitation"
        "409":
          description: |
            No invitation was created when the code is
            `email_not_configured` (this server has no SMTP set up, so the
            accept link could not be delivered) or `invalid_state`
            (`DASHBOARD_BASE_URL` is not set). Also returned when the email
            is already a member or already has a pending invitation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Invalid email
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/members/invitations/{id}:
    delete:
      tags: [Members]
      operationId: revokeMemberInvitation
      security:
        - SessionCookie: []
      summary: Revoke a pending invitation
      description: |
        Stamps the invitation revoked; its accept link stops working
        immediately (revoke racing an accept: exactly one wins). Only
        pending invitations can be revoked — accepted/revoked ones 404.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Invitation revoked
        "404":
          description: No pending invitation with this id (code `not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/members/{userID}:
    delete:
      tags: [Members]
      operationId: removeMember
      security:
        - SessionCookie: []
      summary: Remove a member from the workspace
      description: |
        Deletes the membership AND revokes all of the user's sessions
        (sessions pin the tenant — without revocation an active session
        would keep full access). Guards: self-removal is blocked
        (another member must remove you) and the last member cannot be
        removed. ADR-081.
      parameters:
        - name: userID
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Member removed and sessions revoked
        "404":
          description: User is not a member of this workspace (code `not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Self-removal or last-member removal blocked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/whoami:
    get:
      tags: [Auth]
      summary: Resolve the current credential to its tenant context
      description: |
        Returns the resolved auth context for whichever credential the
        request carries — `hardcount_session` cookie (dashboard) or
        `Authorization: Bearer` (SDK / curl). Cookie path returns
        `user_id` + `email`; Bearer path returns `key_id` + `key_type`.
        All callers get `tenant_id`, `livemode`,
        `can_email_setup_links`, `can_send_invites` and `smtp_configured`.
      responses:
        "200":
          description: Resolved auth context
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, livemode, can_email_setup_links, can_send_invites, smtp_configured]
                properties:
                  tenant_id:
                    type: string
                  livemode:
                    type: boolean
                  can_email_setup_links:
                    type: boolean
                    description: |
                      Whether this server can email a customer a link to add
                      a payment method. False when email sending (SMTP) or
                      PAYMENT_UPDATE_URL is not set up; resending a setup
                      link then fails.
                  can_send_invites:
                    type: boolean
                    description: |
                      Whether this server can invite a teammate. False when
                      email sending (SMTP) or DASHBOARD_BASE_URL is not set
                      up; inviting then fails with 409.
                  smtp_configured:
                    type: boolean
                    description: |
                      Whether this server has SMTP set up (SMTP_HOST). False
                      means no email of any kind is sent. Settings → Email
                      (`GET /v1/settings/email`) has the details.
                  user_id:
                    type: string
                  email:
                    type: string
                  key_id:
                    type: string
                  key_type:
                    type: string
                    enum: [platform, secret]
        "401":
          description: Missing, revoked, or expired credential
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/billing/run:
    post:
      tags: [Billing]
      summary: Trigger billing cycle
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Every due subscription was billed. `errors` is empty.
          content:
            application/json:
              schema:
                type: object
                required: [invoices_generated, errors]
                properties:
                  invoices_generated:
                    type: integer
                  errors:
                    type: array
                    items:
                      type: string
        "206":
          description: |
            `errors` is not empty. The invoices the run made are kept. Each
            entry is one of:
            - `subscription <id>: billing failed`: that subscription could
              not be billed.
            - `billing run failed`: the run stopped before it reached every
              due subscription, because the list of due subscriptions could
              not be read or the request ran out of time (30 seconds). The
              subscriptions it did not reach are still due, and the next
              billing run bills them.
            - `billing run completed but its audit record failed`: the run's
              audit record could not be written.
          content:
            application/json:
              schema:
                type: object
                required: [invoices_generated, errors]
                properties:
                  invoices_generated:
                    type: integer
                  errors:
                    type: array
                    items:
                      type: string

  /v1/provider-costs:
    get:
      tags: [ProviderCosts]
      summary: List provider cost rates
      description: |
        The operator's COGS table (ADR-079): what THEY pay LLM providers per
        token, keyed by provider/model/token_type. Current-rate semantics —
        one row per key, edited in place; per-event stamps are the history.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Rates
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ProviderCostRate"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"
    put:
      tags: [ProviderCosts]
      summary: Create or update a provider cost rate
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, model, token_type, unit_cost_cents]
              properties:
                provider:
                  type: string
                model:
                  type: string
                  description: Model family token or raw snapshot id — raw ids match first at ingest.
                token_type:
                  type: string
                  description: ADR-044 role — input / output / cache_read.
                unit_cost_cents:
                  type: string
                  description: >-
                    US cents per token, as a decimal string (e.g. "0.0003" =
                    $3.00 per 1,000,000 tokens). 0 or more, under 10^10, at
                    most 12 decimal places (trailing zeros do not count), and
                    at most 100 characters. A JSON number is also read.
                currency:
                  type: string
                  enum: [USD]
                  default: USD
                  description: |
                    Rates are in USD only, the currency LiteLLM reports cost
                    in. Any other value is 422.
      responses:
        "200":
          description: Upserted rate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProviderCostRate"
        "422":
          description: "`validation_error`: a required field is missing, `unit_cost_cents` is not a number or is outside its limits, `currency` is not USD, or the body has an unknown field such as `cost_per_token` (`param` names the field; the body must use only the fields listed here)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/provider-costs/{id}:
    delete:
      tags: [ProviderCosts]
      summary: Delete a provider cost rate
      description: Stamped events keep their snapshot — deletion never rewrites history.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Deleted

  /v1/customers/{id}/margin:
    get:
      tags: [ProviderCosts]
      summary: Per-customer margin report (operator only)
      description: |
        Rated usage revenue vs stamped provider COGS for a window (ADR-079).
        A usage unit-economics view — base fees, credits, and taxes are not
        included. Per-model margin renders only where the price that billed
        the usage names the model; other revenue is reported unattributed,
        never allocated by heuristic.

        The window is `[from, to)`: `from` inclusive, `to` exclusive. A
        missing bound defaults to the last 30 days ending at the customer's
        current time, which is the test clock's frozen time when the
        customer is on a test clock. `from` and `to` take RFC 3339 instants
        only (no bare dates), and `from` must be before `to`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: from
          in: query
          description: Window start, inclusive. RFC 3339. Default is 30 days before the customer's current time.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Window end, exclusive. RFC 3339. Default is the customer's current time (test clock time for a customer on a test clock).
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Margin report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarginReport"
        "400":
          description: "`from` or `to` is not RFC 3339, or `from` is not before `to`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Customer not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: The window is longer than one year.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credits/grant:
    post:
      tags: [Credits]
      summary: Grant credits to customer
      description: |
        Adds a block of credit in `currency`. The credit pays only the
        customer's invoices in that currency; it is never converted. A
        grant in a currency the customer is not billed in is accepted and
        shows as its own balance.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, currency, amount_cents]
              properties:
                customer_id:
                  type: string
                currency:
                  type: string
                  description: |
                    ISO 4217 code of the credit. Required. Case-insensitive;
                    stored uppercase. Zero-decimal currencies (such as JPY)
                    are not supported yet.
                  example: USD
                amount_cents:
                  type: integer
                  format: int64
                description:
                  type: string
                expires_at:
                  type: string
                  format: date-time
                  description: |
                    Optional expiry; must be in the future. Omitted = never
                    expires. The credit can be drawn up to and including this
                    instant; a period invoice draws it when its period ends
                    at or before it. The dashboard sends the local midnight
                    after the picked day.
                grant_kind:
                  type: string
                  enum: [promotional]
                  description: |
                    Optional cost-basis class (ADR-078). `promotional` marks
                    free/marketing credits, drained BEFORE all paid-class
                    blocks. Omitted = unclassified (drains in the paid
                    class). `commit` is reserved for the invoice-finalize
                    funding path and rejected here.
      responses:
        "201":
          description: Credits granted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditLedgerEntry"
        "422":
          description: |
            A field failed validation (a missing or unsupported `currency` has `param` `currency`), or the body has an unknown field (`param` names it; the body must use only the fields listed here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credits/balance/{customer_id}:
    get:
      tags: [Credits]
      summary: Get customer credit balance
      description: |
        The customer's balances, one per currency the customer's credit
        ledger holds, ordered by currency. Empty when the customer has no
        ledger entry.
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credit balances, one per currency.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreditBalance"
        "404":
          description: No customer with this ID in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/credits/balance/{customer_id}/live:
    get:
      tags: [Credits]
      summary: Get customer live credit balance
      description: |
        How much credit the customer has left right now, per currency: the
        settled balance less the usage no invoice has billed yet in that
        currency. Use it to decide in your
        own code whether to keep serving a prepaid customer; Hardcount records
        usage and never refuses it.

        The read aggregates the customer's usage events for the current
        period on every call, so its cost grows with the number of events.
        Poll it on an interval (for example once a minute per active
        customer) and cache the answer. Do not call it on every request.
        A top-up shows on the next read.

        Not included, so the figure can read high: tax, base fees billed at
        period end, and amounts due on unpaid invoices. Requires a secret key.
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Live credit balance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditLiveBalance"
        "404":
          description: Customer not found in this tenant and mode

  /v1/test-clocks:
    get:
      tags: [TestClocks]
      summary: List test clocks for the active tenant
      description: |
        Test mode only — returns 403 in live mode. Test clocks let
        operators simulate time-bound billing flows without waiting for
        wall-clock advances. See ADR / docs for the simulator semantics.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Test clocks
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TestClock"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "403":
          description: Caller is in live mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"
    post:
      tags: [TestClocks]
      summary: Create a test clock
      description: |
        Test mode only. The clock starts in `ready` state at
        `frozen_time`. Use `/advance` to move it forward.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [frozen_time]
              properties:
                name:
                  type: string
                  maxLength: 200
                frozen_time:
                  type: string
                  format: date-time
      responses:
        "201":
          description: Clock created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestClock"

  /v1/test-clocks/{id}:
    get:
      tags: [TestClocks]
      summary: Get a test clock by id
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Clock
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestClock"
        "404":
          description: Clock not found
    delete:
      tags: [TestClocks]
      summary: Delete a test clock (cascades to pinned subscriptions)
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Clock deleted
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [deleted]

  /v1/test-clocks/{id}/subscriptions:
    get:
      tags: [TestClocks]
      summary: List subscriptions pinned to this clock
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Subscriptions
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Subscription"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "404":
          description: Clock not found
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/test-clocks/{id}/advance:
    post:
      tags: [TestClocks]
      summary: Advance the clock to a new frozen_time
      description: |
        Moves the clock forward and runs billing catchup asynchronously
        for every subscription pinned to this clock. The 200 returns as
        soon as the clock is marked `advancing` — a background worker
        drains the catchup; poll `GET /v1/test-clocks/{id}` until status
        leaves `advancing`. Concurrent advances 409 while catchup runs.
        Catchup failures park the clock in `internal_failure` — resume
        it with `POST /v1/test-clocks/{id}/retry-advance` (deletion is
        not required). ADR-018. Catchup bills every skipped period
        before it runs payment retries and dunning, so when a dunning
        policy cancels or pauses a subscription, advance to the time of
        the invoice's last retry first, then to the next billing date.
        The cancel or pause is dated at the advance target, and one
        longer advance bills a period a real clock would not (#1276).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [frozen_time]
              properties:
                frozen_time:
                  type: string
                  format: date-time
      responses:
        "200":
          description: Advance accepted — clock returned in `advancing` state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestClock"
        "409":
          description: Clock is not in `ready` state
        "422":
          description: New frozen_time is not strictly after the current
            frozen_time, or is more than one year after it

  /v1/test-clocks/{id}/retry-advance:
    post:
      tags: [TestClocks]
      summary: Retry catchup on a clock parked in internal_failure
      description: |
        Resumes a clock parked in `internal_failure` after a prior
        catchup error. Catchup is idempotent (only subscriptions with
        `next_billing_at <= frozen_time` are processed), so resuming
        from where the previous attempt stopped is safe; `frozen_time`
        keeps the value stamped by the original advance. Asynchronous
        like advance: the 200 returns the clock back in `advancing`;
        poll `GET /v1/test-clocks/{id}` until status leaves `advancing`.
        ADR-018.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Retry accepted — clock returned in `advancing` state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestClock"
        "404":
          description: Clock not found
        "409":
          description: Clock is not in `internal_failure` state

  /v1/test-clocks/{id}/customers:
    get:
      tags: [TestClocks]
      summary: List customers attached to a test clock
      operationId: listTestClockCustomers
      description: |
        Test mode only; a live-mode key gets 403. Returns every customer
        attached to the clock. The list is not paginated.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Customers
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Customer"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "403":
          description: Caller is in live mode, or the key lacks the `testclock:write` permission (needed even to read test clocks).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Clock not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/recipes:
    get:
      tags: [Recipes]
      summary: List recipes
      description: |
        Every recipe in the embedded registry, tagged with this tenant's
        installation state in the session's livemode. `instantiated` present
        means installed (the dashboard renders an "Installed" badge instead
        of the Install CTA); re-apply is an idempotent no-op and there is no
        uninstall (ADR-085).
      operationId: listRecipes
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Recipe catalog with per-tenant install state
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/RecipeListItem"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/recipes/instances:
    get:
      tags: [Recipes]
      summary: List installed recipe instances
      operationId: listRecipeInstances
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Install badges for this tenant (session's livemode)
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/RecipeInstance"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/recipes/{key}:
    get:
      tags: [Recipes]
      summary: Get recipe
      description: Registry only — no DB read, no install state.
      operationId: getRecipe
      parameters:
        - in: path
          name: key
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Recipe template with creates summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecipeDetail"
        "404":
          description: Unknown recipe key

  /v1/recipes/{key}/preview:
    post:
      tags: [Recipes]
      summary: Preview recipe
      description: |
        Renders the recipe with caller-supplied overrides and returns the
        would-be-created object graph. Pure in-memory — no DB writes, no
        audit row.
      operationId: previewRecipe
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: key
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                overrides:
                  type: object
                  additionalProperties: true
      responses:
        "200":
          description: Rendered object graph
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecipePreviewResult"
        "404":
          description: Unknown recipe key
        "422":
          description: Overrides failed the recipe's overridable schema

  /v1/recipes/{key}/instantiate:
    post:
      tags: [Recipes]
      summary: Instantiate recipe (idempotent apply)
      description: |
        Applies the recipe for the tenant under one transaction: meters are
        adopted-or-created, a fresh born-unique plan is generated with the
        recipe's prices, then the install badge written. It creates nothing
        else: no dunning policy and no webhook endpoint. Adopting a meter
        other plans price changes none of their prices.
        Idempotent per (tenant, livemode): re-posting a recipe that is
        installed returns the EXISTING instance with 201 and creates
        nothing (ADR-085; badge livemode-scoped per m0157). Two installs
        that run at the same time can conflict: the second gets a 409 and
        installs nothing. A retry returns the first install, or, for
        another recipe, reuses the meter it made.
      operationId: instantiateRecipe
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: key
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                overrides:
                  type: object
                  additionalProperties: true
      responses:
        "201":
          description: Install badge (fresh, or existing on idempotent re-apply)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecipeInstance"
        "404":
          description: Unknown recipe key
        "409":
          description: >-
            Nothing is installed. Either a meter with the recipe's
            `event_name` has another aggregation, so the recipe cannot use
            it, or another install that ran at the same time created the
            meter, the plan code or this recipe's install first (retry).
        "422":
          description: Overrides failed the recipe's overridable schema

  /v1/exports/customers.csv:
    get:
      tags: [Customers]
      summary: Export customers as CSV
      description: |
        Streams every customer, newest first, from one consistent
        snapshot. Columns: id, external_id, display_name, email, status,
        email_status, created_at, updated_at.

        Each export writes an `export` row to the audit log before any
        data is sent. If that row cannot be written, the call returns 500
        and sends nothing. If the export fails after it started, the last
        row's first cell is `EXPORT_INCOMPLETE`: discard the file and
        retry. Text cells that start with `= + - @` get a leading `'`
        so spreadsheets do not run them as formulas.
      operationId: exportCustomersCsv
      parameters:
        - name: from
          in: query
          description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: to
          in: query
          description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC).
          schema:
            type: string
      responses:
        "200":
          description: CSV file (sent as an attachment).
          content:
            text/csv:
              schema:
                type: string
        "422":
          description: "`from` or `to` is malformed, or `to` is before `from`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The export's audit row could not be written; nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/exports/invoices.csv:
    get:
      tags: [Invoices]
      summary: Export invoices as CSV
      description: |
        Streams every invoice, newest first, from one consistent
        snapshot. Amounts are in cents. Columns: id, invoice_number,
        customer_id, subscription_id, status, payment_status, currency,
        subtotal_cents, tax_amount_cents, discount_cents,
        total_amount_cents, amount_due_cents, amount_paid_cents,
        credits_applied_cents, billing_period_start, billing_period_end,
        tax_provider, tax_transaction_id, tax_reversed_at, issued_at,
        due_at, paid_at, voided_at, uncollectible_at, created_at.

        Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work
        as in the customers export.
      operationId: exportInvoicesCsv
      parameters:
        - name: from
          in: query
          description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: to
          in: query
          description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC).
          schema:
            type: string
      responses:
        "200":
          description: CSV file (sent as an attachment).
          content:
            text/csv:
              schema:
                type: string
        "422":
          description: "`from` or `to` is malformed, or `to` is before `from`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The export's audit row could not be written; nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/exports/subscriptions.csv:
    get:
      tags: [Subscriptions]
      summary: Export subscriptions as CSV
      description: |
        Streams every subscription, newest first, from one consistent
        snapshot. Columns: id, code, display_name, customer_id, status,
        billing_time, trial_start_at, trial_end_at, started_at,
        activated_at, canceled_at, current_billing_period_start,
        current_billing_period_end, next_billing_at, plan_ids (plan IDs
        joined with `|`), created_at.

        Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work
        as in the customers export.
      operationId: exportSubscriptionsCsv
      parameters:
        - name: from
          in: query
          description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: to
          in: query
          description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC).
          schema:
            type: string
      responses:
        "200":
          description: CSV file (sent as an attachment).
          content:
            text/csv:
              schema:
                type: string
        "422":
          description: "`from` or `to` is malformed, or `to` is before `from`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The export's audit row could not be written; nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/exports/usage-events.csv:
    get:
      tags: [Usage]
      summary: Export usage events as CSV
      description: |
        Streams usage events whose `timestamp` falls in the window,
        newest first, from one consistent snapshot. `from` and `to` are
        both required and may span at most 366 days. `customer_id`,
        `meter_id` and `dimensions` narrow it like the event list. Columns: id,
        customer_id, meter_id, quantity, timestamp, idempotency_key,
        origin, dimensions_json, provider_cost_cents (US cents, a decimal),
        provider_cost_source.

        Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work
        as in the customers export.
      operationId: exportUsageEventsCsv
      parameters:
        - name: from
          in: query
          required: true
          description: Lower bound on the event timestamp, inclusive. RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: to
          in: query
          required: true
          description: Upper bound on the event timestamp, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC).
          schema:
            type: string
        - name: customer_id
          in: query
          description: Only this customer's events.
          schema:
            type: string
        - name: meter_id
          in: query
          description: Only this meter's events.
          schema:
            type: string
        - name: dimensions
          in: query
          description: Comma-separated key=value pairs, same syntax as `GET /v1/usage-events`.
          schema:
            type: string
      responses:
        "200":
          description: CSV file (sent as an attachment).
          content:
            text/csv:
              schema:
                type: string
        "400":
          description: "`from` or `to` is missing, or the window is longer than 366 days."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`from` or `to` is malformed, `to` is before `from`, or a `dimensions` pair is malformed."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The export's audit row could not be written; nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/exports/audit-log.csv:
    get:
      tags: [System]
      summary: Export the audit log as CSV
      description: |
        Streams the audit log for the caller's mode (test or live), newest
        first, with no row limit. Settings changes are recorded in test
        mode, so they appear only in a test-mode export.
        Takes the same filters as the audit-log list, so the dashboard's
        filters and its export give the same rows. The date filters are
        `date_from`/`date_to`; `from`/`to` are ignored here.

        The export's own `export` row is written before the file is
        sent. An unfiltered export includes it as the first data row;
        a filtered export includes it only if it matches the filters.
        Columns: id, created_at, actor_type, actor_id, actor_name,
        action, resource_type, resource_id, resource_label, ip_address,
        request_id, sim_effective_at, test_clock_id, metadata_json.

        `EXPORT_INCOMPLETE` marker and formula escaping work as in the
        customers export.
      operationId: exportAuditLogCsv
      parameters:
        - name: date_from
          in: query
          description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: date_to
          in: query
          description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC).
          schema:
            type: string
        - name: resource_type
          in: query
          schema:
            type: string
        - name: resource_id
          in: query
          schema:
            type: string
        - name: action
          in: query
          schema:
            type: string
        - name: actor_id
          in: query
          schema:
            type: string
        - name: test_clock_id
          in: query
          description: Only rows from this test clock's simulation.
          schema:
            type: string
        - name: sim_from
          in: query
          description: Lower bound on simulated time (sim_effective_at). RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
        - name: sim_to
          in: query
          description: Upper bound on simulated time (sim_effective_at). RFC 3339 or `YYYY-MM-DD`.
          schema:
            type: string
      responses:
        "200":
          description: CSV file (sent as an attachment).
          content:
            text/csv:
              schema:
                type: string
        "422":
          description: A date filter is malformed, or an upper bound is before its lower bound.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The export's audit row could not be written; nothing was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/analytics/overview:
    get:
      tags: [Billing]
      summary: Get headline billing metrics
      operationId: getAnalyticsOverview
      description: |
        Returns the dashboard's headline metrics for a rolling window that
        ends now, with values for the window before it where a comparison
        makes sense.

        Money is in cents of the tenant's default currency (USD when none
        is set), named in `currency`. Invoices and plans in other
        currencies are left out. MRR (monthly recurring revenue) counts
        plan base fees only: base amount times quantity, with yearly
        plans divided by 12. Usage charges are not included. Data from
        test clocks is left out.
      parameters:
        - name: period
          in: query
          description: The window. Any other value is treated as `30d`. `12m` is 365 days.
          schema:
            $ref: "#/components/schemas/AnalyticsPeriod"
      responses:
        "200":
          description: Headline metrics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalyticsOverview"

  /v1/analytics/revenue-chart:
    get:
      tags: [Billing]
      summary: Get revenue over time
      operationId: getAnalyticsRevenueChart
      description: |
        Returns paid-invoice revenue per day, or per month for `12m`.
        Only invoices in the tenant's default currency are counted; the
        currency is the `currency` of `GET /v1/analytics/overview`.
        Buckets with no paid invoices are left out. Data from test clocks
        is left out.
      parameters:
        - name: period
          in: query
          description: The window. Any other value is treated as `30d`. `12m` is 365 days.
          schema:
            $ref: "#/components/schemas/AnalyticsPeriod"
      responses:
        "200":
          description: Revenue series.
          content:
            application/json:
              schema:
                type: object
                required: [period, data]
                properties:
                  period:
                    $ref: "#/components/schemas/AnalyticsPeriod"
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/AnalyticsRevenuePoint"

  /v1/analytics/mrr-movement:
    get:
      tags: [Billing]
      summary: Get MRR movement over time
      operationId: getAnalyticsMrrMovement
      description: |
        Returns how MRR (monthly recurring revenue from plan base fees)
        changed, per day, or per month for `12m`, with totals.

        - `new`: subscriptions activated in the bucket.
        - `churned`: subscriptions canceled in the bucket.
        - `expansion` and `contraction`: item adds, removals, and plan or
          quantity changes on subscriptions that were active for the whole
          window.

        Only plans in the tenant's default currency are counted; the
        currency is the `currency` of `GET /v1/analytics/overview`.
        Buckets with no movement are left out. Data from test clocks is
        left out.
      parameters:
        - name: period
          in: query
          description: The window. Any other value is treated as `30d`. `12m` is 365 days.
          schema:
            $ref: "#/components/schemas/AnalyticsPeriod"
      responses:
        "200":
          description: MRR movement series and totals.
          content:
            application/json:
              schema:
                type: object
                required: [period, data, totals]
                properties:
                  period:
                    $ref: "#/components/schemas/AnalyticsPeriod"
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/AnalyticsMrrMovementPoint"
                  totals:
                    $ref: "#/components/schemas/AnalyticsMrrMovementTotals"

  /v1/analytics/usage:
    get:
      tags: [Billing]
      summary: Get usage over time
      operationId: getAnalyticsUsage
      description: |
        Returns usage-event counts and summed quantities per day, or per
        month for `12m`, the 10 meters with the most events, and totals.
        Buckets and meters with no events are left out. Data from
        customers on test clocks is left out.
      parameters:
        - name: period
          in: query
          description: The window. Any other value is treated as `30d`. `12m` is 365 days.
          schema:
            $ref: "#/components/schemas/AnalyticsPeriod"
      responses:
        "200":
          description: Usage series, top meters and totals.
          content:
            application/json:
              schema:
                type: object
                required: [period, data, top_meters, totals]
                properties:
                  period:
                    $ref: "#/components/schemas/AnalyticsPeriod"
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/AnalyticsUsagePoint"
                  top_meters:
                    type: array
                    maxItems: 10
                    items:
                      $ref: "#/components/schemas/AnalyticsTopMeter"
                  totals:
                    type: object
                    required: [events, quantity]
                    properties:
                      events:
                        type: integer
                        format: int64
                      quantity:
                        type: string
                        description: Summed quantity, as a decimal string.

  /v1/dunning/runs:
    get:
      tags: [Dunning]
      summary: List dunning runs
      operationId: listDunningRuns
      description: |
        Dunning is the process of retrying a failed invoice payment on a
        schedule. A dunning run tracks those retries for one invoice. Runs
        are listed newest first.
      parameters:
        - name: invoice_id
          in: query
          schema:
            type: string
        - name: state
          in: query
          schema:
            type: string
            enum: [active, escalated, resolved]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Dunning run list.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/DunningRun"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/dunning/runs/{id}:
    get:
      tags: [Dunning]
      summary: Get a dunning run with its events
      operationId: getDunningRun
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The run and its event history. `events` is empty if the event history could not be read, and `degraded` then names it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningRunDetail"
        "404":
          description: Dunning run not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/dunning/runs/{id}/resolve:
    post:
      tags: [Dunning]
      summary: Resolve a dunning run
      operationId: resolveDunningRun
      description: |
        Stops payment retries for a run and applies the chosen outcome to
        its invoice first:
        - `payment_recovered`: records an offline payment for the full
          amount due, as `POST /v1/invoices/{id}/record-payment` does.
        - `invoice_voided`: voids the invoice and stops collection at
          Stripe, as `POST /v1/invoices/{id}/void` does.
        - `invoice_not_collectible`: writes the invoice off, as
          `POST /v1/invoices/{id}/mark-uncollectible` does.

        If the invoice step fails, the run stays open and the error is
        returned. If the invoice is already in the requested state, that
        step is skipped. Fires the `dunning.resolved` webhook event once.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolution]
              properties:
                resolution:
                  type: string
                  enum: [payment_recovered, invoice_voided, invoice_not_collectible]
      responses:
        "200":
          description: |
            The run as stored after the call. If another process resolved it
            first, its existing resolution is returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningRun"
        "400":
          description: Body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Dunning run or invoice not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            The invoice step was refused because of the invoice's state, for
            example voiding a paid invoice or a charge still running. Codes
            as on the matching invoice endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `resolution` is missing or not one of the three values.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/dunning/stats:
    get:
      tags: [Dunning]
      summary: Get dunning totals
      operationId: getDunningStats
      description: |
        Counts of dunning runs by state, and the amount still due on active
        and escalated runs. The amount covers only invoices in the tenant's
        default currency, named in `currency`.
      responses:
        "200":
          description: Dunning totals.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningStats"

  /v1/dunning/policies:
    get:
      tags: [Dunning]
      summary: List dunning policies
      description: |
        Every dunning policy in this tenant and mode, the default first, then
        oldest first. Each one carries how many customers are assigned to it.
      operationId: listDunningPolicies
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Policies
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/DunningPolicyListItem"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"
    post:
      tags: [Dunning]
      summary: Create dunning policy
      description: |
        A dunning policy decides how failed payments are retried and what
        happens when the retries run out. Every account starts with one
        policy per mode, "Default retries", as the default. The first
        enabled policy you create in a mode takes the default from it,
        unless you have edited it or made it the default yourself; later
        ones do not (use set-default). Customers without an assigned policy
        use the default, so check `is_default` in the response.

        `enabled` defaults to false. A disabled policy starts no dunning, so
        send `enabled: true` for the policy to take effect. A policy created
        disabled never takes the default from "Default retries".
      operationId: createDunningPolicy
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DunningPolicyInput"
      responses:
        "201":
          description: The new policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningPolicy"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: a replaced field was sent (`final_action`,
            `retry_schedule`, `max_retry_attempts`, `grace_period_days`),
            `retry_days` is missing or out of bounds, or an unknown final
            action.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/dunning/policies/{id}:
    get:
      tags: [Dunning]
      summary: Get dunning policy
      operationId: getDunningPolicy
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningPolicy"
        "404":
          description: "`not_found`: no policy with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      tags: [Dunning]
      summary: Replace dunning policy settings
      description: |
        Replaces every setting of the policy with the body. This is not a
        partial update: an omitted field takes its default (`enabled`
        false, `name` empty, pause the subscription, leave the invoice
        open), and `retry_days` is required. Send the full policy.
        `is_default` cannot be changed here; use set-default.
      operationId: updateDunningPolicy
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DunningPolicyInput"
      responses:
        "200":
          description: The updated policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningPolicy"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`not_found`: no policy with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Same validation as create.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Dunning]
      summary: Delete dunning policy
      description: |
        Deletes the policy. Refused for the default policy and for a policy
        that customers are still assigned to.
      operationId: deleteDunningPolicy
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [deleted]
        "404":
          description: "`not_found`: no policy with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `invalid_state`: the policy is the default (make another policy
            the default first), or customers are assigned to it (reassign
            them first).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/dunning/policies/{id}/set-default:
    post:
      tags: [Dunning]
      summary: Make a dunning policy the default
      description: |
        Makes this policy the default and the previous default a normal
        policy, in one step. Customers without an assigned policy use the
        new default for dunning started from now on.
      operationId: setDefaultDunningPolicy
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Default changed
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [default_updated]
        "404":
          description: "`not_found`: no policy with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/price-overrides:
    post:
      tags: [Pricing]
      summary: Create a customer price override
      description: |
        Gives one customer its own rate for one plan price. The override is
        keyed by the price, so it keeps applying when the price's rate
        changes; removing the price ends it. If the customer already has an
        active override for that price, it is closed and replaced by this
        one. A new or replaced rate applies from the customer's next
        billing period, in the customer's time (its test clock's, if it has
        one); the current period keeps the rate it started with. It applies
        from the join when a subscription of the customer first joins the
        plan in a period after it is created: an override created before a
        subscription starts (after a trial, before the trial ends), even on
        the same day, applies from its first period. On a test clock, it takes effect just after the
        clock's time, so a subscription created at that same clock time
        bills the list price; advance the clock first. The override takes
        the plan's currency. A plan swap does not carry
        it: the new plan's price is another price.
      operationId: createPriceOverride
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, price_id, mode]
              properties:
                customer_id:
                  type: string
                price_id:
                  type: string
                  description: The plan price to override.
                mode:
                  type: string
                  enum: [per_unit, graduated, package]
                  description: >-
                    As on PriceShape, also for the fields a mode does not
                    use.
                unit_amount_cents:
                  type: string
                  description: >-
                    Price per unit in cents, as a decimal string. Used when
                    `mode` is `per_unit`. 0 or more, under 10^18, at most 12 decimal places (trailing zeros
                    do not count), and at most 100 characters. A JSON number
                    is also read. A value outside them is refused with 422
                    and `param` naming the field.
                tiers:
                  type: array
                  description: >-
                    Used when `mode` is `graduated`. Each `up_to` is a whole
                    number above the one before. The last tier has
                    `up_to: null` (no upper bound), and only the last.
                    Otherwise 422 with `param` `tiers`.
                  items:
                    type: object
                    properties:
                      up_to:
                        type: integer
                        format: int64
                        nullable: true
                      unit_amount_cents:
                        type: string
                        description: >-
                          Price per unit in cents in this tier, as a decimal
                          string, with the limits of `unit_amount_cents`. A
                          refusal names `tiers[i].unit_amount_cents` (i counts
                          from 0).
                package_size:
                  type: integer
                  format: int64
                  description: Used when `mode` is `package`. Must be above 0.
                package_amount_cents:
                  type: string
                  description: >-
                    Price of one package in cents, as a decimal string, with
                    the limits of `unit_amount_cents`. Used when `mode` is
                    `package`.
                reason:
                  type: string
      responses:
        "201":
          description: The new override
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerPriceOverride"
        "400":
          description: "`invalid_request`: the body is not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`not_found`: no customer with this id in this tenant and mode."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `already_exists`: another request created an override for the
            same customer and price at the same moment. Retry.
            `invalid_state`: the price was removed from its plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `validation_error`: a missing field, an unknown price, a
            rate that is not a number or is outside its limits, a price
            shape that cannot be billed (bad
            tier table, package size of 0), or an unknown field at any depth
            (`param` names it; the body must use only the fields listed
            here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Pricing]
      summary: List active price overrides
      description: |
        Active overrides only, newest first. Closed or deleted
        overrides are not listed. Requires pricing write permission.
      operationId: listPriceOverrides
      parameters:
        - name: customer_id
          in: query
          schema:
            type: string
          description: Only this customer's overrides.
        - name: price_id
          in: query
          schema:
            type: string
          description: Only this price's overrides.
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Overrides
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CustomerPriceOverride"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/price-overrides/{id}:
    delete:
      tags: [Pricing]
      summary: End a customer price override
      description: |
        Marks the override inactive. The row is kept. The customer goes back
        to the price's rate from its next billing period, in the customer's
        time; the current period keeps the rate it started with. A
        subscription of the customer that first joins the plan in a period
        after the override ends (a new subscription, an immediate plan
        change, an added item) pays the price's rate from the join.
      operationId: deletePriceOverride
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The override, now with `active` false.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerPriceOverride"
        "404":
          description: "`not_found`: no active override with this id (it may already be ended)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/integrations/litellm/spend:
    post:
      tags: [Usage]
      summary: Ingest LiteLLM spend logs
      description: |
        Target for LiteLLM Proxy's `generic_api` logging callback. Accepts
        one LiteLLM log record, a JSON array of records, or
        `{"events": [...]}`. Each completion, chat, text-completion or
        embedding record becomes up to five usage events on the `tokens`
        meter, one per `token_type` dimension: `input` (prompt tokens not
        read from or written to the cache), `cache_read`, `cache_write_5m`,
        `cache_write_1h` and `output`. A record's events are recorded in
        one transaction. Each event also carries `model` (a model family
        name when recognised), `model_raw` and `provider`, plus `team_id`
        and `request_tags` from `metadata` when present. Other call types,
        records with no token counts, failed calls and LiteLLM
        response-cache hits are counted as `skipped`.

        The customer is the record's `user` or `end_user` (or
        `metadata.user_api_key_end_user_id`), matched against the
        customer's `external_id`. Event idempotency keys are the record
        `id` plus the token type, so a resent record is counted as
        `deduplicated`, not billed twice.

        A token row whose customer or meter does not exist yet is held,
        not billed and not lost: it is counted in `held` and listed by
        `GET /v1/usage-events/held` until you release or discard it. A
        resent row that is already held is counted as `deduplicated`. A
        record with no `user` or no `id`, or an invalid event, is listed in
        `errors` and the rest of the batch still goes in, with status 200.
        If the usage store cannot be reached, the whole request fails with
        503 so LiteLLM retries it. Requires usage write permission.
      operationId: ingestLitellmSpend
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/LitellmSpendLog"
                - type: array
                  items:
                    $ref: "#/components/schemas/LitellmSpendLog"
                - type: object
                  required: [events]
                  properties:
                    events:
                      type: array
                      items:
                        $ref: "#/components/schemas/LitellmSpendLog"
      responses:
        "200":
          description: The batch was processed. Check `errors` for records that were not recorded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LitellmSpendResult"
        "400":
          description: "`invalid_request`: empty body or not valid JSON."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            `ingest_unavailable`: the usage store could not be reached.
            Retry the whole batch; events already recorded come back as
            `deduplicated`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/api-keys:
    post:
      tags: [Auth]
      summary: Create an API key
      operationId: createApiKey
      description: |
        Creates a key in the caller's mode: a test-mode caller gets a test
        key, a live-mode caller gets a live key. The full key (`raw_key`)
        is returned once and is never shown again. Hardcount stores only a
        salted hash. `key_type` defaults to `secret`.

        A `platform` key can only be issued by an existing platform key.
        Platform keys do not hold the `apikey:write` permission this route
        needs, so in practice this route never issues one (403).
        Writes an audit row (never the key value).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Label shown in the key list. Must not be blank.
                key_type:
                  type: string
                  enum: [secret, platform]
                  default: secret
                expires_at:
                  type: string
                  format: date-time
                  description: Optional. Must be in the future. Omit for a key that does not expire.
      responses:
        "201":
          description: Key created. `raw_key` is shown only in this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeyCreated"
        "400":
          description: Body is not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Caller lacks the `apikey:write` permission, or asked for a platform key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`name` missing, unknown `key_type`, or `expires_at` not in the future"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Auth]
      summary: List API keys
      operationId: listApiKeys
      description: |
        Returns the newest 50 keys in the caller's mode (test or live),
        newest first. Revoked and expired keys are included; check
        `revoked_at` and `expires_at`. Key values are never returned.
        There are no paging parameters and no `total`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Key list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/APIKey"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "403":
          description: Caller lacks the `apikey:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/api-keys/{id}:
    delete:
      tags: [Auth]
      summary: Revoke an API key
      operationId: revokeApiKey
      description: |
        Sets `revoked_at` to now. The key fails authentication on its next
        use. A caller may revoke the key it is using; its next request
        then gets 401. Dashboard sessions are not tied to API keys, so
        they are not affected. Writes an audit row.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: hc_key_1a2b3c4d5e6f7890
      responses:
        "200":
          description: The revoked key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKey"
        "404":
          description: No such key in this mode, or it is already revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/api-keys/{id}/rotate:
    post:
      tags: [Auth]
      summary: Rotate an API key
      operationId: rotateApiKey
      description: |
        Issues a new key with the same name, type, mode and `expires_at`
        as the old one, and returns its full value once (`raw_key`).

        The body is optional. With no body, or `expires_in_seconds: 0`,
        the old key is revoked at once. A positive value keeps the old
        key working for that many seconds (at most 604800, 7 days), so
        deployed clients can switch without downtime. Writes an audit row.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in_seconds:
                  type: integer
                  format: int64
                  minimum: 0
                  maximum: 604800
                  default: 0
                  description: Grace period for the old key. 0 revokes it immediately.
      responses:
        "200":
          description: Rotation done. `raw_key` is the new key, shown only in this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeyRotation"
        "400":
          description: Body is present but not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such key in this mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The key is a platform key and the caller is not a platform key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The key is already revoked (code `invalid_state`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`expires_in_seconds` is negative or over 604800, or the old key's `expires_at` has passed (the new key would copy it)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/settings:
    get:
      tags: [System]
      summary: Get workspace settings
      operationId: getTenantSettings
      description: |
        Returns the tenant's billing settings: currency, timezone, invoice
        numbering, payment terms, tax setup and the company details printed
        on invoices. Settings are shared by test and live mode. A tenant
        that never saved settings gets the defaults (USD, UTC, prefix
        `HC`, net 30, tax provider `manual`).
        Requires the `apikey:write` permission (secret keys and dashboard
        sessions).
      responses:
        "200":
          description: Current settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantSettings"
        "403":
          description: Caller lacks the `apikey:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      tags: [System]
      summary: Update workspace settings
      operationId: updateTenantSettings
      description: |
        Merges the body into the current settings. A field you leave out
        keeps its stored value. A field you send is set, even to `""` or
        `0`. Send `credit_balance_low_threshold_cents: null` to turn
        low-balance alerts off.

        Empty `default_currency`, `timezone`, `invoice_prefix`,
        `credit_note_prefix`, `tax_provider` and `tax_on_failure` fall back
        to their defaults.
        `default_currency`, `company_country`, `invoice_prefix` and
        `credit_note_prefix` are stored in upper case, and `brand_color` in
        lower case. Choosing `stripe_tax` with no
        `default_product_tax_code` sets it to `txcd_10103001` (SaaS,
        business use).

        A save that changes a field writes one audit row listing each
        change.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantSettingsInput"
      responses:
        "200":
          description: Saved settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantSettings"
        "400":
          description: Body is not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Caller lacks the `apikey:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: A field failed validation. `error.param` names the field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/settings/email:
    get:
      tags: [System]
      summary: Email setup status
      operationId: getEmailSettings
      description: |
        Read-only. Email is configured in the server environment, not per
        workspace: `SMTP_HOST` and friends, plus the base URLs email links
        are built from. Never returns credentials.
      responses:
        "200":
          description: Email setup
          content:
            application/json:
              schema:
                type: object
                required: [smtp_configured, smtp_host, from_address, tls_mode, links, can_email_setup_links, can_send_invites]
                properties:
                  smtp_configured:
                    type: boolean
                    description: False when `SMTP_HOST` is unset; every email then fails.
                  smtp_host:
                    type: string
                    description: The SMTP relay host name, without port or credentials. Empty when not configured.
                  from_address:
                    type: string
                    description: The envelope sender (`SMTP_FROM`).
                  tls_mode:
                    type: string
                    enum: [starttls, implicit, none]
                  links:
                    type: array
                    description: Every environment variable an email link is built from, set or not.
                    items:
                      type: object
                      required: [env, set, effect]
                      properties:
                        env:
                          type: string
                          enum: [HOSTED_INVOICE_BASE_URL, PAYMENT_UPDATE_URL, DASHBOARD_BASE_URL]
                        set:
                          type: boolean
                        effect:
                          type: string
                          description: What doesn't work while it is unset.
                  can_email_setup_links:
                    type: boolean
                  can_send_invites:
                    type: boolean
  /v1/settings/email/test:
    post:
      tags: [System]
      summary: Send a test email
      operationId: sendTestEmail
      description: |
        Dashboard sessions only. Sends one plain-text email to the signed-in
        member's own address, directly over SMTP (not queued), and returns
        the server's answer. Writes an audit row on success.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                required: [sent_to]
                properties:
                  sent_to:
                    type: string
        "409":
          description: "`email_not_configured`: SMTP_HOST is not set."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: "`smtp_error`: the SMTP server refused the email or could not be reached. The message carries its answer."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/settings/stripe:
    post:
      tags: [System]
      summary: Connect a Stripe account
      operationId: connectStripe
      description: |
        Saves your own Stripe keys for one mode (`livemode` in the body
        picks the mode, not the caller's mode). Calling it again for the
        same mode replaces the keys. Key prefixes must match the mode:
        `sk_test_`/`rk_test_` and `pk_test_` for test, `sk_live_`/`rk_live_`
        and `pk_live_` for live. Secrets are stored encrypted and are never
        returned.

        Hardcount then calls Stripe to check the key. A failed check still
        returns 200: the keys are saved and `last_verified_error` says why
        the check failed. On success, `verified_at` and the account fields
        are set, and invoices that were stuck waiting for Stripe keys are
        retried in the background (`retries_queued` gives the count).

        Stripe sends payment webhooks to `/v1/webhooks/stripe/{id}`, where
        `{id}` is the returned `id`. Writes an audit row.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [livemode, secret_key, publishable_key]
              properties:
                livemode:
                  type: boolean
                  description: false = test-mode keys, true = live-mode keys.
                secret_key:
                  type: string
                  description: Secret (`sk_`) or restricted (`rk_`) key for this mode.
                publishable_key:
                  type: string
                  description: Publishable (`pk_`) key for this mode.
                webhook_secret:
                  type: string
                  description: Optional. The `whsec_` signing secret of the Stripe webhook endpoint. Can be added later with PATCH.
      responses:
        "200":
          description: Keys saved. Check `last_verified_error` to see whether Stripe accepted them.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StripeConnection"
        "400":
          description: Body is not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: A key has the wrong prefix for the mode. `error.param` names the field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [System]
      summary: List Stripe connections
      operationId: listStripeConnections
      description: |
        Returns the tenant's Stripe connections for both modes in one list
        (at most two rows, live first), whatever the caller's mode. No
        secret values are returned, only prefixes and last four characters.
      responses:
        "200":
          description: Connections
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    maxItems: 2
                    items:
                      $ref: "#/components/schemas/StripeConnection"

  /v1/settings/stripe/{mode}/webhook:
    patch:
      tags: [System]
      summary: Set the Stripe webhook signing secret
      operationId: setStripeWebhookSecret
      description: |
        Saves the `whsec_` signing secret for a mode that is already
        connected, without entering the API keys again. Hardcount does not call
        Stripe here; the secret is checked on each incoming webhook.
        Writes an audit row (never the secret).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: mode
          in: path
          required: true
          schema:
            type: string
            enum: [test, live]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhook_secret]
              properties:
                webhook_secret:
                  type: string
                  description: Must start with `whsec_`.
      responses:
        "200":
          description: Updated connection
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StripeConnection"
        "400":
          description: "`mode` is not `test` or `live`, or the body is not valid JSON"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Stripe is not connected for this mode. Connect it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`webhook_secret` is missing or does not start with `whsec_`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/settings/stripe/{mode}:
    delete:
      tags: [System]
      summary: Disconnect Stripe for one mode
      operationId: disconnectStripe
      description: |
        Deletes the saved Stripe keys for the mode. Hardcount cannot reach
        Stripe in that mode until you connect again. Writes an audit row.
      parameters:
        - name: mode
          in: path
          required: true
          schema:
            type: string
            enum: [test, live]
      responses:
        "204":
          description: Disconnected
        "400":
          description: "`mode` is not `test` or `live`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Stripe is not connected for this mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/tenants:
    post:
      tags: [System]
      summary: Create a tenant
      operationId: createTenant
      description: |
        Platform keys only (`hc_platform_`), sent as
        `Authorization: Bearer`. Dashboard sessions are not accepted.
        Creates an active tenant. It has no users and no API keys yet.
        Writes an audit row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Must not be blank.
      responses:
        "201":
          description: Tenant created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400":
          description: Body is not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The key is not a platform key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`name` is missing"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [System]
      summary: List tenants
      operationId: listTenants
      description: |
        Platform keys only. Returns the newest 50 tenants across the whole
        installation, newest first. There are no paging parameters and no
        `total`.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [active, suspended, deleted]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Tenant list
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Tenant"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The key is not a platform key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidPage"

  /v1/tenants/{id}:
    get:
      tags: [System]
      summary: Get a tenant
      operationId: getTenant
      description: Platform keys only. Reads any tenant in the installation.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: hc_ten_1a2b3c4d5e6f7890
      responses:
        "200":
          description: The tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The key is not a platform key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/bootstrap:
    post:
      tags: [System]
      summary: Set up a new installation
      operationId: bootstrap
      description: |
        One-time setup for a new installation. It works only while no
        tenant exists and only when the server has `HARDCOUNT_BOOTSTRAP_TOKEN`
        set. Send that token as `token` in the body or as
        `Authorization: Bearer <token>`. Never send it in the query string.

        In one transaction it creates the first tenant, its owner user, a
        test secret key and a live secret key. The
        response is the only place the keys appear, and the password too
        when Hardcount generated it. The response has `Cache-Control: no-store`.

        Defaults: tenant name "Demo Tenant", owner email
        `admin@hardcount.local`, and a generated password when
        `owner_password` is empty. The `hardcount-bootstrap` command does the
        same setup from the shell.
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: The value of `HARDCOUNT_BOOTSTRAP_TOKEN`. Or send it as a Bearer token.
                tenant_name:
                  type: string
                  default: Demo Tenant
                owner_email:
                  type: string
                  default: admin@hardcount.local
                  description: Must contain `@`.
                owner_password:
                  type: string
                  minLength: 12
                  description: 12 or more characters, at most 72 bytes, and not a common password from a leaked-password list. Omit to have Hardcount generate one.
      responses:
        "201":
          description: Installation set up. Save these credentials; they are not shown again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BootstrapResult"
        "403":
          description: Bootstrap is disabled (`HARDCOUNT_BOOTSTRAP_TOKEN` not set) or the token is wrong
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            A tenant already exists (code `already_bootstrapped`), or a user
            with `owner_email` already exists (code `already_exists`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: "`owner_email` is not an email, or `owner_password` is too short or too long. Nothing was written."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/audit-log:
    get:
      tags: [System]
      summary: List audit log entries
      operationId: listAuditLog
      description: |
        Returns the tenant's audit entries in the caller's mode, newest
        first (by `created_at`, then `id`). All filters are exact matches
        and can be combined.

        Paged with `limit` and `offset`, like every list. Here `total`
        stops counting at 10,001, so a value above 10,000 means "more than
        10,000".

        Settings changes are always recorded in test mode, so they do not
        appear when you read the log in live mode.
      parameters:
        - name: resource_type
          in: query
          schema:
            type: string
          description: For example `invoice`, `api_key`, `setting`. See `/v1/audit-log/filters`.
        - name: resource_id
          in: query
          schema:
            type: string
        - name: action
          in: query
          schema:
            type: string
          description: For example `create`, `update`, `revoke`. See `/v1/audit-log/filters`.
        - name: actor_id
          in: query
          schema:
            type: string
          description: A user id or an API key id.
        - name: date_from
          in: query
          schema:
            type: string
          description: Lower bound on `created_at`, inclusive. RFC3339 or YYYY-MM-DD (start of day, UTC).
        - name: date_to
          in: query
          schema:
            type: string
          description: Upper bound on `created_at`, inclusive. RFC3339 or YYYY-MM-DD (23:59:59.999999 UTC, the end of that day).
        - name: test_clock_id
          in: query
          schema:
            type: string
          description: Only entries made inside this test clock's simulation. Works for deleted clocks too.
        - name: sim_from
          in: query
          schema:
            type: string
          description: Lower bound on `sim_effective_at` (simulated time), inclusive. Excludes entries with no simulated time.
        - name: sim_to
          in: query
          schema:
            type: string
          description: Upper bound on `sim_effective_at`, inclusive.
        - name: order
          in: query
          schema:
            type: string
            enum: [created_at]
          description: The only supported order. Any other value is a 400.
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Audit entries
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuditLogEntry"
                  total:
                    $ref: "#/components/schemas/ListTotal"
        "400":
          description: Unsupported `order`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Caller lacks the `apikey:read` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: A date is not RFC3339 or YYYY-MM-DD, a range ends before it starts, or `limit` or `offset` is out of range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/audit-log/filters:
    get:
      tags: [System]
      summary: List audit log filter values
      operationId: getAuditLogFilters
      description: |
        Returns the distinct `action` and `resource_type` values recorded
        for the tenant in the caller's mode, plus the test clocks that
        appear in the log. Deleted test clocks are included, named from
        their audit entries. Use these to fill filter menus.
      responses:
        "200":
          description: Filter values
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditLogFilters"
        "403":
          description: Caller lacks the `apikey:read` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
