# The merchant REST contract. PRODUCT.md §12.1 and §12.2 are the source.
#
# This file holds only what §12 defines completely. Everything else is listed at the
# bottom against the milestone that serves it. A route named here and absent at runtime
# is worse than a route not yet written down.
openapi: 3.1.0
info:
  title: Payment API
  version: "2026-09-01"
servers:
  # §12.1 gives the base as .../v1. The prefix is written into every path instead, so a
  # reader never has to join two halves to know what is served.
  - url: https://api.example.com

security:
  - bearerAuth: []

paths:
  /v1/payment_intents:
    post:
      operationId: createPaymentIntent
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentCreate" }
      responses:
        "201":
          description: Created, or the replay of an earlier identical request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        "400":
          description: A parameter is invalid. A refused return_url answers invalid_return_url.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: The idempotency key is in flight, or the merchant order id is taken.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: The idempotency key was reused with a different body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    get:
      operationId: listPaymentIntents
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/PaymentIntentStatus" }
        - name: merchant_order_id
          in: query
          schema: { type: string }
        - name: created[gte]
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: A page of payment intents, newest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/payment_intents/{id}:
    get:
      operationId: paymentIntent
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The payment intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/payment_intents/{id}/cancel:
    post:
      operationId: cancelPaymentIntent
      description: Only a payment intent in created or requires_payment can be canceled.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The canceled payment intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/payment_intents/{id}/attempts:
    post:
      operationId: createPaymentAttempt
      description: >-
        Start one payment attempt against a channel and return the payment credential.
        The PaymentAttempt object is not in this spec yet. PRODUCT.md §12.2 names the
        route and defines no response object. M3 adds the object.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_method]
              properties:
                payment_method: { type: string }
                channel: { type: string }
      responses:
        "201":
          description: >-
            The attempt and its payment credential. An intent that already holds a pending
            attempt answers with that one, so a second call places no second order with the
            channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAttempt" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    get:
      operationId: listPaymentAttempts
      description: >-
        Every attempt on one payment intent, oldest first, which is the order they were
        tried in. A failed attempt stays: it is what a dispute is read against.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: Every attempt on the intent, oldest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAttemptList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/test/payment_intents/{id}/simulate:
    post:
      operationId: simulatePaymentIntent
      description: >-
        Test mode only. Tell the channel that serves this payment intent what to report,
        then deliver the callback it answers with on the same path a real delivery takes,
        signature check included. A live key, or a channel that cannot be told what to
        report, is refused. The Idempotency-Key is claimed like every other POST: a retry
        answers the stored result, and the same key on another intent is 422.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentSimulate" }
      responses:
        "200":
          description: The payment intent, as it stands the moment the callback landed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/test/payment_intents/{id}/advance:
    post:
      operationId: advancePaymentIntent
      description: >-
        Test mode only. Move one timer of this payment intent to now: expiry moves
        expires_at, release moves available_at. Nothing else changes; the scheduled job that
        reads the timer acts on its next tick, on the same path it takes in live mode. A
        timer is never moved later, so a second call changes nothing. A live key is refused
        with test_mode_required, and a timer that is not running, such as release before
        channel_settled, is 409 invalid_state_transition.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentAdvance" }
      responses:
        "200":
          description: The payment intent, with the timer moved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /c/{token}:
    get:
      operationId: checkoutPage
      description: >-
        The hosted checkout page. The session token in the path is the whole authority,
        so this route takes no key and the page carries none.
      security: []
      parameters:
        - $ref: "#/components/parameters/CheckoutToken"
      responses:
        "200":
          description: The page.
          content:
            text/html:
              schema: { type: string }
        "410":
          description: The session expired. No attempt is created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /checkout/sessions/{token}/pay:
    post:
      operationId: payCheckoutSession
      description: >-
        Start the attempt for the method the payer picked. A session that already holds a
        pending attempt answers with the credential it already has.
      security: []
      parameters:
        - $ref: "#/components/parameters/CheckoutToken"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutPay" }
      responses:
        "200":
          description: The payment credential.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutState" }
        "409":
          description: >-
            live_not_enabled: the session is live and the merchant's review no longer holds
            live mode open. No attempt is created and no credential is served.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "410":
          description: The session expired. No attempt is created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /checkout/sessions/{token}/simulate:
    post:
      operationId: simulateCheckoutSession
      description: >-
        The test panel of the hosted page. Test mode only: tell the channel behind the
        pending attempt what to report, as POST /v1/test/payment_intents/{id}/simulate
        does. A session of a live intent answers 404, as a missing session does.
      security: []
      parameters:
        - $ref: "#/components/parameters/CheckoutToken"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutSimulate" }
      responses:
        "200":
          description: The state of the payment the moment the callback landed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutState" }
        "410":
          description: The session expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /checkout/sessions/{token}/status:
    get:
      operationId: checkoutSessionStatus
      description: What the page polls. It carries the redirect once the payment succeeds.
      security: []
      parameters:
        - $ref: "#/components/parameters/CheckoutToken"
      responses:
        "200":
          description: The state of the payment behind this session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutState" }
        "410":
          description: The session expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/disputes:
    get:
      operationId: listDisputes
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/DisputeStatus" }
      responses:
        "200":
          description: A page of disputes, newest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DisputeList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/disputes/{id}:
    get:
      operationId: dispute
      parameters:
        - $ref: "#/components/parameters/DisputeID"
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The dispute.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dispute" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/disputes/{id}/evidence:
    post:
      operationId: submitDisputeEvidence
      description: >-
        Merge evidence into the dispute. The platform forwards it to the channel and
        rules on the outcome; a dispute that already carries a ruling accepts none.
      parameters:
        - $ref: "#/components/parameters/DisputeID"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DisputeEvidence" }
      responses:
        "200":
          description: The dispute, with the evidence merged in.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dispute" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/settlements:
    post:
      operationId: createSettlement
      description: >-
        Settle the available balance now. Only a merchant whose settlement_schedule is
        manual may ask. Every other merchant is paid by the batch, on its own period.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "201":
          description: Created, or the replay of an earlier identical request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settlement" }
        "403":
          description: The merchant is paid on a schedule and may not ask.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: >-
            Nothing waits to be settled, the available balance does not cover it, or a
            request with this idempotency key is still running.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: The idempotency key was reused with a different body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/balance:
    get:
      operationId: balance
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The merchant balance, one entry per currency in each bucket.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/fx/quotes:
    get:
      operationId: fxQuote
      description: >-
        The indicative effective rate and the estimated settlement amount. Indicative
        only. The rate that a payment uses is fixed by the merchant lock point.
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - name: from
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Currency" }
        - name: to
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Currency" }
        - name: payment_method
          in: query
          schema: { type: string }
        - name: amount
          in: query
          description: Minor units of the from currency.
          schema: { type: integer, format: int64, minimum: 0 }
      responses:
        "200":
          description: The indicative quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FxQuote" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/events:
    get:
      operationId: events
      description: >-
        The events behind the webhook deliveries, newest first. An event id is stable and
        globally unique, so a merchant deduplicates an at-least-once delivery on it.
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
        - $ref: "#/components/parameters/Limit"
        - name: starting_after
          in: query
          schema: { type: string, pattern: "^evt_[A-Z2-7]+$" }
      responses:
        "200":
          description: The events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/payout_methods:
    get:
      operationId: listPayoutMethods
      security:
        - dashboardSession: []
      description: >-
        Where your settlements are sent. Details are MASKED: PRODUCT.md §7.3 shows a payout
        account redacted in the console, because a session that can read a full account
        number is a session that can copy one somewhere else. Each mode has its own
        destinations.
      parameters:
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: Your payout destinations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutMethodList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: createPayoutMethod
      security:
        - dashboardSession: []
      description: >-
        Register a destination. PRODUCT.md §7.3 gates this three ways and all three apply: a
        one-time code re-proves the person at the keyboard, a notice of the change is written,
        and settlement is held for 24 hours. The notice and the row commit together. It is
        for the account's email and is never a webhook event; the email sender is not yet
        built (DECISIONS N120). In test mode the one type is
        simulated: a test settlement is paid on the spot and nothing is wired.
      parameters:
        - $ref: "#/components/parameters/Mode"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PayoutMethodCreate" }
      responses:
        "201":
          description: The destination, masked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutMethod" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/payout_methods/{id}/default:
    post:
      operationId: usePayoutMethod
      security:
        - dashboardSession: []
      description: >-
        Send settlements here. This re-arms the 24-hour hold, because pointing the money
        somewhere else is the change §7.3 guards whether the row is new or not. An
        unverified destination is 409 destination_unverified.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SecondFactor" }
      responses:
        "200":
          description: The destination, masked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutMethod" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/webhook_deliveries:
    get:
      operationId: listWebhookDeliveries
      security:
        - dashboardSession: []
      description: >-
        What was tried, when, and what came back. Newest first, because a merchant opens
        this after something went wrong and the thing that went wrong is the most recent.
        A delivery whose schedule ran off its end has no next_retry_at and waits for a
        resend: PRODUCT.md §7.2 stops rather than retrying for ever.
      parameters:
        - { name: endpoint, in: query, schema: { type: string } }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: A page of deliveries, newest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDeliveryList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/webhook_deliveries/{id}/resend:
    post:
      operationId: resendWebhookDelivery
      security:
        - dashboardSession: []
      description: >-
        Ask the relay to try one delivery again. This route never calls the endpoint: the
        relay is the one thing that delivers, so a resend cannot bypass the signature, the
        backoff or the row that records what happened.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "202":
          description: Armed. The relay picks it up on its next pass.
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/webhook_public_keys:
    get:
      operationId: webhookPublicKeys
      description: >-
        The platform Ed25519 public keys. The v2 half of X-Webhook-Signature verifies
        against one of these, so a merchant needs no secret to check a delivery. The
        header names the key it was signed with in v2_kid, which is the id of one entry
        here: X-Webhook-Signature: t=<unix>,v1=<hex>,v2=<base64>,v1_kid=<n>,v2_kid=<key_id>.
      parameters:
        - $ref: "#/components/parameters/ApiVersion"
      responses:
        "200":
          description: The published keys.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookPublicKeyList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  # The dashboard surface of PRODUCT.md §12.4. It is a SECOND authentication scheme on a
  # prefix of its own: every /dashboard/v1 route takes the dashboard_session cookie and
  # NEVER an API key, and every /v1 route takes the API key and never a cookie. A route
  # belongs to exactly one group, so neither credential can reach the other's surface.
  /dashboard/v1/session:
    post:
      operationId: signIn
      security: []
      description: >-
        Sign in with an email, a password and the one-time code. Every rejection is one
        401 invalid_credentials, so this route says nothing about which merchants hold an
        account here.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignIn" }
      responses:
        "200":
          description: The signed-in user. The session arrives as a Set-Cookie header.
          headers:
            Set-Cookie:
              description: dashboard_session, HttpOnly, SameSite=Strict, 30 minutes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUser" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    get:
      operationId: currentSession
      security:
        - dashboardSession: []
      responses:
        "200":
          description: The user this session belongs to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUser" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    delete:
      operationId: signOut
      security:
        - dashboardSession: []
      responses:
        "204":
          description: Signed out. The cookie is cleared.
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/mfa:
    post:
      operationId: enrolMfa
      security: []
      description: >-
        Claim the one-time code secret of a user that has none, with that user's email and
        password. It is answered once: a user that already carries a second factor gets
        the same 401 a wrong password gets, so this is not a way to read a secret back.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MfaEnrol" }
      responses:
        "200":
          description: The secret, shown once. The platform stores no copy of it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MfaSecret" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/users:
    get:
      operationId: listDashboardUsers
      security:
        - dashboardSession: []
      description: The whole team. A team is small, so this list carries no cursor.
      responses:
        "200":
          description: Every user of this merchant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUserList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: inviteDashboardUser
      security:
        - dashboardSession: []
      description: >-
        Add a team member. Only an owner may grant the owner role. The new user holds no
        second factor, so its first call is POST /dashboard/v1/mfa.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardUserCreate" }
      responses:
        "201":
          description: The new team member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUser" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/users/{id}:
    patch:
      operationId: updateDashboardUser
      security:
        - dashboardSession: []
      description: >-
        Change one team member's role or status. Nobody edits their own row, and only an
        owner may grant or take away the owner role.
      parameters:
        - $ref: "#/components/parameters/DashboardUserID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardUserUpdate" }
      responses:
        "200":
          description: The team member, as it now stands.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUser" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/users/{id}/mfa:
    post:
      operationId: resetDashboardUserMfa
      security:
        - dashboardSession: []
      description: >-
        Reset one team member's second factor after a lost device. The old code stops
        working at once, and that user claims a new secret through POST /dashboard/v1/mfa.
      parameters:
        - $ref: "#/components/parameters/DashboardUserID"
      responses:
        "200":
          description: The team member, with its second factor cleared.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardUser" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/settings:
    get:
      operationId: dashboardSettings
      security:
        - dashboardSession: []
      responses:
        "200":
          description: The settings this merchant may change itself.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSettings" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    patch:
      operationId: updateDashboardSettings
      security:
        - dashboardSession: []
      description: >-
        The four settings PRODUCT.md §12.4 gives a merchant. Status, KYB status, the
        settlement currency and the settlement delay are Admin's, and no field here
        reaches them.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardSettingsUpdate" }
      responses:
        "200":
          description: The settings, as they now stand.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSettings" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  # The developer surface of PRODUCT.md §12.4: the merchant's own API keys, its Ed25519
  # signing keys and its webhook endpoints. A SECRET IS SHOWN ONCE. Only createApiKey
  # answers with a plaintext key, and it is the only schema here that carries one: every
  # list and every read carries the prefix, which locates the row and authenticates
  # nothing.
  /dashboard/v1/api_keys:
    get:
      operationId: listApiKeys
      security:
        - dashboardSession: []
      description: >-
        The live API keys of this merchant. A revoked key is not listed, and no response
        here carries a secret: the prefix is the first sixteen characters, which is what
        the sign-in of a key looks like in a log.
      responses:
        "200":
          description: Every live key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: createApiKey
      security:
        - dashboardSession: []
      description: >-
        Mint one API key. The response carries the plaintext ONCE. The platform stores the
        prefix and a SHA-256 digest, so no later call can answer with it again. A live key
        answers 403 live_not_enabled until the merchant's review opens live mode.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyCreate" }
      responses:
        "201":
          description: The new key, with its secret. This is the only time the secret is served.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/api_keys/{id}/revoke:
    post:
      operationId: revokeApiKey
      security:
        - dashboardSession: []
      description: Revoke one key of this merchant. A key of another merchant is 404.
      parameters:
        - $ref: "#/components/parameters/ApiKeyID"
      responses:
        "200":
          description: The key, now revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/signing_keys:
    get:
      operationId: listSigningKeys
      security:
        - dashboardSession: []
      description: >-
        The live Ed25519 keys this merchant signs requests with. Only the PUBLIC half ever
        reached the platform, so there is no secret here to hide.
      responses:
        "200":
          description: Every live signing key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SigningKeyList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: createSigningKey
      security:
        - dashboardSession: []
      description: >-
        Register one Ed25519 public key. The merchant generates the pair and sends the
        public half, so the private key never exists on this side.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SigningKeyCreate" }
      responses:
        "201":
          description: The registered key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SigningKey" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/signing_secret/reveal:
    post:
      operationId: revealSigningSecret
      security:
        - dashboardSession: []
      description: >-
        Show the secret of the hmac_sha256 request signature. Owner, admin and developer,
        and a one-time code re-proves the person at the keyboard on every call. The secret
        is derived from the master key and never stored, so each call derives it again and
        writes an audit row that records the reveal and never the value. There is no
        rotation yet: a merchant that suspects a leak moves to ed25519, whose keys revoke.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SecondFactor" }
      responses:
        "200":
          description: The secret. The response is not cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SigningSecret" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/signing_keys/{id}/revoke:
    post:
      operationId: revokeSigningKey
      security:
        - dashboardSession: []
      description: >-
        Revoke one signing key. A request signed with it fails from the moment this
        commits, so this is the answer to a leaked private key.
      parameters:
        - $ref: "#/components/parameters/SigningKeyID"
      responses:
        "200":
          description: The key, now revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SigningKey" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/webhook_endpoints:
    get:
      operationId: listWebhookEndpoints
      security:
        - dashboardSession: []
      parameters:
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: Every endpoint of this merchant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: createWebhookEndpoint
      security:
        - dashboardSession: []
      description: >-
        Register one endpoint. The answer carries the v1 HMAC secret ONCE: every delivery is
        signed with it, so a merchant that can never obtain it can never verify the v1 half
        of X-Webhook-Signature. The v2 half needs nothing, because
        GET /v1/webhook_public_keys publishes the key it verifies against. The endpoint takes
        the mode of the request and receives that mode's events alone.
      parameters:
        - $ref: "#/components/parameters/Mode"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreate" }
      responses:
        "201":
          description: The registered endpoint, with its secret. This is the only time it is served.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointCreated" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/webhook_endpoints/{id}:
    patch:
      operationId: updateWebhookEndpoint
      security:
        - dashboardSession: []
      description: Change the address, the event filter or the status of one endpoint.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/WebhookEndpointID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdate" }
      responses:
        "200":
          description: The endpoint, as it now stands.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    delete:
      operationId: deleteWebhookEndpoint
      security:
        - dashboardSession: []
      description: >-
        Remove one endpoint. The row is tombstoned, not deleted, so the deliveries already
        made to it keep the address they were made to.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/WebhookEndpointID"
      responses:
        "204":
          description: Removed. No further event is queued for it.
        default:
          $ref: "#/components/responses/ErrorResponse"


  # The money routes on the session, DECISIONS N90. Each runs the handler of its /v1 twin;
  # the merchant comes from the session and the mode from the REQUIRED mode parameter.
  # Every POST here refuses a request without Sec-Fetch-Site: same-origin and
  # Content-Type: application/json with 403 cross_site_request.
  /dashboard/v1/payment_intents:
    get:
      operationId: dashboardListPaymentIntents
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/payment_intents.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/PaymentIntentStatus" }
        - name: merchant_order_id
          in: query
          schema: { type: string }
        - name: created[gte]
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: dashboardCreatePaymentIntent
      security:
        - dashboardSession: []
      description: >-
        Owner and admin. Twin of POST /v1/payment_intents.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentCreate" }
      responses:
        "201":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/payment_intents/{id}:
    get:
      operationId: dashboardPaymentIntent
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/payment_intents/{id}.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/payment_intents/{id}/cancel:
    post:
      operationId: dashboardCancelPaymentIntent
      security:
        - dashboardSession: []
      description: >-
        Owner and admin. Twin of POST /v1/payment_intents/{id}/cancel.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/payment_intents/{id}/attempts:
    get:
      operationId: dashboardListPaymentAttempts
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/payment_intents/{id}/attempts.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAttemptList" }
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      operationId: dashboardCreatePaymentAttempt
      security:
        - dashboardSession: []
      description: >-
        Owner and admin. Twin of POST /v1/payment_intents/{id}/attempts.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [payment_method], properties: { payment_method: { type: string }, channel: { type: string } } }
      responses:
        "201":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAttempt" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/test/payment_intents/{id}/simulate:
    post:
      operationId: dashboardSimulatePaymentIntent
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and developer, test mode only: mode=live is refused with test_mode_required. Twin of POST /v1/test/payment_intents/{id}/simulate.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentSimulate" }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/test/payment_intents/{id}/advance:
    post:
      operationId: dashboardAdvancePaymentIntent
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and developer, test mode only: mode=live is refused with test_mode_required. Twin of POST /v1/test/payment_intents/{id}/advance.
      parameters:
        - $ref: "#/components/parameters/PaymentIntentID"
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentAdvance" }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/test/reset:
    post:
      operationId: dashboardResetTestData
      security:
        - dashboardSession: []
      description: >-
        Owner and admin, test mode only: mode=live is refused with test_mode_required.
        Hides every test object of the merchant created before now from every merchant
        read, moves every test balance to zero, and removes the test webhook endpoints and
        test payout methods. Nothing is deleted and no live object changes. API keys stay.
        A test payment that was still open may finish later; its event reaches no endpoint.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The reset, with the moment it took effect.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TestReset" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/balance:
    get:
      operationId: dashboardBalance
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/balance.
      parameters:
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/settlements:
    post:
      operationId: dashboardCreateSettlement
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and finance. Twin of POST /v1/settlements.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "201":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settlement" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/disputes:
    get:
      operationId: dashboardListDisputes
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/disputes.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/DisputeStatus" }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DisputeList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/disputes/{id}:
    get:
      operationId: dashboardDispute
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. Twin of GET /v1/disputes/{id}.
      parameters:
        - $ref: "#/components/parameters/DisputeID"
        - $ref: "#/components/parameters/Mode"
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dispute" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/disputes/{id}/evidence:
    post:
      operationId: dashboardSubmitDisputeEvidence
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and finance. Twin of POST /v1/disputes/{id}/evidence.
      parameters:
        - $ref: "#/components/parameters/DisputeID"
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DisputeEvidence" }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dispute" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/fx/quotes:
    get:
      operationId: dashboardFxQuote
      security:
        - dashboardSession: []
      description: >-
        Read. The session names the merchant. No mode: an indicative quote touches no balance. Twin of GET /v1/fx/quotes.
      parameters:
        - name: from
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Currency" }
        - name: to
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Currency" }
        - name: payment_method
          in: query
          schema: { type: string }
        - name: amount
          in: query
          schema: { type: integer, format: int64, minimum: 0 }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FxQuote" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/events:
    get:
      operationId: dashboardEvents
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and developer. Twin of GET /v1/events.
      parameters:
        - $ref: "#/components/parameters/Mode"
        - $ref: "#/components/parameters/Limit"
        - name: starting_after
          in: query
          schema: { type: string, pattern: "^evt_[A-Z2-7]+$" }
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /dashboard/v1/webhook_public_keys:
    get:
      operationId: dashboardWebhookPublicKeys
      security:
        - dashboardSession: []
      description: >-
        Owner, admin and developer. No mode: the keys are the platform's. Twin of GET /v1/webhook_public_keys.
      responses:
        "200":
          description: As the /v1 twin answers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookPublicKeyList" }
        default:
          $ref: "#/components/responses/ErrorResponse"

  /public/v1/demo_requests:
    post:
      operationId: createDemoRequest
      description: >-
        A "Book a demo" request. The edge Worker alone calls this: it takes the site's form at
        POST /demo on the apex, checks its shape and its rate, and forwards it here as JSON
        under a signed Edge-Assertion. Without a valid assertion this answers 404. It is not
        an integration route and no key reaches it.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DemoRequestCreate" }
      responses:
        "201":
          description: The request is stored. The confirmation email is queued after the commit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoRequestReceipt" }
        default:
          $ref: "#/components/responses/ErrorResponse"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        The merchant API key, sk_live_ or sk_test_. It reaches /v1 alone. A test key works
        from the moment the merchant exists. A live key answers 403 live_not_enabled until
        the merchant's review opens live mode, before any rate limit is spent.
    dashboardSession:
      type: apiKey
      in: cookie
      name: dashboard_session
      description: >-
        The dashboard sign-in cookie. It reaches /dashboard/v1 alone: a /v1 route never
        reads a cookie and a /dashboard/v1 route never reads an Authorization header.

  parameters:
    Mode:
      name: mode
      in: query
      required: true
      description: >-
        live or test. Required on every dashboard money route: absent is 400
        mode_required, anything else 400 invalid_mode. A default would let a forgotten
        parameter touch live money.
      schema: { type: string, enum: [live, test] }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 255 }
    ApiVersion:
      name: Api-Version
      in: header
      description: >-
        The version this request is answered at. Absent, the account's pinned version
        applies, and an account is pinned to info.version on its first request. A version
        this service does not render is refused with 400 invalid_api_version.
        api/CHANGELOG.md lists every version.
      schema: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }
    PaymentIntentID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^pi_[0-9A-Za-z]+$" }
    DisputeID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^dp_[0-9A-Za-z]+$" }
    DashboardUserID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^mu_[0-9A-Za-z]+$" }
    ApiKeyID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^ak_[0-9A-Za-z]+$" }
    SigningKeyID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^key_[0-9A-Za-z]+$" }
    WebhookEndpointID:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^whe_[0-9A-Za-z]+$" }
    CheckoutToken:
      name: token
      in: path
      required: true
      description: The checkout session token. It is unguessable and short lived.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    StartingAfter:
      name: starting_after
      in: query
      description: The id of the last object on the previous page.
      schema: { type: string }

  responses:
    ErrorResponse:
      description: >-
        400 bad parameter, 401 authentication, 402 payment failed, 403 forbidden,
        404 not found, 409 conflict, 413 request_too_large (a body over 64 KiB),
        422 idempotency key reused, 429 rate limited, 5xx api error.
      headers:
        Request-Id:
          description: >-
            Names this request. Every response carries it, success or failure, and an
            error envelope repeats it as request_id.
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/Error" }
    Error:
      type: object
      required: [type, code, message]
      properties:
        type:
          type: string
          enum:
            - invalid_request_error
            - authentication_error
            - idempotency_error
            - rate_limit_error
            - api_error
        code: { type: string }
        message: { type: string }
        param: { type: string }
        request_id: { type: string }

    Currency:
      type: string
      pattern: "^[A-Z]{3}$"
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount:
          type: integer
          format: int64
          description: Minor units. Never a decimal.
        currency: { $ref: "#/components/schemas/Currency" }

    PaymentIntentStatus:
      type: string
      enum:
        - created
        - requires_payment
        - processing
        - succeeded
        - channel_settled
        - failed
        - expired
        - canceled

    PaymentIntentCreate:
      type: object
      required: [amount, currency, merchant_order_id]
      properties:
        amount: { type: integer, format: int64, minimum: 1 }
        currency: { $ref: "#/components/schemas/Currency" }
        merchant_order_id: { type: string, maxLength: 255 }
        payment_method: { type: string }
        channel_preference: { type: string }
        description: { type: string }
        customer: { type: string }
        return_url:
          type: string
          format: uri
          maxLength: 2048
          description: >-
            The page the payer returns to after a successful payment. It must be an absolute
            https URL on a host name, with no user info. An IP literal and localhost are
            refused, in test mode too. Any other value answers 400 invalid_return_url.
        expires_in: { type: integer, description: Seconds. }
        metadata: { type: object, additionalProperties: { type: string } }

    SecondFactor:
      type: object
      required: [totp]
      properties:
        totp: { type: string, pattern: "^[0-9]{6}$" }
    PayoutMethod:
      type: object
      required: [id, object, type, masked, is_default, settles_from, created_at]
      properties:
        id: { type: string }
        object: { const: payout_method }
        type: { type: string, enum: [usdc_onchain, swift, simulated] }
        masked:
          type: string
          description: >-
            The last four characters of the destination, and nothing else: no mask glyph and
            no padding, so a client draws its own. An empty string when the destination has
            four characters or fewer, or none, as a simulated one has. The rest is never served.
        is_default: { type: boolean }
        verified_at:
          type: string
          format: date-time
          description: >-
            When the platform's own checks passed. Absent means the destination cannot be
            the default, and reason says why.
        reason:
          type: string
          enum: [beneficiary_name_mismatch]
          description: Why verified_at is absent. Absent on a verified destination.
        settles_from:
          type: string
          format: date-time
          description: >-
            When this destination may be paid to. A settlement sweep skips the merchant
            entirely until it passes.
        created_at: { type: string, format: date-time }
    PayoutMethodList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/PayoutMethod" }
    PayoutMethodCreate:
      type: object
      required: [type, totp]
      properties:
        type:
          type: string
          enum: [usdc_onchain, swift, simulated]
          description: simulated in test mode and only there.
        details:
          description: >-
            The destination itself, SWIFTDetails for swift and USDCDetails for usdc_onchain.
            Served back masked and never in full. Required for a live destination; a
            simulated one takes none. A field that fails its format is 400
            invalid_destination with param naming it.
          # The domain decodes the details by type and refuses an unknown field, so the
          # transport passes the bytes through; a generated union would pull in a runtime module.
          x-go-type: json.RawMessage
          oneOf:
            - $ref: "#/components/schemas/SWIFTDetails"
            - $ref: "#/components/schemas/USDCDetails"
        is_default:
          type: boolean
          description: >-
            Ignored for a destination that is stored unverified: it never becomes the default.
        totp: { type: string, pattern: "^[0-9]{6}$" }
    SWIFTDetails:
      type: object
      additionalProperties: false
      required: [beneficiary_name, bic, bank_name, bank_country]
      description: >-
        A wire. The beneficiary must be your own legal name: a row whose name differs after
        normalisation (case, accents, punctuation, a trailing legal suffix such as Ltd) is
        stored unverified with reason beneficiary_name_mismatch and cannot become the
        default. Give an IBAN or an account number.
      properties:
        beneficiary_name: { type: string, maxLength: 140 }
        iban: { type: string, description: Checked by ISO 13616 mod-97. Spaces are dropped. }
        account_number: { type: string }
        bic: { type: string, description: "8 or 11 characters; its country is bank_country." }
        bank_name: { type: string, maxLength: 140 }
        bank_country: { type: string, pattern: "^[A-Z]{2}$" }
        bank_address: { type: string, maxLength: 140 }
        intermediary_bic: { type: string }
        reference: { type: string, maxLength: 35 }
    USDCDetails:
      type: object
      additionalProperties: false
      required: [network, address]
      properties:
        network:
          type: string
          enum: [ethereum, solana]
        address:
          type: string
          description: >-
            Ethereum 0x and 40 hex digits, held to its EIP-55 checksum when mixed-case;
            Solana a base58 public key of 32 bytes.

    WebhookDelivery:
      type: object
      required: [id, object, endpoint, event, event_type, mode, attempt, created]
      properties:
        id: { type: string }
        object: { const: webhook_delivery }
        endpoint: { type: string }
        event: { type: string }
        event_type: { type: string }
        mode:
          type: string
          enum: [live, test]
          description: >-
            Which book the event belongs to. A dashboard session carries no mode, unlike an
            API key, so the log shows both and names each row.
        attempt: { type: integer }
        status_code:
          type: integer
          description: What the endpoint answered. Absent when the request never completed.
        next_retry_at:
          type: string
          format: date-time
          description: Absent when the schedule ran off its end and the delivery waits for a resend.
        created: { type: string, format: date-time }
    WebhookDeliveryList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookDelivery" }

    PaymentAttempt:
      type: object
      required: [id, object, status, amount, currency, created]
      description: >-
        One try at a channel. An intent holds at most one PENDING attempt, and a failed one
        stays: the history of what was tried is what a dispute and a reconciliation are read
        against. The channel's own response body is NOT here — it is operator data, and a
        merchant reads the outcome rather than the wire.
      properties:
        id: { type: string, pattern: "^pa_[A-Z2-7]+$" }
        object: { const: payment_attempt }
        status: { type: string, enum: [pending, succeeded, failed] }
        amount: { type: integer, format: int64 }
        currency: { $ref: "#/components/schemas/Currency" }
        payment_method: { type: string }
        next_action: { $ref: "#/components/schemas/NextAction" }
        failure_code: { type: string }
        failure_message: { type: string }
        succeeded_at: { type: string, format: date-time }
        created: { type: string, format: date-time }
    PaymentAttemptList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/PaymentAttempt" }

    PaymentIntent:
      type: object
      required:
        - id
        - object
        - mode
        - amount
        - currency
        - merchant_order_id
        - status
        - created
      properties:
        id: { type: string }
        object: { const: payment_intent }
        mode: { type: string, enum: [live, test] }
        amount: { type: integer, format: int64 }
        currency: { $ref: "#/components/schemas/Currency" }
        merchant_order_id: { type: string }
        status: { $ref: "#/components/schemas/PaymentIntentStatus" }
        payment_method: { type: string }
        fee: { $ref: "#/components/schemas/Money" }
        fx: { $ref: "#/components/schemas/FxLock" }
        net:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: What the merchant receives, in the settlement currency.
        available_at: { type: string, format: date-time }
        next_action: { $ref: "#/components/schemas/NextAction" }
        checkout_url:
          type: string
          format: uri
          description: >-
            The hosted checkout page. It is returned by the call that creates the intent
            and by no other, because the store keeps only a digest of the session token.
        created: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        metadata: { type: object, additionalProperties: { type: string } }

    SettlementStatus:
      type: string
      enum:
        - pending
        - processing
        - paid
        - failed

    Settlement:
      type: object
      required: [id, object, mode, status, amount, period_start, period_end, created]
      properties:
        id: { type: string, pattern: "^st_[A-Z2-7]+$" }
        object: { const: settlement }
        mode: { type: string, enum: [live, test] }
        status: { $ref: "#/components/schemas/SettlementStatus" }
        amount:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: What the merchant is paid, and the sum of the settled payments.
        payments:
          type: integer
          description: How many payment intents this settlement covers.
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        created: { type: string, format: date-time }

    PaymentIntentSimulate:
      type: object
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [succeeded, failed, pending, dispute_opened, dispute_won, dispute_lost]
          description: >-
            A payment outcome, or a dispute the channel reports on a paid intent. A
            dispute claims the whole ordered amount, and won or lost rules on the dispute
            opened before. A payment outcome replaces the outcome a magic amount set.
        failure_code: { $ref: "#/components/schemas/SimulatedDecline" }
        amount:
          type: integer
          format: int64
          description: >-
            Minor units the callback reports for a payment outcome. It defaults to the
            ordered amount. A different value is how a caller exercises the confirming
            query, which is what decides a success; the callback amount alone never does.
        currency: { $ref: "#/components/schemas/Currency" }

    PaymentIntentAdvance:
      type: object
      required: [clock]
      properties:
        clock:
          type: string
          enum: [expiry, release]
          description: expiry moves expires_at; release moves available_at.

    TestReset:
      type: object
      required: [object, reset_at]
      properties:
        object: { const: test_reset }
        reset_at:
          type: string
          format: date-time
          description: Test objects created at or before this moment are hidden.

    SimulatedDecline:
      type: string
      enum: [insufficient_funds, payer_cancelled, risk_blocked]
      description: >-
        The decline reason a failed outcome reports. It becomes the attempt failure_code.
        Allowed with outcome failed alone.

    CheckoutSimulate:
      type: object
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [succeeded, failed]
        failure_code: { $ref: "#/components/schemas/SimulatedDecline" }

    PaymentIntentList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/PaymentIntent" }
        has_more: { type: boolean }

    Event:
      type: object
      required: [id, object, type, created, livemode, data]
      properties:
        id: { type: string, pattern: "^evt_[A-Z2-7]+$" }
        object: { const: event }
        type:
          type: string
          description: For example payment_intent.succeeded.
        created: { type: string, format: date-time }
        livemode:
          type: boolean
          description: >-
            false for an event of test mode. A test event reaches only a test endpoint, and
            the delivered body carries this field too.
        data: { type: object, additionalProperties: true }

    EventList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/Event" }
        has_more: { type: boolean }

    WebhookPublicKey:
      type: object
      required: [id, object, algorithm, public_key]
      properties:
        id: { type: string, pattern: "^key_[A-Z2-7]+$" }
        object: { const: webhook_public_key }
        algorithm: { const: ed25519 }
        public_key:
          type: string
          description: The raw 32 byte key, base64.

    WebhookPublicKeyList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookPublicKey" }
        has_more: { type: boolean }

    FxLock:
      type: object
      required: [locked]
      properties:
        locked: { type: boolean }
        lock_point:
          type: string
          enum: [on_create, on_paid, on_channel_settled]
        rate:
          type: string
          description: A decimal string. A rate is never a float on the wire.
        quote_currency: { $ref: "#/components/schemas/Currency" }
        locked_at: { type: string, format: date-time }

    NextAction:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [redirect, qr_code] }
        url: { type: string, format: uri }
        qr_data: { type: string }

    DemoRequestCreate:
      type: object
      required: [name, email, company, country, volume_band, consent]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        email: { type: string, maxLength: 254 }
        company: { type: string, minLength: 1, maxLength: 200 }
        country: { type: string, pattern: "^[A-Z]{2}$" }
        volume_band:
          type: string
          description: Expected monthly volume in US dollars.
          enum: [under_10k, 10k_100k, 100k_1m, 1m_10m, over_10m]
        methods:
          type: array
          maxItems: 20
          description: Codes of payment_methods.
          items: { type: string }
        message: { type: string, maxLength: 4000 }
        preferred_window: { type: string, maxLength: 200 }
        consent: { type: boolean, const: true, description: Consent to be contacted. Required. }

    DemoRequestReceipt:
      type: object
      required: [id, object, status]
      properties:
        id: { type: string }
        object: { const: demo_request }
        status: { const: new }

    CheckoutPay:
      type: object
      required: [payment_method]
      properties:
        payment_method: { type: string }

    CheckoutState:
      type: object
      required: [status]
      properties:
        status: { $ref: "#/components/schemas/PaymentIntentStatus" }
        next_action: { $ref: "#/components/schemas/NextAction" }
        redirect_url:
          type: string
          format: uri
          description: Where to send the payer once the payment succeeded.

    DisputeStatus:
      type: string
      enum: [opened, under_review, won, lost]

    Dispute:
      type: object
      required: [id, object, payment_intent, status, amount, currency, opened_at]
      properties:
        id: { type: string }
        object: { const: dispute }
        payment_intent: { type: string }
        status: { $ref: "#/components/schemas/DisputeStatus" }
        amount:
          type: integer
          format: int64
          description: What the channel claims back, in the currency the payer paid in.
        currency: { $ref: "#/components/schemas/Currency" }
        held:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: The settlement currency frozen while the dispute is open.
        fee:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: What a lost dispute costs on top of the claim.
        reason: { type: string }
        evidence: { type: object, additionalProperties: true }
        due_by: { type: string, format: date-time }
        opened_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }

    DisputeEvidence:
      type: object
      required: [evidence]
      properties:
        evidence:
          type: object
          additionalProperties: true
          description: Merged into the evidence already on the dispute, key by key.

    DisputeList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/Dispute" }
        has_more: { type: boolean }

    Balance:
      type: object
      required: [available, pending, dispute_hold]
      properties:
        available:
          type: array
          items: { $ref: "#/components/schemas/Money" }
        pending:
          type: array
          items: { $ref: "#/components/schemas/Money" }
        dispute_hold:
          type: array
          items: { $ref: "#/components/schemas/Money" }

    FxQuote:
      type: object
      required: [indicative, from, to, rate]
      properties:
        indicative: { const: true }
        from: { $ref: "#/components/schemas/Currency" }
        to: { $ref: "#/components/schemas/Currency" }
        rate:
          type: string
          description: The effective rate, mid rate plus the spread, as a decimal string.
        estimated: { $ref: "#/components/schemas/Money" }

    DashboardRole:
      type: string
      enum: [owner, admin, developer, finance, viewer]
    DashboardUser:
      type: object
      required: [id, object, email, role, status, created_at]
      properties:
        id: { type: string, pattern: "^mu_[0-9A-Za-z]+$" }
        object: { const: dashboard_user }
        email: { type: string }
        role: { $ref: "#/components/schemas/DashboardRole" }
        status: { type: string, enum: [active, suspended] }
        created_at: { type: string, format: date-time }
    DashboardUserList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/DashboardUser" }
    SignIn:
      type: object
      required: [email, password, totp]
      properties:
        email: { type: string }
        password: { type: string }
        totp: { type: string, pattern: "^[0-9]{6}$" }
    MfaEnrol:
      type: object
      required: [email, password]
      properties:
        email: { type: string }
        password: { type: string }
    MfaSecret:
      type: object
      required: [object, secret]
      properties:
        object: { const: mfa_secret }
        secret:
          type: string
          description: The base32 secret an authenticator app reads. It is shown once.
    DashboardUserCreate:
      type: object
      required: [email, password, role]
      properties:
        email: { type: string }
        password: { type: string, minLength: 12 }
        role: { $ref: "#/components/schemas/DashboardRole" }
    DashboardUserUpdate:
      type: object
      properties:
        role: { $ref: "#/components/schemas/DashboardRole" }
        status: { type: string, enum: [active, suspended] }
    DashboardSettings:
      type: object
      required: [object, fx_lock_point, settlement_schedule, signature_scheme, ip_allowlist]
      properties:
        object: { const: settings }
        fx_lock_point: { type: string, enum: [on_create, on_paid, on_channel_settled] }
        settlement_schedule:
          type: string
          enum: [daily, weekly, manual]
          description: >-
            How often the batch pays this merchant. manual means the batch skips it and
            POST /v1/settlements is the only way it is paid.
        signature_scheme: { type: string, enum: [none, hmac_sha256, ed25519] }
        ip_allowlist:
          type: array
          description: CIDR prefixes. An empty list allows every source.
          items: { type: string }
        logo_url:
          type: string
          description: >-
            The mark shown above your name on the hosted checkout. https only, because the
            page is served over TLS and a browser refuses to draw a mixed-content image.
            An empty string removes it. No colour is taken: see DECISIONS N89.
    DashboardSettingsUpdate:
      type: object
      properties:
        logo_url: { type: string }
        fx_lock_point: { type: string, enum: [on_create, on_paid, on_channel_settled] }
        settlement_schedule: { type: string, enum: [daily, weekly, manual] }
        signature_scheme: { type: string, enum: [none, hmac_sha256, ed25519] }
        ip_allowlist:
          type: array
          items: { type: string }

    ApiKey:
      type: object
      required: [id, object, mode, prefix, name, created_at]
      description: >-
        One live API key, WITHOUT its secret. This schema has no secret field at all, so a
        list or a read cannot serve one however the handler is written.
      properties:
        id: { type: string, pattern: "^ak_[0-9A-Za-z]+$" }
        object: { const: api_key }
        mode: { type: string, enum: [live, test] }
        prefix:
          type: string
          description: The first sixteen characters, such as sk_live_A1B2C3D4. It authenticates nothing.
        name: { type: string }
        created_at: { type: string, format: date-time }
    ApiKeyCreated:
      type: object
      required: [id, object, mode, prefix, name, created_at, secret]
      description: The answer to createApiKey, and the ONE place a plaintext key is served.
      properties:
        id: { type: string, pattern: "^ak_[0-9A-Za-z]+$" }
        object: { const: api_key }
        mode: { type: string, enum: [live, test] }
        prefix: { type: string }
        name: { type: string }
        created_at: { type: string, format: date-time }
        secret:
          type: string
          description: >-
            The plaintext key, shown once. The platform keeps only the prefix and a
            SHA-256 digest of it.
    ApiKeyCreate:
      type: object
      required: [mode, name]
      properties:
        mode: { type: string, enum: [live, test] }
        name: { type: string, maxLength: 100 }
    ApiKeyList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/ApiKey" }

    SigningKey:
      type: object
      required: [id, object, name, public_key, created_at]
      properties:
        id: { type: string, pattern: "^key_[0-9A-Za-z]+$" }
        object: { const: signing_key }
        name: { type: string }
        public_key:
          type: string
          description: >-
            The Ed25519 public key, 32 bytes, base64. A v2 request signature verifies
            against it. X-Key-Id carries the key's id, key_..., and never this value.
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    SigningSecret:
      type: object
      required: [object, secret]
      properties:
        object: { const: signing_secret }
        secret:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: >-
            Hex. The HMAC-SHA256 key of X-Signature v1= is the 32 decoded bytes. One secret
            per merchant, for test and live keys alike.
    SigningKeyCreate:
      type: object
      required: [name, public_key]
      properties:
        name: { type: string, maxLength: 100 }
        public_key: { type: string, description: 32 bytes of Ed25519 public key, base64. }
        expires_at: { type: string, format: date-time }
    SigningKeyList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/SigningKey" }

    WebhookEndpoint:
      type: object
      required: [id, object, url, enabled_events, status, api_version, created_at]
      properties:
        id: { type: string, pattern: "^whe_[0-9A-Za-z]+$" }
        object: { const: webhook_endpoint }
        url: { type: string }
        enabled_events:
          type: array
          description: The event types this endpoint receives. An empty list receives every type.
          items: { type: string }
        status: { type: string, enum: [enabled, disabled] }
        api_version: { $ref: "#/components/schemas/EndpointApiVersion" }
        created_at: { type: string, format: date-time }
    WebhookEndpointCreated:
      type: object
      required: [id, object, url, enabled_events, status, api_version, created_at, secret]
      description: >-
        The answer to createWebhookEndpoint, and the ONE place the v1 HMAC secret is served.
        WebhookEndpoint has no secret field at all, so a list or a read cannot serve one.
      properties:
        id: { type: string }
        object: { const: webhook_endpoint }
        url: { type: string }
        enabled_events:
          type: array
          items: { type: string }
        status: { type: string, enum: [enabled, disabled] }
        api_version: { $ref: "#/components/schemas/EndpointApiVersion" }
        created_at: { type: string, format: date-time }
        secret:
          type: string
          description: >-
            The secret the v1 half of X-Webhook-Signature is computed under, hex. The HMAC
            key is the 32 decoded bytes. Shown once and never stored: PRODUCT.md §7.3 keeps
            the id and the key version, and the value is derived from the master on every
            signature. The header's v1_kid names that key version. The v2 half needs
            nothing, so a merchant that prefers it can ignore this.
    WebhookEndpointCreate:
      type: object
      required: [url]
      properties:
        url:
          type: string
          description: >-
            An https address. Plain http is refused, because a delivery carries payment
            data.
        enabled_events:
          type: array
          items: { type: string }
    WebhookEndpointUpdate:
      type: object
      properties:
        url: { type: string }
        enabled_events:
          type: array
          items: { type: string }
        status: { type: string, enum: [enabled, disabled] }
        api_version: { $ref: "#/components/schemas/EndpointApiVersion" }
    EndpointApiVersion:
      type: string
      pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
      description: >-
        The API version every delivery to this endpoint is rendered at before it is signed.
        Set at creation to the account's pinned version.
    WebhookEndpointList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { const: list }
        has_more: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookEndpoint" }

# Not in this spec yet. Each line names the milestone that serves the route and writes
# the object. No schema is invented ahead of the code.
#
#   GET  /v1/payment_methods                               M8
#   GET  /v1/events, /v1/events/{id}                       M4
#   GET  /v1/webhook_public_keys                           M4
#   GET  /v1/balance_transactions                          M9
#   GET  /v1/settlements, /{id}, /{id}/items                M13
#   The expand[] parameter of PRODUCT.md §12.1             the milestone that needs it
