# ChainMore Gateway - Public HTTP Surface
#
# Version:  1.0.0-rc1
# Frozen:     2026-04-21  (CKO Checkout Session Closure delivery)
# Re-frozen:  2026-04-26  (post-MerchantAuth-JWT + Merchant-UI cluster
#                          OpenAPI-Drift-Audit sweep - IdempotencyKey
#                          on all 19 mutation endpoints, BadRequest
#                          response on every Idempotency-required path,
#                          N3 cross-merchant asymmetry documented in
#                          info.description, MerchantAuth-JWT codes
#                          jwt_invalid / jwt_expired / membership_missing
#                          merged, IDX webhook codes
#                          replay_of_test_event_forbidden,
#                          delivery_endpoint_mismatch,
#                          endpoint_secret_missing_envelope merged.
#                          F-UI-6 follow-up (same day): wrong_auth_kind
#                          split out of jwt_invalid for credential-kind
#                          mismatches caught at the dispatcher boundary.)
# Scope:    all routes mounted by internal/gateway/server.go.
#           Authoritative manifest lives in internal/gateway/routes.go;
#           cmd/openapi-drift-check --service gateway enforces spec↔code agreement.
# Non-goals: SDK generation, wallet-signing payload formats (owned by the
#            checkout-web frontend contract), long-polling streams.
#
# Semver contract:
#   MAJOR - breaking change to path, method, or required field.
#   MINOR - additive field, new optional query param, new endpoint.
#   PATCH - bugfix, docs clarification, non-behavioral schema tightening.
#
# Invariants enforced by this surface (cross-referenced in per-route descriptions):
#   REC-I1   - Route Engine must be a productive consumer on /v1/routing/plan
#   PFV-I1   - Pre-Flight Validation must be productive on /v1/validation/validate
#   DISP-I1  - Dispatcher gate must be productive on /v1/dispatch/decide
#   EXE-GATE-I1 - /v1/payments/{id}/attempts refuses without a wired gate
#   CKO-I1   - Session-Is-Truth: hosted checkout routes read/write through
#              internal/checkout, never directly against operator-only rows.
#   CKO-I2   - Transitions-Are-Guarded: every mutation passes through the
#              state machine in internal/domain/checkout.go.
#   CKO-I3   - Call-Matches-State: handlers reject calls whose required
#              current state is not met (409 invalid_state).
#   CKO-I4   - Token-Hash-Indexed: /v1/checkout/sessions/by-token hashes
#              the raw token server-side; no raw token ever hits storage.
#   N3       - Cross-merchant asymmetry by bearer kind (deliberately
#              accepted): API-key path returns 404, JWT path returns 403
#              cross_merchant_forbidden. See info.description for the
#              full statement and ADR_MERCHANT_AUTH_JWT.md section Deliberately
#              Accepted Asymmetry.
#   N6       - Access tokens only on the JWT path: ID tokens are
#              rejected via the audience check. See
#              ADR_MERCHANT_AUTH_JWT.md.
#
openapi: 3.1.0
info:
  title: ChainMore Gateway API
  version: 1.0.0-rc1
  description: |
    Public HTTP surface for the ChainMore payment platform.

    **Audience:** merchant backends (payment creation, webhook correlation) and
    the hosted-checkout frontend (wallet binding, route selection, submit).
    The gateway is the ONLY public API ingress for merchant payment flows
    (routing -> validation -> dispatch -> execution). Internal operator surfaces
    are not served from the public documentation site.

    **Error envelope:** every non-2xx response uses RFC 7807 `application/problem+json`
    with the shape defined by `#/components/schemas/Problem`. The `code` field
    carries a stable machine-readable identifier; new codes are additive.

    **Cross-merchant access (N3 asymmetry):** this surface accepts two
    bearer kinds - server API keys (`cm_test_...` / `cm_live_...`) and OIDC
    JWTs (Keycloak access tokens carrying `aud=chainmore-gateway`).
    A request that authenticates successfully but targets a resource
    owned by a *different* merchant is treated differently per bearer
    kind, by deliberate design:

      - **API-key path:** returns `404 not_found`. The server-to-server
        contract treats "resource does not exist in your scope" as a
        valid machine-facing semantic; surfacing the cross-merchant
        distinction would leak the existence of resources owned by
        other tenants.
      - **JWT path:** returns `403 cross_merchant_forbidden`. The
        merchant-user-in-UI flow benefits from an explicit message -
        a confused user who clicked into the wrong tenant's bookmarked
        URL gets an actionable response rather than a misleading 404.

    Merchant integrations using the API-key path should therefore
    handle 404 as the canonical "wrong merchant or genuinely missing"
    signal. The same logical condition surfaces as 403 only on the
    OIDC JWT path used by the merchant dashboard frontend.

    **Credential-kind mismatch (`wrong_auth_kind`):** the gateway's
    auth dispatcher rejects requests where the bearer SHAPE doesn't
    match the route's auth class - an API key (`cm_test_...` /
    `cm_live_...`) on a control-plane (JWT-only) route, or a JWT on a
    machine (API-key-only) route. This refusal lands on
    `401 wrong_auth_kind` and is distinct from `jwt_invalid`: the
    latter means "the bearer was presented as a JWT but failed
    signature / issuer / audience validation"; `wrong_auth_kind`
    means "you sent the wrong credential kind for this surface".
    The webhook test endpoint
    (`POST /v1/webhook_endpoints/{id}/test`) is the single
    dual-auth route that accepts either kind.

    **Idempotency:** every POST, PUT, PATCH, and DELETE request MUST
    include an `Idempotency-Key` header. The key scopes server-side
    deduplication: a retry of the same request with the same key
    returns the cached response without producing a second side
    effect. Generate one key per logical operation (e.g. per
    user-clicked Submit), not per network attempt. Missing keys
    return `400 idempotency_required`.
  contact:
    name: ChainMore
    url: https://github.com/chainmore/chainmore
  license:
    name: Proprietary

servers:
  - url: https://api.chainmore.example
    description: Production URL configured per deployment.
  - url: http://localhost:8080
    description: Local development

