openapi: 3.1.1
info:
  title: QuotaStack API
  version: 1.0.0
  description: |
    Credit-native billing primitives for developers. QuotaStack tracks customer
    credit balances, meters usage events, manages subscriptions, gates entitlement
    checks, and orchestrates reservations for async workloads.

    ## API surfaces and what this spec covers

    Three auth surfaces exist. This spec documents the first two:

    1. **Product API** — `X-API-Key` header (`qs_live_` / `qs_test_`).
       Everything an integrated application calls.
    2. **Tenant admin surface** — what a tenant operator needs to provision
       and configure an integration: self-serve signup, API-key management,
       and tenant configuration (webhook endpoint, balance-alert thresholds).
       Authenticated by the `qs_admin_session` cookie obtained by signing in
       to the dashboard.
    3. **Dashboard-internal and platform operations** — admin session and
       password flows, team management, platform tenant administration.
       Deliberately undocumented: they power the QuotaStack dashboard, are
       not a stable integration surface, and may change without notice. (The
       platform live/sandbox access-control endpoints are the exception,
       documented for tenants going through the launch process.)
  contact:
    name: QuotaStack Support
    url: https://quotastack.io
    email: support@quotastack.io
  license:
    name: Proprietary
    url: https://quotastack.io/terms

servers:
  - url: https://api.quotastack.io
    description: Production

security:
  - apiKey: []

# Every 4xx and 5xx carries `type: https://api.quotastack.io/errors/<slug>`
# (internal/api/response.go). That URI is an RFC 7807 identifier — clients match
# on it, so a slug is never renamed. This block is what makes "which errors can
# this API return?" answerable, and it is what the docs site's error catalogue
# is generated from. internal/api/errors_catalog_test.go fails if the two drift.
x-error-catalog:
  bad-request:
    status: 400
    title: Bad Request
  unauthorized:
    status: 401
    title: Unauthorized
  invalid-credentials:
    status: 401
    title: Invalid credentials
  forbidden:
    status: 403
    title: Forbidden
  email_not_verified:
    status: 403
    title: Email not verified
  access_controls_required:
    status: 403
    title: Access controls required
  live_access_required:
    status: 403
    title: Live access required
  live_access_suspended:
    status: 403
    title: Live access suspended
  sandbox_access_required:
    status: 403
    title: Sandbox access required
  sandbox_access_suspended:
    status: 403
    title: Sandbox access suspended
  not-found:
    status: 404
    title: Not Found
  conflict:
    status: 409
    title: Conflict
  insufficient-credits:
    status: 409
    title: Insufficient Credits
  idempotency-key-reuse:
    status: 409
    title: Conflict
  reservation-expired:
    status: 409
    title: Reservation Expired
  key_quota_exceeded:
    status: 409
    title: Key quota exceeded
  validation-error:
    status: 422
    title: Validation Error
  variant-not-active:
    status: 422
    title: Unprocessable Entity
  rate-limited:
    status: 429
    title: Too Many Requests
  account-locked:
    status: 429
    title: Account locked
  internal:
    status: 500
    title: Internal Server Error
  internal-error:
    status: 500
    title: Internal Server Error
  commit-failed:
    status: 500
    title: Commit Failed

tags:
  - name: Customers
    description: The people or organizations that consume your product. Keyed by your own `external_id` or by the QuotaStack `id`.
  - name: Credits
    description: Per-customer credit balances, grants, adjustments, and ledger history.
  - name: Entitlements
    description: Real-time checks for "can this customer do X?" without mutating state.
  - name: Usage
    description: Record events that consume credits.
  - name: Subscriptions
    description: Plan subscriptions per customer, including lifecycle actions (upgrade, downgrade, cancel, pause, renew).
  - name: Reservations
    description: Hold credits during an in-flight operation (reserve), then either commit (consume) or release (cancel).
  - name: Plans
    description: Plan and plan-variant catalogue. Credit grants attached to plan variants define what subscribers receive.
  - name: MeteringRules
    description: Per-billable-metric pricing rules — what one unit of a metric costs in credits.
  - name: BillableMetrics
    description: The named things you meter (e.g., `chat_message`, `tokens_in`, `api_call`).
  - name: Overrides
    description: Per-subscription entitlement overrides. Override a single customer's entitlement without creating a new plan variant.
  - name: Topups
    description: One-time credit grants. Pre-defined packages or ad-hoc grants.
  - name: Webhooks
    description: Inbound webhooks QuotaStack sends to your endpoint plus the delivery log.
  - name: AuditLog
    description: Append-only record of every mutation. Filter by actor, action, entity type, and time range.
  - name: Stats
    description: Aggregated dashboard statistics scoped to the authenticated tenant + environment.
  - name: AdminAuth
    description: Admin dashboard authentication and public self-serve signup flows.
  - name: AdminAccessControl
    description: Platform-admin controls for public signup tenant sandbox and live access.
  - name: Imports
    description: |
      Legacy customer cutover — mid-plan import of customers, subscriptions,
      remaining credit blocks, entitlement overrides, and gauge states.
      Prefer this over `POST /v1/subscriptions` when migrating existing users
      so period clocks and remaining balances are preserved.

