openapi: 3.1.0

info:
  title: PayXiro API
  version: v1
  summary: PayXiro payment orchestration — machine (Platform-facing) REST API.
  description: >
    The public PayXiro `/api/v1` REST API used by Platforms (Brokeret CRM, Prop CRM, X9 Trader).


    **This spec is the source of truth** (docs/01-architecture/10-api-design.md §9). It is
    hand-maintained (no generator) and describes only the endpoints that are LIVE in the current
    phase. A Pest drift test (`platform/tests/Feature/Api/OpenApiSpecDriftTest.php`) asserts the
    `paths` below match the Laravel route table exactly, so the spec can never silently diverge from
    the code, and Redocly lints it in CI.


    **Phase scope (ADR-0012 crypto-first):** Phase 1 exposes Customers, Payments, Checkout Sessions
    and Webhook Endpoints. Payouts, Refunds, Wallets and the Events log are specified in the API-design
    doc's resource catalog (§6) and ship — additively — when they are built; they are deliberately
    absent from `paths` here so the spec matches the running server. The outbound webhook wire contract
    (the events PayXiro POSTs to a registered endpoint) IS documented, under the top-level `webhooks`
    object.


    **Conventions (api-design §4):**

    - **Auth:** bearer secret key scoped to one (merchant, environment); the `sk_test_` / `sk_live_`
      prefix selects the environment — there is no environment path or header.

    - **Amounts** are decimal strings (`"500.00"`), never JSON numbers, rendered to the asset's
      display decimals.

    - **Asset codes** are `CODE` or `CODE.NETWORK` (`MYR`, `USDT.TRC20`); `USDT.TRC20` and
      `USDT.ERC20` are distinct assets.

    - **Timestamps** are ISO-8601 UTC with a `Z` suffix; field names end in `_at`.

    - **Public IDs** are `<prefix>_<ULID>` (`pay_`, `cust_`, `cs_`, `pacc_`, `mch_`, `evt_`, …).

    - **Idempotency:** money-creating POSTs (`/payments`, `/checkout_sessions`) require an
      `Idempotency-Key` header.

    - **Errors** always use the `{ "error": { type, code, message, param, request_id } }` envelope.

    - **v1 evolves additively only** — new fields/endpoints/enum values, never renamed or removed.
      Clients MUST ignore unknown fields and tolerate unknown enum values on read.
  contact:
    name: PayXiro Engineering
  license:
    name: Proprietary — PayXiro
    identifier: LicenseRef-PayXiro-Proprietary

servers:
  - url: https://payxiro.com/api/v1
    description: >
      Base URL (the platform app serves the API; a dedicated api. host may come later). The environment
      (sandbox vs live) is selected by the API key prefix, NOT by the URL: an `sk_test_` key reads and
      writes sandbox data; an `sk_live_` key, live data. Sandbox is backed by `test_gateway`
      (api-design §10) with identical behaviour.

security:
  - BearerSecretKey: []

tags:
  - name: Health
    description: Liveness / auth-stack diagnostics.
  - name: Customers
    description: Traders on whose behalf Payments are created; upserted by the Platform's own reference.
  - name: Payments
    description: Money-in. States follow the canonical Payment state machine (payment-flows §2.1).
  - name: Checkout Sessions
    description: The hosted Cashier flow — create a session, redirect the Customer to its hosted URL.
  - name: Webhook Endpoints
    description: >
      A Platform's registered outbound endpoints — where PayXiro POSTs signed events. Managed by the
      Platform's API key; scoped to that one Platform (eventing §6).
  - name: Webhooks
    description: >
      Outbound events PayXiro POSTs to a Platform's registered Webhook Endpoint. Documented under the
      top-level `webhooks` object (eventing §5–6).