# ---------------------------------------------------------------------------
# Security
# ---------------------------------------------------------------------------
security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cm_{mode}_{hex}
      description: |
        Merchant API key presented as `Authorization: Bearer
        cm_test_<hex>` (test mode) or `Authorization: Bearer
        cm_live_<hex>` (live mode). Required for every route except
        `GET /health`, the public `/checkout*` HTML shell, and the
        widget JSON routes under `/v1/checkout/widget/*`, which use
        a short-lived checkout token minted by `POST
        /v1/checkout/sessions` instead of the merchant key.

        IDX-I2: mode (test/live) is derived server-side from the
        authenticated key and is NEVER accepted as a body, header,
        or query field. Attempts to assert mode on the request are
        silently ignored in favour of the key's mode.

  parameters:
    CorrelationID:
      name: X-Correlation-ID
      in: header
      required: false
      description: |
        Client-supplied correlation ID for request tracing. If absent,
        the gateway generates one and echoes it on the response. Always
        present in error envelopes for log joining.
      schema: { type: string, minLength: 1, maxLength: 128 }

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Idempotency key for mutation endpoints that explicitly declare
        this parameter. Required on create/plan/decision surfaces such
        as `/v1/payment-intents`, `/v1/routing/plan`,
        `/v1/validation/validate`, `/v1/dispatch/decide`, and
        `POST /v1/payments/{id}/attempts` in strict modes; optional on
        checkout endpoints where the session ID already provides
        idempotency.
      schema: { type: string, minLength: 1, maxLength: 128 }

    RequiredIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Required idempotency key for this mutation. Reuse the same value
        when retrying the same logical request.
      schema: { type: string, minLength: 1, maxLength: 128 }

    PaymentID:
      name: id
      in: path
      required: true
      description: Payment aggregate identifier (ULID-shaped `pay_...`).
      schema: { type: string, minLength: 1, maxLength: 128 }

    PaymentIntentID:
      name: paymentId
      in: path
      required: true
      description: CCTP payment-intent UUID.
      schema: { type: string, format: uuid }

    AttemptID:
      name: aid
      in: path
      required: true
      description: Payment-attempt identifier (ULID-shaped `att_...`).
      schema: { type: string, minLength: 1, maxLength: 128 }

    CheckoutPaymentID:
      name: payment_id
      in: path
      required: true
      description: Payment ID the checkout session is bound to.
      schema: { type: string, minLength: 1, maxLength: 128 }

    CheckoutToken:
      name: token
      in: path
      required: true
      description: |
        Raw checkout token issued by `InitSession`. The gateway hashes
        it server-side and looks up the session by the hash (CKO-I4);
        the raw token is never persisted.
      schema: { type: string, minLength: 1, maxLength: 256 }

    DecisionID:
      name: id
      in: path
      required: true
      description: Opaque decision identifier (ULID-shaped).
      schema: { type: string, minLength: 1, maxLength: 128 }

    APIKeyID:
      name: id
      in: path
      required: true
      description: Merchant API-key identifier (UUID-shaped `ak_...`).
      schema: { type: string, minLength: 1, maxLength: 128 }

    WebhookEndpointID:
      name: id
      in: path
      required: true
      description: Webhook endpoint identifier (UUID-shaped `wh_...`).
      schema: { type: string, minLength: 1, maxLength: 128 }

    DashboardPaymentID:
      name: id
      in: path
      required: true
      description: Payment aggregate identifier used by the dashboard read plane.
      schema: { type: string, minLength: 1, maxLength: 128 }

    DashboardSessionID:
      name: id
      in: path
      required: true
      description: Checkout session identifier used by the dashboard read plane.
      schema: { type: string, minLength: 1, maxLength: 128 }

    DashboardExportID:
      name: id
      in: path
      required: true
      description: Async dashboard export job UUID.
      schema: { type: string, format: uuid }

    DashboardCursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor as returned by the previous page's
        `next_cursor` field. Omit on the first page.
      schema: { type: string, maxLength: 256 }

    DashboardLimit:
      name: limit
      in: query
      required: false
      description: |
        Maximum rows to return. Server-clamped to the dashboard-tier
        default if absent or out of range.
      schema: { type: integer, minimum: 1, maximum: 200 }

    DashboardPaymentSearch:
      name: q
      in: query
      required: false
      description: Exact lookup by merchant_order_id or ChainMore payment_id.
      schema: { type: string, maxLength: 128 }

    DashboardPaymentStatus:
      name: status
      in: query
      required: false
      description: Merchant-facing payment status filter.
      schema:
        type: string
        enum:
          - payment_created
          - checkout_started
          - authorization_pending
          - authorized
          - executing
          - completed
          - failed
          - expired

    DashboardPaymentCreatedFrom:
      name: created_from
      in: query
      required: false
      description: Inclusive created_at lower bound. Accepts YYYY-MM-DD or RFC3339.
      schema: { type: string }

    DashboardPaymentCreatedTo:
      name: created_to
      in: query
      required: false
      description: Exclusive created_at upper bound. YYYY-MM-DD values include that calendar day.
      schema: { type: string }

    DashboardExportLimit:
      name: limit
      in: query
      required: false
      description: Maximum rows to include in the CSV export. Server-clamped to 5000.
      schema: { type: integer, minimum: 1, maximum: 5000 }

  # -------------------------------------------------------------------------
  # Schemas
  # -------------------------------------------------------------------------
  schemas:
    Problem:
      type: object
      description: |
        RFC 7807 error envelope. Every failure response from this API
        carries this shape; match on `code` for stable integration
        behaviour. See the per-code merchant-facing remedy pages at
        `https://docs.chainmore.dev/errors/<slug>` (the full URL is
        also returned inline as `docs_url`).
      required: [title, code]
      properties:
        title:
          type: string
          description: Short human-readable summary.
        status:
          type: integer
          description: HTTP status code (mirrors the response status).
        detail:
          type: string
          description: Longer human-readable detail; may include sanitized error text.
        code:
          type: string
          description: |
            Stable machine-readable error code. See the merchant error
            reference at `docs/merchant/error-reference.md` for the
            canonical remedy for each code. New codes may be added at
            any time; clients MUST NOT treat an unknown code as a parse
            error.
          enum:
            - attempt_binding_mismatch
            - cctp_attestation_pending
            - cctp_awaiting_deposit
            - cctp_burn_pending
            - cctp_customer_sanctioned
            - cctp_destination_gas_exhausted
            - cctp_manual_review
            - cctp_merchant_blocked
            - cctp_merchant_sanctioned
            - cctp_mint_pending
            - cctp_provider_degraded
            - cctp_source_reorged
            - cctp_stuck_attestation
            - cctp_stuck_mint
            - checkout_operation_failed
            - create_endpoint_failed
            - create_key_failed
            - create_payment_failed
            - cross_merchant_forbidden
            - dashboard_operation_failed
            - delivery_endpoint_mismatch
            - disable_endpoint_failed
            - dispatch_failed
            - dispatch_lookup_failed
            - dispatch_precondition_failed
            - endpoint_disabled
            - endpoint_secret_missing_envelope
            - exec_dispatch_binding_mismatch
            - exec_dispatch_decision_not_found
            - exec_dispatch_not_allowed
            - exec_no_dispatch_decision
            - idempotency_required
            - invalid_authorization_mode
            - invalid_json
            - invalid_request
            - invalid_state
            - invalid_transition
            - jwt_expired
            - jwt_invalid
            - list_endpoints_failed
            - list_keys_failed
            - membership_missing
            - method_not_allowed
            - not_found
            - not_implemented
            - payment_lookup_failed
            - rate_limited
            - register_direct_tx_failed
            - replay_delivery_failed
            - replay_of_test_event_forbidden
            - revoke_key_failed
            - rotate_endpoint_secret_failed
            - rotate_key_failed
            - routing_lookup_failed
            - routing_plan_failed
            - routing_precondition_failed
            - server_misconfigured
            - session_expired
            - start_attempt_failed
            - start_attempt_precondition_failed
            - test_event_failed
            - unauthorized
            - update_endpoint_failed
            - validation_failed
            - validation_lookup_failed
            - validation_precondition_failed
            - wallet_insufficient_native_gas
            - wallet_insufficient_usdc
            - wallet_not_connected
            - wallet_unsupported
            - wallet_wrong_chain
            - wrong_auth_kind
        retryable:
          type: boolean
          description: |
            `true` when the client can safely retry the same request;
            `false` (the default) when the failure requires a shape
            change. Rate-limit and some server-side failures are
            retryable.
        docs_url:
          type: string
          format: uri
          description: |
            Merchant-facing docs URL for this specific refusal code.
            Stable per `code` value - safe to link to from dashboards
            or verifiers. Empty when the code is unregistered (which
            the refusalcodes drift-check guards against in CI).
          example: https://docs.chainmore.dev/errors/endpoint-disabled
        correlation_id:
          type: string
          description: Server-generated correlation ID; echoed in `X-Correlation-ID`.

    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
        version:
          type: string

    # ── Payments (Execution) ────────────────────────────────────────────────
    CreatePaymentRequest:
      type: object
      required: [merchant_id, amount_minor, currency]
      properties:
        merchant_id:      { type: string }
        merchant_order_id:{ type: string }
        amount_minor:     { type: integer, minimum: 1 }
        currency:         { type: string, description: "ISO-4217 fiat code, e.g. USD." }
        payment_case:
          type: string
          description: "Payment case, e.g. stable_to_stable."
        canonical_asset:
          type: string
          description: "Stablecoin symbol settled to the merchant (e.g. USDC, USDT)."
        checkout_required:
          type: boolean
          description: "Whether a hosted-checkout session must be issued before the attempt path is usable."

    CreatePaymentResponse:
      type: object
      required: [payment_id]
      properties:
        payment_id: { type: string }

    PaymentView:
      type: object
      description: Read-model view of a Payment aggregate.
      required: [id, merchant_id, public_status, orchestration_state]
      properties:
        id:                     { type: string }
        merchant_id:            { type: string }
        merchant_order_id:      { type: string }
        requested_amount_minor: { type: integer }
        requested_currency:     { type: string }
        canonical_asset:        { type: string }
        payment_case:           { type: string }
        public_status:          { type: string, description: "Merchant-visible status." }
        orchestration_state:    { type: string, description: "Internal orchestration state." }
        checkout_required:      { type: boolean }
        created_at:             { type: string, format: date-time }
        updated_at:             { type: string, format: date-time }

    PaymentIntentStatus:
      type: object
      description: |
        Surface-only CCTP payment-intent status used by browser mutation
        responses and other minimal status responses. The raw internal
        CCTP state is deliberately not exposed.
      required: [payment_id, surface_status, updated_at]
      properties:
        payment_id: { type: string, format: uuid }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        updated_at: { type: string, format: date-time }

    PaymentIntentReadResponse:
      type: object
      description: |
        Merchant-facing CCTP payment-intent status and evidence. The
        response is scoped by the authenticated API key's merchant_id and mode.
        Unknown, cross-merchant, and cross-mode UUIDs all return 404.
        Test/live mode is derived from the bearer token; callers cannot
        select or override mode on this route. The raw internal CCTP
        state is deliberately not exposed.
      required: [payment_id, surface_status, source_chain, dest_chain, amount_usdc, asset, merchant_wallet, updated_at]
      properties:
        payment_id: { type: string, format: uuid }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        source_chain: { type: string }
        dest_chain: { type: string }
        amount_usdc: { type: string, description: "USDC atomic units, 6 decimals." }
        merchant_amount_captured_atomic:
          type: string
          description: Merchant-net USDC atomic units, 6 decimals. Present after settlement evidence records the exact captured amount.
        asset: { type: string, enum: [USDC] }
        merchant_order_id:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied deposit/order reference for reconciliation.
        customer_reference:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied pseudonymous customer/player reference. Do not send PII.
        merchant_wallet: { type: string, pattern: '^0x[a-f0-9]{40}$' }
        customer_wallet:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
          description: Present after the customer wallet is bound.
        return_url:
          type: string
          format: uri
          maxLength: 2048
          description: Merchant-owned URL returned on a successful hosted checkout.
        evidence:
          type: object
          description: Present after ChainMore has at least source or settlement transaction evidence.
          required: [settlement_contract, cctp_version, finality_mode]
          properties:
            source_tx_hash:
              type: string
              pattern: '^0x[a-f0-9]{64}$'
              description: Source-chain customer transaction claimed and/or verified by ChainMore.
            settlement_tx_hash:
              type: string
              pattern: '^0x[a-f0-9]{64}$'
              description: Destination-chain settlement transaction hash once available.
            settlement_contract:
              type: string
              pattern: '^0x[a-f0-9]{40}$'
            cctp_version: { type: integer, enum: [2] }
            finality_mode: { type: string, enum: [fast, standard] }
        updated_at: { type: string, format: date-time }

    PaymentIntentNeedsReviewList:
      type: object
      description: Merchant-facing CCTP payment intents that need operator review.
      required: [items, review_threshold_seconds, updated_at]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/PaymentIntentNeedsReviewItem"
        review_threshold_seconds:
          type: integer
          minimum: 1
          description: Seconds an internal stuck state must exceed before it appears in this merchant-facing needs-review queue.
        updated_at: { type: string, format: date-time }

    PaymentIntentNeedsReviewItem:
      type: object
      required:
        - intent_id
        - amount_usdc
        - asset
        - source_chain
        - dest_chain
        - refusal_code
        - refusal_title
        - reason_category
        - estimated_wait
        - entered_review_at
      properties:
        intent_id: { type: string, format: uuid }
        amount_usdc: { type: string }
        asset: { type: string, enum: [USDC] }
        merchant_order_id:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied deposit/order reference for reconciliation.
        customer_reference:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied pseudonymous customer/player reference. Do not send PII.
        source_chain: { type: string }
        dest_chain: { type: string }
        refusal_code: { type: string }
        refusal_title: { type: string }
        reason_category:
          type: string
          enum: [provider, settlement, compliance, customer_action, manual_review]
          description: Bounded operator-facing reason category derived from the refusal code; never a raw internal CCTP state.
        estimated_wait: { type: string }
        entered_review_at: { type: string, format: date-time }

    CreatePaymentIntentRequest:
      type: object
      description: |
        Creates a Pattern-E CCTP payment intent. The active V4/V5
        settlement route is resolved server-side from ChainMore config;
        callers cannot supply the destinationCaller or mint recipient.
        Test/live mode is derived from the authenticated API key.
      required: [amount_usdc, source_chain, dest_chain, merchant_wallet, fee_bps]
      properties:
        amount_usdc:
          type: string
          pattern: '^[1-9][0-9]*$'
          description: Atomic USDC units as a decimal string.
        source_chain:
          type: string
          enum: [ethereum, base, arbitrum, polygon]
        dest_chain:
          type: string
          enum: [ethereum, base, arbitrum, polygon]
        merchant_wallet:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
        customer_wallet:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
          description: Optional while the hosted checkout is still awaiting wallet connection.
        merchant_order_id:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied deposit/order reference for reconciliation.
        customer_reference:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
          description: Merchant-supplied pseudonymous customer/player reference. Do not send PII.
        return_url:
          type: string
          format: uri
          maxLength: 2048
          description: Optional merchant-owned URL shown after Hosted Checkout reaches completed.
        fee_bps:
          type: integer
          minimum: 0
          maximum: 100
          description: ChainMore fee in basis points; must match the settlement-contract cap.
        cctp_version:
          type: integer
          enum: [2]
          default: 2
        finality_mode:
          type: string
          enum: [fast, standard]
          default: fast
          description: Fast finality is the Mini-MVP default. Standard finality is operator-gated because it extends the in-flight window.
        expires_at:
          type: string
          format: date-time
          description: Optional expiry. Defaults to 30 minutes after creation.

    BindSourceBurnAuthorizationRequest:
      type: object
      description: |
        Binds the payer wallet after Hosted Checkout wallet connection and
        returns the signed PaymentSourceBurnAdapter authorization. The
        operation is idempotent for the same customer_wallet while the intent
        is still awaiting deposit. Different-wallet retries or later lifecycle
        states return 409 so one payment never has two live payer-bound
        authorizations.
      required: [customer_wallet]
      properties:
        customer_wallet:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'

    ClaimCheckoutSourceTxRequest:
      type: object
      description: |
        Browser-side tx handoff after the wallet provider returns a submitted
        source-chain transaction hash. Hosted Checkout must call this only
        after wallet confirmation, not when the wallet modal merely opens. The
        endpoint stores the claim for reconciliation; the on-chain observer is
        still responsible for verifying the tx and moving the intent to BURNED.
      required: [tx_hash]
      properties:
        tx_hash:
          type: string
          pattern: '^0x[a-f0-9]{64}$'

    RelayCheckoutSourceBurnRequest:
      type: object
      description: |
        Browser-side relay handoff for the adapter v2 premium path. Hosted
        Checkout submits the customer's EIP-3009 receiveWithAuthorization
        signature; ChainMore relays burnWithReceiveAuthorization and returns
        the source tx hash. The submitted authorization must match the
        receive_authorization_typed_data previously returned for this checkout
        token.
      required: [receive_authorization]
      properties:
        receive_authorization:
          $ref: "#/components/schemas/SourceBurnReceiveAuthorizationSubmission"

    SourceBurnReceiveAuthorizationSubmission:
      type: object
      required: [valid_after, valid_before, nonce, v, r, s]
      properties:
        valid_after:
          type: string
          description: Decimal uint256. Must match typed-data message.validAfter.
        valid_before:
          type: string
          description: Decimal uint256. Must match typed-data message.validBefore.
        nonce:
          type: string
          pattern: '^0x[a-fA-F0-9]{64}$'
          description: Must equal the adapter authorization digest for this checkout.
        v:
          type: integer
          minimum: 0
          maximum: 28
          description: Wallet recovery id; ChainMore accepts 0/1 or 27/28 and normalizes before relay.
        r:
          type: string
          pattern: '^0x[a-fA-F0-9]{64}$'
        s:
          type: string
          pattern: '^0x[a-fA-F0-9]{64}$'

    PaymentIntentCreateResponse:
      allOf:
        - $ref: "#/components/schemas/PaymentIntentStatus"
        - type: object
          required:
            - hook_payment_id
            - source_chain
            - dest_chain
            - mint_recipient
            - merchant_wallet
            - amount_usdc
            - fee_bps
            - route_hash
            - cctp_version
            - finality_mode
            - source_burn
            - expires_at
            - created_at
            - updated_at
          properties:
            checkout_token:
              type: string
              description: |
                Raw customer-browser checkout token. Returned only on first
                create, never on idempotent replay, and stored server-side
                only as a SHA-256 hash. Scoped to this payment intent's
                browser operations until expires_at.
            hook_payment_id: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }
            merchant_order_id:
              type: string
              maxLength: 128
              pattern: '^[A-Za-z0-9._:-]+$'
              description: Merchant-supplied deposit/order reference for reconciliation.
            customer_reference:
              type: string
              maxLength: 128
              pattern: '^[A-Za-z0-9._:-]+$'
              description: Merchant-supplied pseudonymous customer/player reference. Do not send PII.
            source_chain: { type: string }
            dest_chain: { type: string }
            mint_recipient:
              type: string
              pattern: '^0x[a-f0-9]{40}$'
              description: Active Pattern-E settlement route. Source burns must use this address as destinationCaller and mintRecipient.
            merchant_wallet: { type: string, pattern: '^0x[a-f0-9]{40}$' }
            amount_usdc: { type: string }
            fee_bps: { type: integer, minimum: 0, maximum: 100 }
            return_url:
              type: string
              format: uri
              maxLength: 2048
            route_hash: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }
            cctp_version: { type: integer, enum: [2] }
            finality_mode: { type: string, enum: [fast, standard] }
            source_burn:
              $ref: "#/components/schemas/PatternESourceBurnInstructions"
            expires_at: { type: string, format: date-time }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    SourceBurnAuthorizationResponse:
      type: object
      description: |
        Signed source-burn authorization for a Pattern-E payment intent.
        The response remains surface-only: it returns the merchant-facing
        status vocabulary and never exposes the raw internal CCTP state.
      required: [payment_id, surface_status, source_burn, updated_at]
      properties:
        payment_id: { type: string, format: uuid }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        source_burn:
          $ref: "#/components/schemas/PatternESourceBurnInstructions"
        updated_at: { type: string, format: date-time }

    SourceBurnRelayResponse:
      type: object
      description: |
        Result of a ChainMore-relayed adapter v2 source burn. The returned tx
        hash is a claim pending on-chain observer verification; the response
        does not mean the intent is BURNED yet.
      required: [payment_id, surface_status, source_tx_hash, updated_at]
      properties:
        payment_id: { type: string, format: uuid }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        source_tx_hash:
          type: string
          pattern: '^0x[a-f0-9]{64}$'
        updated_at: { type: string, format: date-time }

    PaymentIntentCheckoutSessionResponse:
      type: object
      description: |
        Customer-browser checkout view for one payment intent, resolved by
        checkout token. This response is intentionally smaller than the
        merchant-machine read surface and never includes merchant API-key
        material or internal CCTP state.
      required: [payment_id, surface_status, checkout_step, checkout_phase, source_chain, dest_chain, amount_usdc, asset, expires_at, updated_at]
      properties:
        payment_id: { type: string, format: uuid }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        checkout_step:
          type: string
          enum: [connect_wallet, confirm_payment, waiting_for_chain, done]
          description: |
            Coarse browser action state. This is not the internal CCTP state:
            it only tells Hosted Checkout whether to connect a wallet, confirm
            the source transaction, wait for chain verification, or show a
            terminal screen.
        checkout_phase:
          type: string
          enum: [choose_wallet, ready_to_confirm, wallet_submitted, confirming_payment, updating_merchant, checking_payment, done]
          description: |
            Human-readable hosted-checkout progress phase. This is intentionally
            coarser than internal CCTP state and drives customer-facing copy.
        source_chain: { type: string, enum: [ethereum, base, arbitrum, polygon] }
        dest_chain: { type: string, enum: [ethereum, base, arbitrum, polygon] }
        amount_usdc: { type: string }
        asset: { type: string, enum: [USDC] }
        customer_wallet:
          type: string
          description: Present only after the customer browser binds a wallet for this intent.
        source_tx_hash:
          type: string
          description: Present only after the browser has claimed the submitted source-chain tx hash.
        return_url:
          type: string
          format: uri
          maxLength: 2048
          description: Optional merchant-owned URL shown on the completed checkout screen.
        expires_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PatternESourceBurnInstructions:
      type: object
      description: |
        Server-derived fields for constructing the source-chain CCTP V2 burn.
        Hosted Checkout calls the ChainMore source-burn adapter with these
        values. It must not call Circle TokenMessengerV2 directly, because
        Circle encodes TokenMessengerV2 caller as BurnMessage.messageSender.
        The settlement_payload is ABI-encoded for
        PaymentSettlementV4.SettlementPayload.
      required:
        - source_burn_adapter
        - source_token_messenger
        - burn_token
        - recipient
        - destination_domain
        - mint_recipient
        - destination_caller
        - max_fee
        - min_finality_threshold
        - settlement_payload
        - authorization_status
        - adapter_contract_version
      properties:
        source_burn_adapter:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
          description: Source-chain ChainMore adapter that calls TokenMessengerV2 and becomes the V4 trusted burn sender.
        adapter_contract_version:
          type: string
          enum: [v1, v2]
          description: |
            Source adapter contract generation configured by ChainMore.
            v1 uses the approval-plus-burn transaction path. v2 can also
            support the EIP-3009 receiveWithAuthorization premium path.
        source_token_messenger:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
        burn_token:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
        recipient:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
          description: Destination-chain TokenMessengerV2 recipient.
        destination_domain:
          type: integer
          format: int32
        mint_recipient:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
        destination_caller:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
          description: Must equal mint_recipient for Pattern E.
        max_fee:
          type: string
          description: Maximum Circle fee, in atomic USDC units, bound into the source adapter authorization.
        min_finality_threshold:
          type: integer
          format: int32
          description: 1 for fast finality, 2000 for standard finality.
        settlement_payload:
          type: string
          pattern: '^0x[a-fA-F0-9]+$'
        adapter_call_data:
          type: string
          pattern: '^0x[a-fA-F0-9]+$'
          description: |
            Fully encoded PaymentSourceBurnAdapter burn calldata. Present only
            once the source burn authorization is ready.
        authorization_status:
          type: string
          enum: [ready, requires_customer_wallet]
          description: Whether the response includes a signed PaymentSourceBurnAdapter authorization.
        authorization:
          $ref: "#/components/schemas/SourceBurnAuthorization"
        signature:
          type: string
          pattern: '^0x[a-fA-F0-9]{130}$'
          description: EIP-712 signature over authorization, encoded r||s||v.
        authorizer:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
          description: Backend authorizer address configured on PaymentSourceBurnAdapter.
        eip712_domain:
          $ref: "#/components/schemas/SourceBurnEIP712Domain"
        authorization_digest:
          type: string
          pattern: '^0x[a-fA-F0-9]{64}$'
          description: |
            EIP-712 digest of the ChainMore adapter authorization. For the
            adapter v2 premium path this exact digest is also used as the
            Circle-USDC EIP-3009 receiveWithAuthorization nonce, binding the
            customer's token authorization to one specific ChainMore route.
        receive_authorization_typed_data:
          $ref: "#/components/schemas/SourceBurnReceiveAuthorizationTypedData"
        relay_available:
          type: boolean
          description: |
            True only when ChainMore has an internal source-burn relay
            configured for the adapter v2 one-confirm path. Checkout must
            fall back to approval-plus-transaction when this is false.

    SourceBurnAuthorization:
      type: object
      required:
        - payer
        - amount
        - destination_domain
        - mint_recipient
        - destination_caller
        - max_fee
        - min_finality_threshold
        - hook_data_hash
        - deadline
        - nonce
      properties:
        payer: { type: string, pattern: '^0x[a-f0-9]{40}$' }
        amount: { type: string }
        destination_domain: { type: integer, format: int32 }
        mint_recipient: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }
        destination_caller: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }
        max_fee: { type: string }
        min_finality_threshold: { type: integer, format: int32 }
        hook_data_hash: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }
        deadline:
          type: string
          description: Unix timestamp seconds. Equals the payment intent expires_at value; the default intent window is 30 minutes.
        nonce: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }

    SourceBurnEIP712Domain:
      type: object
      required: [name, version, chain_id, verifying_contract]
      properties:
        name: { type: string }
        version: { type: string }
        chain_id: { type: integer, format: int64 }
        verifying_contract: { type: string, pattern: '^0x[a-f0-9]{40}$' }

    SourceBurnReceiveAuthorizationTypedData:
      type: object
      description: |
        Wallet-ready EIP-712 payload for Circle USDC's EIP-3009
        ReceiveWithAuthorization message. Present only for adapter v2
        instructions. Hosted Checkout may pass this object directly to
        eth_signTypedData_v4 so the customer signs one bounded USDC
        authorization instead of approving an unlimited allowance.
      required: [types, primaryType, domain, message]
      properties:
        types:
          type: object
          required: [EIP712Domain, ReceiveWithAuthorization]
          properties:
            EIP712Domain:
              type: array
              items: { $ref: "#/components/schemas/EIP712TypedDataField" }
            ReceiveWithAuthorization:
              type: array
              items: { $ref: "#/components/schemas/EIP712TypedDataField" }
        primaryType:
          type: string
          enum: [ReceiveWithAuthorization]
        domain:
          type: object
          required: [name, version, chainId, verifyingContract]
          properties:
            name: { type: string, enum: [USD Coin] }
            version: { type: string, enum: ["2"] }
            chainId: { type: integer, format: int64 }
            verifyingContract: { type: string, pattern: '^0x[a-f0-9]{40}$' }
        message:
          type: object
          required: [from, to, value, validAfter, validBefore, nonce]
          properties:
            from: { type: string, pattern: '^0x[a-f0-9]{40}$' }
            to: { type: string, pattern: '^0x[a-f0-9]{40}$' }
            value: { type: string }
            validAfter: { type: string }
            validBefore: { type: string }
            nonce: { type: string, pattern: '^0x[a-fA-F0-9]{64}$' }

    EIP712TypedDataField:
      type: object
      required: [name, type]
      properties:
        name: { type: string }
        type: { type: string }

    StartAttemptRequest:
      type: object
      properties:
        route_candidate_id:  { type: string }
        route_validation_id: { type: string }

    StartAttemptResponse:
      type: object
      required: [attempt_id]
      properties:
        attempt_id: { type: string }
        leg_ids:
          type: array
          items: { type: string }

    RegisterDirectTxRequest:
      type: object
      required: [leg_id, tx_hash]
      properties:
        leg_id:  { type: string }
        tx_hash: { type: string, description: "0x-prefixed hex." }

    RegisterDirectTxResponse:
      type: object
      required: [leg_id, tx_hash]
      properties:
        leg_id:  { type: string }
        tx_hash: { type: string }

    # ── Checkout (CKO) ──────────────────────────────────────────────────────
    InitSessionRequest:
      type: object
      required: [payment_id]
      properties:
        payment_id: { type: string }

    InitSessionResponse:
      type: object
      required: [session_id, checkout_token, expires_at]
      properties:
        session_id:
          type: string
          description: "CKO session ID (bound 1:1 to payment_id)."
        checkout_token:
          type: string
          description: |
            Raw checkout token. Present only in this response; the server
            persists `sha256(token)` (CKO-I4) and never the raw value.
        expires_at:
          type: string
          format: date-time

    BindWalletRequest:
      type: object
      required: [wallet_address, chain_id]
      properties:
        wallet_address: { type: string }
        chain_id:       { type: string, description: "CAIP-2 chain identifier, e.g. eip155:137." }

    BindWalletResponse:
      type: object
      required: [state]
      properties:
        state:                      { type: string, description: "New CKO state after binding." }
        route_candidate_id:         { type: string }
        route_validation_id:        { type: string }
        selected_route_fingerprint: { type: string }
        quote_expires_at:           { type: string, format: date-time }

    SubmitExecutionRequest:
      type: object
      required: [authorization_mode]
      properties:
        tx_hash:
          type: string
          description: |
            0x-prefixed hex. Required for `direct_wallet_tx`; ignored for
            sponsored flows where the relay reports back asynchronously.
        authorization_mode:
          type: string
          description: |
            One of: `direct_wallet_tx`, `permit_2612`, `transfer_with_authorization`,
            `gasless_userop`. Must be supported by the selected route.

    SubmitExecutionResponse:
      type: object
      required: [state]
      properties:
        state:                  { type: string, description: "New CKO state after submit." }
        dispatch_decision_id:   { type: string }
        payment_attempt_id:     { type: string }
        submitted_tx_hash:      { type: string }

    CheckoutState:
      type: object
      description: Read-model view the hosted-checkout frontend polls.
      required: [state]
      properties:
        state:                      { type: string }
        payment_id:                 { type: string }
        wallet_address:             { type: string }
        chain_id:                   { type: string }
        selected_route_fingerprint: { type: string }
        route_candidate_id:         { type: string }
        route_validation_id:        { type: string }
        dispatch_decision_id:       { type: string }
        payment_attempt_id:         { type: string }
        settlement_journal_id:      { type: string }
        submitted_tx_hash:          { type: string }
        failure_reason:             { type: string }
        quote_expires_at:           { type: string, format: date-time }
        expires_at:                 { type: string, format: date-time }
        terminal_at:                { type: string, format: date-time }
        updated_at:                 { type: string, format: date-time }

    CheckoutSession:
      type: object
      description: Full CKO session record returned by the by-token lookup.
      required: [id, payment_id, state]
      properties:
        id:                         { type: string }
        payment_id:                 { type: string }
        checkout_token_hash:        { type: string }
        state:                      { type: string }
        wallet_address:             { type: string }
        chain_id:                   { type: string }
        selected_route_fingerprint: { type: string }
        route_candidate_id:         { type: string }
        route_validation_id:        { type: string }
        dispatch_decision_id:       { type: string }
        payment_attempt_id:         { type: string }
        settlement_journal_id:      { type: string }
        submitted_tx_hash:          { type: string }
        failure_reason:             { type: string }
        quote_expires_at:           { type: string, format: date-time }
        expires_at:                 { type: string, format: date-time }
        terminal_at:                { type: string, format: date-time }
        created_at:                 { type: string, format: date-time }
        updated_at:                 { type: string, format: date-time }

    # ── Merchants (reserved; Phase-1) ───────────────────────────────────────
    MerchantsReservedResponse:
      type: object
      description: |
        Phase-1 reserved response. The merchants surface is frozen as an
        observable shape so clients can detect when it becomes available.
      properties:
        status:
          type: string
          enum: [reserved]

    # ── Routing (REC) ───────────────────────────────────────────────────────
    PlanRouteRequest:
      type: object
      required: [payment_id, checkout_session_id]
      properties:
        payment_id:          { type: string }
        checkout_session_id: { type: string }

    RoutingDecision:
      type: object
      description: |
        Authoritative engine output. Per REC-I1 C1 every engine-relevant
        field is reconstructed server-side from Payment + CheckoutSession;
        the caller only supplies the two binding IDs.
      required: [id, candidates]
      properties:
        id:                 { type: string }
        payment_id:         { type: string }
        checkout_session_id:{ type: string }
        candidates:
          type: array
          items: { $ref: "#/components/schemas/RouteCandidate" }
        created_at:         { type: string, format: date-time }

    RouteCandidate:
      type: object
      required: [id, fingerprint]
      properties:
        id:             { type: string }
        fingerprint:    { type: string }
        provider_id:    { type: string }
        source_chain:   { type: string }
        destination_chain: { type: string }
        source_asset:   { type: string }
        destination_asset:{ type: string }
        score:          { type: number }
        supports_permit: { type: boolean }
        supports_auth_tx: { type: boolean }
        gasless_supported:{ type: boolean }
        quote_ttl_ms:    { type: integer }

    # ── Validation (PFV) ────────────────────────────────────────────────────
    ValidateRouteRequest:
      type: object
      required: [payment_id, checkout_session_id, route_candidate_id]
      properties:
        payment_id:          { type: string }
        checkout_session_id: { type: string }
        route_candidate_id:  { type: string }

    RouteValidation:
      type: object
      required: [id, passed]
      properties:
        id:                { type: string }
        route_candidate_id:{ type: string }
        passed:            { type: boolean }
        would_deny_if_enforced:
          type: boolean
          description: |
            Rollout projection. True means the route may still pass in
            the current visible/advisory stage, but at least one failed
            or unknown check would deny the route once PFV enforcement is
            enabled.
        would_deny_codes:
          type: array
          items: { type: string }
          description: Structured check codes behind the would-deny projection.
        checks:
          type: array
          items: { $ref: "#/components/schemas/ValidationCheck" }
        expires_at:        { type: string, format: date-time }
        created_at:        { type: string, format: date-time }

    ValidationCheck:
      type: object
      required: [type, passed, status]
      properties:
        type:   { type: string }
        passed: { type: boolean }
        status:
          type: string
          enum: [passed, failed, unknown]
        code:   { type: string }
        detail: { type: string }

    # ── Dispatch (DISP) ─────────────────────────────────────────────────────
    DispatchDecideRequest:
      type: object
      required: [payment_id, checkout_session_id, route_candidate_id]
      properties:
        payment_id:          { type: string }
        checkout_session_id: { type: string }
        route_candidate_id:  { type: string }
        route_validation_id: { type: string }

    DispatchDecision:
      type: object
      required: [id, admitted]
      properties:
        id:                  { type: string }
        payment_id:          { type: string }
        checkout_session_id: { type: string }
        route_candidate_id:  { type: string }
        route_validation_id: { type: string }
        admitted:            { type: boolean }
        rejection_code:      { type: string }
        rejection_detail:    { type: string }
        engine_version:      { type: string }
        created_at:          { type: string, format: date-time }

    # ── Widget / Hosted Checkout (WGT-I1) ────────────────────────────────────
    WidgetSessionView:
      type: object
      description: |
        Redacted, server-computed view the Hosted Checkout / Widget
        renders from. WGT-I1: every pixel is a pure function of this
        body. The raw checkout token is NEVER echoed in this view.
      required: [session_id, payment_id, state, next_action, display, expires_at]
      properties:
        session_id:        { type: string }
        payment_id:        { type: string }
        state:             { type: string, description: "Raw CKO state; UI MUST drive from next_action, not state." }
        next_action:
          type: string
          description: |
            Server-issued UI directive. One of: `connect_wallet`,
            `select_route`, `awaiting_user_signature`, `processing`,
            `settled`, `failed`, `expired`, `cancelled`.
        display:
          type: object
          required: [amount_minor, currency]
          properties:
            amount_minor:     { type: integer }
            currency:         { type: string }
            asset:            { type: string }
            merchant_display: { type: string }
            merchant_order_id:{ type: string }
        wallet:
          type: object
          properties:
            address:  { type: string }
            chain_id: { type: string }
        routes:
          type: array
          items:
            type: object
            required: [fingerprint, authorization_mode]
            properties:
              fingerprint:         { type: string }
              authorization_mode:  { type: string }
              estimated_fee_minor: { type: integer }
              is_primary:          { type: boolean }
        submitted:
          type: object
          properties:
            tx_hash: { type: string }
        failure:
          type: object
          properties:
            code:        { type: string }
            description: { type: string }
        settlement:
          type: object
          properties:
            settled_at: { type: string, format: date-time }
        expires_at:       { type: string, format: date-time }
        quote_expires_at: { type: string, format: date-time }

    # ── Merchant Dashboard read plane (MDA-I2 - read-only) ───────────────────
    MerchantIntegrationView:
      type: object
      description: |
        Summary of the merchant's integration posture, scoped to the
        bearer-resolved mode. Mirrors `domain.MerchantDashboardIntegrationView`
        in `internal/domain/dashboard.go`. All fields are redacted
        projections - no secrets, no api-key plaintext, no webhook
        signing material.

        The `mode` field reflects which mode the response was scoped
        to (the merchant's currently-resolved bearer mode); `active_api_key_count`
        and the webhook counters are filtered to that mode.
      required:
        - merchant_id
        - legal_name
        - status
        - default_currency
        - mode
        - account_approval_status
        - live_mode_enabled
        - active_api_key_count
        - webhook_endpoint_count
        - webhook_endpoint_enabled_count
        - created_at
        - generated_at
      properties:
        merchant_id:                    { type: string }
        legal_name:                     { type: string }
        status:                         { type: string, description: "merchant account status (e.g. 'active')." }
        default_currency:               { type: string, description: "ISO 4217 fiat code." }
        mode:                           { type: string, enum: [test, live], description: "Bearer-resolved mode this response was scoped to." }
        account_approval_status:        { type: string, enum: [sandbox, needs_info, live_approved, rejected, not_configured], description: "Server-side account live-access state; not a provider or rail readiness claim." }
        live_mode_enabled:              { type: boolean, description: "True only after the server-side live access gate is approved." }
        live_enabled_at:                { type: string, format: date-time, nullable: true, description: "Timestamp when live access was approved, if any." }
        active_api_key_count:           { type: integer, minimum: 0 }
        webhook_endpoint_count:         { type: integer, minimum: 0 }
        webhook_endpoint_enabled_count: { type: integer, minimum: 0 }
        created_at:                     { type: string, format: date-time, description: "Merchant joined-on timestamp." }
        generated_at:                   { type: string, format: date-time, description: "Server snapshot timestamp." }

    MerchantOnboardingProgressView:
      type: object
      description: |
        Read-only merchant setup progress contract. The response is
        computed from existing account profile, API-key, webhook,
        test-event, and settled test-payment evidence. It never
        returns key material, webhook signing material, raw provider
        payloads, or production credentials, and it never toggles live
        access.
      required:
        - merchant_id
        - mode
        - account_approval_status
        - live_mode_enabled
        - steps
        - generated_at
      properties:
        merchant_id:             { type: string }
        mode:                    { type: string, enum: [test, live], description: "Bearer-resolved dashboard mode." }
        account_approval_status: { type: string, enum: [sandbox, needs_info, live_approved, rejected, not_configured] }
        live_mode_enabled:       { type: boolean, description: "True only after server-side live access approval." }
        steps:
          type: array
          items: { $ref: "#/components/schemas/MerchantOnboardingStepView" }
        generated_at:            { type: string, format: date-time }

    MerchantOnboardingStepView:
      type: object
      required: [step_id, status, required]
      properties:
        step_id:
          type: string
          enum:
            - account_profile
            - test_api_key
            - webhook_endpoint
            - webhook_test_event
            - first_payment_evidence
            - live_access
            - live_api_key
        status:
          type: string
          enum: [complete, missing, blocked, needs_review]
        required: { type: boolean }
        completed_at: { type: string, format: date-time, nullable: true }
        blocking_reason:
          type: string
          description: Bounded merchant-facing reason when status is blocked or needs_review.

    SubmitAccountProfileRequest:
      type: object
      description: |
        Merchant-user-submitted business profile that starts ChainMore
        account/DD review. The merchant_id is not accepted in this body;
        it is resolved from the authenticated dashboard user.
      required:
        - website_url
        - contact_email
        - country
        - business_type
        - use_case
      properties:
        website_url:
          type: string
          format: uri
          description: Public merchant website URL.
        contact_email:
          type: string
          format: email
          description: Operational contact for account review.
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO-3166 alpha-2 country code.
        business_type:
          type: string
          enum: [registered_business, sole_trader, platform, nonprofit, other]
        expected_monthly_volume_usd_bucket:
          type: string
          enum: [lt_10k, 10k_50k, 50k_250k, 250k_1m, gt_1m, unknown]
          default: unknown
        use_case:
          type: string
          minLength: 20
          maxLength: 1000
          description: Bounded free-text description of the intended payment use case.

    SubmitAccountProfileResponse:
      type: object
      required: [submission_id, merchant_id, submitted_at]
      properties:
        submission_id: { type: string }
        merchant_id:   { type: string, description: "Authenticated merchant id resolved by the server." }
        submitted_at:  { type: string, format: date-time }

    AccountProfileReadbackResponse:
      type: object
      description: |
        Current merchant business-profile submission for dashboard readback.
        It is the merchant-facing version of the DD/KYB intake state and
        deliberately excludes internal review notes, operator identities,
        document storage, and live-approval actions.
      required:
        - submitted
        - merchant_id
        - approval_status
        - live_mode_enabled
        - profile_updated_at
        - destructive_actions
      properties:
        submitted:          { type: boolean }
        submission_id:      { type: string }
        merchant_id:        { type: string, description: "Authenticated merchant id resolved by the server." }
        approval_status:    { type: string, enum: [sandbox, needs_info, live_approved, rejected, not_configured] }
        live_mode_enabled:  { type: boolean }
        website_url:        { type: string, format: uri }
        contact_email:      { type: string, format: email }
        country:            { type: string, minLength: 2, maxLength: 2 }
        business_type:      { type: string, enum: [registered_business, sole_trader, platform, nonprofit, other] }
        expected_monthly_volume_usd_bucket:
          type: string
          enum: [lt_10k, 10k_50k, 50k_250k, 250k_1m, gt_1m, unknown]
        use_case:           { type: string, minLength: 20, maxLength: 1000 }
        submitted_at:       { type: string, format: date-time }
        profile_updated_at: { type: string, format: date-time }
        destructive_actions:
          type: string
          enum: [not_available_on_read_endpoint]

    DashboardPaymentView:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardPaymentView` - the redacted
        merchant-facing projection of a Payment row. Pipeline-internal
        fields (rail, route candidate, dispatcher decision, operator
        wallet) are deliberately absent.
      required:
        - payment_id
        - merchant_order_id
        - environment
        - requested_amount_minor
        - requested_currency
        - public_status
        - checkout_required
        - created_at
        - updated_at
      properties:
        payment_id:             { type: string }
        merchant_order_id:      { type: string }
        environment:            { type: string, enum: [sandbox, production] }
        requested_amount_minor: { type: integer, format: int64 }
        requested_currency:     { type: string, description: "ISO 4217 fiat code." }
        canonical_asset:        { type: string, description: "Settlement asset symbol; omitted before resolution." }
        public_status:          { type: string, description: "Merchant-facing payment status (same enum the webhook payload carries)." }
        checkout_required:      { type: boolean }
        mode:                   { type: string, enum: [test, live], description: "Per-row mode mirror; redundant with the page-level scope but kept for single-row debug copies." }
        created_at:             { type: string, format: date-time }
        updated_at:             { type: string, format: date-time }

    DashboardRequestedAmountTotal:
      type: object
      required: [currency, amount_minor, payment_count]
      properties:
        currency:      { type: string, description: "Requested fiat currency. This is not the settlement asset." }
        amount_minor:  { type: integer, format: int64, description: "Sum of requested fiat minor units for the filtered set." }
        payment_count: { type: integer, minimum: 0 }

    DashboardPaymentStatusCount:
      type: object
      required: [public_status, payment_count]
      properties:
        public_status: { type: string, description: "Merchant-facing payment status." }
        payment_count: { type: integer, minimum: 0 }

    DashboardExceptionStatusCount:
      type: object
      required: [surface_status, payment_count]
      properties:
        surface_status:
          type: string
          enum: [delayed, needs_review, failed_safely, expired]
          description: Merchant-facing CCTP exception status; raw internal CCTP state is not exposed.
        payment_count: { type: integer, minimum: 0 }
        oldest_entered_at:
          type: string
          format: date-time
          description: Oldest state-entered timestamp for this exception group, when present.

    DashboardExceptionSummary:
      type: object
      required: [items, total_count, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardExceptionStatusCount" }
        total_count:
          type: integer
          minimum: 0
          description: Sum of all exception rows for the authenticated merchant and mode.
        generated_at: { type: string, format: date-time }

    DashboardPaymentsSummary:
      type: object
      required: [total_count, requested_amount_totals, status_counts, export_row_limit, filtered]
      properties:
        total_count: { type: integer, minimum: 0, description: "Full filtered result count, not just the current cursor page." }
        requested_amount_totals:
          type: array
          description: Requested fiat totals grouped by requested_currency. Settlement assets remain separate; no USD↔USDC conversion is implied.
          items: { $ref: "#/components/schemas/DashboardRequestedAmountTotal" }
        status_counts:
          type: array
          items: { $ref: "#/components/schemas/DashboardPaymentStatusCount" }
        export_row_limit:
          type: integer
          minimum: 1
          description: Current synchronous CSV export row cap used by the Gateway.
        filtered: { type: boolean, description: "True when q/status/date filters narrowed the result set." }

    DashboardPaymentsPage:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardPaymentsPage`. Cursor-based
        pagination via `next_cursor` + `has_more`. `summary` describes
        the full filtered set, not only the current page.
      required: [items, has_more, summary, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardPaymentView" }
        has_more:     { type: boolean }
        next_cursor:  { type: string }
        summary:      { $ref: "#/components/schemas/DashboardPaymentsSummary" }
        generated_at: { type: string, format: date-time }

    DashboardCustomerActivityStatusCount:
      type: object
      required: [surface_status, payment_count]
      properties:
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
          description: Merchant-facing status bucket; raw internal CCTP states are not exposed.
        payment_count: { type: integer, minimum: 0 }

    DashboardCustomerActivitySummary:
      type: object
      required: [payment_count, amount_usdc, status_counts]
      properties:
        payment_count: { type: integer, minimum: 0 }
        amount_usdc:
          type: string
          description: Sum of CCTP payment-intent amount_usdc values for this merchant-scoped customer_reference.
        status_counts:
          type: array
          items: { $ref: "#/components/schemas/DashboardCustomerActivityStatusCount" }
        first_seen_at: { type: string, format: date-time }
        last_seen_at:  { type: string, format: date-time }

    DashboardCustomerActivityPage:
      type: object
      description: |
        First Customer-360 slice: merchant/mode-scoped CCTP activity for
        one merchant-supplied customer_reference. The rows use the same
        stage-based reconciliation vocabulary and never expose raw CCTP
        states, attestations, route hashes, or private material.
      required: [customer_reference, summary, items, generated_at]
      properties:
        customer_reference:
          type: string
          maxLength: 128
          pattern: '^[A-Za-z0-9._:-]+$'
        summary: { $ref: "#/components/schemas/DashboardCustomerActivitySummary" }
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardReconciliationView" }
        generated_at: { type: string, format: date-time }

    DashboardSessionView:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardSessionView` - the merchant-
        facing redacted projection of a CheckoutSession. Distinct
        from the customer-browser WidgetSessionView: this surface
        carries correlation IDs the merchant already owns.
      required:
        - session_id
        - payment_id
        - state
        - next_action
        - expires_at
      properties:
        session_id:        { type: string }
        payment_id:        { type: string }
        state:             { type: string, description: "Raw CheckoutSessionState." }
        next_action:       { type: string, description: "Same widget-side terminal-vs-live classification enum." }
        wallet_address:    { type: string, description: "Set once the user has bound a wallet." }
        chain_id:          { type: string }
        submitted_tx_hash: { type: string, description: "User-signed tx hash (on-chain public) - never stored secret material." }
        failure_code:      { type: string, description: "Bounded failure-reason code; reuses the widget-view mapping." }
        expires_at:        { type: string, format: date-time }
        quote_expires_at:  { type: string, format: date-time }
        terminal_at:       { type: string, format: date-time }
        mode:              { type: string, enum: [test, live] }

    DashboardSessionsPage:
      type: object
      required: [items, has_more, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardSessionView" }
        has_more:     { type: boolean }
        next_cursor:  { type: string }
        generated_at: { type: string, format: date-time }

    DashboardAuditEventView:
      type: object
      description: |
        Merchant-facing account activity row. This is a deliberately
        redacted projection over merchant_account_audit_events: it never
        exposes idempotency keys, metadata JSON, raw DD/KYB review
        reasons, operator notes, or cross-merchant identifiers.
      required: [event_id, event_type, actor_type, created_at]
      properties:
        event_id:   { type: string }
        event_type:
          type: string
          description: Bounded account event name.
          enum:
            - merchant.created
            - merchant.approved
            - merchant.needs_info
            - merchant.info_resolved
            - merchant.profile_submitted
            - invitation.created
            - member.bound
            - role.changed
            - api_key.created
            - webhook_endpoint.created
            - live_mode.enabled
        actor_type:
          type: string
          enum: [operator, system, merchant_user]
        target_type:
          type: string
          description: Optional bounded target type, never raw metadata.
        target_id:
          type: string
          description: Optional target identifier that belongs to the authenticated merchant account.
        created_at: { type: string, format: date-time }

    DashboardAuditEventsPage:
      type: object
      description: |
        Account-level audit activity for the authenticated merchant. It
        is merchant-scoped but not test/live-mode-scoped because profile,
        membership, API-key, webhook, and live-access events are account
        control-plane activity, not payment ledger rows.
      required: [items, has_more, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardAuditEventView" }
        has_more:     { type: boolean }
        next_cursor:  { type: string }
        generated_at: { type: string, format: date-time }

    DashboardTeamMemberView:
      type: object
      description: |
        Merchant-facing team member row. This projection never exposes
        OIDC issuer, OIDC subject, or cross-merchant membership rows.
      required: [member_id, role, status, created_at, updated_at]
      properties:
        member_id:  { type: string }
        email:
          type: string
          format: email
          description: Email is present only when it can be derived from merchant invitation records.
        role:
          type: string
          enum: [owner, admin, developer, viewer, support]
        status:
          type: string
          enum: [active, revoked]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }

    DashboardTeamInvitationView:
      type: object
      description: |
        Merchant-facing team invitation row. It never exposes
        invitation_token_hash or plaintext invitation tokens.
      required: [invitation_id, email, role, status, created_at, expires_at]
      properties:
        invitation_id: { type: string }
        email:         { type: string, format: email }
        role:
          type: string
          enum: [owner, admin, developer, viewer, support]
        status:
          type: string
          enum: [pending, accepted, revoked, expired]
        created_at:  { type: string, format: date-time }
        expires_at:  { type: string, format: date-time }
        accepted_at: { type: string, format: date-time }
        revoked_at:  { type: string, format: date-time }

    DashboardTeamAccessPage:
      type: object
      description: |
        Account-level team access snapshot for the authenticated
        merchant. Team state is merchant-scoped but not test/live-mode
        scoped. The response omits identity-provider internals,
        invitation token material, and operator-only review context.
      required: [members, invitations, generated_at]
      properties:
        members:
          type: array
          items: { $ref: "#/components/schemas/DashboardTeamMemberView" }
        invitations:
          type: array
          items: { $ref: "#/components/schemas/DashboardTeamInvitationView" }
        generated_at: { type: string, format: date-time }

    DashboardWebhookEndpointView:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardWebhookEndpointView` - the
        dashboard-side redacted projection. Differs from the IDX
        WebhookEndpoint shape by surfacing `has_secret` instead of
        any secret material.
      required:
        - endpoint_id
        - url
        - enabled
        - has_secret
        - secret_rotation_due
        - secret_grace_active
        - secret_rotation_cadence
        - created_at
        - updated_at
      properties:
        endpoint_id: { type: string }
        url:         { type: string, format: uri }
        events:
          type: array
          items: { type: string }
          description: "Subscribed event types; empty array = wildcard."
        enabled:     { type: boolean }
        has_secret:  { type: boolean, description: "True iff a rotatable signing secret is currently on the row (envelope columns populated)." }
        secret_last_rotated_at:
          type: string
          format: date-time
          description: "Last signing-secret rotation timestamp, omitted for legacy rows where the rotation timestamp is unknown."
        secret_rotation_due_at:
          type: string
          format: date-time
          description: "Computed quarterly rotation due timestamp when the last rotation timestamp is known."
        secret_rotation_due:
          type: boolean
          description: "True when the endpoint is past the quarterly signing-secret rotation window."
        secret_grace_until:
          type: string
          format: date-time
          description: "Secondary signing-secret grace-window expiry, if a rotate is in progress."
        secret_grace_active:
          type: boolean
          description: "True while the previous signing secret is still accepted during the rotate grace window."
        secret_rotation_cadence:
          type: string
          enum: [quarterly]
          description: "Merchant-facing label for the ADR 0050 signing-secret rotation cadence."
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }

    DashboardWebhookEndpointsPage:
      type: object
      required: [items, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardWebhookEndpointView" }
        generated_at: { type: string, format: date-time }

    DashboardWebhookDeliveryView:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardWebhookDeliveryView`. Hard-
        redacted: signing material, raw body bytes, sig timestamp,
        signature hex, and merchant_id are all absent - this surface
        is for correlation + diagnostics, not for replay (replay
        uses the IDX `/replay` endpoint).
      required:
        - delivery_id
        - payment_id
        - endpoint_id
        - event_type
        - event_id
        - state
        - attempt_count
        - created_at
      properties:
        delivery_id:   { type: string }
        payment_id:    { type: string }
        endpoint_id:   { type: string }
        event_type:    { type: string }
        event_id:      { type: string, description: "Idempotency key the merchant dedupes on at their receiving side." }
        state:
          type: string
          enum: [pending, delivered, failed]
          description: "Mirrors the outbox row state. The internal SELECT-FOR-UPDATE-SKIP-LOCKED 'dispatched' / 'in_flight' transitions are deliberately not surfaced."
        attempt_count: { type: integer, minimum: 0 }
        http_status:   { type: integer, description: "Last observed HTTP status; zero before any attempt has landed." }
        last_error:    { type: string, description: "Bounded reason code (WHK-I4); never raw response text." }
        created_at:    { type: string, format: date-time }
        delivered_at:  { type: string, format: date-time, description: "Set when the row reaches 'delivered'; absent while pending/failed." }

    DashboardWebhookDeliveriesPage:
      type: object
      required: [items, has_more, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardWebhookDeliveryView" }
        has_more:     { type: boolean }
        next_cursor:  { type: string }
        generated_at: { type: string, format: date-time }

    DashboardWebhookHealthWindow:
      type: object
      required: [created_from, created_to]
      properties:
        created_from: { type: string, format: date-time, description: "Start of the bounded delivery window, inclusive." }
        created_to:   { type: string, format: date-time, description: "End of the bounded delivery window, exclusive." }

    DashboardWebhookEndpointHealthView:
      type: object
      description: |
        Redacted per-endpoint health projection for the merchant dashboard.
        It is derived from webhook_endpoints plus bounded webhook_outbox
        counters. It never returns signing secrets, webhook bodies,
        signatures, raw response text, or cross-merchant identifiers.
      required:
        - endpoint_id
        - url
        - enabled
        - delivery_count
        - delivered_count
        - failed_count
        - pending_count
      properties:
        endpoint_id:      { type: string }
        url:              { type: string, format: uri }
        enabled:          { type: boolean }
        delivery_count:   { type: integer, minimum: 0 }
        delivered_count:  { type: integer, minimum: 0 }
        failed_count:     { type: integer, minimum: 0 }
        pending_count:    { type: integer, minimum: 0 }
        last_state:       { type: string, enum: [pending, delivered, failed] }
        last_http_status: { type: integer, minimum: 100, maximum: 599 }
        last_error:       { type: string, description: "Bounded delivery reason code, never raw response text." }
        last_event_at:    { type: string, format: date-time }

    DashboardWebhookHealthSummary:
      type: object
      description: |
        Real Overview metric contract for webhook delivery health over a
        bounded 24h window. Merchant and mode are resolved from auth
        context. This response is intentionally limited to webhook health;
        it does not claim rail volume, success rate, settlement latency, or
        chain health.
      required:
        - window
        - total_endpoint_count
        - enabled_endpoint_count
        - delivery_count
        - delivered_count
        - failed_count
        - pending_count
        - endpoints
        - generated_at
      properties:
        window:                 { $ref: "#/components/schemas/DashboardWebhookHealthWindow" }
        total_endpoint_count:   { type: integer, minimum: 0 }
        enabled_endpoint_count: { type: integer, minimum: 0 }
        delivery_count:         { type: integer, minimum: 0 }
        delivered_count:        { type: integer, minimum: 0 }
        failed_count:           { type: integer, minimum: 0 }
        pending_count:          { type: integer, minimum: 0 }
        last_event_at:          { type: string, format: date-time }
        endpoints:
          type: array
          items: { $ref: "#/components/schemas/DashboardWebhookEndpointHealthView" }
        generated_at:           { type: string, format: date-time }

    DashboardReconciliationView:
      type: object
      description: |
        Mirrors `domain.MerchantDashboardReconciliationView` - a
        merchant-facing CCTP payment-intent lifecycle row. The shape
        exposes product-stage timestamps and canonical surface status;
        raw CCTP state names, proofs, route hashes, attestation bytes,
        and checkout token hashes are deliberately absent.
      required:
        - intent_id
        - amount_usdc
        - asset
        - source_chain
        - dest_chain
        - surface_status
        - requested_at
        - expires_at
        - received_at
        - routed_at
        - settled_at
        - drift_reason
      properties:
        intent_id:          { type: string, description: "Payment-intent UUID." }
        merchant_order_id:  { type: string, description: "Merchant-supplied order/deposit reference." }
        customer_reference: { type: string, description: "Merchant-supplied customer/support reference." }
        amount_usdc:        { type: string, description: "Atomic USDC units (6 decimals)." }
        asset:              { type: string, enum: [USDC] }
        source_chain:       { type: string }
        dest_chain:         { type: string }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        requested_at:       { type: string, format: date-time }
        expires_at:         { type: string, format: date-time }
        received_at:        { type: string, format: date-time, nullable: true }
        routed_at:          { type: string, format: date-time, nullable: true }
        settled_at:         { type: string, format: date-time, nullable: true }
        drift_reason:
          type: string
          nullable: true
          description: "Bounded merchant-facing phrase; null for normal in-progress or settled rows."

    DashboardReconciliationPage:
      type: object
      required: [items, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardReconciliationView" }
        generated_at: { type: string, format: date-time }

    DashboardReconciliationEvidenceView:
      type: object
      description: |
        Bounded technical evidence for one reconciliation detail page.
        It exposes merchant-verifiable transaction references, but
        never raw attestation bytes, CCTP message bytes, hook_payment_id,
        route_hash, or internal state names.
      required:
        - settlement_contract
        - cctp_version
        - finality_mode
      properties:
        source_tx_hash:
          type: string
          pattern: '^0x[a-f0-9]{64}$'
        settlement_tx_hash:
          type: string
          pattern: '^0x[a-f0-9]{64}$'
        settlement_contract:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
        cctp_version: { type: integer, enum: [2] }
        finality_mode: { type: string, enum: [fast, standard] }

    DashboardReconciliationDetailView:
      type: object
      description: |
        Click-through reconciliation view for one CCTP payment intent.
        It keeps the overview's lifecycle language and adds only a
        bounded evidence object for merchant verification.
      required:
        - intent_id
        - amount_usdc
        - asset
        - source_chain
        - dest_chain
        - surface_status
        - requested_at
        - expires_at
        - received_at
        - routed_at
        - settled_at
        - drift_reason
        - updated_at
      properties:
        intent_id:          { type: string, description: "Payment-intent UUID." }
        merchant_order_id:  { type: string, description: "Merchant-supplied order/deposit reference." }
        customer_reference: { type: string, description: "Merchant-supplied customer/support reference." }
        amount_usdc:        { type: string, description: "Atomic USDC units (6 decimals)." }
        asset:              { type: string, enum: [USDC] }
        source_chain:       { type: string }
        dest_chain:         { type: string }
        surface_status:
          type: string
          enum: [awaiting, processing, delayed, needs_review, completed, failed_safely, expired]
        requested_at:       { type: string, format: date-time }
        expires_at:         { type: string, format: date-time }
        received_at:        { type: string, format: date-time, nullable: true }
        routed_at:          { type: string, format: date-time, nullable: true }
        settled_at:         { type: string, format: date-time, nullable: true }
        drift_reason:
          type: string
          nullable: true
          description: "Bounded merchant-facing phrase; null for normal in-progress or settled rows."
        evidence:
          $ref: "#/components/schemas/DashboardReconciliationEvidenceView"
        updated_at: { type: string, format: date-time }

    DashboardSettlementReconciliationWindow:
      type: object
      required: [created_from, created_to]
      properties:
        created_from: { type: string, format: date-time }
        created_to: { type: string, format: date-time }
        dest_chain: { type: string, enum: [ethereum, base, arbitrum, polygon] }
        settlement_contract:
          type: string
          pattern: '^0x[a-f0-9]{40}$'
        merchant_wallet:
          type: string
          pattern: '^0x[a-f0-9]{40}$'

    DashboardSettlementLedgerSummary:
      type: object
      required:
        - payment_count
        - settled_count
        - gross_amount_usdc
        - merchant_amount_captured_atomic
        - fee_delta_atomic
        - incomplete_settlement_amount_count
        - stuck_count
        - failed_count
        - expired_count
      properties:
        payment_count:
          type: integer
          minimum: 0
          description: Authenticated merchant/mode payment-intent rows in the bounded created_at window and optional route filters.
        settled_count:
          type: integer
          minimum: 0
          description: Rows in the window whose status is SETTLED and whose mint_tx_hash is present.
        gross_amount_usdc:
          type: string
          pattern: '^[0-9]+$'
          description: Gross CCTP USDC atomic units summed from amount_usdc for settled rows only.
        merchant_amount_captured_atomic:
          type: string
          pattern: '^[0-9]+$'
          description: Merchant-net USDC atomic units summed from merchant_amount_captured_atomic for settled rows only.
        fee_delta_atomic:
          type: string
          pattern: '^[0-9]+$'
          description: Derived gross minus merchant-net atomic USDC for settled rows with captured merchant amount.
        incomplete_settlement_amount_count:
          type: integer
          minimum: 0
          description: Settled rows missing merchant_amount_captured_atomic; non-zero means the ledger total is incomplete.
        stuck_count: { type: integer, minimum: 0 }
        failed_count: { type: integer, minimum: 0 }
        expired_count: { type: integer, minimum: 0 }

    DashboardSettlementOnChainSummary:
      type: object
      required:
        - status
        - event_count
        - merchant_amount_atomic
        - chainmore_fee_atomic
        - distinct_tx_count
      properties:
        status:
          type: string
          enum: [matched, ledger_ahead, chain_ahead, partial, unavailable]
        unavailable_reason:
          type: string
          description: Present when route/RPC event evidence is not configured.
        event_count: { type: integer, minimum: 0 }
        merchant_amount_atomic:
          type: string
          pattern: '^[0-9]+$'
        chainmore_fee_atomic:
          type: string
          pattern: '^[0-9]+$'
        distinct_tx_count: { type: integer, minimum: 0 }
        first_scanned_block: { type: integer, minimum: 0 }
        last_scanned_block: { type: integer, minimum: 0 }
        scanned_at: { type: string, format: date-time }

    DashboardSettlementReconciliationResult:
      type: object
      required: [status, message, next_action]
      properties:
        status:
          type: string
          enum: [matched, ledger_ahead, chain_ahead, partial, unavailable]
        message:
          type: string
        next_action:
          type: string

    DashboardSettlementReconciliationSummary:
      type: object
      description: |
        Aggregate Finance reconciliation snapshot for a bounded period.
        Merchant and mode are resolved from auth context; requested fiat,
        gross CCTP USDC, and merchant-net settlement amounts remain
        distinct. On-chain comparison returns unavailable unless bounded
        settlement-event evidence is configured for the selected route.
      required: [window, ledger, on_chain, result, generated_at]
      properties:
        window: { $ref: "#/components/schemas/DashboardSettlementReconciliationWindow" }
        ledger: { $ref: "#/components/schemas/DashboardSettlementLedgerSummary" }
        on_chain: { $ref: "#/components/schemas/DashboardSettlementOnChainSummary" }
        result: { $ref: "#/components/schemas/DashboardSettlementReconciliationResult" }
        generated_at: { type: string, format: date-time }

    DashboardRouteLedgerWindow:
      type: object
      required: [created_from, created_to]
      properties:
        created_from: { type: string, format: date-time }
        created_to: { type: string, format: date-time }

    DashboardRouteLedgerRow:
      type: object
      required:
        - source_chain
        - dest_chain
        - payment_count
        - settled_count
        - stuck_count
        - failed_count
        - expired_count
        - requested_amount_usdc
        - settled_amount_usdc
        - merchant_amount_captured_atomic
        - fee_delta_atomic
      properties:
        source_chain:
          type: string
          description: Source chain recorded on payment_intents for the authenticated merchant and mode.
        dest_chain:
          type: string
          description: Destination chain recorded on payment_intents for the authenticated merchant and mode.
        payment_count:
          type: integer
          minimum: 0
          description: Payment-intent rows in the bounded created_at window for this route.
        settled_count:
          type: integer
          minimum: 0
          description: Rows whose status is SETTLED and whose mint_tx_hash is present.
        stuck_count: { type: integer, minimum: 0 }
        failed_count: { type: integer, minimum: 0 }
        expired_count: { type: integer, minimum: 0 }
        requested_amount_usdc:
          type: string
          pattern: '^[0-9]+$'
          description: Gross USDC atomic units summed from amount_usdc for all rows in this route/window.
        settled_amount_usdc:
          type: string
          pattern: '^[0-9]+$'
          description: Gross USDC atomic units summed from amount_usdc for settled rows only.
        merchant_amount_captured_atomic:
          type: string
          pattern: '^[0-9]+$'
          description: Merchant-net USDC atomic units summed from merchant_amount_captured_atomic for settled rows only.
        fee_delta_atomic:
          type: string
          pattern: '^[0-9]+$'
          description: Derived gross minus merchant-net atomic USDC for settled rows with captured merchant amount.

    DashboardRouteLedgerSummary:
      type: object
      description: |
        Route-level ledger aggregate for future Merchant Dashboard rail
        cards. Merchant and mode are resolved from auth context. This
        endpoint exposes only source_chain -> dest_chain counts and
        amount totals already present in payment_intents; it does not
        claim provider success rates, latency, or route availability.
      required: [window, items, generated_at]
      properties:
        window: { $ref: "#/components/schemas/DashboardRouteLedgerWindow" }
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardRouteLedgerRow" }
        generated_at: { type: string, format: date-time }

    DashboardRouteHealthWindow:
      type: object
      required: [created_from, created_to]
      properties:
        created_from: { type: string, format: date-time }
        created_to: { type: string, format: date-time }

    DashboardRouteLatencyQuantiles:
      type: object
      required: [p50, p95, p99]
      properties:
        p50:
          type: integer
          minimum: 0
          description: Median seconds from payment intent creation to SETTLED state-history event.
        p95:
          type: integer
          minimum: 0
          description: 95th percentile seconds from payment intent creation to SETTLED state-history event.
        p99:
          type: integer
          minimum: 0
          description: 99th percentile seconds from payment intent creation to SETTLED state-history event.

    DashboardRouteHealthRow:
      type: object
      required:
        - source_chain
        - dest_chain
        - payment_count
        - settled_count
        - active_count
        - failed_count
        - expired_count
        - needs_attention_count
        - completion_rate_bps
        - settlement_latency_sample_count
      properties:
        source_chain:
          type: string
          description: Source chain recorded on payment_intents for the authenticated merchant and mode.
        dest_chain:
          type: string
          description: Destination chain recorded on payment_intents for the authenticated merchant and mode.
        payment_count:
          type: integer
          minimum: 0
          description: Payment-intent rows in the bounded created_at window for this route.
        settled_count:
          type: integer
          minimum: 0
          description: Rows whose status is SETTLED and whose SETTLED state-history timestamp is present.
        active_count:
          type: integer
          minimum: 0
          description: Non-terminal rows that are neither SETTLED, FAILED, nor EXPIRED.
        failed_count: { type: integer, minimum: 0 }
        expired_count: { type: integer, minimum: 0 }
        needs_attention_count:
          type: integer
          minimum: 0
          description: Rows that are failed, expired, or in a stuck internal state. Raw internal states are not emitted.
        completion_rate_bps:
          type: integer
          minimum: 0
          maximum: 10000
          description: Settled rows divided by all rows for this route/window, expressed in basis points.
        settlement_latency_seconds:
          $ref: "#/components/schemas/DashboardRouteLatencyQuantiles"
        settlement_latency_sample_count:
          type: integer
          minimum: 0
          description: Count of settled rows with usable created_at and SETTLED timestamps.

    DashboardRouteHealthSummary:
      type: object
      description: |
        Backend-only route-health projection for Merchant Dashboard route
        analysis. Merchant and mode are resolved from auth context. Values
        are computed from payment_intents plus payment_intent_state_history;
        the response omits provider names, internal route hashes,
        attestation material, and raw CCTP state.
      required: [window, items, generated_at]
      properties:
        window: { $ref: "#/components/schemas/DashboardRouteHealthWindow" }
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardRouteHealthRow" }
        generated_at: { type: string, format: date-time }

    DashboardExportJobPaymentsFilter:
      type: object
      description: |
        Filter for an async payments export. Merchant and mode are never
        accepted here; they come from auth context. created_from and
        created_to are required so large exports stay bounded.
      required: [created_from, created_to]
      additionalProperties: false
      properties:
        q:
          type: string
          maxLength: 128
          description: Exact lookup by merchant_order_id or ChainMore payment_id.
        status:
          type: string
          description: Merchant-facing payment status filter. Refund lifecycle values are reserved for reviewed, non-automatic remediation flows and are not accepted as export filters until the refund backend is implemented.
          enum:
            - payment_created
            - checkout_started
            - authorization_pending
            - authorized
            - executing
            - completed
            - failed
            - expired
        created_from:
          type: string
          description: Inclusive created_at lower bound. Accepts YYYY-MM-DD or RFC3339.
        created_to:
          type: string
          description: Exclusive created_at upper bound. YYYY-MM-DD values include that calendar day.

    DashboardExportJobCreateRequest:
      type: object
      required: [export_type, format, filter]
      additionalProperties: false
      properties:
        export_type:
          type: string
          enum: [payments]
        format:
          type: string
          enum: [csv, jsonl]
        filter:
          $ref: "#/components/schemas/DashboardExportJobPaymentsFilter"

    DashboardExportJobView:
      type: object
      description: |
        Redacted async export job status. It does not expose merchant_id,
        filter_json, idempotency_key, storage_uri, or raw failure_message.
      required:
        - export_id
        - export_type
        - format
        - status
        - mode
        - filter_hash
        - created_at
        - expires_at
        - updated_at
      properties:
        export_id: { type: string, format: uuid }
        export_type: { type: string, enum: [payments] }
        format: { type: string, enum: [csv, jsonl] }
        status: { type: string, enum: [queued, running, succeeded, failed, expired, cancelled] }
        mode: { type: string, enum: [test, live] }
        filter_hash:
          type: string
          pattern: '^[a-f0-9]{64}$'
        row_count: { type: integer, format: int64, minimum: 0 }
        byte_count: { type: integer, format: int64, minimum: 0 }
        sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
        failure_code:
          type: string
          description: Bounded failure code when a worker marks the job failed; raw failure messages are not exposed.
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DashboardExportJobsPage:
      type: object
      description: |
        Recent async export jobs for the authenticated merchant and mode.
        Items use the same redacted job projection as the status endpoint;
        storage_uri, filter_json, idempotency_key, and raw failure_message
        remain server-side.
      required: [items, generated_at]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/DashboardExportJobView" }
        generated_at: { type: string, format: date-time }

    # ── Merchant Integration control plane (IDX-M2 / IDX-M4) ─────────────────
    APIKey:
      type: object
      description: |
        Projection of an `api_keys` row. Never carries the secret -
        that is present only in the one-shot response of create /
        rotate and never re-derivable thereafter.
      required: [id, merchant_id, name, mode, key_prefix, status, created_at]
      properties:
        id:           { type: string }
        merchant_id:  { type: string }
        name:         { type: string }
        mode:         { type: string, enum: [test, live] }
        key_prefix:   { type: string, description: "First 9 characters of the plaintext key; safe to display." }
        status:       { type: string, enum: [active, revoked] }
        rotated_from: { type: string }
        created_at:   { type: string, format: date-time }
        revoked_at:   { type: string, format: date-time }

    CreateKeyRequest:
      type: object
      description: |
        Body for `POST /v1/keys`. IDX-I2: `mode` MUST NOT appear in
        the body - it is derived from the authenticated key. A `mode`
        field, if sent, is silently ignored.
      properties:
        name: { type: string, description: "Human-readable label." }

    CreateKeyResponse:
      type: object
      required: [api_key, secret]
      properties:
        api_key: { $ref: "#/components/schemas/APIKey" }
        secret:
          type: string
          description: |
            Plaintext `cm_{mode}_{hex}` secret. Present only in this
            response; the server persists only `sha256(secret)`.

    ListKeysResponse:
      type: object
      required: [api_keys, mode]
      properties:
        api_keys:
          type: array
          items: { $ref: "#/components/schemas/APIKey" }
        mode: { type: string, enum: [test, live] }

    RotateKeyResponse:
      type: object
      required: [api_key, secret, predecessor]
      properties:
        api_key:     { $ref: "#/components/schemas/APIKey" }
        secret:      { type: string, description: "Plaintext of the new key - returned once." }
        predecessor: { $ref: "#/components/schemas/APIKey" }

    WebhookEndpoint:
      type: object
      required:
        - id
        - merchant_id
        - url
        - events
        - enabled
        - mode
        - secret_rotation_due
        - secret_grace_active
        - secret_rotation_cadence
        - created_at
      properties:
        id:          { type: string }
        merchant_id: { type: string }
        url:         { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        enabled:     { type: boolean }
        mode:        { type: string, enum: [test, live] }
        secret_last_rotated_at:
          type: string
          format: date-time
          description: "Last signing-secret rotation timestamp, omitted for legacy rows where the rotation timestamp is unknown."
        secret_rotation_due_at:
          type: string
          format: date-time
          description: "Computed quarterly rotation due timestamp when the last rotation timestamp is known."
        secret_rotation_due:
          type: boolean
          description: "True when the endpoint is past the quarterly signing-secret rotation window."
        secret_grace_until:
          type: string
          format: date-time
          description: "Secondary signing-secret grace-window expiry, if a rotate is in progress."
        secret_grace_active:
          type: boolean
          description: "True while the previous signing secret is still accepted during the rotate grace window."
        secret_rotation_cadence:
          type: string
          enum: [quarterly]
          description: "Merchant-facing label for the ADR 0050 signing-secret rotation cadence."
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }

    CreateEndpointRequest:
      type: object
      required: [url, events]
      description: |
        Body for `POST /v1/webhook_endpoints`. IDX-I2: `mode` is NOT
        accepted in the body; it is derived from the authenticated
        key. Non-https URLs are rejected.
      properties:
        url:
          type: string
          format: uri
          description: "Must be an https URL."
        events:
          type: array
          description: "Event types to subscribe to (e.g. payment.settled, payment_intent.settled)."
          items: { type: string }

    CreateEndpointResponse:
      type: object
      required: [webhook_endpoint, secret]
      properties:
        webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        secret:
          type: string
          description: |
            Plaintext `whsec_{hex}` signing secret. Present only in
            this response; the server persists the envelope-encrypted
            ciphertext and the caller must store this value in their
            own secret manager.

    UpdateEndpointRequest:
      type: object
      description: |
        Partial update. Any field omitted is left unchanged. `mode`
        is NOT updatable - rotate a new endpoint instead.
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          items: { type: string }
        enabled:
          type: boolean

    ListEndpointsResponse:
      type: object
      required: [webhook_endpoints, mode]
      properties:
        webhook_endpoints:
          type: array
          items: { $ref: "#/components/schemas/WebhookEndpoint" }
        mode: { type: string, enum: [test, live] }

    # ── Webhook delivery body (merchant consumes) ────────────────────────────
    WebhookEvent:
      type: object
      description: |
        Shape of the JSON body ChainMore POSTs to a registered
        webhook endpoint. The body is signed with HMAC-SHA256 over
        the exact raw bytes using the endpoint's `whsec_*` secret;
        the signature is carried in the `X-ChainMore-Signature`
        header as lowercase hex. See docs/quickstart.md section 4 for the
        verification snippet.
      required: [id, type, created_at, mode, data]
      properties:
        id:         { type: string, description: "Unique event id - dedupe by this." }
        type:
          type: string
          description: "Event type, e.g. `payment.settled`, `payment_intent.settled`."
        created_at: { type: string, format: date-time }
        mode:       { type: string, enum: [test, live] }
        data:
          type: object
          description: "Event-specific payload. For payment.* events: { payment, checkout_session }."

  responses:
    BadRequest:
      description: Malformed request.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Resource not found.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: |
        State/precondition conflict - e.g. CKO state disallows the
        requested transition (CKO-I2/I3), validation precondition
        unmet, or dispatch gate refused.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Gone:
      description: Resource has expired (e.g. quote or session TTL passed).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Request refused by the fixed-window limiter.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ServerMisconfigured:
      description: Server-side configuration is missing or inconsistent.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

# ---------------------------------------------------------------------------
# Paths
#
# NOTE: Each top-level path key is indented with exactly two spaces; each
# method under it is indented with exactly four. The drift-checker parser
# relies on this formatting - preserve it on edits.
# ---------------------------------------------------------------------------
paths:
  /health:
    get:
      summary: Liveness probe.
      operationId: health
      security: []
      responses:
        '200':
          description: Service is listening.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }

  /internal/providers/paysafe/webhooks:
    post:
      summary: Receive Paysafe sandbox webhooks.
      operationId: receivePaysafeWebhook
      security: []
      description: |
        Provider callback ingress authenticated by Paysafe's `Signature`
        header, not by a ChainMore merchant bearer. The receiver verifies
        Base64(HMAC-SHA256(webhook secret, raw JSON body)) before parsing,
        applies timestamp freshness when configured, and records provider
        event ids through the persistent idempotency guard before running
        callback logic.

        Current implementation is sandbox-gated at gateway boot:
        `PAYSAFE_WEBHOOK_MOUNT=true` refuses unless
        `PAYSAFE_ENVIRONMENT=sandbox`. Production webhook ingestion requires
        a separate approval and wiring pass.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: Signature
          in: header
          required: true
          schema: { type: string }
          description: Paysafe Base64 HMAC-SHA256 signature over the raw JSON request body.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '202':
          description: Webhook accepted or duplicate event already recorded.
        '400':
          description: Invalid payload, missing/stale timestamp, or malformed webhook event.
          content:
            text/plain:
              schema: { type: string }
        '401':
          description: Invalid webhook signature.
          content:
            text/plain:
              schema: { type: string }
        '405':
          description: Method not allowed.
          content:
            text/plain:
              schema: { type: string }
        '413':
          description: Webhook body exceeds the receiver limit.
          content:
            text/plain:
              schema: { type: string }
        '500':
          description: Receiver not configured or idempotency/business callback failed.
          content:
            text/plain:
              schema: { type: string }

  /v1/payment-intents/checkout/session:
    get:
      summary: Read the customer checkout view for a CCTP payment intent.
      operationId: getPaymentIntentCheckoutSession
      security: []
      description: |
        Browser-facing checkout-token endpoint. The token is scoped to one
        payment intent and may be sent as `Authorization: Bearer <token>` or as
        the `token` query parameter. It can read only the customer checkout
        view and expires with the payment intent. If both credentials are sent,
        they must match exactly.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: Checkout token. Authorization Bearer is preferred when available.
      responses:
        '200':
          description: Customer checkout view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentCheckoutSessionResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '429': { $ref: "#/components/responses/RateLimited" }

  /v1/payment-intents/checkout/source-burn-authorization:
    post:
      summary: Bind a checkout wallet and return source-burn calldata.
      operationId: authorizePaymentIntentCheckoutSourceBurn
      security: []
      description: |
        Browser-facing checkout-token endpoint. It binds the connected payer
        wallet while the intent is still awaiting deposit, returns the signed
        PaymentSourceBurnAdapter authorization, and includes server-encoded
        adapter calldata. The checkout token can only operate on its own
        intent; it does not grant merchant API access. If both Authorization
        Bearer and query token credentials are sent, they must match exactly.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: Checkout token. Authorization Bearer is preferred when available.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BindSourceBurnAuthorizationRequest" }
      responses:
        '200':
          description: Source-burn authorization and adapter calldata ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SourceBurnAuthorizationResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '429': { $ref: "#/components/responses/RateLimited" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  /v1/payment-intents/checkout/source-burn-relay:
    post:
      summary: Relay an adapter v2 source burn from a checkout EIP-3009 signature.
      operationId: relayPaymentIntentCheckoutSourceBurn
      security: []
      description: |
        Browser-facing checkout-token endpoint for the premium adapter v2 path.
        Hosted Checkout sends the payer's Circle-USDC receiveWithAuthorization
        signature. ChainMore validates that it exactly matches the checkout's
        server-derived typed data, relays burnWithReceiveAuthorization through
        the configured source-burn relay, and stores the returned source tx
        hash as a pending browser/server claim. The on-chain observer still
        owns final BURNED verification.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: Checkout token. Authorization Bearer is preferred when available.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RelayCheckoutSourceBurnRequest" }
      responses:
        '200':
          description: Source burn relay submitted; intent awaits on-chain confirmation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SourceBurnRelayResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '429': { $ref: "#/components/responses/RateLimited" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  /v1/payment-intents/checkout/source-tx:
    post:
      summary: Claim the source-chain burn transaction hash from Hosted Checkout.
      operationId: claimPaymentIntentCheckoutSourceTx
      security: []
      description: |
        Browser-facing checkout-token endpoint. It records the tx hash returned
        by the customer's wallet after `eth_sendTransaction` succeeds. This is
        only a browser claim: it does not move the intent to BURNED. The
        source-chain observer verifies the transaction and MessageSent event
        before advancing state. Repeating the same tx hash is idempotent;
        claiming a different tx hash for the same intent returns 409. If both
        Authorization Bearer and query token credentials are sent, they must
        match exactly.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: Checkout token. Authorization Bearer is preferred when available.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClaimCheckoutSourceTxRequest" }
      responses:
        '200':
          description: Source tx claim stored; intent still awaits on-chain confirmation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentStatus" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '429': { $ref: "#/components/responses/RateLimited" }

  /v1/payment-intents/{paymentId}:
    get:
      summary: Read a CCTP payment-intent surface status.
      operationId: getPaymentIntent
      description: |
        Returns the merchant-visible surface status for one CCTP
        payment intent. The lookup is scoped by the authenticated API
        key's merchant_id and mode; unknown, cross-merchant, and
        cross-mode UUIDs all return 404 to avoid resource-existence
        leaks. Test/live mode is resolved from the API key and cannot
        be supplied by the caller. The raw internal CCTP lifecycle
        state is not returned.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: CCTP payment-intent surface status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentReadResponse" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '429': { $ref: "#/components/responses/RateLimited" }

  /v1/payment-intents/{paymentId}/source-burn-authorization:
    post:
      summary: Bind a payer wallet and return a source-burn authorization.
      operationId: authorizePaymentIntentSourceBurn
      description: |
        Merchant-machine endpoint for server-side checkout integrations. The
        public Hosted Checkout page should use
        /v1/payment-intents/checkout/source-burn-authorization with the
        checkout token instead of exposing a merchant API key in the browser.
        The lookup is scoped by the authenticated API key's merchant_id and
        mode; unknown, cross-merchant, and cross-mode UUIDs all return 404.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BindSourceBurnAuthorizationRequest" }
      responses:
        '200':
          description: Source-burn authorization ready for adapter call.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SourceBurnAuthorizationResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '429': { $ref: "#/components/responses/RateLimited" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  /v1/payment-intents:
    get:
      summary: List CCTP payment intents that need review.
      operationId: listPaymentIntentsNeedsReview
      description: |
        Narrow list endpoint for the merchant dashboard review queue.
        Only surface_status=needs_review is supported. Unknown, delayed,
        processing, and completed intents are intentionally outside this
        endpoint.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - name: surface_status
          in: query
          required: true
          schema:
            type: string
            enum: [needs_review]
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        '200':
          description: CCTP payment intents that need review.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentNeedsReviewList" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '429': { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create a CCTP payment intent.
      operationId: createPaymentIntent
      description: |
        Creates a Pattern-E CCTP payment intent for the authenticated
        merchant and API-key mode. The server resolves the active
        settlement route (V4/V5) for the destination chain; callers
        cannot choose the route. Idempotent replays with the same
        request return the existing intent with 200.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePaymentIntentRequest" }
      responses:
        '201':
          description: CCTP payment intent created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentCreateResponse" }
        '200':
          description: Idempotent replay of an existing CCTP payment intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentCreateResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '429': { $ref: "#/components/responses/RateLimited" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  /v1/payments:
    post:
      summary: Create a payment.
      operationId: createPayment
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePaymentRequest" }
      responses:
        '201':
          description: Payment created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatePaymentResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '429': { $ref: "#/components/responses/RateLimited" }

  /v1/payments/{id}:
    get:
      summary: Read a payment.
      operationId: getPayment
      parameters:
        - $ref: "#/components/parameters/PaymentID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Payment view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/payments/{id}/attempts:
    post:
      summary: Start a payment attempt (EXE-GATE-I1 - refuses without wired gate).
      operationId: startAttempt
      parameters:
        - $ref: "#/components/parameters/PaymentID"
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartAttemptRequest" }
      responses:
        '201':
          description: Attempt created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartAttemptResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/payments/{id}/attempts/{aid}/register-direct-tx:
    post:
      summary: Record the user-signed tx for a direct-wallet leg.
      operationId: registerDirectTx
      parameters:
        - $ref: "#/components/parameters/PaymentID"
        - $ref: "#/components/parameters/AttemptID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterDirectTxRequest" }
      responses:
        '200':
          description: Direct tx recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterDirectTxResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/checkout/sessions:
    post:
      summary: Initialise a hosted-checkout session (CKO-I1 entry point).
      operationId: initCheckoutSession
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InitSessionRequest" }
      responses:
        '201':
          description: Session created; raw token returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InitSessionResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/checkout/sessions/by-payment/{payment_id}:
    get:
      summary: Poll the checkout state for a payment.
      operationId: getCheckoutState
      parameters:
        - $ref: "#/components/parameters/CheckoutPaymentID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Current CKO read-model view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutState" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/checkout/sessions/by-payment/{payment_id}/bind-wallet:
    post:
      summary: Bind wallet + source chain; triggers routing + validation.
      operationId: bindCheckoutWallet
      parameters:
        - $ref: "#/components/parameters/CheckoutPaymentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BindWalletRequest" }
      responses:
        '200':
          description: Session advanced to route_ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BindWalletResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '410': { $ref: "#/components/responses/Gone" }

  /v1/checkout/sessions/by-payment/{payment_id}/submit:
    post:
      summary: User-signed submit; runs dispatch gate then starts execution.
      operationId: submitCheckoutExecution
      parameters:
        - $ref: "#/components/parameters/CheckoutPaymentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitExecutionRequest" }
      responses:
        '200':
          description: Session advanced to submitted/executing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubmitExecutionResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '410': { $ref: "#/components/responses/Gone" }

  /v1/checkout/sessions/by-payment/{payment_id}/cancel:
    post:
      summary: Cancel a non-terminal session.
      operationId: cancelCheckoutSession
      parameters:
        - $ref: "#/components/parameters/CheckoutPaymentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '204':
          description: Session cancelled (terminal).
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/checkout/sessions/by-token/{token}:
    get:
      summary: Look up the session bound to a raw checkout token (CKO-I4).
      operationId: getCheckoutSessionByToken
      parameters:
        - $ref: "#/components/parameters/CheckoutToken"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Session record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/merchants:
    get:
      summary: Phase-1 merchants reserved response.
      operationId: merchantsPlaceholder
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Reserved shape; stable until the merchants surface becomes available.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MerchantsReservedResponse" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/routing/plan:
    post:
      summary: Plan a route from aggregate bindings (REC-I1 C1).
      operationId: planRoute
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlanRouteRequest" }
      responses:
        '200':
          description: Authoritative routing decision.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoutingDecision" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/routing/decisions/{id}:
    get:
      summary: Fetch a routing decision by ID.
      operationId: getRoutingDecision
      parameters:
        - $ref: "#/components/parameters/DecisionID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Decision found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoutingDecision" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/validation/validate:
    post:
      summary: Pre-flight-validate a route candidate (PFV-I1 C1).
      operationId: validateRoute
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ValidateRouteRequest" }
      responses:
        '200':
          description: Validation complete.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RouteValidation" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/validation/validations/{id}:
    get:
      summary: Fetch a route validation by ID.
      operationId: getRouteValidation
      parameters:
        - $ref: "#/components/parameters/DecisionID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Validation found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RouteValidation" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dispatch/decide:
    post:
      summary: Run the dispatcher gate (DISP-I1 D1/D4).
      operationId: decideDispatch
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DispatchDecideRequest" }
      responses:
        '200':
          description: Dispatch decision.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DispatchDecision" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '409': { $ref: "#/components/responses/Conflict" }

  /v1/dispatch/decisions/{id}:
    get:
      summary: Fetch a dispatch decision by ID.
      operationId: getDispatchDecision
      parameters:
        - $ref: "#/components/parameters/DecisionID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Decision found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DispatchDecision" }
        '404': { $ref: "#/components/responses/NotFound" }

  # -------------------------------------------------------------------------
  # Widget / Hosted Checkout JSON surface (WGT-I1)
  # -------------------------------------------------------------------------
  /v1/checkout/widget/bind-wallet:
    post:
      summary: Bind the end-user wallet using the checkout token.
      description: |
        Public browser endpoint authenticated only with the short-lived
        checkout token. The browser never sends or sees a merchant API key or
        payment_id; the server resolves the payment binding from the token.
      operationId: bindWidgetWallet
      security: []
      parameters:
        - name: token
          in: query
          required: false
          description: "Raw checkout token fallback when Authorization header is not used."
          schema: { type: string, minLength: 1, maxLength: 256 }
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BindWalletRequest" }
      responses:
        '200':
          description: Session advanced to route_ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BindWalletResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '410': { $ref: "#/components/responses/Gone" }

  /v1/checkout/widget/session:
    get:
      summary: Redacted widget view for the hosted checkout browser page.
      description: |
        Authenticated with the short-lived checkout token (not the merchant
        key): `Authorization: Bearer <raw-checkout-token>`, or as a
        `?token=` query fallback for curl. Returns the redacted view
        the widget renders from (WGT-I1).
      operationId: getWidgetSession
      security: []
      parameters:
        - name: token
          in: query
          required: false
          description: "Raw checkout token fallback when Authorization header is not used."
          schema: { type: string, minLength: 1, maxLength: 256 }
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Widget view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WidgetSessionView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/checkout/widget/submit:
    post:
      summary: Submit the user-signed transaction using the checkout token.
      description: |
        Public browser endpoint authenticated only with the short-lived
        checkout token. The server resolves the payment binding from the token
        and runs the same dispatch/execution path as the machine endpoint.
      operationId: submitWidgetExecution
      security: []
      parameters:
        - name: token
          in: query
          required: false
          description: "Raw checkout token fallback when Authorization header is not used."
          schema: { type: string, minLength: 1, maxLength: 256 }
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitExecutionRequest" }
      responses:
        '200':
          description: Session advanced to submitted/executing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubmitExecutionResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '410': { $ref: "#/components/responses/Gone" }

  # -------------------------------------------------------------------------
  # Merchant Dashboard async export control plane
  # -------------------------------------------------------------------------
  /v1/dashboard/exports:
    get:
      summary: List recent async dashboard export jobs.
      operationId: listDashboardExportJobs
      description: |
        Lists recent async export job metadata for the authenticated
        merchant and mode. Cross-merchant and cross-mode jobs are not
        visible, and storage/provider internals are never exposed.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Recent export jobs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardExportJobsPage" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

    post:
      summary: Queue an async dashboard export job for the authenticated merchant.
      operationId: createDashboardExportJob
      description: |
        Creates export job metadata only. Merchant and mode are resolved
        from the dashboard auth context; request bodies cannot assert
        merchant_id or mode. This endpoint does not run a worker, create
        a downloadable file, expose storage URIs, or move funds.
      parameters:
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardExportJobCreateRequest" }
      responses:
        '202':
          description: Export job accepted or existing matching idempotent job returned.
          headers:
            Location:
              description: Status URL for the queued export job.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardExportJobView" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/exports/{id}:
    get:
      summary: Read async dashboard export job status.
      operationId: getDashboardExportJob
      description: |
        Reads export job metadata for the authenticated merchant and
        mode. Cross-merchant or cross-mode job IDs do not return another
        merchant's job; storage_uri and raw failure_message are never
        exposed.
      parameters:
        - $ref: "#/components/parameters/DashboardExportID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Export job status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardExportJobView" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/exports/{id}/download:
    get:
      summary: Download a completed async dashboard export.
      operationId: downloadDashboardExportJob
      description: |
        Streams the generated export artifact for the authenticated
        merchant and mode. Cross-merchant, cross-mode, missing, queued,
        running, failed, or cancelled jobs do not expose another export.
        The response never returns storage_uri, object keys, signed URLs,
        or storage-provider credentials.
      parameters:
        - $ref: "#/components/parameters/DashboardExportID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Export artifact stream.
          headers:
            Cache-Control:
              schema: { type: string, enum: [no-store] }
            Content-Disposition:
              description: Deterministic attachment filename.
              schema: { type: string }
          content:
            text/csv:
              schema: { type: string, format: binary }
            application/x-ndjson:
              schema: { type: string, format: binary }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
        '409': { $ref: "#/components/responses/Conflict" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  # -------------------------------------------------------------------------
  # Merchant onboarding control plane
  # -------------------------------------------------------------------------
  /v1/onboarding/account-profile:
    get:
      summary: Read the authenticated merchant's current business profile submission.
      operationId: getAccountProfileSubmission
      description: |
        Merchant-user readback for the current submitted business profile.
        The server resolves merchant_id from the authenticated dashboard user.
        This endpoint is read-only: it does not grant live access, issue keys,
        create provider handles, or expose DD/KYB decision notes.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Current account profile readback.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountProfileReadbackResponse" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

    post:
      summary: Submit the authenticated merchant's business profile for account review.
      operationId: submitAccountProfile
      description: |
        Merchant-user control-plane write. The server resolves
        merchant_id from the authenticated dashboard user; callers must
        not send tenant identity in the body. This starts DD/KYB review
        only. It does not grant live access, issue API keys, create
        provider handles, or trigger payments.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitAccountProfileRequest" }
      responses:
        '201':
          description: Account profile submission accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubmitAccountProfileResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '500': { $ref: "#/components/responses/ServerMisconfigured" }

  # -------------------------------------------------------------------------
  # Merchant Dashboard read plane (MDA-I2 - GET-only; exports above are
  # separate control-plane metadata endpoints)
  # -------------------------------------------------------------------------
  /v1/dashboard/integration:
    get:
      summary: Merchant-scoped integration posture summary.
      operationId: getDashboardIntegration
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Integration view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MerchantIntegrationView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/onboarding/progress:
    get:
      summary: Read merchant onboarding setup progress.
      operationId: getDashboardOnboardingProgress
      description: |
        Read-only setup-progress contract for the authenticated
        merchant. The response is derived from existing account,
        API-key, webhook, test-event, and settled test-payment rows.
        It does not issue keys, fire webhooks, trigger payments,
        create provider handles, or enable live access.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Onboarding setup progress view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MerchantOnboardingProgressView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/audit-events:
    get:
      summary: List account audit events for the authenticated merchant.
      operationId: listDashboardAuditEvents
      description: |
        Read-only account activity for the authenticated merchant. The
        endpoint is merchant-scoped and deliberately redacted: it returns
        event type, actor type, optional bounded target, and timestamp
        only. It never returns metadata JSON, idempotency keys, raw
        DD/KYB review reasons, operator notes, or another merchant's row.
      parameters:
        - $ref: "#/components/parameters/DashboardCursor"
        - $ref: "#/components/parameters/DashboardLimit"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Page of account audit events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardAuditEventsPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/team-access:
    get:
      summary: Read team members and invitations for the authenticated merchant.
      operationId: getDashboardTeamAccess
      description: |
        Read-only account-level team access snapshot. The endpoint is
        merchant-scoped and redacted: it returns member role/status and
        invitation role/status, but never OIDC issuer/subject,
        invitation_token_hash, plaintext invitation tokens, or
        cross-merchant rows.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Redacted team access snapshot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardTeamAccessPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/exceptions/summary:
    get:
      summary: Read CCTP exception depth for the authenticated merchant (mode-scoped).
      operationId: getDashboardExceptionSummary
      description: |
        Compact read-only Ops snapshot for delayed, needs_review,
        failed_safely, and expired CCTP payment intents. Merchant and
        mode come from the authenticated dashboard context; raw CCTP
        states such as STUCK_MINT are deliberately not exposed.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Exception-depth summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardExceptionSummary" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/customers/{id}:
    get:
      summary: Read Customer-360 activity for one merchant-supplied customer reference.
      operationId: getDashboardCustomerActivity
      description: |
        Returns a merchant- and mode-scoped CCTP activity summary plus
        recent stage-based payment-intent rows for one pseudonymous
        customer_reference. The path id is the merchant-supplied
        customer_reference; merchant and mode come from the
        authenticated dashboard context.
      parameters:
        - name: id
          in: path
          required: true
          description: Merchant-supplied customer_reference.
          schema:
            type: string
            maxLength: 128
            pattern: '^[A-Za-z0-9._:-]+$'
        - $ref: "#/components/parameters/DashboardLimit"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Customer activity view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardCustomerActivityPage" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/payments:
    get:
      summary: List payments for the authenticated merchant (mode-scoped).
      operationId: listDashboardPayments
      parameters:
        - $ref: "#/components/parameters/DashboardCursor"
        - $ref: "#/components/parameters/DashboardLimit"
        - $ref: "#/components/parameters/DashboardPaymentSearch"
        - $ref: "#/components/parameters/DashboardPaymentStatus"
        - $ref: "#/components/parameters/DashboardPaymentCreatedFrom"
        - $ref: "#/components/parameters/DashboardPaymentCreatedTo"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Page of payments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardPaymentsPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/payments/export.csv:
    get:
      summary: Export the filtered dashboard payment result set as CSV.
      operationId: exportDashboardPaymentsCSV
      parameters:
        - $ref: "#/components/parameters/DashboardPaymentSearch"
        - $ref: "#/components/parameters/DashboardPaymentStatus"
        - $ref: "#/components/parameters/DashboardPaymentCreatedFrom"
        - $ref: "#/components/parameters/DashboardPaymentCreatedTo"
        - $ref: "#/components/parameters/DashboardExportLimit"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: CSV export. Requested fiat amount/currency and settlement asset are separate columns.
          headers:
            X-ChainMore-Export-Returned-Count:
              description: Number of rows included in this CSV response.
              schema: { type: integer, minimum: 0 }
            X-ChainMore-Export-Row-Limit:
              description: Server-enforced maximum rows for this CSV response.
              schema: { type: integer, minimum: 1, maximum: 5000 }
            X-ChainMore-Export-Total-Count:
              description: Full filtered result count before the CSV row cap.
              schema: { type: integer, minimum: 0 }
            X-ChainMore-Export-Truncated:
              description: True when the filtered result set is larger than the returned CSV.
              schema: { type: boolean }
          content:
            text/csv:
              schema: { type: string }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/payments/{id}:
    get:
      summary: Read a single payment from the dashboard projection.
      operationId: getDashboardPayment
      parameters:
        - $ref: "#/components/parameters/DashboardPaymentID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Payment view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardPaymentView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/checkout_sessions:
    get:
      summary: List checkout sessions for the authenticated merchant (mode-scoped).
      operationId: listDashboardSessions
      parameters:
        - $ref: "#/components/parameters/DashboardCursor"
        - $ref: "#/components/parameters/DashboardLimit"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Page of sessions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSessionsPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/checkout_sessions/{id}:
    get:
      summary: Read a single checkout session from the dashboard projection.
      operationId: getDashboardSession
      parameters:
        - $ref: "#/components/parameters/DashboardSessionID"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Session view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSessionView" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/reconciliation:
    get:
      summary: Search CCTP reconciliation rows for the authenticated merchant (mode-scoped).
      operationId: listDashboardReconciliation
      description: |
        Read-only merchant dashboard projection over payment_intents.
        The required `q` parameter is exact-match support lookup by
        merchant_order_id, customer_reference, or payment-intent ID.
        The repository always derives merchant and mode from auth
        context; `q` never broadens tenant or mode scope.
      parameters:
        - $ref: "#/components/parameters/DashboardLimit"
        - name: q
          in: query
          required: true
          schema:
            type: string
            maxLength: 128
          description: "Required exact lookup by merchant_order_id, customer_reference, or payment-intent ID."
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Reconciliation rows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardReconciliationPage" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/reconciliation/settlement-summary:
    get:
      summary: Read aggregate settlement reconciliation for a bounded period.
      operationId: getDashboardSettlementReconciliationSummary
      description: |
        Read-only high-volume Finance summary. Merchant and mode are
        resolved from auth context; the query only bounds the time
        window and optional route filters. The endpoint exposes ledger-side
        totals and returns on_chain.status = unavailable unless bounded
        settlement-event evidence is configured for the selected route.
      parameters:
        - name: created_from
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Start of the created_at window, inclusive. YYYY-MM-DD is also accepted by the Gateway.
        - name: created_to
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: End of the created_at window, exclusive. YYYY-MM-DD is accepted and treated as the end of that date.
        - name: dest_chain
          in: query
          schema:
            type: string
            enum: [ethereum, base, arbitrum, polygon]
          description: Optional destination chain filter.
        - name: settlement_contract
          in: query
          schema:
            type: string
            pattern: '^0x[a-f0-9]{40}$'
          description: Optional destination settlement contract filter.
        - name: merchant_wallet
          in: query
          schema:
            type: string
            pattern: '^0x[a-f0-9]{40}$'
          description: Optional merchant wallet filter.
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Aggregate settlement reconciliation summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSettlementReconciliationSummary" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/reconciliation/route-summary:
    get:
      summary: Read route-level ledger summary for a bounded period.
      operationId: getDashboardRouteLedgerSummary
      description: |
        Read-only Merchant Dashboard route ledger contract. Merchant and
        mode are resolved from auth context; query parameters only bound
        the created_at window. If both created_from and created_to are
        omitted, the Gateway uses the last 24 hours. The response is a
        payment_intents ledger aggregate, not a provider health, latency,
        success-rate, or availability claim.
      parameters:
        - name: created_from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Start of the created_at window, inclusive. YYYY-MM-DD is also accepted by the Gateway. Must be paired with created_to when provided.
        - name: created_to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: End of the created_at window, exclusive. YYYY-MM-DD is accepted and treated as the end of that date. Must be paired with created_from when provided.
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Route-level ledger summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardRouteLedgerSummary" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/reconciliation/route-health-summary:
    get:
      summary: Read route-level health projection for a bounded period.
      operationId: getDashboardRouteHealthSummary
      description: |
        Backend-only Merchant Dashboard route-health projection. Merchant
        and mode are resolved from auth context; query parameters only
        bound the created_at window. If both created_from and created_to
        are omitted, the Gateway uses the last 24 hours. Values are
        computed from payment_intents plus payment_intent_state_history
        and omit provider names, internal route hashes, attestation
        material, and raw CCTP state.
      parameters:
        - name: created_from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Start of the created_at window, inclusive. YYYY-MM-DD is also accepted by the Gateway. Must be paired with created_to when provided.
        - name: created_to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: End of the created_at window, exclusive. YYYY-MM-DD is accepted and treated as the end of that date. Must be paired with created_from when provided.
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Route-level health projection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardRouteHealthSummary" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/reconciliation/{id}:
    get:
      summary: Read one CCTP payment-intent reconciliation detail row.
      operationId: getDashboardReconciliation
      description: |
        Read-only merchant dashboard detail for one payment intent.
        Merchant and mode are resolved from auth context; cross-tenant
        or cross-mode IDs do not return another merchant's row.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Bounded reconciliation lifecycle detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardReconciliationDetailView" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/dashboard/webhooks/endpoints:
    get:
      summary: List webhook endpoints for the authenticated merchant (mode-scoped).
      operationId: listDashboardWebhookEndpoints
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Webhook endpoints.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardWebhookEndpointsPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/webhooks/summary:
    get:
      summary: Read webhook delivery health summary for the authenticated merchant (mode-scoped).
      operationId: getDashboardWebhookHealthSummary
      description: |
        Read-only merchant dashboard metric contract over
        webhook_endpoints and webhook_outbox for the last 24h. Merchant
        and mode are resolved from auth context; the response never
        exposes webhook secrets, signing material, body bytes, raw
        response text, or cross-merchant identifiers.
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Bounded webhook health summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardWebhookHealthSummary" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/dashboard/webhooks/deliveries:
    get:
      summary: List webhook delivery attempts for the authenticated merchant (mode-scoped).
      operationId: listDashboardWebhookDeliveries
      parameters:
        - $ref: "#/components/parameters/DashboardCursor"
        - $ref: "#/components/parameters/DashboardLimit"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Page of deliveries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardWebhookDeliveriesPage" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  # -------------------------------------------------------------------------
  # Merchant Integration control plane (IDX-I3 - self-service lifecycle)
  #
  # All mutations in this section derive (merchant_id, mode) from the
  # authenticated API key. IDX-I2 forbids mode assertion from the caller.
  # -------------------------------------------------------------------------
  /v1/keys:
    get:
      summary: List API keys for the authenticated merchant, scoped to the key's mode.
      operationId: listKeys
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Keys in the caller's mode.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListKeysResponse" }
        '401': { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Mint a new API key for the authenticated merchant, in the caller's mode.
      operationId: createKey
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateKeyRequest" }
      responses:
        '201':
          description: Key created - plaintext secret returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateKeyResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/keys/{id}:
    delete:
      summary: Revoke an API key. Idempotent - revoking an already-revoked key is a no-op.
      operationId: revokeKey
      parameters:
        - $ref: "#/components/parameters/APIKeyID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Revoked key record.
          content:
            application/json:
              schema:
                type: object
                required: [api_key]
                properties:
                  api_key: { $ref: "#/components/schemas/APIKey" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/keys/{id}/rotate:
    post:
      summary: Rotate an API key - mint a successor, revoke the predecessor, in one transaction.
      operationId: rotateKey
      parameters:
        - $ref: "#/components/parameters/APIKeyID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '201':
          description: Successor key minted; plaintext returned once; predecessor revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RotateKeyResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/webhook_endpoints:
    get:
      summary: List webhook endpoints for the authenticated merchant, scoped to the key's mode.
      operationId: listWebhookEndpoints
      parameters:
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Webhook endpoints in the caller's mode.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListEndpointsResponse" }
        '401': { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Register a new webhook endpoint, in the caller's mode.
      operationId: createWebhookEndpoint
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateEndpointRequest" }
      responses:
        '201':
          description: Endpoint created; `whsec_*` signing secret returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateEndpointResponse" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }

  /v1/webhook_endpoints/{id}:
    patch:
      summary: Update a webhook endpoint (url / events / enabled). Mode is not patchable.
      operationId: updateWebhookEndpoint
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateEndpointRequest" }
      responses:
        '200':
          description: Updated endpoint record.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }
    delete:
      summary: Disable a webhook endpoint. Idempotent.
      operationId: disableWebhookEndpoint
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Disabled endpoint record.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/webhook_endpoints/{id}/test:
    post:
      summary: Send a synthetic settlement test event to the endpoint.
      description: |
        IDX-R2b: exercises the real webhook delivery code path without
        real money movement. The resulting outbox row carries
        `is_test_event = TRUE` and `payment_id IS NULL`.

        If the endpoint subscribes to `payment_intent.settled` (or has
        an empty wildcard event list), the stored body mirrors the CCTP
        payment-intent settlement shape and carries
        `event_type: payment_intent.settled`, synthetic
        payment/route/settlement evidence, and `test_event: true`.

        Legacy endpoints subscribed only to `payment.settled` keep the
        legacy body shape with synthetic `payment_id` / `merchant_order_id`
        / `event_id` values on the `pmt_test_*` / `ord_test_*` /
        `whevt_test_*` shapes.

        Endpoints whose event list contains neither settlement event are
        rejected with `invalid_request`; the test endpoint never sends an
        event type outside the endpoint subscription.

        The outbox row's `mode` is stamped from the authenticated
        bearer; a live-mode key may not test a test-mode endpoint and
        vice versa.
      operationId: sendTestWebhookEvent
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '202':
          description: Test event enqueued for delivery.
          content:
            application/json:
              schema:
                type: object
                required: [event_id, outbox_id, endpoint_url, mode, created_at, is_test_event]
                properties:
                  event_id:
                    type: string
                    description: Synthetic event ID on the `whevt_test_*` shape.
                  outbox_id:
                    type: string
                    format: uuid
                    description: Primary key of the `webhook_outbox` row just written.
                  endpoint_url:
                    type: string
                    format: uri
                  mode:
                    type: string
                    enum: [test, live]
                  created_at:
                    type: string
                    format: date-time
                  is_test_event:
                    type: boolean
                    description: Always `true` for this endpoint's response.
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/webhook_endpoints/{id}/rotate-secret:
    post:
      summary: Rotate the HMAC signing secret for a webhook endpoint. Same endpoint id, fresh secret.
      description: |
        IDX-R3: self-service secret rotation. Mints a new `whsec_*`
        signing secret, envelope-encrypts it, and atomically replaces
        `(secret_hash, secret_ciphertext, secret_kid, updated_at)` on
        the endpoint row. The endpoint's id, url, events, enabled, and
        mode are intentionally unchanged - same endpoint, new key.

        The plaintext is returned exactly once in the response body. At
        rest the row carries only the envelope ciphertext and the
        one-way hash; a lost plaintext means the merchant must call
        this endpoint again.

        Cutover semantics - any payload already signed with the OLD
        secret that arrives after the rotate commits will fail HMAC
        verification on the merchant side. Operate rotate around a
        verifier-update window; the suggested sequence is (1) call
        rotate-secret, (2) deploy the returned plaintext to the
        verifier, (3) observe that new deliveries verify, (4) accept
        the old secret is dead.

        Mode-scoped: a live-mode bearer cannot rotate a test-mode
        endpoint's secret (and vice versa) - the request returns 404.
      operationId: rotateWebhookEndpointSecret
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '200':
          description: Secret rotated; plaintext returned once; endpoint id unchanged.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint, secret]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret:
                    type: string
                    description: |
                      New `whsec_*` signing secret. Returned exactly
                      once; never re-derivable thereafter.
        '400': { $ref: "#/components/responses/BadRequest" }
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

  /v1/webhook_endpoints/{id}/deliveries/{did}/replay:
    post:
      summary: Replay a previously-emitted webhook delivery. Fresh event_id, fresh signature.
      description: |
        IDX-R2a: self-service replay of a historical webhook delivery.
        Writes a new `webhook_outbox` row that targets the same endpoint
        and carries the same semantic event, but is:

          - Signed with the CURRENT endpoint signing secret (not the
            historical secret stored on the original row).
          - Signed with a fresh `sig_timestamp` (now, RFC3339 UTC).
          - Stamped with a fresh `event_id` (the UNIQUE idempotency key).

        This signing contract is deliberate: if the merchant rotated the
        endpoint's signing secret via the rotate-secret endpoint between
        the original emit and this replay, the merchant's verifier no
        longer accepts signatures from the old secret. Re-signing with
        the current secret is the only shape that can verify at the
        merchant.

        The merchant correlates a replay back to the original delivery
        via the `replayed_from_event_id` field on the body. That field
        names the ORIGINAL `event_id` (the whevt-prefixed id the merchant
        already saw, or would have seen if the original delivered). The
        new delivery's own `event_id` is the FRESH replay id - merchants
        dedupe on event_id on their receiving side; a replay is a
        distinct delivery that happens to reference an earlier one.

        Refusals:
          - `replay_of_test_event_forbidden` (400): the delivery id
            references a test event (is_test_event=TRUE). Test events
            have their own replay path - call `POST /v1/webhook_endpoints/
            {id}/test` again.
          - `delivery_endpoint_mismatch` (400): the delivery id exists
            (merchant- and mode-scoped) but was emitted against a
            different endpoint id than the one in the path.
          - `endpoint_disabled` (400): the endpoint is currently disabled.
          - `endpoint_secret_missing_envelope` (400): the endpoint
            predates envelope-encrypted secrets (pre-MDB rollout).
            Resolve via rotate-secret, which populates envelope columns.

        Mode-scoped: a live-mode bearer cannot replay a test-mode
        delivery (and vice versa) - the request returns 404 with no
        leak of which scope the row actually lives in.

        Delivery itself is asynchronous - the response 202 confirms a
        new outbox row was written; the merchant observes completion at
        their HTTP endpoint (or via the delivery-history dashboard).
      operationId: replayWebhookDelivery
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: did
          in: path
          required: true
          description: |
            The `delivery_id` (webhook_outbox.id) the merchant observed
            in the dashboard's delivery history. Server-side scoped by
            (merchant_id, mode, endpoint_id).
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/CorrelationID"
      responses:
        '202':
          description: Replay row accepted; delivery is asynchronous via the outbox worker.
          content:
            application/json:
              schema:
                type: object
                required:
                  - event_id
                  - replayed_from_event_id
                  - outbox_id
                  - original_outbox_id
                  - endpoint_url
                  - mode
                  - created_at
                properties:
                  event_id:
                    type: string
                    description: The fresh event id for this replay delivery.
                  replayed_from_event_id:
                    type: string
                    description: The original delivery's event id. Merchants correlate on this field.
                  outbox_id:
                    type: string
                    format: uuid
                    description: The new webhook_outbox row id.
                  original_outbox_id:
                    type: string
                    format: uuid
                    description: The replayed webhook_outbox row id (same as path `did`).
                  endpoint_url:
                    type: string
                    format: uri
                  mode:
                    type: string
                    enum: [test, live]
                  created_at:
                    type: string
                    format: date-time
        '400':
          description: |
            Replay refused (test-event, endpoint mismatch, endpoint
            disabled, or missing envelope secret). Body is a Problem
            with one of the codes above.
        '401': { $ref: "#/components/responses/Unauthorized" }
        '404': { $ref: "#/components/responses/NotFound" }

# ---------------------------------------------------------------------------
# Webhooks (informational; body shape for merchant-side verifiers)
# ---------------------------------------------------------------------------
webhooks:
  payment.settled:
    post:
      summary: Sent when a payment reaches its settled terminal state.
      description: |
        Body is signed with HMAC-SHA256 over the raw bytes using the
        endpoint's `whsec_*` secret. The hex signature is carried in
        the `X-ChainMore-Signature` header. Consumers MUST use a
        timing-safe comparison.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        '2XX':
          description: Delivery acknowledged.
  payment.failed:
    post:
      summary: Sent when a payment reaches a failed terminal state.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        '2XX':
          description: Delivery acknowledged.