paths:
  ########################################################################
  # Customers
  ########################################################################

  /v1/customers:
    post:
      tags: [Customers]
      operationId: createCustomer
      summary: Create a customer.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCustomerRequest'
      responses:
        '201':
          description: Created.
          headers:
            X-RateLimit-Limit:       { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining:   { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset:       { $ref: '#/components/headers/RateLimitReset' }
            X-Idempotent-Replayed:   { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    get:
      tags: [Customers]
      operationId: listCustomers
      summary: List customers (paginated).
      parameters:
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: Substring match on `external_id` or `display_name`.
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          headers:
            X-RateLimit-Limit:     { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset:     { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Customer' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{id}:
    get:
      tags: [Customers]
      operationId: getCustomer
      summary: Retrieve a customer by QuotaStack id.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    patch:
      tags: [Customers]
      operationId: updateCustomer
      summary: Update a customer by QuotaStack id.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateCustomerRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    delete:
      tags: [Customers]
      operationId: deleteCustomer
      summary: Soft-delete a customer by QuotaStack id.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
      responses:
        '204':
          description: Deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}:
    get:
      tags: [Customers]
      operationId: getCustomerByExternalId
      summary: Retrieve a customer by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    patch:
      tags: [Customers]
      operationId: updateCustomerByExternalId
      summary: Update a customer by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateCustomerRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    delete:
      tags: [Customers]
      operationId: deleteCustomerByExternalId
      summary: Soft-delete a customer by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
      responses:
        '204':
          description: Deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  ########################################################################
  # Credits
  ########################################################################

  /v1/customers/{customer_id}/credits:
    get:
      tags: [Credits]
      operationId: getCustomerCredits
      summary: Get a customer's credit balance (optionally with blocks).
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - name: include_blocks
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: Include the list of active credit blocks backing this balance.
      responses:
        '200':
          description: OK. Zero-balance synthesized response when the customer has no credit activity yet.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{customer_id}/credits/grant:
    post:
      tags: [Credits]
      operationId: grantCustomerCredits
      summary: Grant credits to a customer (admin/manual grant).
      description: >
        QuotaStack returns `409` for one of two reasons. A concurrent grant
        or adjustment touched the same credit account. Retry the request
        with the same `Idempotency-Key`. Or `stack_after` had `fallback:
        reject` and no active block matched `metadata_match`. QuotaStack
        granted nothing in that case.
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditGrantRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditGrantResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{customer_id}/credits/adjust:
    post:
      tags: [Credits]
      operationId: adjustCustomerCredits
      summary: Apply a credit adjustment (positive or negative).
      description: >
        QuotaStack returns `409` when a concurrent grant or adjustment
        touched the same credit account. Retry the request with the same
        `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditAdjustRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditAdjustResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{customer_id}/credits/history:
    get:
      tags: [Credits]
      operationId: listCustomerCreditsHistory
      summary: List ledger entries for a customer (paginated).
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - name: type
          in: query
          required: false
          schema:
            type: string
            enum: [plan_grant, topup, consumption, reservation, release, expiry, adjustment]
        - name: source
          in: query
          required: false
          schema:
            type: string
            enum: [plan_grant, promotional, compensation, referral, manual, topup, migration]
        - name: billable_metric_key
          in: query
          required: false
          schema: { type: string }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CreditLedgerEntry' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{customer_id}/overage:
    get:
      tags: [Credits]
      operationId: getCustomerOverage
      summary: Per-metric overage totals for a customer over a period.
      description: >
        Returns overage (credits consumed beyond spendable balance under an
        `allow`/`notify` policy, recorded in `overage_events`) grouped by
        billable metric over the half-open window `[from, to)`. Amounts are in
        millicredits. Use this to reconcile arrears without waiting for — or
        replaying — the `subscription.renewed` webhook. Reads never create a
        customer; an unknown customer is a 404.
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - name: from
          in: query
          required: true
          description: Inclusive start of the window (RFC3339).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          description: Exclusive end of the window (RFC3339). Must be after `from`.
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerOverageResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}/credits:
    get:
      tags: [Credits]
      operationId: getCustomerCreditsByExternalId
      summary: Get a customer's credit balance by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - name: include_blocks
          in: query
          required: false
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}/credits/grant:
    post:
      tags: [Credits]
      operationId: grantCustomerCreditsByExternalId
      summary: Grant credits to a customer by your `external_id`.
      description: >
        QuotaStack returns `409` for one of two reasons. A concurrent grant
        or adjustment touched the same credit account. Retry the request
        with the same `Idempotency-Key`. Or `stack_after` had `fallback:
        reject` and no active block matched `metadata_match`. QuotaStack
        granted nothing in that case.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditGrantRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditGrantResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}/credits/adjust:
    post:
      tags: [Credits]
      operationId: adjustCustomerCreditsByExternalId
      summary: Apply a credit adjustment by your `external_id`.
      description: >
        QuotaStack returns `409` when a concurrent grant or adjustment
        touched the same credit account. Retry the request with the same
        `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditAdjustRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditAdjustResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}/credits/history:
    get:
      tags: [Credits]
      operationId: listCustomerCreditsHistoryByExternalId
      summary: List ledger entries by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CreditLedgerEntry' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customer-by-external-id/{external_id}/overage:
    get:
      tags: [Credits]
      operationId: getCustomerOverageByExternalId
      summary: Per-metric overage totals by your `external_id` over a period.
      description: >
        Twin of `GET /v1/customers/{customer_id}/overage` keyed by your
        `external_id`. Returns overage grouped by billable metric over the
        half-open window `[from, to)`, in millicredits.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - name: from
          in: query
          required: true
          description: Inclusive start of the window (RFC3339).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          description: Exclusive end of the window (RFC3339). Must be after `from`.
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerOverageResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  ########################################################################
  # Entitlements
  ########################################################################

  /v1/entitlements/check:
    get:
      tags: [Entitlements]
      operationId: checkEntitlement
      summary: Check if a customer is entitled to consume a metric.
      parameters:
        - name: customer_id
          in: query
          required: true
          schema: { type: string }
          description: QuotaStack customer id or your external_id.
        - name: billable_metric_key
          in: query
          required: true
          schema: { type: string }
        - name: units
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/customers/{id}/entitlements/{metric}:
    get:
      tags: [Entitlements]
      operationId: checkCustomerEntitlement
      summary: Check entitlement for a specific metric by customer id + metric path.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
        - name: metric
          in: path
          required: true
          schema: { type: string }
        - name: units
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/customers/{id}/entitlements:
    get:
      tags: [Entitlements]
      operationId: getCustomerEntitlements
      summary: Get the full entitlement snapshot for a customer.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementMap' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/customer-by-external-id/{external_id}/entitlements/{metric}:
    get:
      tags: [Entitlements]
      operationId: checkCustomerEntitlementByExternalId
      summary: Check entitlement by your `external_id` + metric.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - name: metric
          in: path
          required: true
          schema: { type: string }
        - name: units
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/customer-by-external-id/{external_id}/entitlements:
    get:
      tags: [Entitlements]
      operationId: getCustomerEntitlementsByExternalId
      summary: Get full entitlement snapshot by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementMap' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  ########################################################################
  # Gauge Mutations
  ########################################################################

  /v1/customers/{id}/entitlements/{billable_metric_key}/increment:
    post:
      tags: [Entitlements]
      operationId: incrementGauge
      summary: Increment a gauge entitlement for a customer.
      description: |
        Atomically increments the customer's gauge counter for the given billable metric.
        Uses optimistic locking with automatic retries. The `policy` field controls behavior
        when the increment would exceed the cap: `block` (default) returns 409, `allow` permits
        the overshoot and returns negative headroom.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GaugeMutationRequest'
      responses:
        '200':
          description: Increment succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Cap exceeded (policy=block) or concurrent modification.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeCapExceededError' }
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }

  /v1/customers/{id}/entitlements/{billable_metric_key}/decrement:
    post:
      tags: [Entitlements]
      operationId: decrementGauge
      summary: Decrement a gauge entitlement for a customer.
      description: |
        Atomically decrements the customer's gauge counter. Returns 409 if the
        decrement would bring the value below zero.
      parameters:
        - $ref: '#/components/parameters/CustomerIdPath'
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GaugeMutationRequest'
      responses:
        '200':
          description: Decrement succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Cannot decrement below zero or concurrent modification.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }

  /v1/entitlements/consume:
    post:
      tags: [Entitlements]
      operationId: consumeEntitlement
      summary: Atomic check-and-debit for metered entitlements.
      description: |
        Synchronously checks whether the customer can afford the requested units
        and debits credits in one atomic operation. Returns balance feedback inline.
        Metered metrics only — returns 400 for boolean/gauge/static types.
        Coexists with the async POST /v1/usage path.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsumeRequest' }
      responses:
        '200':
          description: |
            Consume result. Check `allowed` and `debited` fields. A duplicate
            request under an Idempotency-Key whose debit already committed (HTTP
            cache expired) returns 200 with `reason: duplicate_replay` and the
            ORIGINAL request's `units`/`estimated_cost` — the original debit
            stands and is not repeated. `debited` and `overage` also replay the
            ORIGINAL debit's EXACT split: if the original consume (under an
            `allow`/`notify` policy) drained partial credit and recorded the
            remainder as overage, the replay reports `debited: true` AND the
            original `overage` amount — it never collapses to a generic
            `debited: true` with `overage` silently omitted. `original_reason`
            carries the original response's own business reason when it was
            `overage_recorded`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsumeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The Idempotency-Key was already used for a debit with a DIFFERENT
            request body (different customer, metric, or units). Mirrors the HTTP
            idempotency middleware's key-reuse conflict, but surfaced here when
            the 24h HTTP cache had expired and the duplicate reached the service.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/customer-by-external-id/{external_id}/entitlements/{billable_metric_key}/increment:
    post:
      tags: [Entitlements]
      operationId: incrementGaugeByExternalId
      summary: Increment a gauge entitlement by external customer ID.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GaugeMutationRequest'
      responses:
        '200':
          description: Increment succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Cap exceeded or concurrent modification.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeCapExceededError' }
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }

  /v1/customer-by-external-id/{external_id}/entitlements/{billable_metric_key}/decrement:
    post:
      tags: [Entitlements]
      operationId: decrementGaugeByExternalId
      summary: Decrement a gauge entitlement by external customer ID.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GaugeMutationRequest'
      responses:
        '200':
          description: Decrement succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GaugeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Cannot decrement below zero or concurrent modification.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }

  ########################################################################
  # Usage
  ########################################################################

  /v1/usage:
    post:
      tags: [Usage]
      operationId: recordUsage
      summary: Record a usage event.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UsageEvent' }
      responses:
        '202':
          description: Accepted and queued for processing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/usage/batch:
    post:
      tags: [Usage]
      operationId: recordUsageBatch
      summary: Record a batch of usage events (max 500 per request).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchUsageRequest' }
      responses:
        '200':
          description: |
            Response returns 200 regardless of partial failures — inspect the
            per-event `results[]` array for `status` and `error_code`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchUsageResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  ########################################################################
  # Imports (legacy customer cutover)
  ########################################################################

  /v1/imports/customers:
    post:
      tags: [Imports]
      operationId: importCustomer
      summary: Import an existing customer (mid-plan cutover).
      description: |
        Atomic import of a customer with optional subscription clocks, remaining
        credit blocks, entitlement overrides, and gauge states.

        **Defaults differ from create:**
        - `grant_policy` defaults to `none` (no plan activation grants)
        - `emit_webhooks` defaults to `false`
        - Period bounds are tenant-supplied, not computed from now

        Requires `subscriptions:write` scope. Strongly recommended: send an
        `Idempotency-Key` header.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImportCustomerRequest' }
            example:
              customer:
                external_id: user_abc
                display_name: Ada Lovelace
              subscription:
                plan_variant_id: "019f0000-0000-7000-8000-000000000001"
                status: active
                started_at: "2025-11-01T00:00:00Z"
                current_period_start: "2025-11-01T00:00:00Z"
                current_period_end: "2026-11-01T00:00:00Z"
                grant_policy: none
              credit_blocks:
                - credits: 42000
                  source: migration
                  reason: Remaining balance at cutover
                  expires_at: "2026-11-01T00:00:00Z"
                  idempotency_key: "migrate-block:user_abc:remaining"
              options:
                emit_webhooks: false
                dry_run: false
      responses:
        '200':
          description: Import applied (or dry-run projection).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportCustomerResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: |
            Conflict — customer already has a non-terminal subscription, or
            Idempotency-Key reused with a different body.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  ########################################################################
  # Subscriptions
  ########################################################################

  /v1/subscriptions:
    post:
      tags: [Subscriptions]
      operationId: createSubscription
      summary: Create a subscription for a customer.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSubscriptionRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    get:
      tags: [Subscriptions]
      operationId: listSubscriptions
      summary: List subscriptions, optionally filtered by customer_id.
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [trialing, active, cancelling, canceled, expired, paused, overdue, contract_ended]
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  /v1/subscriptions/{id}:
    get:
      tags: [Subscriptions]
      operationId: getSubscription
      summary: Retrieve a subscription by id.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/subscriptions/{id}/upgrade:
    post:
      tags: [Subscriptions]
      operationId: upgradeSubscription
      summary: Upgrade a subscription to a different plan variant (takes effect now).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpgradeSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/subscriptions/{id}/downgrade:
    post:
      tags: [Subscriptions]
      operationId: downgradeSubscription
      summary: Schedule a downgrade to take effect at the next renewal.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DowngradeSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/subscriptions/{id}/cancel:
    post:
      tags: [Subscriptions]
      operationId: cancelSubscription
      summary: Cancel a subscription (immediately or at period end).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CancelSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/subscriptions/{id}/pause:
    post:
      tags: [Subscriptions]
      operationId: pauseSubscription
      summary: Pause an active subscription.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PauseSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }

  /v1/subscriptions/{id}/resume:
    post:
      tags: [Subscriptions]
      operationId: resumeSubscription
      summary: Resume a paused subscription.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResumeSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }

  /v1/subscriptions/{id}/renew:
    post:
      tags: [Subscriptions]
      operationId: renewSubscription
      summary: Explicitly renew a subscription (prepaid, records payment).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RenewSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }

  /v1/subscriptions/{id}/extend:
    post:
      tags: [Subscriptions]
      operationId: extendSubscription
      summary: Extend a postpaid contract's end date.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExtendSubscriptionRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }

  ########################################################################
  # Overrides
  ########################################################################

  /v1/subscriptions/{id}/overrides:
    post:
      tags: [Overrides]
      operationId: createOverride
      summary: Create an entitlement override for a subscription.
      description: |
        Override a single entitlement value on a subscription. Supports boolean,
        gauge, and static metric types. Metered metrics are rejected (400).
        If an expired override exists for the same (subscription, metric) pair,
        it is soft-deleted and replaced.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOverrideRequest' }
      responses:
        '201':
          description: Override created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionEntitlementOverride' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

    get:
      tags: [Overrides]
      operationId: listOverrides
      summary: List all overrides for a subscription.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                properties:
                  overrides:
                    type: array
                    items: { $ref: '#/components/schemas/SubscriptionEntitlementOverride' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/subscriptions/{id}/overrides/{billable_metric_key}:
    patch:
      tags: [Overrides]
      operationId: updateOverride
      summary: Update an override's value, expiry, or reason.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateOverrideRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionEntitlementOverride' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      tags: [Overrides]
      operationId: deleteOverride
      summary: Soft-delete an override.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: billable_metric_key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204': { description: Override deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  ########################################################################
  # Reservations
  ########################################################################

  /v1/reservations:
    post:
      tags: [Reservations]
      operationId: reserveCredits
      summary: Hold credits for an in-flight operation.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReserveRequest' }
      responses:
        '201':
          description: Created. Reservation is active until committed, released, or expired.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reservation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/reservations/{id}:
    get:
      tags: [Reservations]
      operationId: getReservation
      summary: Retrieve a reservation by id.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reservation' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/reservations/{id}/commit:
    post:
      tags: [Reservations]
      operationId: commitReservation
      summary: Commit an active reservation (debit actual_units, release remainder).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CommitReservationRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reservation' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/reservations/{id}/release:
    post:
      tags: [Reservations]
      operationId: releaseReservation
      summary: Release an active reservation without consuming credits.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reservation' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/customers/{customer_id}/reservations:
    get:
      tags: [Reservations]
      operationId: listCustomerReservations
      summary: List reservations for a customer.
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, committed, released, expired] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Reservation' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  /v1/customer-by-external-id/{external_id}/reservations:
    get:
      tags: [Reservations]
      operationId: listCustomerReservationsByExternalId
      summary: List reservations by your `external_id`.
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Reservation' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  ########################################################################
  # Plans + PlanVariants + Grants
  ########################################################################

  /v1/plans:
    post:
      tags: [Plans]
      operationId: createPlan
      summary: Create a plan.
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePlanRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Plan' }
        '422': { $ref: '#/components/responses/ValidationError' }

    get:
      tags: [Plans]
      operationId: listPlans
      summary: List plans.
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, retired] }
        - name: include_variants
          in: query
          required: false
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PlanWithVariants' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  /v1/plans/{id}:
    get:
      tags: [Plans]
      operationId: getPlan
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Plan' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      tags: [Plans]
      operationId: updatePlan
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdatePlanRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Plan' }

  /v1/plans/{id}/variants:
    post:
      tags: [Plans]
      operationId: createPlanVariant
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePlanVariantRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanVariant' }

    get:
      tags: [Plans]
      operationId: listPlanVariants
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PlanVariant' }

  /v1/plans/{id}/variants/{vid}:
    patch:
      tags: [Plans]
      operationId: updatePlanVariant
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: vid
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdatePlanVariantRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanVariant' }

  /v1/plans/{id}/variants/{vid}/grants:
    post:
      tags: [Plans]
      operationId: createCreditGrant
      summary: Attach a credit grant to a plan variant.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: vid
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCreditGrantRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditGrant' }

  /v1/plan-variants/{id}:
    get:
      tags: [Plans]
      operationId: getPlanVariant
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanVariant' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/plans/{id}/variants/{vid}/entitlements:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: vid
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Plans]
      operationId: attachVariantEntitlement
      summary: Attach a billable metric to a plan variant with a type-shaped value.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePlanVariantEntitlementRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanVariantEntitlement' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Already attached.
        '422': { $ref: '#/components/responses/ValidationError' }
    get:
      tags: [Plans]
      operationId: listVariantEntitlements
      summary: List entitlements attached to a plan variant.
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PlanVariantEntitlement' }

  /v1/plans/{id}/variants/{vid}/entitlements/{billable_metric_key}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: vid
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: billable_metric_key
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Plans]
      operationId: updateVariantEntitlement
      summary: Update the value of an existing variant entitlement.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdatePlanVariantEntitlementRequest' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanVariantEntitlement' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Plans]
      operationId: detachVariantEntitlement
      summary: Detach a billable metric from a plan variant (soft-delete).
      responses:
        '204':
          description: Deleted.
        '404': { $ref: '#/components/responses/NotFound' }

  ########################################################################
  # MeteringRules
  ########################################################################

  /v1/metering-rules:
    post:
      tags: [MeteringRules]
      operationId: createMeteringRule
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMeteringRuleRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeteringRule' }
        '422': { $ref: '#/components/responses/ValidationError' }

    get:
      tags: [MeteringRules]
      operationId: listMeteringRules
      parameters:
        - name: billable_metric_key
          in: query
          required: false
          schema: { type: string }
        - name: active_only
          in: query
          required: false
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MeteringRule' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  ########################################################################
  # BillableMetrics
  ########################################################################

  /v1/billable-metrics:
    get:
      tags: [BillableMetrics]
      operationId: listBillableMetrics
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/BillableMetric' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }
    post:
      tags: [BillableMetrics]
      operationId: createBillableMetric
      summary: Create a new billable metric.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateBillableMetricRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillableMetric' }
        '409':
          description: Key already exists for this tenant.
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/billable-metrics/{key}:
    parameters:
      - name: key
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [BillableMetrics]
      operationId: getBillableMetric
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillableMetric' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [BillableMetrics]
      operationId: updateBillableMetric
      summary: Update a billable metric (Type is immutable).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateBillableMetricRequest' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillableMetric' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  ########################################################################
  # Topups
  ########################################################################

  /v1/topup-packages:
    post:
      tags: [Topups]
      operationId: createTopupPackage
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTopupPackageRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TopupPackage' }

    get:
      tags: [Topups]
      operationId: listTopupPackages
      parameters:
        - name: active_only
          in: query
          required: false
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TopupPackage' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  /v1/topup-packages/{id}:
    patch:
      tags: [Topups]
      operationId: updateTopupPackage
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateTopupPackageRequest' }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TopupPackage' }

  /v1/topups/grant:
    post:
      tags: [Topups]
      operationId: grantTopup
      summary: Grant a topup (package-based or ad-hoc).
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TopupGrantRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Topup' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/customers/{customer_id}/topups:
    get:
      tags: [Topups]
      operationId: listCustomerTopups
      parameters:
        - $ref: '#/components/parameters/CustomerIdInPath'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Topup' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  /v1/customer-by-external-id/{external_id}/topups:
    get:
      tags: [Topups]
      operationId: listCustomerTopupsByExternalId
      parameters:
        - $ref: '#/components/parameters/ExternalIdPath'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Topup' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  ########################################################################
  # AuditLog
  ########################################################################

  /v1/audit-log:
    get:
      tags: [AuditLog]
      operationId: listAuditLog
      summary: List audit log entries with filters.
      parameters:
        - name: action
          in: query
          required: false
          schema: { type: string }
        - name: entity_type
          in: query
          required: false
          schema: { type: string }
        - name: actor_type
          in: query
          required: false
          schema: { type: string, enum: [api, system, admin] }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AuditLogEntry' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  ########################################################################
  # Webhooks (delivery log — not the inbound webhooks section below)
  ########################################################################

  /v1/webhooks/events:
    get:
      tags: [Webhooks]
      operationId: listWebhookEvents
      summary: List webhook events QuotaStack has emitted to your configured URL.
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, delivered, failed, dead_letter] }
        - name: event_type
          in: query
          required: false
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEventWithDeliveries' }
                  pagination: { $ref: '#/components/schemas/PaginationMeta' }

  ########################################################################
  # Stats
  ########################################################################

  /v1/webhooks/events/{id}/redeliver:
    post:
      tags: [Webhooks]
      operationId: redeliverWebhookEvent
      summary: Requeue a dead-lettered webhook event for delivery.
      description: >
        Works only on events with status `dead_letter`. The event goes back
        to `pending` with a fresh retry ladder (attempts reset to 0). The
        next delivery attempt signs with the current secret — useful after
        a secret rotation or after fixing your endpoint. An event in any
        other status returns 409 and is not changed. Calling twice is safe:
        the second call returns 409 because the event is already pending.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The webhook event id (`webhook-id` header / delivery-log `id`).
      responses:
        '200':
          description: Requeued. Returns the event, now `pending`.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The event is not dead-lettered (it is pending, delivered, or failed-and-retrying).
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/webhooks/test:
    post:
      tags: [Webhooks]
      operationId: sendTestWebhook
      summary: Send a test event to your configured webhook URL.
      description: >
        Enqueues a `test.ping` event through the normal pipeline — outbox,
        HMAC signing, retry schedule — so it exercises exactly what real
        events do. Use it to verify your endpoint and signature check
        before going live. Requires a configured webhook URL and signing
        secret; without both, the request returns 409 and nothing is
        enqueued. Check the result in the delivery log:
        `GET /v1/webhooks/events?event_type=test.ping`.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      responses:
        '202':
          description: Test event enqueued. Delivery happens asynchronously.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [event_id, event_type, status]
                properties:
                  event_id: { type: string, format: uuid }
                  event_type: { type: string, const: test.ping }
                  status: { type: string, const: pending }
        '409':
          description: No webhook URL or no signing secret configured. Set both first.
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/stats/dashboard:
    get:
      tags: [Stats]
      operationId: getDashboardStats
      summary: Aggregate stats scoped to the authenticated tenant + environment.
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardStats' }

  ########################################################################
  # AdminAuth and self-service access controls
  ########################################################################

  /v1/admin/auth/signup:
    post:
      tags: [AdminAuth]
      operationId: adminSignup
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Start public self-serve signup.
      description: Accepts signup and sends email without revealing whether the email already exists. Creates no API keys and no session.
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, password, company_name]
              properties:
                name: { type: string }
                email: { type: string, format: email }
                password: { type: string, minLength: 12, maxLength: 72 }
                company_name: { type: string }
                captcha_token: { type: string }
      responses:
        '202':
          description: Accepted. Check email to continue.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message:
                    type: string
                    const: Check your email to continue.
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/auth/verify-email:
    post:
      tags: [AdminAuth]
      operationId: adminVerifyEmail
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Verify signup email and create an admin session.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: Verified and logged in.
          content:
            application/json:
              schema:
                type: object
                required: [user, tenant, token]
                properties:
                  user:
                    type: object
                    required: [id, tenant_id, email, name, role, email_verified, is_platform_admin]
                    properties:
                      id: { type: string, format: uuid }
                      tenant_id: { type: string, format: uuid }
                      email: { type: string, format: email }
                      name: { type: string }
                      role: { type: string, enum: [owner, admin, viewer] }
                      email_verified: { type: boolean }
                      is_platform_admin: { type: boolean }
                  tenant:
                    type: object
                    required: [id, name, slug, status]
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      slug: { type: string }
                      status: { type: string }
                  token: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }

  /v1/admin/auth/resend-verification:
    post:
      tags: [AdminAuth]
      operationId: adminResendVerification
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Resend signup email verification when available.
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '202':
          description: Accepted without revealing whether verification is available.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message:
                    type: string
                    const: If verification is available for this email, a new link has been sent.
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/api-keys:
    get:
      tags: [AdminAuth]
      operationId: adminListAPIKeys
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: List the tenant's API keys.
      description: >
        Any admin role may list. Key material is never returned — only the
        prefix. Revoked keys appear with `revoked_at` set.
      security:
        - adminSession: []
      responses:
        '200':
          description: The tenant's API keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        tenant_id: { type: string, format: uuid }
                        key_prefix: { type: string }
                        name: { type: [string, 'null'] }
                        environment: { $ref: '#/components/schemas/Environment' }
                        scopes:
                          type: array
                          items: { type: string }
                        created_at: { type: string, format: date-time }
                        last_used_at: { type: string, format: date-time }
                        revoked_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [AdminAuth]
      operationId: adminCreateAPIKey
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Create a tenant API key from the admin dashboard.
      security:
        - adminSession: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [environment]
              properties:
                environment: { $ref: '#/components/schemas/Environment' }
                name: { type: string }
                scopes:
                  type: array
                  items: { type: string }
      responses:
        '201':
          description: Created. The raw key is shown once.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  tenant_id: { type: string, format: uuid }
                  environment: { $ref: '#/components/schemas/Environment' }
                  key_prefix: { type: string }
                  name: { type: string }
                  scopes:
                    type: array
                    items: { type: string }
                  created_at: { type: string, format: date-time }
                  key: { type: string }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/api-keys/{id}/revoke:
    post:
      tags: [AdminAuth]
      operationId: adminRevokeAPIKey
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Revoke a tenant API key from the admin dashboard.
      security:
        - adminSession: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Revoked.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/tenants/{tenant_id}/config:
    get:
      tags: [AdminAuth]
      operationId: adminGetTenantConfig
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Read the tenant's configuration.
      description: >
        Webhook endpoint, overage policy, currency/timezone, and balance-alert
        thresholds. Requires the owner or admin role; `tenant_id` must match
        the session's tenant.
      security:
        - adminSession: []
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current tenant configuration.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantConfig' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [AdminAuth]
      operationId: adminUpdateTenantConfig
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Update the tenant's configuration (partial).
      description: >
        Requires the owner or admin role and a verified email. Only the fields
        in the request body are settable from this surface. `webhook_secret`
        is not one of them — rotate it with
        POST /v1/admin/tenants/{tenant_id}/webhook-secret/rotate instead.
      security:
        - adminSession: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateTenantConfigRequest' }
      responses:
        '200':
          description: Updated configuration.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantConfig' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/tenants/{tenant_id}/webhook-secret/rotate:
    post:
      tags: [AdminAuth]
      operationId: adminRotateWebhookSecret
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      summary: Rotate the tenant's webhook signing secret.
      description: >
        Generates a new signing secret, saves it, and returns it one time.
        Save the returned value — no other endpoint returns it.
        Rotation is immediate. There is no grace window: the old secret
        stops signing as soon as the call returns. Update your verifier
        with the new secret right away. Deliveries retried after rotation
        are signed with the new secret, because each delivery attempt
        computes a fresh signature.
        Requires the owner or admin role and a verified email.
      security:
        - adminSession: []
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The new secret. Returned only here, only once.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [webhook_secret]
                properties:
                  webhook_secret:
                    type: string
                    description: New base64 signing secret. Store it now — it is not shown again.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/tenants/{id}/live-access/approve:
    post:
      tags: [AdminAccessControl]
      operationId: approveTenantLiveAccess
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      # Platform-operator surface. These are not callable with a tenant API key.
      security:
        - adminSession: []
      summary: Approve live access for a tenant.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/TenantIdPath'
      responses:
        '200':
          description: Approved.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, live_access_status]
                properties:
                  tenant_id: { type: string, format: uuid }
                  live_access_status: { type: string, const: approved }
                  reviewed_at: { type: string, format: date-time }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/tenants/{id}/live-access/reject:
    post:
      tags: [AdminAccessControl]
      operationId: rejectTenantLiveAccess
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      # Platform-operator surface. These are not callable with a tenant API key.
      security:
        - adminSession: []
      summary: Reject live access for a tenant.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/TenantIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Rejected.
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, live_access_status]
                properties:
                  tenant_id: { type: string, format: uuid }
                  live_access_status: { type: string, const: rejected }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/tenants/{id}/live-access/suspend:
    post:
      tags: [AdminAccessControl]
      operationId: suspendTenantLiveAccess
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      # Platform-operator surface. These are not callable with a tenant API key.
      security:
        - adminSession: []
      summary: Suspend live access for a tenant.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/TenantIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Suspended.
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, live_access_status]
                properties:
                  tenant_id: { type: string, format: uuid }
                  live_access_status: { type: string, const: suspended }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/tenants/{id}/sandbox-access/suspend:
    post:
      tags: [AdminAccessControl]
      operationId: suspendTenantSandboxAccess
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      # Platform-operator surface. These are not callable with a tenant API key.
      security:
        - adminSession: []
      summary: Suspend sandbox access for a tenant.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/TenantIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Suspended.
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, sandbox_access_status]
                properties:
                  tenant_id: { type: string, format: uuid }
                  sandbox_access_status: { type: string, const: suspended }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/tenants/{id}/sandbox-access/enable:
    post:
      tags: [AdminAccessControl]
      operationId: enableTenantSandboxAccess
      # Tenant operator surface, not the product API. The docs site keeps these
      # on /docs/api/account rather than in the object-first reference.
      x-surface: account
      # Platform-operator surface. These are not callable with a tenant API key.
      security:
        - adminSession: []
      summary: Enable sandbox access for a tenant.
      parameters:
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/TenantIdPath'
      responses:
        '200':
          description: Enabled.
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, sandbox_access_status]
                properties:
                  tenant_id: { type: string, format: uuid }
                  sandbox_access_status: { type: string, const: enabled }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