paths:

  /ping:
    get:
      operationId: ping
      tags: [Health]
      summary: Verify credentials and echo the resolved tenant
      description: >
        The smallest endpoint that proves the whole machine-API stack end-to-end: request-id stamping →
        bearer-key auth → tenant resolution → per-key rate limiting → Platform attribution. Echoes the
        resolved Merchant public id, Environment, and creating Platform code.
      responses:
        '200':
          description: Credentials valid; the resolved tenant context.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ping' }
              example:
                object: ping
                merchant: mch_01J7Q2W8XN0F5G6H7J8K9L0M1N
                environment: sandbox
                platform: brokeret_crm
                request_id: req_01J9ZTW4C8ABCDEF01234567
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /customers:
    post:
      operationId: upsertCustomer
      tags: [Customers]
      summary: Create or update a Customer by external reference
      description: >
        Upserts by (merchant, environment, platform, `external_ref`). Returns `201` on create and `200`
        on update. `external_ref` is the identity handle and is never mutated by an update; omitted
        optional fields are left unchanged (merge semantics), an explicit `null` clears the field. Not
        money-creating — no `Idempotency-Key`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerUpsertRequest' }
            example:
              external_ref: crm-88214
              email: aisyah@example.com
              phone: '+60123456789'
              country: MY
              kyc_status: verified
              custom_fields: { trading_account: '500123' }
      responses:
        '200':
          description: Existing Customer updated.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '201':
          description: New Customer created.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    get:
      operationId: listCustomers
      tags: [Customers]
      summary: List Customers
      description: Cursor-paginated, newest-first, with exact-match filters on the standard indexed columns.
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - name: email
          in: query
          description: Exact email match.
          required: false
          schema: { type: string }
        - name: external_ref
          in: query
          description: Exact external-reference match.
          required: false
          schema: { type: string }
      responses:
        '200':
          description: A page of Customers.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerList' }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /customers/{id}:
    get:
      operationId: getCustomer
      tags: [Customers]
      summary: Retrieve a Customer
      description: >
        Fetch by `cust_…` public id. Returns `404` (never `403`) when the id is malformed, unknown, or
        belongs to another tenant — no cross-tenant existence leak.
      parameters:
        - name: id
          in: path
          required: true
          description: The Customer public id (`cust_…`).
          schema: { $ref: '#/components/schemas/CustomerId' }
      responses:
        '200':
          description: The Customer.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /payments:
    post:
      operationId: createPayment
      tags: [Payments]
      summary: Create a Payment
      description: >
        Create a Payment directly (API-driven, no Cashier). Runs Risk → Routing → the selected
        Provider Account's Adapter and returns `201` with the Payment resource and its `next_action`
        (a redirect, or crypto-deposit instructions) when the provider requires customer action.
        Requires an `Idempotency-Key`: a replay of the same key + same body returns the stored original
        response with `Idempotency-Replayed: true`; the same key + a different body is a `409`.
        Routing failures (no route / fallback exhausted) return a `201` Payment envelope in status
        `failed` with a normalized `failure_code`, not an error envelope.
        If the requesting key's Platform restricts its enabled payment methods and `payment_method` is
        not on that allow-list, the request is refused up front with `422 invalid_request` (param
        `payment_method`, code `payment_method_not_enabled`) — a fail-closed guard, not a `failed`
        Payment.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCreateRequest' }
            example:
              amount: '500.00'
              asset_code: USDT.TRC20
              payment_method: crypto
              country: AE
              customer: cust_01J9ZR2KD7VE8F9G0H1J2K3L4M
              metadata: { crm_transaction_id: DEP-448901 }
      responses:
        '201':
          description: Payment created (may already be terminal, e.g. `failed`, if routing was exhausted).
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
            Idempotency-Replayed: { $ref: '#/components/headers/IdempotencyReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
              example:
                id: pay_01J9ZX8KT2M1N2P3Q4R5S6T7U8
                object: payment
                status: requires_action
                amount: '500.00'
                asset_code: USDT.TRC20
                payment_method: crypto
                country: AE
                customer: cust_01J9ZR2KD7VE8F9G0H1J2K3L4M
                provider_account: pacc_01J8XN5QW9BCDEF01234567890
                provider_reference: null
                platform_reference: null
                failure_code: null
                metadata: { crm_transaction_id: DEP-448901 }
                expires_at: '2026-07-05T11:41:22Z'
                succeeded_at: null
                created_at: '2026-07-05T09:41:22Z'
                next_action:
                  type: crypto_deposit
                  deposit_address: TWd4gh8N1tK9qkGf2vX7ExampleAddr
                  network: TRC20
                  qr: TWd4gh8N1tK9qkGf2vX7ExampleAddr
                  expires_at: '2026-07-05T11:41:22Z'
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    get:
      operationId: listPayments
      tags: [Payments]
      summary: List Payments
      description: Cursor-paginated, newest-first, filterable by status, customer, and created range.
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - name: status
          in: query
          required: false
          description: Comma-separated list of Payment states; only canonical states are accepted.
          schema: { type: string }
          example: succeeded,failed
        - name: customer
          in: query
          required: false
          description: Restrict to one Customer (`cust_…` handle).
          schema: { $ref: '#/components/schemas/CustomerId' }
        - name: created_after
          in: query
          required: false
          description: Half-open lower bound on `created_at` (ISO-8601).
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          required: false
          description: Half-open upper bound on `created_at` (ISO-8601).
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: A page of Payments.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentList' }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /payments/{id}:
    get:
      operationId: getPayment
      tags: [Payments]
      summary: Retrieve a Payment
      description: >
        Fetch by `pay_…` public id. Returns `404` (never `403`) when the id is malformed, unknown, or
        belongs to another tenant.
      parameters:
        - name: id
          in: path
          required: true
          description: The Payment public id (`pay_…`).
          schema: { $ref: '#/components/schemas/PaymentId' }
      responses:
        '200':
          description: The Payment.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /payments/{id}/submit-form:
    post:
      operationId: submitPaymentForm
      tags: [Payments]
      summary: Submit a manual Payment's dynamic-form values
      description: >
        Posts the manual-method dynamic-form values (Flow 3/4): validated against the Payment Method's
        seeded `form_definition`, stored in `form_values`, and transitions the Payment
        `requires_action → under_review` for the ops confirmation queue. Not money-creating, so it
        carries no `Idempotency-Key`; re-submitting an already-submitted (`under_review`) Payment is a
        clean no-op (idempotent on state). A non-submittable state is a `409`; a field that fails
        validation is `invalid_request` with the offending field in `param`.
      parameters:
        - name: id
          in: path
          required: true
          description: The Payment public id (`pay_…`).
          schema: { $ref: '#/components/schemas/PaymentId' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubmitPaymentFormRequest' }
            example:
              form_values:
                reference: DEP-448901
                sender_name: Imran Khalid
                receipt_url: https://files.example.com/receipt/9284712.jpg
      responses:
        '200':
          description: Form accepted; the Payment moved to `under_review`.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /checkout_sessions:
    post:
      operationId: createCheckoutSession
      tags: [Checkout Sessions]
      summary: Create a Checkout Session
      description: >
        Open a hosted Checkout Session (the standard CRM deposit flow). The Platform creates the session
        server-side and redirects the Customer to the returned `url`. `success_url` / `cancel_url` are
        validated against the creating Platform's allowed redirect patterns (open-redirect protection);
        a URL outside the whitelist is rejected with the offending field in `param`. The Customer is
        upserted inline by `customer.external_ref`. Requires an `Idempotency-Key`. The result reaches
        the Platform via webhook (`checkout_session.completed` / `payment.*`) — treat the webhook as
        authoritative, never the browser redirect.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutSessionCreateRequest' }
            example:
              amount: '1000.00'
              asset_code: MYR
              customer:
                external_ref: crm-88214
                email: aisyah@example.com
                phone: '+60123456789'
                country: MY
                kyc_status: verified
              success_url: https://crm.brokeret.com/deposits/return?ref=DEP-448812
              cancel_url: https://crm.brokeret.com/deposits/cancelled?ref=DEP-448812
              metadata: { crm_transaction_id: DEP-448812, trading_account: '500123' }
      responses:
        '201':
          description: Session opened.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
            Idempotency-Replayed: { $ref: '#/components/headers/IdempotencyReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutSession' }
              example:
                id: cs_01J9ZW3XQ4R8S9T0U1V2W3X4Y5
                object: checkout_session
                status: open
                amount: '1000.00'
                asset_code: MYR
                customer: cust_01J9ZR2KD7VE8F9G0H1J2K3L4M
                payment: null
                success_url: https://crm.brokeret.com/deposits/return?ref=DEP-448812
                cancel_url: https://crm.brokeret.com/deposits/cancelled?ref=DEP-448812
                metadata: { crm_transaction_id: DEP-448812, trading_account: '500123' }
                expires_at: '2026-07-05T10:41:22Z'
                completed_at: null
                created_at: '2026-07-05T09:41:22Z'
                url: https://pay.payxiro.com/c/cs_01J9ZW3XQ4R8S9T0U1V2W3X4Y5
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /checkout_sessions/{id}:
    get:
      operationId: getCheckoutSession
      tags: [Checkout Sessions]
      summary: Retrieve a Checkout Session
      description: >
        Fetch by `cs_…` public id. Returns `404` (never `403`) when the id is malformed, unknown, or
        belongs to another tenant.
      parameters:
        - name: id
          in: path
          required: true
          description: The Checkout Session public id (`cs_…`).
          schema: { $ref: '#/components/schemas/CheckoutSessionId' }
      responses:
        '200':
          description: The Checkout Session.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /webhook_endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Register a Webhook Endpoint
      description: >
        Register an outbound endpoint for the calling key's Platform. The `whsec_…` signing secret is
        returned in the `secret` field EXACTLY ONCE, in this response — it is never shown again by any
        other endpoint, so store it now (eventing §6; security §7.2). `url` must be https. `events` is an
        optional list of exact catalog event types (eventing §3) or the `*` wildcard; omitting it (or an
        empty list) subscribes to all events. Not money movement — no `Idempotency-Key` (api-design §4.2).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointCreateRequest' }
            example:
              url: https://crm.brokeret.com/payxiro/webhooks
              events: [payment.succeeded, payment.failed]
      responses:
        '201':
          description: Endpoint registered. The `secret` is present here and only here.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointWithSecret' }
              example:
                id: whk_01J9ZZC4N8RH0J1K2L3M4N5P6Q
                object: webhook_endpoint
                url: https://crm.brokeret.com/payxiro/webhooks
                platform: brokeret_crm
                subscribed_events: [payment.succeeded, payment.failed]
                status: active
                created_at: '2026-07-09T09:41:22Z'
                secret: whsec_5f3a8e9c1d2b4a6f8e0c1d2b4a6f8e0c1d2b4a6f8e0c1d2b
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook Endpoints]
      summary: List Webhook Endpoints
      description: >
        Cursor-paginated, newest-first, scoped to the calling key's Platform — the response never includes
        another Platform's (or another tenant's) endpoints, and never the signing secret.
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
      responses:
        '200':
          description: A page of Webhook Endpoints (no secrets).
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointList' }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /webhook_endpoints/{id}:
    get:
      operationId: getWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Retrieve a Webhook Endpoint
      description: >
        Fetch by `whk_…` public id. Returns `404` (never `403`) when the id is malformed, unknown, or
        belongs to another Platform or tenant — no existence leak. The signing secret is never returned.
      parameters:
        - name: id
          in: path
          required: true
          description: The Webhook Endpoint public id (`whk_…`).
          schema: { $ref: '#/components/schemas/WebhookEndpointId' }
      responses:
        '200':
          description: The Webhook Endpoint (no secret).
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Update a Webhook Endpoint
      description: >
        Partial update of `url`, `events`, and/or `status` for one of the calling Platform's endpoints.
        Only fields present in the body change. The signing secret is never updatable here and never
        returned — rotate it in the Merchant Portal. Re-enabling (`status: active`) clears any
        persistent-failure flag. `404` when the endpoint is not this Platform's.
      parameters:
        - name: id
          in: path
          required: true
          description: The Webhook Endpoint public id (`whk_…`).
          schema: { $ref: '#/components/schemas/WebhookEndpointId' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointUpdateRequest' }
            example:
              events: [payment.succeeded, payment.failed, payment.expired]
              status: disabled
      responses:
        '200':
          description: The updated Webhook Endpoint (no secret).
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/InvalidRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Delete a Webhook Endpoint
      description: >
        Permanently delete one of the calling Platform's endpoints; no further events are delivered to it.
        Past delivery history is retained (visible in the Merchant Portal). To pause instead of delete,
        `PATCH` the `status` to `disabled`. `404` when the endpoint is not this Platform's.
      parameters:
        - name: id
          in: path
          required: true
          description: The Webhook Endpoint public id (`whk_…`).
          schema: { $ref: '#/components/schemas/WebhookEndpointId' }
      responses:
        '204':
          description: Endpoint deleted. No body.
          headers:
            PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

webhooks:

  payment.created:
    post:
      operationId: onPaymentCreated
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.created
      description: A Payment row was created (API call or Cashier).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.requires_action:
    post:
      operationId: onPaymentRequiresAction
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.requires_action
      description: The Payment is waiting on the Customer or a provider redirect/action.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.under_review:
    post:
      operationId: onPaymentUnderReview
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.under_review
      description: A Risk Rule (or a submitted manual form) moved the Payment to the manual review queue.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.processing:
    post:
      operationId: onPaymentProcessing
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.processing
      description: The Payment was handed to a Provider Account (risk cleared or review approved).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.succeeded:
    post:
      operationId: onPaymentSucceeded
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.succeeded
      description: The Provider confirmed funds; the ledger journal posted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
            example:
              id: evt_01J9ZZC4N8RH0J1K2L3M4N5P6Q
              object: event
              type: payment.succeeded
              api_version: v1
              environment: live
              merchant_id: mch_01J7Q2W8XN0F5G6H7J8K9L0M1N
              created_at: '2026-07-05T09:58:03Z'
              data:
                object:
                  id: pay_01J9ZX8KT2M1N2P3Q4R5S6T7U8
                  object: payment
                  status: succeeded
                  amount: '500.00'
                  asset_code: USDT.TRC20
                  payment_method: crypto
                  country: AE
                  customer: cust_01J9ZR2KD7VE8F9G0H1J2K3L4M
                  provider_account: pacc_01J8XN5QW9BCDEF01234567890
                  provider_reference: TRON-9284712
                  platform_reference: null
                  failure_code: null
                  metadata: { crm_transaction_id: DEP-448901 }
                  expires_at: '2026-07-05T11:41:22Z'
                  succeeded_at: '2026-07-05T09:58:03Z'
                  created_at: '2026-07-05T09:41:22Z'
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.failed:
    post:
      operationId: onPaymentFailed
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.failed
      description: Terminal failure. The payload's `failure_code` carries the normalized reason.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.cancelled:
    post:
      operationId: onPaymentCancelled
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.cancelled
      description: The Payment was cancelled before completion.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  payment.expired:
    post:
      operationId: onPaymentExpired
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: payment.expired
      description: The Payment timed out while awaiting action (expiry sweeper).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  checkout_session.completed:
    post:
      operationId: onCheckoutSessionCompleted
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: checkout_session.completed
      description: >
        The Customer finished the hosted Cashier flow and a linked Payment succeeded. The payload's
        `data.object` is the Checkout Session plus the chosen `payment_method`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutSessionWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

  checkout_session.expired:
    post:
      operationId: onCheckoutSessionExpired
      parameters:
        - { $ref: '#/components/parameters/WebhookSignatureHeader' }
        - { $ref: '#/components/parameters/WebhookEventIdHeader' }
      tags: [Webhooks]
      summary: checkout_session.expired
      description: The session lifetime elapsed while still `open` (session sweeper).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutSessionWebhookEvent' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

components:

  securitySchemes:
    BearerSecretKey:
      type: http
      scheme: bearer
      description: >
        A PayXiro secret key sent as `Authorization: Bearer sk_live_…` (live) or `sk_test_…` (sandbox).
        The prefix selects the environment; a key grants full API access for exactly one
        (merchant, environment). Only a SHA-256 hash is stored server-side (api-design §2). Portals use
        Sanctum sessions and never reach this API.

  parameters:

    Limit:
      name: limit
      in: query
      required: false
      description: Page size (default 20, max 100). Values above the max are clamped, not rejected.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    StartingAfter:
      name: starting_after
      in: query
      required: false
      description: >
        A resource public id; the page returns items strictly after it in the newest-first order. Pass
        the previous page's `next_cursor` here.
      schema:
        type: string

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >
        A client-chosen unique key that makes the create safe to retry. Same key + same body replays the
        stored original response (`Idempotency-Replayed: true`); same key + different body is a `409`
        `idempotency_key_reuse`; same key while the first request is still running is a `409`
        `idempotency_key_in_flight`. Recommended: a stable reference from the Platform's own database.
      schema:
        type: string
        maxLength: 255
      example: crm-dep-2026-07-05-448901

    WebhookSignatureHeader:
      name: PayXiro-Signature
      in: header
      required: true
      description: >
        Delivered on every outbound webhook. `t=<unix>,v1=<hex>` where
        `v1 = HMAC-SHA256(endpoint_secret, "{t}.{raw_body}")`. Verify over the raw bytes, in constant
        time, and reject when `|now - t| > 300` (eventing §6.3).
      schema: { type: string }
      example: t=1751709483,v1=5f3a8e9c1d2b4a6f8e0c1d2b4a6f8e0c1d2b4a6f8e0c1d2b4a6f8e0c1d2b4a6f

    WebhookEventIdHeader:
      name: PayXiro-Event-Id
      in: header
      required: true
      description: The delivered event's `evt_…` id; dedupe on it (deliveries are at-least-once).
      schema: { type: string }
      example: evt_01J9ZZC4N8RH0J1K2L3M4N5P6Q

  headers:

    PayXiroRequestId:
      description: The request's id (`req_…`); log it and quote it in support tickets. On every response.
      schema:
        type: string
      example: req_01J9ZTW4C8ABCDEF01234567

    IdempotencyReplayed:
      description: Present and `true` only when this response was replayed from a stored idempotent original.
      schema:
        type: string
        enum: ['true']

    XRateLimitLimit:
      description: The per-key request ceiling for the current window.
      schema: { type: integer }

    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }

    RetryAfter:
      description: Seconds to wait before retrying (on `429`).
      schema: { type: integer }

  responses:

    InvalidRequest:
      description: >
        Malformed body, a missing/invalid parameter, or a missing `Idempotency-Key` (HTTP `400` or
        `422`). `param` names the offending field.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: invalid_request
              code: amount_below_minimum
              message: Amount 5.00 MYR is below the minimum of 10.00 MYR for this payment method.
              param: amount
              request_id: req_01J9ZTW4C8ABCDEF01234567

    Unauthorized:
      description: Missing, revoked, malformed, or environment-mismatched API key.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: authentication
              code: invalid_api_key
              message: The API key is invalid, revoked, or does not match the requested environment.
              param: null
              request_id: req_01J9ZTW4C8ABCDEF01234567

    Forbidden:
      description: The key is valid but not allowed (e.g. the Merchant is suspended, or a capability is disabled).
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: permission
              code: account_suspended
              message: This account is suspended and cannot process API requests. Contact PayXiro support.
              param: null
              request_id: req_01J9ZTW4C8ABCDEF01234567

    NotFound:
      description: >
        No such resource in this (merchant, environment). Also returned for a foreign resource — there is
        no existence leak across tenants.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: not_found
              code: payment_not_found
              message: No such payment.
              param: null
              request_id: req_01J9ZTW4C8ABCDEF01234567

    Conflict:
      description: Idempotency conflict, or an invalid state transition.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: conflict
              code: idempotency_key_reuse
              message: This Idempotency-Key was already used with a different request body.
              param: Idempotency-Key
              request_id: req_01J9ZTW4C8ABCDEF01234567

    RateLimited:
      description: Too many requests for this key.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: rate_limit
              code: too_many_requests
              message: API rate limit exceeded. Retry after 1 seconds.
              param: null
              request_id: req_01J9ZTW4C8ABCDEF01234567

    InternalError:
      description: A PayXiro fault. Safe to retry with the same `Idempotency-Key`.
      headers:
        PayXiro-Request-Id: { $ref: '#/components/headers/PayXiroRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: internal
              code: internal_error
              message: Something went wrong on our end.
              param: null
              request_id: req_01J9ZTW4C8ABCDEF01234567

    WebhookAck:
      description: >
        The Platform must acknowledge with any `2xx` within 10 seconds; anything else (or a timeout)
        schedules a retry (1m, 5m, 30m, 2h, 12h, 24h, 24h — 8 attempts total). The response body is ignored.

  schemas:

    # ---- Scalar / shared value types -------------------------------------------------------------

    Amount:
      type: string
      description: >
        A decimal amount as a string, rendered to the asset's display decimals (e.g. USD/MYR = 2,
        USDT = 6). Never a JSON number, never client-side math.
      pattern: '^\d+(\.\d+)?$'
      examples: ['500.00', '1000.00', '150000.00']

    AssetCode:
      type: string
      description: >
        An asset from the registry, serialized `CODE` or `CODE.NETWORK`. `USDT.TRC20` and `USDT.ERC20`
        are distinct codes. Seeded set (extensible): USD, EUR, AED, MYR, PKR, USDT.TRC20, USDT.ERC20,
        BTC, ETH.
      pattern: '^[A-Z0-9]+(\.[A-Z0-9]+)?$'
      examples: ['MYR', 'USDT.TRC20', 'USDT.ERC20', 'BTC']

    CountryCode:
      type: [string, 'null']
      description: ISO 3166-1 alpha-2 country code (uppercased).
      pattern: '^[A-Z]{2}$'
      examples: [MY, AE, PK]

    PaymentMethodCode:
      type: string
      description: >
        A Payment Method catalog code. Data-driven (a new method is a catalog row, not a code change),
        so treat as extensible and tolerate unknown values. Seeded set: `card`, `bank_transfer`,
        `crypto`, `e_wallet`.
      examples: [crypto, bank_transfer]

    KycStatus:
      type: [string, 'null']
      description: The KYC status the Platform asserts for the Customer (free-form; passed through, not verified by PayXiro).
      maxLength: 64
      examples: [verified, unverified, pending]

    Metadata:
      type: object
      description: Free-form key/value pairs echoed back verbatim. Always a JSON object (`{}` when empty), never an array.
      additionalProperties: true

    Timestamp:
      type: string
      format: date-time
      description: ISO-8601 UTC with a `Z` suffix.
      examples: ['2026-07-05T09:41:22Z']

    NullableTimestamp:
      type: [string, 'null']
      format: date-time
      description: ISO-8601 UTC with a `Z` suffix, or null.
      examples: ['2026-07-05T09:41:22Z']

    CustomerId:
      type: string
      pattern: '^cust_[0-9A-Z]{26}$'
      examples: [cust_01J9ZR2KD7VE8F9G0H1J2K3L4M]

    PaymentId:
      type: string
      pattern: '^pay_[0-9A-Z]{26}$'
      examples: [pay_01J9ZX8KT2M1N2P3Q4R5S6T7U8]

    CheckoutSessionId:
      type: string
      pattern: '^cs_[0-9A-Z]{26}$'
      examples: [cs_01J9ZW3XQ4R8S9T0U1V2W3X4Y5]

    WebhookEndpointId:
      type: string
      pattern: '^whk_[0-9A-Z]{26}$'
      examples: [whk_01J9ZZC4N8RH0J1K2L3M4N5P6Q]

    WebhookEndpointStatus:
      type: string
      description: >
        Whether the endpoint receives events. A `disabled` endpoint is skipped by fan-out (no new
        deliveries) but its history stays visible; re-enabling clears any persistent-failure flag.
      enum: [active, disabled]

    EventTypeSubscription:
      type: string
      description: >
        An outbound event type to subscribe to — one exact type from the catalog (eventing §3) or the
        `*` wildcard (all events). The catalog grows additively; treat it as extensible.
      examples: ['*', payment.succeeded, payment.failed, checkout_session.completed, wallet.balance_changed]

    ProviderAccountId:
      type: [string, 'null']
      pattern: '^pacc_[0-9A-Z]{26}$'
      examples: [pacc_01J8XN5QW9BCDEF01234567890]

    Environment:
      type: string
      description: The tenant environment. Selected by the API key prefix, never sent by the client.
      enum: [sandbox, live]

    PaymentStatus:
      type: string
      description: >
        The canonical Payment state machine (payment-flows §2.1):
        `created → requires_action | under_review | processing → succeeded | failed | cancelled | expired`.
        Terminal: succeeded, failed, cancelled, expired.
      enum: [created, requires_action, under_review, processing, succeeded, failed, cancelled, expired]

    CheckoutSessionStatus:
      type: string
      description: 'The canonical Checkout Session lifecycle (payment-flows §2.4): `open → completed | expired | cancelled`.'
      enum: [open, completed, expired, cancelled]

    ErrorType:
      type: string
      description: The error class. Branch on `type` and `code`, never on `message` text.
      enum: [invalid_request, authentication, permission, not_found, conflict, rate_limit, provider, internal]

    # ---- Error envelope --------------------------------------------------------------------------

    Error:
      type: object
      description: The error detail (api-design §5).
      required: [type, code, message, param, request_id]
      properties:
        type: { $ref: '#/components/schemas/ErrorType' }
        code:
          type: string
          description: A stable machine-readable code (e.g. `idempotency_key_reuse`, `parameter_missing`).
        message:
          type: string
          description: A human-readable explanation. Not stable — do not branch on it.
        param:
          type: [string, 'null']
          description: The offending request field, when the error is tied to one; otherwise null.
        request_id:
          type: string
          description: The request id (`req_…`), mirrored in the `PayXiro-Request-Id` header.
      additionalProperties: false

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/Error' }
      additionalProperties: false

    # ---- List envelope ---------------------------------------------------------------------------

    ListEnvelope:
      type: object
      description: Cursor-paginated collection wrapper (api-design §4.3). No page numbers, no total counts.
      required: [object, data, has_more, next_cursor]
      properties:
        object:
          const: list
        data:
          type: array
          items: {}
        has_more:
          type: boolean
          description: Whether more items exist after this page.
        next_cursor:
          type: [string, 'null']
          description: Pass as `starting_after` for the next page; null on the last page.
      additionalProperties: false

    # ---- Ping ------------------------------------------------------------------------------------

    Ping:
      type: object
      required: [object, merchant, environment, platform, request_id]
      properties:
        object: { const: ping }
        merchant:
          type: string
          pattern: '^mch_[0-9A-Z]{26}$'
        environment: { $ref: '#/components/schemas/Environment' }
        platform:
          type: string
          description: The stable code of the Platform the API key is bound to.
          examples: [brokeret_crm]
        request_id:
          type: string
      additionalProperties: false

    # ---- Customer --------------------------------------------------------------------------------

    Customer:
      type: object
      description: A Customer resource (CustomerPresenter). `id` carries the `cust_` prefix.
      required:
        [id, object, external_ref, email, phone, country, kyc_status, custom_fields, created_at, updated_at]
      properties:
        id: { $ref: '#/components/schemas/CustomerId' }
        object: { const: customer }
        external_ref:
          type: [string, 'null']
          description: The Platform's own reference — the dedupe/identity handle.
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        country: { $ref: '#/components/schemas/CountryCode' }
        kyc_status: { $ref: '#/components/schemas/KycStatus' }
        custom_fields: { $ref: '#/components/schemas/Metadata' }
        created_at: { $ref: '#/components/schemas/Timestamp' }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
      additionalProperties: false

    CustomerUpsertRequest:
      type: object
      required: [external_ref]
      properties:
        external_ref: { type: string, maxLength: 255 }
        email: { type: [string, 'null'], format: email, maxLength: 255 }
        phone:
          type: [string, 'null']
          pattern: '^\+?[0-9\s\-()]{5,32}$'
        country: { $ref: '#/components/schemas/CountryCode' }
        kyc_status: { $ref: '#/components/schemas/KycStatus' }
        custom_fields: { $ref: '#/components/schemas/Metadata' }
      additionalProperties: false

    CustomerList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Customer' }

    # ---- Payment ---------------------------------------------------------------------------------

    PaymentObject:
      type: object
      description: >
        The canonical Payment object (PaymentSerializer::object). This is the shape carried in webhook
        event payloads. The API adds `next_action` on top (see `Payment`).
      required:
        - id
        - object
        - status
        - amount
        - asset_code
        - payment_method
        - country
        - customer
        - provider_account
        - provider_reference
        - platform_reference
        - failure_code
        - metadata
        - expires_at
        - succeeded_at
        - created_at
      properties:
        id: { $ref: '#/components/schemas/PaymentId' }
        object: { const: payment }
        status: { $ref: '#/components/schemas/PaymentStatus' }
        amount:
          oneOf:
            - { $ref: '#/components/schemas/Amount' }
            - { type: 'null' }
          description: >
            The requested amount, or null for an OPEN-AMOUNT Payment (Q-076: no fixed amount was requested —
            the first confirmed deposit settles it). The settled figure is then in `received_amount`.
        received_amount:
          oneOf:
            - { $ref: '#/components/schemas/Amount' }
            - { type: 'null' }
          description: >
            The RECEIVED amount a Provider reported for a crypto deposit (Q-041 in-band under/overpayment,
            and always for an open-amount Payment), or null when none was reported. This is the amount the
            ledger posts on success.
        asset_code: { $ref: '#/components/schemas/AssetCode' }
        payment_method: { $ref: '#/components/schemas/PaymentMethodCode' }
        country: { $ref: '#/components/schemas/CountryCode' }
        customer:
          oneOf:
            - { $ref: '#/components/schemas/CustomerId' }
            - { type: 'null' }
          description: The linked Customer (`cust_…`), or null.
        provider_account: { $ref: '#/components/schemas/ProviderAccountId' }
        provider_reference:
          type: [string, 'null']
          description: The provider's own reference for this payment (e.g. an on-chain tx or gateway id), when known.
        platform_reference:
          type: [string, 'null']
          description: A reference supplied by / echoed to the creating Platform, when set.
        failure_code:
          type: [string, 'null']
          description: A normalized failure reason, set when `status` is `failed`.
        metadata: { $ref: '#/components/schemas/Metadata' }
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
        succeeded_at: { $ref: '#/components/schemas/NullableTimestamp' }
        created_at: { $ref: '#/components/schemas/Timestamp' }
      # No `additionalProperties: false`: this base object is extended via `allOf` (see `Payment`,
      # which adds `next_action`). Constraining additional properties here would, per JSON Schema
      # allOf semantics, reject the sibling-added field. Additive-only evolution tolerates unknowns.

    Payment:
      description: The Payment API resource — the canonical object plus its `next_action` (api-design §6.2).
      allOf:
        - $ref: '#/components/schemas/PaymentObject'
        - type: object
          required: [next_action]
          properties:
            next_action:
              description: What the Platform must render while the Payment is `requires_action`; null otherwise.
              oneOf:
                - { $ref: '#/components/schemas/CryptoDepositAction' }
                - { $ref: '#/components/schemas/BankTransferAction' }
                - { $ref: '#/components/schemas/RedirectAction' }
                - { $ref: '#/components/schemas/ProviderComponentAction' }
                - { type: 'null' }

    CryptoDepositAction:
      type: object
      description: Crypto deposit instructions (a provider/unique address or the merchant's static receiving address).
      required: [type, deposit_address]
      properties:
        type: { const: crypto_deposit }
        deposit_address: { type: string }
        network: { type: [string, 'null'] }
        amount: { type: [string, 'null'] }
        network_warning: { type: [string, 'null'] }
        qr:
          type: [string, 'null']
          description: >-
            The exact string a headless client should QR-encode to render its own deposit screen
            (ADR-0020) — the plain deposit address, no URI scheme, matching the hosted Cashier. Present
            whenever `deposit_address` is.
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
      additionalProperties: false

    BankTransferAction:
      type: object
      description: Manual bank-transfer beneficiary details plus the reference the Customer must quote (Flow 3).
      required: [type, bank_details, reference, amount, asset_code]
      properties:
        type: { const: bank_transfer }
        bank_details:
          type: object
          description: The merchant's beneficiary bank fields (data-driven from the method's form definition).
          additionalProperties: true
        reference: { type: [string, 'null'] }
        amount: { type: [string, 'null'] }
        asset_code: { $ref: '#/components/schemas/AssetCode' }
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
      additionalProperties: false

    RedirectAction:
      type: object
      description: A provider-hosted redirect the Customer is sent to.
      required: [type, redirect_url]
      properties:
        type: { const: redirect }
        redirect_url: { type: string, format: uri }
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
      additionalProperties: false

    ProviderComponentAction:
      type: object
      description: >-
        An embedded provider component the client mounts in place (ADR-0022 — e.g. Stripe embedded
        Checkout). The client loads the named provider's JS and initializes it with `client_secret` (the
        provider's ephemeral, browser-intended session secret — NOT an API credential) and, where the
        provider needs it, the non-secret `publishable_key`. The hosted Cashier already handles this;
        headless Platforms opt in. Credit is posted only on the signed webhook, never on completion here.
      required: [type, provider, client_secret]
      properties:
        type: { const: provider_component }
        provider:
          type: string
          description: The provider whose client-side component to mount (e.g. `stripe`).
        client_secret:
          type: string
          description: The provider's ephemeral client secret used to initialize the embedded component.
        publishable_key:
          type: [string, 'null']
          description: The provider's non-secret publishable key, when the client needs it to initialize.
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
      additionalProperties: false

    PaymentCreateRequest:
      type: object
      required: [asset_code, payment_method, customer]
      properties:
        amount:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: >
            The amount to charge. REQUIRED unless `open_amount` is true (Q-076). Must be omitted when
            `open_amount` is true.
        open_amount:
          type: boolean
          description: >
            Q-076 — create an OPEN-AMOUNT Payment with no fixed amount; the first confirmed deposit settles
            it. `asset_code` is still required (the customer must know what to send). Mutually exclusive with
            `amount`. Only routes to Provider Accounts that declare the `open_amount` capability
            (B2BinPay/Coinsbuy/manual crypto).
        asset_code: { $ref: '#/components/schemas/AssetCode' }
        payment_method: { $ref: '#/components/schemas/PaymentMethodCode' }
        customer:
          allOf: [{ $ref: '#/components/schemas/CustomerId' }]
          description: An existing Customer handle (`cust_…`).
        country: { $ref: '#/components/schemas/CountryCode' }
        metadata: { $ref: '#/components/schemas/Metadata' }
      additionalProperties: false

    SubmitPaymentFormRequest:
      type: object
      required: [form_values]
      properties:
        form_values:
          type: object
          description: >
            The dynamic-form field values, validated server-side against the Payment Method's seeded
            `form_definition` (required fields, types, regex/max/select options).
          additionalProperties: true
      additionalProperties: false

    PaymentList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Payment' }

    # ---- Checkout Session ------------------------------------------------------------------------

    CheckoutSessionObject:
      type: object
      description: >
        The canonical Checkout Session object (CheckoutSessionSerializer::object). Webhook event payloads
        carry this shape (plus a `payment_method`); the API adds `url` on top (see `CheckoutSession`).
      required:
        - id
        - object
        - status
        - amount
        - asset_code
        - customer
        - payment
        - success_url
        - cancel_url
        - metadata
        - expires_at
        - completed_at
        - created_at
      properties:
        id: { $ref: '#/components/schemas/CheckoutSessionId' }
        object: { const: checkout_session }
        status: { $ref: '#/components/schemas/CheckoutSessionStatus' }
        amount:
          oneOf:
            - { $ref: '#/components/schemas/Amount' }
            - { type: 'null' }
          description: The session amount, or null for an OPEN-AMOUNT session (Q-076 — the Cashier shows "send any amount").
        asset_code: { $ref: '#/components/schemas/AssetCode' }
        customer:
          oneOf:
            - { $ref: '#/components/schemas/CustomerId' }
            - { type: 'null' }
          description: The upserted Customer (`cust_…`), or null.
        payment:
          oneOf:
            - { $ref: '#/components/schemas/PaymentId' }
            - { type: 'null' }
          description: The linked Payment (`pay_…`) once the Customer commits, else null.
        success_url: { type: string, format: uri }
        cancel_url: { type: string, format: uri }
        metadata: { $ref: '#/components/schemas/Metadata' }
        expires_at: { $ref: '#/components/schemas/NullableTimestamp' }
        completed_at: { $ref: '#/components/schemas/NullableTimestamp' }
        created_at: { $ref: '#/components/schemas/Timestamp' }
      # No `additionalProperties: false`: extended via `allOf` (see `CheckoutSession`, which adds `url`;
      # and the webhook payload, which adds `payment_method`). See the note on `PaymentObject`.

    CheckoutSession:
      description: The Checkout Session API resource — the canonical object plus its hosted `url`.
      allOf:
        - $ref: '#/components/schemas/CheckoutSessionObject'
        - type: object
          required: [url]
          properties:
            url:
              type: string
              format: uri
              description: The hosted Cashier URL (`<cashier base>/c/<cs_…>`). The `cs_…` id is itself the URL credential.

    CheckoutSessionCreateRequest:
      type: object
      required: [asset_code, customer, success_url, cancel_url]
      properties:
        amount:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: >
            The session amount. REQUIRED unless `open_amount` is true (Q-076). Must be omitted when
            `open_amount` is true.
        open_amount:
          type: boolean
          description: >
            Q-076 — open an OPEN-AMOUNT session with no fixed amount; the Cashier shows "send any amount" and
            the resulting Payment settles at whatever arrives. `asset_code` is still required. Mutually
            exclusive with `amount`.
        asset_code: { $ref: '#/components/schemas/AssetCode' }
        customer:
          type: object
          description: The Customer, upserted inline by `external_ref` (E2 semantics).
          required: [external_ref]
          properties:
            external_ref: { type: string, maxLength: 255 }
            email: { type: [string, 'null'], format: email, maxLength: 255 }
            phone:
              type: [string, 'null']
              pattern: '^\+?[0-9\s\-()]{5,32}$'
            country: { $ref: '#/components/schemas/CountryCode' }
            kyc_status: { $ref: '#/components/schemas/KycStatus' }
            custom_fields: { $ref: '#/components/schemas/Metadata' }
          additionalProperties: false
        success_url:
          type: string
          format: uri
          maxLength: 2048
          description: Validated against the creating Platform's allowed redirect patterns.
        cancel_url:
          type: string
          format: uri
          maxLength: 2048
          description: Validated against the creating Platform's allowed redirect patterns.
        metadata: { $ref: '#/components/schemas/Metadata' }
        context:
          type: object
          description: The Platform's informational risk-context block; `kyc_status` folds into the Customer upsert if omitted on `customer`.
          properties:
            kyc_status: { $ref: '#/components/schemas/KycStatus' }
            customer_ip: { type: [string, 'null'], maxLength: 64 }
            customer_since: { type: [string, 'null'], maxLength: 64 }
          additionalProperties: true
      additionalProperties: false

    # ---- Outbound webhook event envelope ---------------------------------------------------------

    EventType:
      type: string
      description: >
        The event type (`resource.state`). Mirrors the canonical state machines; the catalog grows
        additively (new types, never renamed). Payouts/Refunds event types exist in the catalog
        (eventing §3) but are not emitted in the current phase.
      enum:
        - payment.created
        - payment.requires_action
        - payment.under_review
        - payment.processing
        - payment.succeeded
        - payment.failed
        - payment.cancelled
        - payment.expired
        - checkout_session.completed
        - checkout_session.expired

    WebhookEventEnvelope:
      type: object
      description: >
        The common outbound event envelope (eventing §5). Delivered as the POST body to a registered
        Webhook Endpoint, signed with `PayXiro-Signature` (header) over the raw bytes.
      required: [id, object, type, api_version, environment, merchant_id, created_at, data]
      properties:
        id:
          type: string
          pattern: '^evt_[0-9A-Z]{26}$'
          description: The event id; receivers dedupe on it (deliveries are at-least-once).
        object: { const: event }
        type: { $ref: '#/components/schemas/EventType' }
        api_version:
          type: string
          const: v1
        environment: { $ref: '#/components/schemas/Environment' }
        merchant_id:
          type: string
          pattern: '^mch_[0-9A-Z]{26}$'
        created_at: { $ref: '#/components/schemas/Timestamp' }
        data:
          type: object
          required: [object]
          properties:
            object: {}
          additionalProperties: false
      additionalProperties: false

    # ---- Webhook Endpoints -----------------------------------------------------------------------

    WebhookEndpoint:
      type: object
      description: >
        A registered outbound Webhook Endpoint (WebhookEndpointPresenter). `id` carries the `whk_`
        prefix. The signing secret is NEVER part of this shape — it appears only in the create response
        (`WebhookEndpointWithSecret`), exactly once.
      required: [id, object, url, platform, subscribed_events, status, created_at]
      properties:
        id: { $ref: '#/components/schemas/WebhookEndpointId' }
        object: { const: webhook_endpoint }
        url:
          type: string
          format: uri
          description: The https URL PayXiro POSTs signed events to.
        platform:
          type: string
          description: The stable code of the Platform this endpoint belongs to (the calling key's Platform).
          examples: [brokeret_crm]
        subscribed_events:
          type: array
          description: The subscribed event types, or a single `*` for all events.
          items: { $ref: '#/components/schemas/EventTypeSubscription' }
        status: { $ref: '#/components/schemas/WebhookEndpointStatus' }
        created_at: { $ref: '#/components/schemas/Timestamp' }
      additionalProperties: false

    WebhookEndpointWithSecret:
      type: object
      description: >
        The create response only: the full Webhook Endpoint PLUS the plaintext `whsec_…` signing secret,
        shown EXACTLY ONCE (eventing §6; security §7.2). No other endpoint ever returns `secret`.
      required: [id, object, url, platform, subscribed_events, status, created_at, secret]
      properties:
        id: { $ref: '#/components/schemas/WebhookEndpointId' }
        object: { const: webhook_endpoint }
        url: { type: string, format: uri }
        platform: { type: string, examples: [brokeret_crm] }
        subscribed_events:
          type: array
          items: { $ref: '#/components/schemas/EventTypeSubscription' }
        status: { $ref: '#/components/schemas/WebhookEndpointStatus' }
        created_at: { $ref: '#/components/schemas/Timestamp' }
        secret:
          type: string
          description: The HMAC signing secret (`whsec_…`). Store it now — it is never shown again.
          pattern: '^whsec_[0-9a-f]+$'
          examples: [whsec_5f3a8e9c1d2b4a6f8e0c1d2b4a6f8e0c1d2b4a6f8e0c1d2b]
      additionalProperties: false

    WebhookEndpointList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/WebhookEndpoint' }

    WebhookEndpointCreateRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: The endpoint URL. Must be https (http tolerated only in local dev).
          examples: ['https://crm.brokeret.com/payxiro/webhooks']
        events:
          type: array
          description: Event types to subscribe to. Omit or send an empty list to subscribe to all events.
          items: { $ref: '#/components/schemas/EventTypeSubscription' }
      additionalProperties: false

    WebhookEndpointUpdateRequest:
      type: object
      description: >
        A partial update — send only the fields to change. An absent field is left unchanged. The signing
        secret is not updatable here.
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        events:
          type: array
          items: { $ref: '#/components/schemas/EventTypeSubscription' }
        status: { $ref: '#/components/schemas/WebhookEndpointStatus' }
      additionalProperties: false

    PaymentWebhookEvent:
      description: An outbound `payment.*` event; `data.object` is the Payment object (no `next_action`).
      allOf:
        - $ref: '#/components/schemas/WebhookEventEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [object]
              properties:
                object: { $ref: '#/components/schemas/PaymentObject' }
              additionalProperties: false

    CheckoutSessionWebhookEvent:
      description: >
        An outbound `checkout_session.*` event; `data.object` is the Checkout Session object plus the
        chosen `payment_method` (eventing §3).
      allOf:
        - $ref: '#/components/schemas/WebhookEventEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [object]
              properties:
                object:
                  allOf:
                    - $ref: '#/components/schemas/CheckoutSessionObject'
                    - type: object
                      properties:
                        payment_method:
                          oneOf:
                            - { $ref: '#/components/schemas/PaymentMethodCode' }
                            - { type: 'null' }
              additionalProperties: false