components:
  ########################################################################
  # Security
  ########################################################################
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Tenant API key. Prefix `qs_live_` for production, `qs_test_` for sandbox.
        Provision at the QuotaStack admin dashboard under Settings → API Keys.
    adminSession:
      type: apiKey
      in: cookie
      name: qs_admin_session
      description: |
        Admin dashboard session cookie, set by signing in at
        platform.quotastack.io. Authenticates the tenant admin surface
        (`/v1/admin/...`). Roles: owner, admin, viewer — mutations require
        owner or admin.

  ########################################################################
  # Parameters
  ########################################################################
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique string you choose, up to 256 characters. It stops a retry from
        doing the work twice.

        This header is required on every POST and PATCH. Leave it out and the
        request fails with `422`, before any change is made.

        Send the same key twice and QuotaStack replays the first response. The
        second call's status and body match the first, and the response carries
        `X-Idempotent-Replayed: true`. Replays are available for 24 hours.

        Derive the key from the business event rather than a random value, so a
        retry from anywhere reuses it — for example
        `topup:{payment_intent_id}` or `renewal:{subscription_id}:{period_start}`.
      schema:
        type: string
        maxLength: 256

    RequiredIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Required idempotency key for this mutating admin/public operation.
        Reusing the same key with a different body returns `409 Conflict`;
        successful 2xx and client 4xx responses are cached for 24 hours.
      schema:
        type: string
        maxLength: 256

    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor from the previous page's `pagination.next_cursor`.
        Omit to start at the first page.
      schema:
        type: string

    Limit:
      name: limit
      in: query
      required: false
      description: Page size. Default 20, max 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    CustomerIdPath:
      name: id
      in: path
      required: true
      description: QuotaStack customer UUID.
      schema:
        type: string
        format: uuid

    TenantIdPath:
      name: id
      in: path
      required: true
      description: QuotaStack tenant UUID.
      schema:
        type: string
        format: uuid

    CustomerIdInPath:
      name: customer_id
      in: path
      required: true
      description: |
        Customer identifier. Can be either a QuotaStack UUID or your
        own `external_id` — the server resolves both. For strict
        external-id resolution, use the `/v1/customer-by-external-id/...`
        path family instead.
      schema:
        type: string

    ExternalIdPath:
      name: external_id
      in: path
      required: true
      description: Your own identifier for the customer (passed at creation time).
      schema:
        type: string

  ########################################################################
  # Response headers
  ########################################################################
  headers:
    RateLimitLimit:
      description: Maximum requests allowed in the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix epoch seconds when the current window resets.
      schema: { type: integer }
    IdempotentReplayed:
      description: |
        `true` when this response was replayed from a cached prior response
        keyed by the request's `Idempotency-Key` header. Absent otherwise.
      schema:
        type: string
        enum: ["true"]
    RetryAfter:
      description: Seconds the client should wait before retrying.
      schema: { type: integer }

  ########################################################################
  # Error responses — all share the RFC 7807 ProblemDetail envelope
  ########################################################################
  responses:
    BadRequest:
      description: Malformed request.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    Unauthorized:
      description: Missing or invalid authentication.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    Forbidden:
      description: Authenticated but not permitted (insufficient scope).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    NotFound:
      description: Resource not found.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    Conflict:
      description: State conflict (e.g., duplicate resource, stale version).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    PaymentRequired:
      description: Insufficient credits for the requested operation.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    ValidationError:
      description: Request shape was valid but field validation failed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

    InternalError:
      description: Unexpected server error.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetail' }

  ########################################################################
  # Schemas
  ########################################################################
  schemas:
    ########################################################################
    # Error envelope (RFC 7807)
    ########################################################################
    ProblemDetail:
      type: object
      description: |
        RFC 7807 problem+json error envelope. Returned with
        `Content-Type: application/problem+json` on every 4xx and 5xx response.
        Public self-serve admin flows may return stable error URI suffixes
        such as `email_not_verified`, `live_access_required`,
        `live_access_suspended`, `sandbox_access_suspended`,
        `access_suspended`, and `key_quota_exceeded`.
      required: [status, type, title, detail]
      properties:
        status:
          type: integer
          description: HTTP status code (duplicated for machine-readability).
        type:
          type: string
          format: uri
          description: |
            Stable URI identifying the error class, e.g.
            `https://api.quotastack.io/errors/insufficient-credits`.
        title:
          type: string
          description: Short human-readable summary of the error.
        detail:
          type: string
          description: Longer human-readable explanation specific to this occurrence.
        instance:
          type: string
          format: uri
          description: URI identifying this specific occurrence (optional).
        validation_errors:
          type: array
          description: Per-field validation failures. Present on 422 responses.
          items: { $ref: '#/components/schemas/ValidationError' }

    ValidationError:
      type: object
      required: [field, message, code]
      properties:
        field:
          type: string
          description: Dotted path to the invalid field, e.g. `metadata.region`.
        message:
          type: string
          description: Human-readable explanation.
        code:
          type: string
          description: Stable machine-readable code, e.g. `required`, `invalid_enum`.

    ########################################################################
    # Pagination
    ########################################################################
    PaginationMeta:
      type: object
      description: |
        Cursor-based pagination envelope. Returned on every list endpoint.
        No `total` — cursor-only.
      required: [has_more, next_cursor]
      properties:
        has_more:
          type: boolean
          description: True if more results exist beyond this page.
        next_cursor:
          type: [string, 'null']
          description: |
            Opaque cursor for the next page. Pass it back as `?cursor=<value>`.
            Null when `has_more` is false.

    ########################################################################
    # Tenant config (GET/PATCH /v1/admin/tenants/{tenant_id}/config)
    ########################################################################
    TenantConfig:
      type: object
      description: >
        Tenant-level configuration returned by GET and PATCH
        /v1/admin/tenants/{tenant_id}/config.
      properties:
        tenant_id: { type: string, format: uuid }
        webhook_url: { type: [string, 'null'] }
        webhook_secret_set:
          type: boolean
          description: >
            True when a webhook signing secret exists for this tenant. The
            secret itself is never returned here. To get a secret, call
            POST /v1/admin/tenants/{tenant_id}/webhook-secret/rotate. That
            endpoint returns the new secret one time.
        overage_policy:
          type: string
          enum: [block, allow, notify]
        default_currency: { type: string }
        timezone: { type: string }
        data_region: { type: string }
        low_balance_threshold_mc:
          type: integer
          format: int64
          minimum: 0
          default: 0
          description: |
            Default threshold (millicredits) for `credit.low_balance` webhooks.
            `0` (default) disables low_balance for customers that inherit this value.
            Per-customer override via `Customer.low_balance_threshold_mc`.
            `credit.exhausted` is always on and ignores this field.
        credit_expiring_soon_hours:
          type: integer
          minimum: 0
          maximum: 8760
          default: 0
          description: |
            Lead time in hours for `credit.expiring_soon` webhooks (per credit block).
            `0` (default) disables the alert. Recommended: `72` for packs / annual grants.
            Maximum: `8760` (1 year). No per-customer override in Phase 1.
        updated_at: { type: string, format: date-time }

    UpdateTenantConfigRequest:
      type: object
      description: >
        Partial update for PATCH /v1/admin/tenants/{tenant_id}/config. Absent
        fields are left unchanged. `webhook_secret` and `data_region` are not
        settable from this surface.
      properties:
        webhook_url: { type: [string, 'null'] }
        overage_policy:
          type: string
          enum: [block, allow, notify]
        default_currency: { type: string }
        timezone:
          type: string
          description: IANA timezone name.
        low_balance_threshold_mc:
          type: integer
          format: int64
          minimum: 0
          description: Millicredits. 0 = low_balance alerts off (default).
        credit_expiring_soon_hours:
          type: integer
          minimum: 0
          maximum: 8760
          description: |
            Hours before `expires_at` to emit `credit.expiring_soon` (0 = off).
            Recommended: 72. Maximum: 8760 (1 year).

    ########################################################################
    # Customers
    ########################################################################
    Customer:
      type: object
      required: [id, tenant_id, environment, external_id, metadata, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
          description: QuotaStack-assigned customer UUID.
        tenant_id:
          type: string
          format: uuid
        environment:
          $ref: '#/components/schemas/Environment'
        external_id:
          type: string
          description: Your identifier for this customer.
        display_name:
          type: [string, 'null']
          description: Optional human label shown in dashboards.
        overage_policy:
          type: [string, 'null']
          enum: [block, allow, notify, null]
          description: |
            Overrides the tenant default when set.
            `block` — refuse usage beyond spendable balance.
            `allow` — permit usage beyond balance; the spendable balance floors
            at zero and the excess is recorded as overage (see the consume
            `overage` field) for arrears billing.
            `notify` — currently identical to `allow`. No webhook is emitted
            yet; tracked as workspace#90.
        low_balance_threshold_mc:
          type: [integer, 'null']
          format: int64
          minimum: 0
          description: |
            Optional per-customer override for the `credit.low_balance` webhook
            threshold (millicredits). `null` inherits the tenant default
            (`tenant_configs.low_balance_threshold_mc`, default 0 = off).
            `0` disables low_balance for this customer only.
            `credit.exhausted` is always evaluated and ignores this field.
        metadata:
          type: object
          additionalProperties: { type: string }
          description: Free-form key-value tags.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateCustomerRequest:
      type: object
      required: [external_id]
      properties:
        external_id:
          type: string
          description: Your identifier for this customer (must be unique within your tenant + environment).
        display_name:
          type: [string, 'null']
          description: Optional label (max 200 chars).
        overage_policy:
          type: [string, 'null']
          enum: [block, allow, notify, '', null]
        low_balance_threshold_mc:
          type: [integer, 'null']
          format: int64
          minimum: 0
          description: Optional override for low_balance threshold (millicredits). Null inherits tenant default; 0 = off for this customer.
        metadata:
          type: object
          additionalProperties: { type: string }

    UpdateCustomerRequest:
      type: object
      description: >
        All fields optional. An empty string clears `display_name` or
        `overage_policy`. `overage_policy` has its own three-way rule — see
        its description below.
      properties:
        display_name:
          type: [string, 'null']
        overage_policy:
          type: [string, 'null']
          enum: [block, allow, notify, '', null]
          description: |
            A value sets the override. An empty string (`""`) clears it and
            returns the customer to the tenant default. `null`, or omitting
            this field, leaves the stored value unchanged.
        low_balance_threshold_mc:
          type: [integer, 'null']
          format: int64
          minimum: 0
          description: Optional override for low_balance threshold (millicredits). Set to 0 to disable low_balance for this customer.
        metadata:
          type: object
          additionalProperties: { type: string }

    ########################################################################
    # Credits
    ########################################################################
    CreditAccount:
      type: object
      required: [id, tenant_id, customer_id, environment, balance, reserved_balance, lifetime_earned, version, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        balance:
          type: integer
          format: int64
          description: Current balance in millicredits (1 credit = 1000 millicredits).
        reserved_balance:
          type: integer
          format: int64
          description: Credits held by active reservations.
        lifetime_earned:
          type: integer
          format: int64
          description: Total credits ever granted (not net of consumption).
        version:
          type: integer
          description: Optimistic-lock version counter.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreditBlock:
      type: object
      required: [id, tenant_id, customer_id, environment, original_amount, remaining_amount, source, priority, metadata, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        original_amount:
          type: integer
          format: int64
          description: Initial grant size in millicredits.
        remaining_amount:
          type: integer
          format: int64
          description: Credits still available in this block.
        source:
          type: string
          enum: [plan_grant, topup, promotional, compensation, referral, manual, migration]
        priority:
          type: integer
          minimum: 0
          maximum: 255
          description: Burn-down order — lower consumes first.
        effective_at:
          type: [string, 'null']
          format: date-time
          description: >-
            When this block becomes consumable. NULL = immediately effective.
            Set by the server when stack_after is used on the grant request.
        expires_at:
          type: [string, 'null']
          format: date-time
        stacked_after_block_id:
          type: [string, 'null']
          format: uuid
          description: >-
            The block whose expires_at was used as the anchor when stack_after
            was used. NULL if fallback was used or stack_after was not present.
            Only populated in grant responses.
        grant_transaction_id:
          type: [string, 'null']
          format: uuid
        grant_rule_id:
          type: [string, 'null']
          format: uuid
        voided_at:
          type: [string, 'null']
          format: date-time
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }

    CreditLedgerEntry:
      type: object
      required: [id, tenant_id, customer_id, environment, delta, type, idempotency_key, metadata, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        delta:
          type: integer
          format: int64
          description: Millicredits. Positive for credits, negative for debits.
        type:
          type: string
          enum: [plan_grant, topup, consumption, reservation, release, expiry, adjustment]
        source:
          type: [string, 'null']
          enum: [plan_grant, topup, promotional, compensation, referral, manual, migration, null]
        credit_block_id:
          type: [string, 'null']
          format: uuid
        billable_metric_key:
          type: [string, 'null']
        cost_units:
          type: [integer, 'null']
          format: int64
        exchange_rate:
          type: [integer, 'null']
          format: int64
        idempotency_key: { type: string }
        reference_id:
          type: [string, 'null']
          format: uuid
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }

    BalanceResponse:
      type: object
      required: [tenant_id, customer_id, environment, balance, reserved_balance, pending_balance, effective_balance, lifetime_earned, version, blocks]
      properties:
        id:
          type: string
          description: QuotaStack credit_account UUID. Empty string when no account row exists yet.
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        balance: { type: integer, format: int64 }
        reserved_balance: { type: integer, format: int64 }
        pending_balance:
          type: integer
          format: int64
          description: >-
            Credits from blocks with effective_at in the future (granted via
            stack_after but not yet consumable). Included in balance but not
            in effective_balance.
        effective_balance:
          type: integer
          format: int64
          description: >-
            balance − reserved_balance − pending_balance (what a new debit
            actually sees).
        lifetime_earned: { type: integer, format: int64 }
        version: { type: integer }
        updated_at:
          type: string
          format: date-time
          description: Zero value (`0001-01-01T00:00:00Z`) when no account row exists yet.
        blocks:
          type: array
          description: Active credit blocks. Always an array (never null). Empty unless `include_blocks=true`.
          items: { $ref: '#/components/schemas/CreditBlock' }

    CreditGrantRequest:
      type: object
      required: [credits, source, reason]
      properties:
        credits:
          type: integer
          format: int64
          minimum: 1
          description: Amount in millicredits.
        source:
          type: string
          enum: [promotional, compensation, referral, manual, migration]
        reason:
          type: string
          description: Human-readable reason (shown in audit log).
        expires_at:
          type: [string, 'null']
          format: date-time
          description: |
            When this credit block expires. ISO 8601 UTC timestamp.

            Omit it and the block never expires.

            Do not send `expires_at` and `duration_seconds` together. QuotaStack
            returns `422` with `validation_errors[0].field = "duration_seconds"`.
            Nothing is granted. QuotaStack does not pick one of the two for you.
        duration_seconds:
          type: [integer, 'null']
          format: int64
          minimum: 1
          maximum: 315360000
          description: |
            How long this credit block lives, in seconds. Maximum 315360000
            (10 years).

            Without `stack_after`, QuotaStack sets `expires_at = now + duration_seconds`.
            With `stack_after`, QuotaStack sets `expires_at = effective_at + duration_seconds`.

            `stack_after` requires `duration_seconds`. Send `stack_after` without it
            and QuotaStack returns `422`.

            Do not send `duration_seconds` and `expires_at` together. QuotaStack
            returns `422` with `validation_errors[0].field = "duration_seconds"`.
            Nothing is granted.
        stack_after:
          $ref: '#/components/schemas/StackAfter'
        priority:
          type: integer
          minimum: 0
          maximum: 255
          default: 0
        idempotency_key:
          type: string
          deprecated: true
          description: |
            Do not send this. The `Idempotency-Key` header is required on this
            call, and the handler always overwrites this field with the header
            value. Anything you put here is discarded.
        metadata:
          type: object
          additionalProperties: { type: string }

    CreditGrantResponse:
      type: object
      required: [transaction, block, account]
      properties:
        transaction: { $ref: '#/components/schemas/CreditLedgerEntry' }
        block: { $ref: '#/components/schemas/CreditBlock' }
        account: { $ref: '#/components/schemas/CreditAccount' }

    StackAfter:
      type: object
      required: [metadata_match]
      description: >-
        Controls block stacking. The new block's effective_at is set to the
        latest expires_at among active blocks matching metadata_match. If no
        block matches, the fallback determines behavior.
      properties:
        metadata_match:
          type: object
          additionalProperties: { type: string }
          description: >-
            Key-value pairs that must all be present in a block's metadata
            for it to be considered as a stack anchor. At least one pair is
            recommended.
        fallback:
          type: string
          enum: [now, reject]
          default: now
          description: >-
            "now" (default): use current time if no matching block exists.
            "reject": return 409 Conflict if no matching block exists.

    CreditAdjustRequest:
      type: object
      required: [delta, source, reason]
      properties:
        delta:
          type: integer
          format: int64
          description: Positive = grant; negative = deduct. Millicredits.
        source:
          type: string
          enum: [promotional, compensation, referral, manual, migration]
        reason: { type: string }
        idempotency_key: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }

    CreditAdjustResponse:
      type: object
      required: [transactions, account]
      properties:
        transactions:
          type: array
          items: { $ref: '#/components/schemas/CreditLedgerEntry' }
        block:
          anyOf:
            - $ref: '#/components/schemas/CreditBlock'
            - type: 'null'
          description: Set on positive adjustments; null on negative.
        account: { $ref: '#/components/schemas/CreditAccount' }

    CustomerOverageResponse:
      type: object
      required: [data, total_overage_mc, period]
      properties:
        data:
          type: array
          description: Per-billable-metric overage totals, sorted by metric key.
          items:
            type: object
            required: [billable_metric_key, overage_mc]
            properties:
              billable_metric_key: { type: string }
              overage_mc:
                type: integer
                format: int64
                description: Overage recorded for this metric in the window (millicredits).
        total_overage_mc:
          type: integer
          format: int64
          description: Sum of `overage_mc` across all metrics (millicredits).
        period:
          type: object
          required: [from, to]
          description: The half-open window `[from, to)` that was queried.
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }

    ########################################################################
    # Entitlements
    ########################################################################
    ConsumeRequest:
      type: object
      required: [customer_id, billable_metric_key, units]
      properties:
        customer_id:
          type: string
          description: Internal UUID or external customer ID.
        billable_metric_key:
          type: string
          description: Must have an active metering rule.
        units:
          type: integer
          format: int64
          minimum: 1
        metadata:
          type: object
          additionalProperties: { type: string }
          description: Passed through to ledger entries.

    ConsumeResult:
      type: object
      required: [type, allowed, debited, customer_id, billable_metric_key, units, estimated_cost, balance, reserved_balance, pending_balance, effective_balance, subscription_status, overage_policy]
      properties:
        type: { type: string, enum: [metered] }
        allowed: { type: boolean }
        debited: { type: boolean }
        reason:
          type: string
          enum: [insufficient_credits, overage_recorded, overdue_blocked, zero_cost, duplicate_replay]
          description: |
            Present in non-standard outcomes. `overage_recorded` — under an
            `allow`/`notify` policy, spendable credit was drained and the
            uncovered remainder recorded as overage (see `overage`).
            `duplicate_replay` — this Idempotency-Key's debit already committed
            in a prior request (the 24h HTTP idempotency cache had expired, so
            the duplicate reached the service). No second debit was performed;
            `units` and `estimated_cost` echo the ORIGINAL request, not this one,
            and `debited`/`overage` echo the ORIGINAL debit's EXACT split (not
            re-derived from current balance) — a duplicate of a partially-debited
            allow/notify consume replays `debited: true` AND the original
            `overage` amount, never silently drops the overage. See
            `original_reason` for the original request's own business reason.
            Reusing the SAME key with a DIFFERENT (customer, metric, units) body
            returns 409 instead (see the 409 response). The hash that decides
            replay-vs-conflict covers customer + metric + units only; metadata is
            excluded.
        original_reason:
          type: string
          enum: [overage_recorded]
          description: |
            Present only when `reason` is `duplicate_replay` AND the ORIGINAL
            debit recorded overage. Echoes the ORIGINAL execution's own
            business `reason` value (currently the only non-empty value an
            original debit can have replayed here is `overage_recorded`;
            absent when the original was a full debit with nothing
            noteworthy). This is distinct from `reason` itself, which always
            stays `duplicate_replay` on a replay — `original_reason` is what
            `reason` WOULD have been on the original (non-duplicate) response,
            preserved so a caller recovering a lost response never loses that
            classification.
        customer_id: { type: string, format: uuid }
        billable_metric_key: { type: string }
        units: { type: integer, format: int64 }
        estimated_cost:
          type: integer
          format: int64
          description: Millicredits cost of the requested units.
        balance: { type: integer, format: int64 }
        reserved_balance: { type: integer, format: int64 }
        pending_balance: { type: integer, format: int64 }
        effective_balance: { type: integer, format: int64 }
        shortfall:
          type: integer
          format: int64
          description: Present when reason is insufficient_credits.
        overage:
          type: integer
          format: int64
          description: |
            Millicredits consumed beyond spendable balance, recorded for
            arrears billing. Present only when overage was accrued
            (`allow`/`notify` policy). The credit balance never goes negative;
            overage is tracked separately and surfaced per-metric in the
            postpaid `subscription.renewed` webhook. On a `duplicate_replay`,
            this is the ORIGINAL debit's overage amount, exactly as recorded —
            never omitted just because the current request is a replay.
        subscription_status: { type: [string, 'null'] }
        overage_policy:
          type: string
          enum: [block, allow, notify]
          description: |
            The effective overage policy for this customer: the customer's own
            `overage_policy` when set, otherwise the tenant default.

    CheckResult:
      type: object
      description: |
        Type-discriminated entitlement check result.
        For metered: credit-balance fields present. For boolean: enabled field. For static: config field.
      required: [type, allowed, customer_id, billable_metric_key]
      properties:
        type:
          type: string
          enum: [metered, boolean, gauge, static]
        allowed: { type: boolean }
        customer_id: { type: string, format: uuid }
        billable_metric_key: { type: string }
        # metered fields (present only when type=metered)
        units: { type: integer, format: int64 }
        balance: { type: integer, format: int64 }
        reserved_balance: { type: integer, format: int64 }
        pending_balance: { type: integer, format: int64 }
        effective_balance: { type: integer, format: int64 }
        estimated_cost: { type: integer, format: int64 }
        balance_after:
          type: integer
          format: int64
          description: |
            Projection: effective_balance - estimated_cost. A negative value
            means the cost exceeds spendable balance — under allow/notify the
            excess would be recorded as overage (the stored balance never goes
            negative); under block the consume would be denied.
        subscription_status:
          type: [string, 'null']
          description: Status of the customer's active subscription; null if none.
        overage_policy:
          type: string
          enum: [block, allow, notify]
          description: |
            The effective overage policy for this customer: the customer's own
            `overage_policy` when set, otherwise the tenant default.
        # boolean fields (present only when type=boolean)
        enabled:
          type: boolean
          description: Whether the boolean feature is enabled for this customer.
        # static fields (present only when type=static)
        config:
          type: object
          additionalProperties: true
          description: Free-form config blob for this customer.
        # gauge fields (present only when type=gauge)
        current:
          type: integer
          format: int64
          description: Current gauge value for this customer.
        cap:
          type: integer
          format: int64
          description: Effective cap (from variant attachment or metric default).
        headroom:
          type: integer
          format: int64
          description: "Cap minus current. Can be negative if customer is over cap after a plan downgrade."

    EntitlementMap:
      type: object
      required: [customer_id, environment, balance, reserved_balance, effective_balance, entitlements, cached_at]
      properties:
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        balance: { type: integer, format: int64 }
        reserved_balance: { type: integer, format: int64 }
        effective_balance: { type: integer, format: int64 }
        subscription_status: { type: [string, 'null'] }
        plan_name: { type: [string, 'null'] }
        entitlements:
          type: object
          description: Map keyed by billable_metric_key.
          additionalProperties: { $ref: '#/components/schemas/FeatureEntitlement' }
        cached_at: { type: string, format: date-time }

    FeatureEntitlement:
      type: object
      description: |
        Type-discriminated feature entitlement in the snapshot.
        Metered entries include cost/unit fields. Boolean entries include enabled. Static entries include config.
      required: [type, billable_metric_key, allowed]
      properties:
        type:
          type: string
          enum: [metered, boolean, gauge, static]
        billable_metric_key: { type: string }
        allowed: { type: boolean }
        # metered fields (present only when type=metered)
        estimated_cost_per_unit: { type: integer, format: int64 }
        affordable_units: { type: integer, format: int64 }
        cost_type:
          type: string
          enum: [flat, per_unit, tiered]
        # boolean fields (present only when type=boolean)
        enabled:
          type: boolean
          description: Whether the boolean feature is enabled for this customer.
        # static fields (present only when type=static)
        config:
          type: object
          additionalProperties: true
          description: Free-form config blob for this customer.
        # gauge fields (present only when type=gauge)
        current:
          type: integer
          format: int64
          description: Current gauge value for this customer.
        cap:
          type: integer
          format: int64
          description: Effective cap (from variant attachment or metric default).
        headroom:
          type: integer
          format: int64
          description: "Cap minus current. Can be negative if customer is over cap."

    GaugeMutationRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: integer
          format: int64
          minimum: 1
          description: The amount to increment or decrement by.
        policy:
          type: string
          enum: [block, allow]
          default: block
          description: |
            `block` (default): reject the mutation if it would exceed the cap.
            `allow`: permit the overshoot; the response will show negative headroom.
        metadata:
          type: object
          additionalProperties: { type: string }
          description: Optional metadata to attach to this mutation.

    GaugeResult:
      type: object
      required: [billable_metric_key, current, cap, allowed, headroom]
      properties:
        billable_metric_key: { type: string }
        current:
          type: integer
          format: int64
          description: Gauge value after the mutation.
        cap:
          type: integer
          format: int64
          description: Effective cap at time of mutation.
        allowed:
          type: boolean
          description: "Whether current is strictly less than cap."
        headroom:
          type: integer
          format: int64
          description: "Cap minus current. Negative when over cap."

    GaugeCapExceededError:
      type: object
      required: [error, current, cap, shortfall]
      properties:
        error:
          type: string
          enum: [gauge_limit_reached]
        current:
          type: integer
          format: int64
          description: Current gauge value at the time of the rejected mutation.
        cap:
          type: integer
          format: int64
          description: Effective cap at the time of the rejected mutation.
        shortfall:
          type: integer
          format: int64
          description: "How much the requested increment exceeds the remaining headroom (current + amount - cap)."

    ########################################################################
    # Usage
    ########################################################################
    UsageEvent:
      type: object
      required: [billable_metric_key, units, idempotency_key]
      properties:
        customer_id:
          type: string
          description: Either customer_id OR external_customer_id is required.
        external_customer_id: { type: string }
        billable_metric_key: { type: string }
        units:
          type: integer
          format: int64
          minimum: 0
        idempotency_key: { type: string }
        occurred_at: { type: string, format: date-time }
        metadata:
          type: object
          additionalProperties: { type: string }

    UsageResponse:
      type: object
      required: [event_id, idempotency_key, status, estimated_cost, duplicate]
      properties:
        event_id: { type: string, format: uuid }
        idempotency_key: { type: string }
        status:
          type: string
          enum: [accepted, rejected]
        estimated_cost: { type: integer, format: int64 }
        duplicate: { type: boolean }
        index:
          type: integer
          description: Position in batch (batch responses only).
        error: { type: [string, 'null'] }
        error_code:
          type: string
          description: |
            Stable machine-readable code on rejection, e.g. `cross_tenant_customer`.
            Absent on accepted events.

    BatchUsageRequest:
      type: object
      required: [events]
      properties:
        events:
          type: array
          maxItems: 500
          items: { $ref: '#/components/schemas/UsageEvent' }

    BatchUsageResponse:
      type: object
      required: [accepted, rejected, results]
      properties:
        accepted: { type: integer }
        rejected: { type: integer }
        results:
          type: array
          items: { $ref: '#/components/schemas/UsageResponse' }

    ########################################################################
    # Subscriptions
    ########################################################################
    Subscription:
      type: object
      required: [id, tenant_id, customer_id, plan_variant_id, environment, status, started_at, cancel_at_period_end, contract_ending_soon_days, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        plan_variant_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        status:
          type: string
          enum: [trialing, active, cancelling, canceled, expired, paused, overdue, contract_ended]
        origin:
          type: string
          enum: [api, import]
          description: How the subscription was created — `api` (normal create) or `import` (legacy cutover).
          default: api
        started_at: { type: string, format: date-time }
        trial_ends_at: { type: [string, 'null'], format: date-time }
        current_period_start: { type: [string, 'null'], format: date-time }
        current_period_end: { type: [string, 'null'], format: date-time }
        billing_anchor:
          type: [integer, 'null']
          description: Day of month (1-28) for monthly renewals.
        cancel_at_period_end: { type: boolean }
        scheduled_variant_id: { type: [string, 'null'], format: uuid }
        external_subscription_id: { type: [string, 'null'] }
        paused_at: { type: [string, 'null'], format: date-time }
        resumed_at: { type: [string, 'null'], format: date-time }
        expired_at: { type: [string, 'null'], format: date-time }
        contract_start: { type: [string, 'null'], format: date-time }
        contract_end: { type: [string, 'null'], format: date-time }
        contract_ending_soon_days: { type: integer }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateSubscriptionRequest:
      type: object
      required: [plan_variant_id]
      properties:
        customer_id: { type: string }
        external_customer_id: { type: string }
        plan_variant_id: { type: string, format: uuid }
        billing_anchor: { type: [integer, 'null'] }
        external_subscription_id: { type: [string, 'null'] }
        contract_end: { type: [string, 'null'], format: date-time }
        contract_ending_soon_days: { type: [integer, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    ########################################################################
    # Imports
    ########################################################################
    ImportCustomerRequest:
      type: object
      required: [customer]
      properties:
        customer:
          $ref: '#/components/schemas/ImportCustomerSpec'
        subscription:
          $ref: '#/components/schemas/ImportSubscriptionBody'
        credit_blocks:
          type: array
          items: { $ref: '#/components/schemas/ImportCreditBlock' }
          default: []
        entitlement_overrides:
          type: array
          items: { $ref: '#/components/schemas/ImportEntitlementOverride' }
        gauge_states:
          type: array
          items: { $ref: '#/components/schemas/ImportGaugeState' }
        options:
          $ref: '#/components/schemas/ImportOptions'

    ImportCustomerSpec:
      type: object
      required: [external_id]
      properties:
        external_id: { type: string }
        display_name: { type: [string, 'null'] }
        overage_policy:
          type: [string, 'null']
          enum: [block, allow, notify, null]
        metadata:
          type: object
          additionalProperties: { type: string }

    ImportSubscriptionBody:
      type: object
      required: [plan_variant_id, current_period_start, current_period_end]
      properties:
        plan_variant_id: { type: string, format: uuid }
        status:
          type: string
          enum: [active, trialing, paused, cancelling]
          default: active
        started_at: { type: [string, 'null'], format: date-time }
        current_period_start: { type: string, format: date-time }
        current_period_end: { type: string, format: date-time }
        trial_ends_at: { type: [string, 'null'], format: date-time }
        cancel_at_period_end: { type: boolean, default: false }
        billing_anchor: { type: [integer, 'null'], minimum: 1, maximum: 31 }
        external_subscription_id: { type: [string, 'null'] }
        contract_start: { type: [string, 'null'], format: date-time }
        contract_end: { type: [string, 'null'], format: date-time }
        contract_ending_soon_days: { type: [integer, 'null'] }
        grant_policy:
          type: string
          enum: [none, seed_only, plan_activation]
          default: none
          description: |
            `none` / `seed_only` — do not issue plan activation grants (default).
            `plan_activation` — issue normal on_activation grants (rare for migration).
            `plan_prorated` is not available in Phase 1.
        metadata:
          type: object
          additionalProperties: { type: string }

    ImportCreditBlock:
      type: object
      required: [credits, source, reason]
      properties:
        credits:
          type: integer
          format: int64
          minimum: 1
          description: Millicredits.
        source:
          type: string
          enum: [migration, manual, promotional, compensation, referral]
        reason: { type: string }
        expires_at: { type: [string, 'null'], format: date-time }
        effective_at: { type: [string, 'null'], format: date-time }
        priority: { type: integer, minimum: 0, maximum: 255, default: 0 }
        metadata:
          type: object
          additionalProperties: { type: string }
        idempotency_key: { type: string }

    ImportEntitlementOverride:
      type: object
      required: [billable_metric_key, value]
      properties:
        billable_metric_key: { type: string }
        value:
          type: object
          additionalProperties: true
        expires_at: { type: [string, 'null'], format: date-time }
        reason: { type: string }

    ImportGaugeState:
      type: object
      required: [billable_metric_key, current]
      properties:
        billable_metric_key: { type: string }
        current:
          type: integer
          format: int64
          minimum: 0

    ImportOptions:
      type: object
      properties:
        emit_webhooks:
          type: boolean
          default: false
          description: When true, emit customer.imported / subscription.imported (not subscription.created).
        dry_run:
          type: boolean
          default: false
          description: Validate and project response without writing.

    ImportCustomerResponse:
      type: object
      required: [customer, credit_blocks, credits_granted_total, dry_run]
      properties:
        customer: { $ref: '#/components/schemas/Customer' }
        subscription:
          anyOf:
            - $ref: '#/components/schemas/Subscription'
            - type: 'null'
        credit_blocks:
          type: array
          items: { $ref: '#/components/schemas/CreditBlock' }
        credits_granted_total:
          type: integer
          format: int64
        entitlement_overrides:
          type: array
          items: { $ref: '#/components/schemas/SubscriptionEntitlementOverride' }
        gauge_states:
          type: array
          items: { $ref: '#/components/schemas/ImportGaugeStateResult' }
        dry_run: { type: boolean }
        idempotent_replay: { type: boolean, default: false }

    ImportGaugeStateResult:
      type: object
      required: [billable_metric_key, current]
      properties:
        billable_metric_key: { type: string }
        current: { type: integer, format: int64 }

    UpgradeSubscriptionRequest:
      type: object
      required: [plan_variant_id]
      properties:
        plan_variant_id: { type: string, format: uuid }
        metadata:
          type: object
          additionalProperties: { type: string }

    DowngradeSubscriptionRequest:
      type: object
      required: [plan_variant_id]
      properties:
        plan_variant_id: { type: string, format: uuid }
        metadata:
          type: object
          additionalProperties: { type: string }

    CancelSubscriptionRequest:
      type: object
      properties:
        cancel_immediately:
          type: boolean
          default: false
          description: True = hard cancel now. False = schedule cancel at period_end.
        reason: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }

    PauseSubscriptionRequest:
      type: object
      properties:
        resume_at: { type: [string, 'null'], format: date-time }
        metadata:
          type: object
          additionalProperties: { type: string }

    ResumeSubscriptionRequest:
      type: object
      properties:
        metadata:
          type: object
          additionalProperties: { type: string }

    RenewSubscriptionRequest:
      type: object
      properties:
        external_payment_id: { type: [string, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    ExtendSubscriptionRequest:
      type: object
      required: [contract_end]
      properties:
        contract_end: { type: string, format: date-time }
        reason: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }

    ########################################################################
    # Overrides
    ########################################################################
    SubscriptionEntitlementOverride:
      type: object
      required: [id, subscription_id, billable_metric_id, value, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        subscription_id: { type: string, format: uuid }
        billable_metric_id: { type: string, format: uuid }
        billable_metric_key: { type: string }
        value:
          type: object
          description: |
            Type-shaped value matching the billable metric type:
            boolean: `{"enabled": true}`, gauge: `{"cap": 250}`,
            static: `{"config": {...}}`.
          additionalProperties: true
        expires_at: { type: [string, 'null'], format: date-time }
        reason: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateOverrideRequest:
      type: object
      required: [billable_metric_key, value]
      properties:
        billable_metric_key:
          type: string
          description: Key of the billable metric to override.
        value:
          type: object
          description: Override value matching the metric type shape.
          additionalProperties: true
        expires_at:
          type: [string, 'null']
          format: date-time
          description: When the override expires. Null means permanent.
        reason:
          type: string
          description: Human-readable reason for the override.

    UpdateOverrideRequest:
      type: object
      properties:
        value:
          type: object
          description: New override value matching the metric type shape.
          additionalProperties: true
        expires_at:
          type: [string, 'null']
          format: date-time
          description: New expiry. Null removes expiry (makes permanent).
        reason:
          type: [string, 'null']
          description: Updated reason text.

    ########################################################################
    # Reservations
    ########################################################################
    Reservation:
      type: object
      required: [id, tenant_id, customer_id, environment, billable_metric_key, estimated_units, estimated_cost, status, reference_id, expires_at, metadata, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        billable_metric_key: { type: string }
        estimated_units: { type: integer, format: int64 }
        estimated_cost:
          type: integer
          format: int64
          description: Millicredits held against the balance.
        actual_units: { type: [integer, 'null'], format: int64 }
        actual_cost: { type: [integer, 'null'], format: int64 }
        status:
          type: string
          enum: [active, committed, released, expired]
        reference_id:
          type: string
          format: uuid
          description: Shared by the reservation ledger entries.
        expires_at: { type: string, format: date-time }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        committed_at: { type: [string, 'null'], format: date-time }
        released_at: { type: [string, 'null'], format: date-time }

    ReserveRequest:
      type: object
      required: [billable_metric_key, estimated_units]
      properties:
        customer_id: { type: string }
        external_customer_id: { type: string }
        billable_metric_key: { type: string }
        estimated_units:
          type: integer
          format: int64
          minimum: 1
        ttl_seconds:
          type: [integer, 'null']
          minimum: 1
          maximum: 86400
          default: 1800
          description: How long the hold stays active before auto-expiry. Default 30 min.
        metadata:
          type: object
          additionalProperties: { type: string }

    CommitReservationRequest:
      type: object
      required: [actual_units]
      properties:
        actual_units:
          type: integer
          format: int64
          minimum: 0
        metadata:
          type: object
          additionalProperties: { type: string }

    ########################################################################
    # Plans + Variants + Grants
    ########################################################################
    Plan:
      type: object
      required: [id, tenant_id, name, is_default, status, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        is_default: { type: boolean }
        status: { type: string, enum: [active, retired] }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PlanWithVariants:
      allOf:
        - $ref: '#/components/schemas/Plan'
        - type: object
          required: [variants]
          properties:
            variants:
              type: array
              items: { $ref: '#/components/schemas/VariantWithGrants' }

    VariantWithGrants:
      allOf:
        - $ref: '#/components/schemas/PlanVariant'
        - type: object
          properties:
            grants:
              type: array
              items: { $ref: '#/components/schemas/CreditGrant' }

    CreatePlanRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }
        is_default: { type: boolean, default: false }
        metadata:
          type: object
          additionalProperties: { type: string }

    UpdatePlanRequest:
      type: object
      properties:
        name: { type: [string, 'null'] }
        status: { type: [string, 'null'], enum: [active, retired, null] }
        is_default: { type: [boolean, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    PlanVariant:
      type: object
      required: [id, plan_id, tenant_id, billing_cycle, trial_days, status, billing_mode, renewal_due_days, grace_period_days, allow_usage_while_overdue, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        plan_id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        billing_cycle:
          type: string
          enum: [daily, weekly, monthly, yearly, one_time]
        trial_days: { type: integer, minimum: 0 }
        status: { type: string, enum: [active, retired] }
        billing_mode: { type: string, enum: [prepaid, postpaid] }
        renewal_due_days: { type: integer }
        grace_period_days: { type: integer }
        allow_usage_while_overdue: { type: boolean }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreatePlanVariantRequest:
      type: object
      required: [billing_cycle, trial_days]
      properties:
        billing_cycle:
          type: string
          enum: [daily, weekly, monthly, yearly, one_time]
        trial_days: { type: integer, minimum: 0 }
        billing_mode:
          type: string
          enum: [prepaid, postpaid]
          default: prepaid
        renewal_due_days: { type: [integer, 'null'] }
        grace_period_days: { type: [integer, 'null'] }
        allow_usage_while_overdue: { type: [boolean, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    UpdatePlanVariantRequest:
      type: object
      properties:
        status: { type: [string, 'null'], enum: [active, retired, null] }
        trial_days: { type: [integer, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    CreditGrant:
      type: object
      required: [id, plan_variant_id, tenant_id, credits, grant_interval, grant_type, source, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        plan_variant_id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        credits:
          type: integer
          format: int64
          description: Millicredits per interval.
        grant_interval:
          type: string
          description: |
            Built-in keywords (daily | weekly | monthly | billing_cycle | on_activation)
            or an ISO 8601 duration (PT5H, PT30M, P3D) for sub-daily / custom cadences.
        expires_after_seconds: { type: [integer, 'null'] }
        accumulation_cap: { type: [integer, 'null'], format: int64 }
        rollover_percentage:
          type: [integer, 'null']
          minimum: 0
          maximum: 100
        max_rollover_cycles: { type: [integer, 'null'] }
        grant_type:
          type: string
          enum: [recurring, one_time, trial]
        source:
          type: string
          enum: [plan_grant, promotional, compensation, referral, manual, migration]
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateCreditGrantRequest:
      type: object
      required: [credits, grant_interval, grant_type]
      properties:
        credits: { type: integer, format: int64, minimum: 1 }
        grant_interval: { type: string }
        expires_after_seconds: { type: [integer, 'null'] }
        accumulation_cap: { type: [integer, 'null'], format: int64 }
        rollover_percentage: { type: [integer, 'null'], minimum: 0, maximum: 100 }
        max_rollover_cycles: { type: [integer, 'null'] }
        grant_type:
          type: string
          enum: [recurring, one_time, trial]
        source:
          type: string
          enum: [plan_grant, promotional, compensation, referral, manual, migration]
          default: plan_grant

    ########################################################################
    # MeteringRules + BillableMetrics
    ########################################################################
    MeteringRule:
      type: object
      required: [id, tenant_id, billable_metric_key, cost_type, effective_from, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        billable_metric_key: { type: string }
        cost_type:
          type: string
          enum: [flat, per_unit, tiered]
        base_cost:
          type: [integer, 'null']
          format: int64
          description: >-
            Flat millicredit component per operation. Required for `flat`
            cost_type. Defaults to 0 for `per_unit` and `tiered` when omitted.
        unit_cost:
          type: [integer, 'null']
          format: int64
          description: Millicredits per unit. Required and must be > 0 for `per_unit` cost_type.
        tier_config:
          anyOf:
            - $ref: '#/components/schemas/TierConfig'
            - type: 'null'
        effective_from: { type: string, format: date-time }
        effective_until: { type: [string, 'null'], format: date-time }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateMeteringRuleRequest:
      type: object
      required: [billable_metric_key, cost_type]
      properties:
        billable_metric_key: { type: string }
        cost_type:
          type: string
          enum: [flat, per_unit, tiered]
        base_cost:
          type: [integer, 'null']
          format: int64
          default: 0
          description: >-
            Flat millicredit component per operation. Required for `flat`
            cost_type. Defaults to 0 for `per_unit` and `tiered` when omitted.
        unit_cost:
          type: [integer, 'null']
          format: int64
          description: Millicredits per unit. Required and must be > 0 for `per_unit` cost_type.
        tier_config:
          anyOf:
            - $ref: '#/components/schemas/TierConfig'
            - type: 'null'
        metadata:
          type: object
          additionalProperties: { type: string }

    TierConfig:
      type: object
      required: [mode, tiers]
      properties:
        mode:
          type: string
          enum: [graduated, volume]
        tiers:
          type: array
          items: { $ref: '#/components/schemas/Tier' }

    Tier:
      type: object
      required: [unit_cost, flat_cost]
      properties:
        up_to:
          type: [integer, 'null']
          format: int64
          description: Upper bound for this tier (inclusive). Null = infinity (last tier).
        unit_cost:
          type: integer
          format: int64
          description: Millicredits per unit consumed within this tier.
        flat_cost:
          type: integer
          format: int64
          description: Fixed charge for entering this tier (in addition to unit_cost × units).

    BillableMetric:
      type: object
      required: [id, tenant_id, key, name, description, status, type, default_value, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        key:
          type: string
          description: Stable identifier referenced by metering rules and usage events.
        name: { type: string }
        description: { type: string }
        status: { type: string, enum: [active, inactive] }
        type:
          type: string
          enum: [metered, boolean, gauge, static]
          default: metered
          description: |
            Entitlement type. `metered` uses credit balance (existing behaviour);
            `boolean` is on/off; `gauge` is count-with-cap; `static` is a JSON config blob.
        default_value:
          type: object
          additionalProperties: true
          description: |
            Type-shaped default. `metered` ignores this field. `boolean` accepts
            `{"enabled": true|false}`. `gauge` accepts `{"cap": <integer>}`.
            `static` accepts arbitrary `{"config": ...}`.
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateBillableMetricRequest:
      type: object
      required: [key, name]
      properties:
        key:
          type: string
          description: Tenant-scoped unique identifier (e.g., 'sso', 'max_team_members').
        name: { type: string }
        description: { type: string }
        type:
          type: string
          enum: [metered, boolean, gauge, static]
          default: metered
        default_value:
          type: object
          additionalProperties: true
        metadata:
          type: object
          additionalProperties: { type: string }

    UpdateBillableMetricRequest:
      type: object
      description: |
        Partial update. Only include fields you want to change. `type` is immutable
        after creation — sending a different value returns 422.
      properties:
        name: { type: string }
        description: { type: string }
        status: { type: string, enum: [active, inactive] }
        type:
          type: string
          enum: [metered, boolean, gauge, static]
          description: Must match the metric's current type (sending the same value is a no-op).
        default_value:
          type: object
          additionalProperties: true
        metadata:
          type: object
          additionalProperties: { type: string }

    PlanVariantEntitlement:
      type: object
      required: [id, tenant_id, plan_variant_id, billable_metric_id, value, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        plan_variant_id: { type: string, format: uuid }
        billable_metric_id: { type: string, format: uuid }
        billable_metric_key:
          type: string
          description: Convenience denormalization (read-only).
        value:
          type: object
          additionalProperties: true
          description: Type-shaped per the linked billable_metric's type.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreatePlanVariantEntitlementRequest:
      type: object
      required: [billable_metric_key, value]
      properties:
        billable_metric_key: { type: string }
        value:
          type: object
          additionalProperties: true

    UpdatePlanVariantEntitlementRequest:
      type: object
      required: [value]
      properties:
        value:
          type: object
          additionalProperties: true

    ########################################################################
    # Topups
    ########################################################################
    TopupPackage:
      type: object
      required: [id, tenant_id, name, credits, is_active, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        credits:
          type: integer
          format: int64
          description: Millicredits granted when this package is purchased.
        is_active: { type: boolean }
        expires_after_seconds: { type: [integer, 'null'], format: int64 }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateTopupPackageRequest:
      type: object
      required: [name, credits]
      properties:
        name: { type: string }
        credits: { type: integer, format: int64, minimum: 1 }
        metadata:
          type: object
          additionalProperties: { type: string }

    UpdateTopupPackageRequest:
      type: object
      properties:
        name: { type: [string, 'null'] }
        is_active: { type: [boolean, 'null'] }
        metadata:
          type: object
          additionalProperties: { type: string }

    Topup:
      type: object
      required: [id, tenant_id, customer_id, environment, credits_granted, price_paid, currency, status, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        package_id: { type: [string, 'null'], format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        credits_granted: { type: integer, format: int64 }
        price_paid:
          type: integer
          format: int64
          description: Tenant-denominated price (minor unit — e.g., cents). Not credits.
        currency:
          type: string
          description: ISO 4217 code (USD, EUR, INR, ...).
        external_payment_id: { type: [string, 'null'] }
        expires_at: { type: [string, 'null'], format: date-time }
        status: { type: string, enum: [completed, refunded] }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TopupGrantRequest:
      type: object
      required: [credits, price_paid, currency]
      properties:
        customer_id: { type: string }
        external_customer_id: { type: string }
        package_id: { type: [string, 'null'], format: uuid }
        credits: { type: integer, format: int64, minimum: 1 }
        price_paid: { type: integer, format: int64, minimum: 0 }
        currency: { type: string }
        external_payment_id: { type: [string, 'null'] }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: >-
            Mutually exclusive with duration_seconds. Use one or the other
            to control block lifetime.
        duration_seconds:
          type: [integer, 'null']
          format: int64
          minimum: 1
          maximum: 315360000
          description: >-
            Block lifetime in seconds (max 315360000 ≈ 10 years). Mutually
            exclusive with expires_at. Without stack_after the server computes
            expires_at = now + duration_seconds. With stack_after it computes
            expires_at = effective_at + duration_seconds (required in that case).
        stack_after:
          $ref: '#/components/schemas/StackAfter'
        priority:
          type: [integer, 'null']
          minimum: 0
          maximum: 255
          description: Burn-order (default 0 = topup before plan credits).
        metadata:
          type: object
          additionalProperties: { type: string }

    ########################################################################
    # AuditLog
    ########################################################################
    AuditLogEntry:
      type: object
      required: [id, tenant_id, environment, actor_id, actor_type, action, entity_type, entity_id, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        actor_id:
          type: string
          description: API key id, system job name, or admin user id (per actor_type).
        actor_type: { type: string, enum: [api, system, admin] }
        action:
          type: string
          description: >
            Verb only — pair with `entity_type` to identify the mutation.
            e.g. `created`, `granted`, `adjusted`, `canceled`, `imported`
            (with `entity_type` `customer`, `subscription`,
            `credit_transaction`, …). Not the dotted webhook event-type
            format.
        entity_type: { type: string }
        entity_id: { type: string, format: uuid }
        before_state:
          type: [object, 'null']
          description: Snapshot before the mutation. Null for creates.
        after_state:
          type: [object, 'null']
          description: Snapshot after the mutation.
        created_at: { type: string, format: date-time }

    ########################################################################
    # Webhooks (delivery log)
    ########################################################################
    WebhookEventWithDeliveries:
      allOf:
        - $ref: '#/components/schemas/WebhookEvent'
        - type: object
          required: [deliveries]
          properties:
            deliveries:
              type: array
              items: { $ref: '#/components/schemas/WebhookDelivery' }

    WebhookEvent:
      type: object
      required: [id, tenant_id, environment, event_type, payload, status, attempts, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        event_type:
          type: string
          description: e.g. `credit.granted`, `subscription.renewal_due`.
        payload:
          type: object
          description: Event body we POSTed (or would have POSTed).
        status: { type: string, enum: [pending, delivered, failed, dead_letter] }
        attempts: { type: integer }
        next_retry_at: { type: [string, 'null'], format: date-time }
        last_error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }

    WebhookDelivery:
      type: object
      required: [id, tenant_id, webhook_event_id, duration_ms, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        webhook_event_id: { type: string, format: uuid }
        status_code: { type: [integer, 'null'] }
        response_body: { type: [string, 'null'] }
        error: { type: [string, 'null'] }
        duration_ms: { type: integer }
        created_at: { type: string, format: date-time }

    ########################################################################
    # Stats
    ########################################################################
    DashboardStats:
      type: object
      required: [customer_count, total_balance, active_subscriptions, credits_consumed_today, credits_consumed_week, credits_granted_week]
      properties:
        customer_count: { type: integer, format: int64 }
        total_balance:
          type: integer
          format: int64
          description: Sum of all active credit_accounts.balance in millicredits.
        active_subscriptions: { type: integer, format: int64 }
        credits_consumed_today:
          type: integer
          format: int64
          description: Millicredits consumed since UTC midnight.
        credits_consumed_week:
          type: integer
          format: int64
          description: Millicredits consumed in rolling 7d window.
        credits_granted_week:
          type: integer
          format: int64

    ########################################################################
    # Outbound webhook envelope
    ########################################################################
    WebhookPayload:
      type: object
      description: |
        Envelope QuotaStack POSTs to your configured webhook URL.

        ## Signature verification

        Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com/)
        specification. Three headers are sent with each request:

        | Header | Description |
        |---|---|
        | `webhook-id` | Unique ID for this delivery. Also usable for dedup. |
        | `webhook-timestamp` | Unix timestamp (seconds) when the event was signed. |
        | `webhook-signature` | `v1,<signature>` — see below. |

        The signature is computed as `base64(HMAC-SHA256(secret, signedContent))`
        — **base64, not hex** — where:

            signedContent = "{webhook-id}.{webhook-timestamp}.{raw-request-body}"

        `secret` is your `webhook_secret`, base64-decoded before use as the HMAC
        key. Concatenation uses literal `.` separators between the three parts,
        and `{raw-request-body}` is the exact bytes received — verify against the
        raw body, not a re-serialized/parsed version. The header value is the
        base64 digest prefixed with `v1,`.

        ### Worked example

        Given:
        - `webhook_secret` (base64): `d2hzZWNfZXhhbXBsZV9zaWduaW5nX2tleQ==`
        - `webhook-id`: `evt_2q9vD4pM8xZk3nQeT7wYbR`
        - `webhook-timestamp`: `1735689600`
        - raw body: `{"event_id":"evt_2q9vD4pM8xZk3nQeT7wYbR","event_type":"credit.granted","data":{"credits":5000}}`

        the expected `webhook-signature` header is:

            v1,r/3zdnogTqoRLD4vB8+9MxoKXhIjKcauQjSeH+gGRzQ=

        **Go:**
        ```go
        func verify(secretB64, id, timestamp string, body []byte, header string) bool {
          secret, err := base64.StdEncoding.DecodeString(secretB64)
          if err != nil {
            return false
          }
          mac := hmac.New(sha256.New, secret)
          mac.Write([]byte(id + "." + timestamp + "." + string(body)))
          expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
          return hmac.Equal([]byte(expected), []byte(header))
        }
        ```

        **Node:**
        ```javascript
        const crypto = require('crypto');

        function verify(secretB64, id, timestamp, body, header) {
          const secret = Buffer.from(secretB64, 'base64');
          const signedContent = `${id}.${timestamp}.${body}`;
          const expected = 'v1,' + crypto.createHmac('sha256', secret).update(signedContent).digest('base64');
          // timingSafeEqual THROWS on unequal-length buffers, which a malformed
          // or absent header triggers — guard the length before comparing.
          const a = Buffer.from(expected), b = Buffer.from(header ?? '');
          if (a.length !== b.length) return false;
          return crypto.timingSafeEqual(a, b);
        }
        ```

        ### Timestamp tolerance

        Reject deliveries where `|now - webhook-timestamp| > 300` seconds
        (5 minutes) to guard against replay. Compare using the
        `webhook-timestamp` header — not any timestamp embedded in the body.

        ### Libraries

        Any [Standard Webhooks](https://www.standardwebhooks.com/) client
        library verifies this scheme directly (Go, Node, Python, Ruby, and
        more) — see https://www.standardwebhooks.com/ for the library list.
        You don't need to hand-roll verification.
      required: [event_id, event_type, tenant_id, environment, created_at, idempotency_key, data]
      properties:
        event_id: { type: string, format: uuid }
        event_type: { type: string }
        tenant_id: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/Environment' }
        customer_id: { type: string, format: uuid }
        external_customer_id: { type: string }
        created_at: { type: string, format: date-time }
        idempotency_key:
          type: string
          description: Stable across retries; dedupe on your side using this.
        data:
          description: Event-specific payload. See the `webhooks:` section for per-event shapes.

    ########################################################################
    # Shared enums / primitives
    ########################################################################
    Environment:
      type: string
      enum: [live, sandbox]
      description: |
        Which environment this resource lives in. Determined by the API key
        prefix used (`qs_live_…` → live, `qs_test_…` → sandbox).

  ########################################################################
  # Outbound webhooks — QuotaStack POSTs these to your configured URL
  # (OpenAPI 3.1 webhooks: section, separate from paths:)
  #
  # All events share the WebhookPayload envelope with event-specific
  # `data` shapes. For signing/verification (Standard Webhooks HMAC-SHA256,
  # `webhook-id`/`webhook-timestamp`/`webhook-signature` headers, worked
  # example), see WebhookPayload's description above — do not duplicate it
  # here, it drifts.
  # Your endpoint should respond 2xx within 10 seconds; non-2xx or
  # timeouts trigger retries with exponential backoff, ending in the
  # dead_letter queue after 24h.
  ########################################################################
webhooks:
  creditGranted:
    post:
      summary: Fired when credits are granted to a customer (any source).
      operationId: webhookCreditGranted
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.granted }
                    data: { $ref: '#/components/schemas/CreditLedgerEntry' }
      responses:
        '2XX': { description: Acknowledge receipt. Body ignored. }

  creditConsumed:
    post:
      summary: Fired when credits are consumed (debit ledger entries committed).
      operationId: webhookCreditConsumed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.consumed }
                    data:
                      type: array
                      items: { $ref: '#/components/schemas/CreditLedgerEntry' }
      responses:
        '2XX': { description: Ack. }

  creditLowBalance:
    post:
      summary: >
        Fired once when a customer's effective balance (balance − reserved)
        crosses below the configured low_balance threshold while remaining > 0.
        Re-arms on grant / reservation release when effective recovers to or above threshold.
        Default tenant threshold is 0 (off). Exhausted cliff (high → ≤0) does not emit this event.
      operationId: webhookCreditLowBalance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.low_balance }
                    data:
                      type: object
                      required: [before_effective_balance, after_effective_balance, threshold_mc, trigger]
                      properties:
                        before_effective_balance: { type: integer, format: int64 }
                        after_effective_balance: { type: integer, format: int64 }
                        threshold_mc: { type: integer, format: int64, description: Resolved threshold (tenant default or customer override), millicredits. }
                        trigger:
                          type: string
                          description: What mutation caused the cross (e.g. consumption, reservation_create, expiry).
      responses:
        '2XX': { description: Ack. }

  creditExhausted:
    post:
      summary: >
        Fired once when a customer's effective balance crosses from > 0 to ≤ 0
        (consumption, reservation create/commit, or expiry). Always enabled.
        Effective may be negative when reserved holds remain after balance drops;
        that still counts as exhausted. Re-arms when effective recovers above 0.
        A high→≤0 one-shot emits exhausted only (not also low_balance).
      operationId: webhookCreditExhausted
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.exhausted }
                    data:
                      type: object
                      required: [before_effective_balance, after_effective_balance, threshold_mc, trigger]
                      properties:
                        before_effective_balance: { type: integer, format: int64 }
                        after_effective_balance: { type: integer, format: int64 }
                        threshold_mc: { type: integer, format: int64, description: Resolved low_balance threshold for context (not used for exhausted itself). }
                        trigger:
                          type: string
                          description: What mutation caused the cross (e.g. consumption, reservation_create, expiry).
      responses:
        '2XX': { description: Ack. }

  creditExpiringSoon:
    post:
      summary: >
        Fired once per credit block when that block's remaining balance will expire
        within the tenant's `credit_expiring_soon_hours` lead window (default 0 = off;
        recommended 72). Distinct from `credit.expired` (fires when the block actually
        expires) and from post-expiry `credit.exhausted` / `credit.low_balance`.
        Debounced per (customer, block_id); emission is best-effort.
      operationId: webhookCreditExpiringSoon
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.expiring_soon }
                    data:
                      type: object
                      required:
                        - block_id
                        - expiring_amount
                        - expires_at
                        - effective_balance
                        - spendable_after_expiry
                      properties:
                        block_id: { type: string, format: uuid }
                        expiring_amount:
                          type: integer
                          format: int64
                          description: Remaining millicredits on this block that will expire.
                        expires_at: { type: string, format: date-time }
                        hours_remaining:
                          type: integer
                          description: Approximate whole hours until expires_at (ceil).
                        lead_time_hours:
                          type: integer
                          description: Tenant-configured lead window that matched this block.
                        effective_balance:
                          type: integer
                          format: int64
                          description: Customer effective balance (balance − reserved) at emit time.
                        spendable_after_expiry:
                          type: integer
                          format: int64
                          description: >
                            max(0, effective_balance − expiring_amount) for this block.
                            Multi-block same-window expiry may understate aggregate spendable.
      responses:
        '2XX': { description: Ack. }

  testPing:
    post:
      summary: >
        Manually triggered test event (POST /v1/webhooks/test). Signed and
        delivered like every real event. `data.message` says what it is;
        `data.requested_at` is when the test was requested.
      operationId: webhookTestPing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: test.ping }
                    data:
                      type: object
                      required: [message, requested_at]
                      properties:
                        message: { type: string }
                        requested_at: { type: string, format: date-time }
      responses:
        '2XX': { description: Ack. }

  creditExpired:
    post:
      summary: >
        Fired when a credit block's remaining balance expires (the block
        reached its expires_at). `data` is the expiry ledger entry; its
        amount is the millicredits written off. See `credit.expiring_soon`
        for the proactive lead-time warning.
      operationId: webhookCreditExpired
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: credit.expired }
                    data: { $ref: '#/components/schemas/CreditLedgerEntry' }
      responses:
        '2XX': { description: Ack. }

  subscriptionRenewalDue:
    post:
      summary: Fired N days before a prepaid subscription's period_end (default 3).
      operationId: webhookSubscriptionRenewalDue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.renewal_due }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionRenewed:
    post:
      summary: >-
        Fired when a subscription advances into a new billing period — prepaid
        renewal, postpaid auto-advance, scheduled downgrade, or the
        tenant-called POST /renew. Unlike the other subscription events, `data`
        is NOT a Subscription object: it is a unified renewal summary carrying
        the just-ended period's usage/overage rollup.
      operationId: webhookSubscriptionRenewed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.renewed }
                    data:
                      type: object
                      description: >-
                        Unified renewal payload emitted by every path that rolls a
                        subscription into a new period (built by
                        subscription.BuildRenewedPayload). `usage_summary` is
                        omitted entirely if the prior-period usage read fails —
                        a partial payload rather than a dropped webhook.
                      required: [subscription_id, customer_id, billing_mode, prior_period, new_period]
                      properties:
                        subscription_id: { type: string, format: uuid }
                        customer_id: { type: string, format: uuid }
                        billing_mode:
                          type: string
                          enum: [prepaid, postpaid]
                        prior_period:
                          type: object
                          description: The just-ended billing period [start, end); usage/overage below are aggregated over this window.
                          required: [start, end]
                          properties:
                            start: { type: string, format: date-time }
                            end: { type: string, format: date-time }
                        new_period:
                          type: object
                          description: The newly-opened billing period [start, end).
                          required: [start, end]
                          properties:
                            start: { type: string, format: date-time }
                            end: { type: string, format: date-time }
                        usage_summary:
                          type: object
                          description: Prior-period usage/overage rollup. Omitted if the usage read failed.
                          required: [total_credits_consumed, net_balance_at_cycle_start, net_balance_at_cycle_end, by_billable_metric]
                          properties:
                            total_credits_consumed:
                              type: integer
                              format: int64
                              description: Millicredits consumed across the prior period (1 credit = 1000 mc).
                            net_balance_at_cycle_start:
                              type: integer
                              format: int64
                              description: Account balance (mc) captured before the new cycle's grants were issued.
                            net_balance_at_cycle_end:
                              type: integer
                              format: int64
                              description: Account balance (mc) captured after the new cycle's grants were issued.
                            by_billable_metric:
                              type: object
                              description: Per-metric credits consumed (mc), keyed by billable_metric_key.
                              additionalProperties: { type: integer, format: int64 }
                            total_overage:
                              type: integer
                              format: int64
                              description: >-
                                Millicredits recorded as overage in the prior period
                                (consumption beyond spendable balance under
                                allow/notify). Present only when the overage read
                                succeeds.
                            overage_by_billable_metric:
                              type: object
                              description: Per-metric overage (mc), keyed by billable_metric_key. Present only when the overage read succeeds.
                              additionalProperties: { type: integer, format: int64 }
      responses:
        '2XX': { description: Ack. }

  subscriptionRenewalOverdue:
    post:
      summary: Fired when a prepaid subscription's period_end passed without renewal.
      operationId: webhookSubscriptionRenewalOverdue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.renewal_overdue }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionExpired:
    post:
      summary: Fired when an overdue subscription passes its grace period.
      operationId: webhookSubscriptionExpired
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.expired }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionContractEndingSoon:
    post:
      summary: Fired N days before a postpaid contract_end (per subscription.contract_ending_soon_days).
      operationId: webhookSubscriptionContractEndingSoon
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.contract_ending_soon }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionContractEnded:
    post:
      summary: Fired when a postpaid contract_end is reached.
      operationId: webhookSubscriptionContractEnded
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.contract_ended }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionCreated:
    post:
      summary: >
        Fired when a subscription is created via POST /v1/subscriptions
        (including trials). Not fired for imported subscriptions — those
        emit subscription.imported instead.
      operationId: webhookSubscriptionCreated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.created }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionUpgraded:
    post:
      summary: >
        Fired when POST /v1/subscriptions/{id}/upgrade switches the plan
        variant (effective immediately). `data` is the post-upgrade
        subscription.
      operationId: webhookSubscriptionUpgraded
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.upgraded }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionDowngraded:
    post:
      summary: >
        Fired when a downgrade is requested via POST
        /v1/subscriptions/{id}/downgrade. The variant change applies at the
        next renewal; `data` is the subscription with the pending change.
      operationId: webhookSubscriptionDowngraded
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.downgraded }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionCanceled:
    post:
      summary: >
        Fired when POST /v1/subscriptions/{id}/cancel cancels a subscription
        — immediately or at period end (inspect `data.status` and
        `data.cancel_at_period_end`).
      operationId: webhookSubscriptionCanceled
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.canceled }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionPaused:
    post:
      summary: Fired when POST /v1/subscriptions/{id}/pause pauses a subscription.
      operationId: webhookSubscriptionPaused
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.paused }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  subscriptionResumed:
    post:
      summary: Fired when POST /v1/subscriptions/{id}/resume resumes a paused subscription.
      operationId: webhookSubscriptionResumed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.resumed }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }

  customerImported:
    post:
      tags: [Webhooks]
      operationId: webhookCustomerImported
      summary: Customer imported via legacy cutover.
      description: Emitted only when `options.emit_webhooks=true` on `POST /v1/imports/customers`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: customer.imported }
                    data: { $ref: '#/components/schemas/Customer' }
      responses:
        '2XX': { description: Ack. }

  subscriptionImported:
    post:
      tags: [Webhooks]
      operationId: webhookSubscriptionImported
      summary: Subscription imported via legacy cutover.
      description: Emitted only when `options.emit_webhooks=true` on `POST /v1/imports/customers`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookPayload'
                - type: object
                  properties:
                    event_type: { const: subscription.imported }
                    data: { $ref: '#/components/schemas/Subscription' }
      responses:
        '2XX': { description: Ack. }
