openapi: 3.1.0

info:
  title: Payment Solution — Client API
  version: "1.0.0"
  summary: Payin, payout, refund and transaction lookup over a brand API key.
  description: |
    The client API is how a merchant backend moves money through the Payment
    Solution platform. Five endpoints, all under `/v1`, all authenticated by a
    **brand API key**.

    ## Transaction types

    Money coming **in** is a **payin** (`POST /v1/payins`); money going **out**
    is a **payout** (`POST /v1/payouts`). A **refund** is client-initiated and
    draws against a completed payin's refundable remainder. A **reversal** is
    provider-initiated compensation — a returned payout, a chargeback — which
    can only ever be observed, never requested. Refunds and reversals draw
    against the same remainder, so do not model them as one thing.

    ## Shape of every call

    * `Authorization: Bearer psk_…` (or `X-Api-Key: psk_…`) on every request.
    * `Idempotency-Key` is **required** on all three POSTs.
    * A fresh operation answers **201**; an idempotent replay of the same key
      with the same payload answers **200** with the stored result. Both are
      success and both carry the same body shape.
    * Errors are always `{status, code, message, details, timestamp}` with a
      stable machine-readable `code`.

    ## Statuses

    `CREATED → PENDING | PROCESSING → COMPLETED | FAILED | CANCELLED | EXPIRED`,
    with `IN_DOUBT` as the ambiguity state. `COMPLETED`, `FAILED`, `CANCELLED`
    and `EXPIRED` are terminal — see `integration-guide.md` for the two
    deliberate exceptions and what each status means for a client.

    ## Callbacks

    Status changes a client could not have learned from its own API call are
    delivered as signed HTTP POSTs to the brand's callback URL. They are
    described under `webhooks` at the bottom of this document; the signature
    recipe and a worked example are in `integration-guide.md`.

    ## Support

    Where this document says *contact support*, use the support channel you
    were given during onboarding.
  contact:
    name: Payment Solution integration support

servers:
  - url: https://api.aurumsys.com
    description: Production

security:
  - bearerApiKey: []
  - headerApiKey: []

tags:
  - name: Operations
    description: Moving money — payins, payouts and refunds.
  - name: Transactions
    description: Reading back what happened.
  - name: Configuration
    description: What this brand can currently accept.

paths:

  /v1/payins:
    post:
      tags: [Operations]
      operationId: createPayin
      summary: Start a payin
      description: |
        Creates a payin and calls the routed PSP. What comes back depends on
        the provider and method:

        * a hosted-page provider answers `PENDING` with a `redirectUrl` — send
          the end user there;
        * a pay-to-address provider answers `PENDING` with
          `paymentInstructions` and no `redirectUrl`;
        * a synchronous refusal answers `FAILED` with `error`;
        * an ambiguous provider failure (timeout, crash mid-call) answers
          `IN_DOUBT` — the platform reconciles it and sends a callback. It is
          never terminal-failed, because the PSP may be executing it.

        Validation order matters, because everything below happens **before a
        transaction row exists**, so a refusal costs nothing: bean validation →
        idempotency → routing → required customer fields → required request
        fields. Only then is a row persisted and the PSP called.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayinRequest'
            examples:
              hostedCard:
                summary: Card payin over a hosted page
                value:
                  amount: 25.00
                  currency: EUR
                  method: CARD
                  userRef: user-10427
                  clientTxRef: order-8891
                  country: DE
                  returnUrl: https://merchant.example.com/checkout/return
                  customer:
                    email: alex@example.com
                    firstName: Alex
                    lastName: Example
              cryptoPayin:
                summary: Crypto payin — answers with pay-to instructions, not a redirect
                value:
                  amount: 50.00
                  currency: EUR
                  method: CRYPTO
                  userRef: user-10427
                  customer:
                    email: alex@example.com
      responses:
        '201':
          $ref: '#/components/responses/TransactionCreated'
        '200':
          $ref: '#/components/responses/TransactionReplayed'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/payouts:
    post:
      tags: [Operations]
      operationId: createPayout
      summary: Send a payout
      description: |
        Executes a payout immediately — the platform performs no approval step
        of its own, because approval already happened on the client's side
        before the call.

        **The destination rule is the whole endpoint.** For a normal method,
        `destination` is required; it is stored encrypted under the brand key
        and only a head+tail mask (`destinationMasked`) is ever readable
        afterwards. For a method flagged `hostedPayout` on
        `GET /v1/payment-methods`, the PSP collects the destination on its own
        page: send **no** `destination` and **no** `bankCode`, and send the
        end user to the `redirectUrl` on the answer. Sending one anyway is
        refused (`DESTINATION_NOT_ACCEPTED`) rather than quietly stored and
        ignored — this is what makes a card payout possible without a PAN ever
        reaching the platform.

        Where the account number does not route itself — a UK sort code on
        Faster Payments — send `bankCode` beside `destination`. Whether that
        applies depends on **method and currency**: read
        `requiresDestinationBankCode` per (method, currency) from
        `GET /v1/payment-methods`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutRequest'
            examples:
              sepaIban:
                summary: SEPA payout to a self-routing IBAN
                value:
                  amount: 120.00
                  currency: EUR
                  method: BANK_TRANSFER
                  userRef: user-10427
                  clientTxRef: withdrawal-4412
                  country: DE
                  destination: DE00000000000000000000
                  customer:
                    email: alex@example.com
                    firstName: Alex
                    lastName: Example
              fasterPayments:
                summary: UK Faster Payments — account number plus sort code
                value:
                  amount: 80.00
                  currency: GBP
                  method: BANK_TRANSFER
                  userRef: user-10427
                  country: GB
                  destination: "00000000"
                  bankCode: 12-34-56
                  customer:
                    email: alex@example.com
                    ipAddress: 203.0.113.10
              hostedPayout:
                summary: Hosted payout — no destination, the answer carries a redirectUrl
                value:
                  amount: 40.00
                  currency: EUR
                  method: CARD
                  userRef: user-10427
                  returnUrl: https://merchant.example.com/checkout/return
      responses:
        '201':
          $ref: '#/components/responses/TransactionCreated'
        '200':
          $ref: '#/components/responses/TransactionReplayed'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/refunds:
    post:
      tags: [Operations]
      operationId: createRefund
      summary: Refund a completed payin
      description: |
        Refunds a `COMPLETED` payin, fully or partially. Omit `amount` to
        refund the full remaining refundable balance.

        The refund is **not routed** — it follows its parent payin to the same
        PSP product that took the money, using the route settings snapshotted
        on the parent row.

        The refundable remainder is enforced under a lock on the parent, so
        concurrent refunds cannot overdraw it (`REFUND_EXCEEDS_REMAINING`). A
        payin under an active dispute is refused outright (`DISPUTED`) — this
        check runs **first**, because refunding a disputed payin is the classic
        double-credit loss and every other refusal message would be a lie
        about the real reason.

        Whether a refund settles inside this response or later over a callback
        is a per-provider property. Do not assume either; read `status` and
        wait for a callback if it is not terminal.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefundRequest'
            examples:
              full:
                summary: Full refund of the remaining amount
                value:
                  transactionId: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ
              partial:
                summary: Partial refund
                value:
                  transactionId: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ
                  amount: 10.00
      responses:
        '201':
          $ref: '#/components/responses/TransactionCreated'
        '200':
          $ref: '#/components/responses/TransactionReplayed'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/transactions/{publicId}:
    get:
      tags: [Transactions]
      operationId: getTransaction
      summary: Read a transaction
      description: |
        The reconciliation route. Use it when a callback never arrived, when a
        POST failed in transport and you never learned its outcome, or after a
        restart mid-flight — ask the platform rather than guessing.

        Scoped to the brand of the presented API key: a transaction belonging
        to another brand answers `404 NOT_FOUND`, exactly as an unknown id does.

        Works for every transaction the brand owns, including `REVERSAL` rows
        the platform created on its own from a PSP notification.
      parameters:
        - name: publicId
          in: path
          required: true
          description: The transaction's public id, as returned in `id` (`tx_…`).
          schema:
            type: string
            examples: [tx_4bQ9xR2mKpLvN7sTfWgHdYcZ]
      responses:
        '200':
          description: The transaction.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionView'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/payment-methods:
    get:
      tags: [Configuration]
      operationId: listPaymentMethods
      summary: What this brand currently accepts
      description: |
        One row per enabled (provider config, method, currency, country) route
        the brand has. **Build your payment form entirely from this list** —
        limits, required customer fields, which directions each option
        supports, and what a payout destination for it actually is. Nothing
        about any specific PSP belongs in client code.

        Rows come from your brand's current routing configuration, so the list
        changes without a release. Re-read it rather than caching it
        indefinitely.

        An empty list means the brand currently has nothing matching: no
        enabled provider configuration, no enabled routes on one, or no route
        matching the filters you passed. That is a `200` with `[]`, not an
        error.
      parameters:
        - name: currency
          in: query
          required: false
          description: |
            Exact ISO-4217 match. Omitted = every currency.
          schema:
            type: string
            examples: [EUR]
        - name: country
          in: query
          required: false
          description: |
            ISO-3166-1 alpha-2. A row whose own `country` is null is
            country-agnostic and matches every filter value. Omitted = no
            country filtering at all.
          schema:
            type: string
            examples: [DE]
      responses:
        '200':
          description: The brand's currently available payment options.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PaymentMethodView'
              examples:
                twoOptions:
                  summary: A payin-only card option and a two-way crypto option
                  description: |
                    Illustrative values — provider codes, labels and limits are
                    whatever your own routing is configured with. Note the
                    shape rather than the contents: `CARD` here takes payins
                    only and needs no customer data, while `CRYPTO` works in
                    both directions, demands an e-mail, and is addressed to a
                    chain address rather than a bank account.
                  value:
                    - provider: EXAMPLEPSP
                      providerName: Example Payments
                      method: CARD
                      currency: EUR
                      country: null
                      minAmount: 10.00
                      maxAmount: 5000.00
                      requiredCustomerFields:
                        payin: []
                        payout: []
                      requiredRequestFields:
                        payin: []
                        payout: []
                      supportsPayins: true
                      supportsPayouts: false
                      hostedPayout: false
                      requiresDestinationBankCode: false
                      destinationKind: null
                      supportsRefunds: true
                      supportsPartialRefunds: true
                    - provider: EXAMPLEPSP
                      providerName: Example Payments
                      method: CRYPTO
                      currency: EUR
                      country: null
                      minAmount: 20.00
                      maxAmount: 10000.00
                      requiredCustomerFields:
                        payin: [EMAIL]
                        payout: [EMAIL]
                      requiredRequestFields:
                        payin: []
                        payout: []
                      supportsPayins: true
                      supportsPayouts: true
                      hostedPayout: false
                      requiresDestinationBankCode: false
                      destinationKind: CRYPTO_ADDRESS
                      supportsRefunds: true
                      supportsPartialRefunds: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalError'

webhooks:

  transactionStatusChanged:
    post:
      operationId: onTransactionStatusChanged
      summary: A transaction changed status
      description: |
        Delivered to the brand's configured callback URL for every status
        transition the client did not itself cause synchronously — a
        notification from the provider, the platform's own reconciliation of an
        unresolved transaction, or a manual intervention. A status the client
        already learned from its own API response produces no callback.

        Answer **2xx to anything whose signature verified**, including
        duplicates, stale deliveries and transactions you do not recognise.
        Anything else buys retries (and eventually a `DEAD` delivery) for
        something a retry cannot fix. Refuse only what fails verification, with
        `401` and no explanation.

        See `integration-guide.md` § "Verifying a callback signature".
      parameters:
        - name: X-PS-Signature
          in: header
          required: true
          description: '`t=<unix seconds>,v1=<hex HMAC-SHA256>` — see the guide.'
          schema:
            type: string
            examples: ['t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f']
        - name: X-PS-Event
          in: header
          required: true
          description: The event name, identical to `event` in the body.
          schema:
            type: string
            const: transaction.status_changed
        - name: X-PS-Delivery
          in: header
          required: true
          description: |
            Delivery id — the **same value on every retry** of this delivery.
            Deduplicate on it.
          schema:
            type: string
            examples: ['84213']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CallbackPayload'
            examples:
              payinCompleted:
                summary: A payin settled
                value:
                  event: transaction.status_changed
                  transactionId: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ
                  parentTransactionId: null
                  type: PAYIN
                  status: COMPLETED
                  method: CARD
                  amount: 25.00
                  currency: EUR
                  settledAmount: 25.00
                  settledCurrency: EUR
                  provider: EXAMPLEPSP
                  userRef: user-10427
                  clientTxRef: order-8891
                  errorCode: null
                  errorMessage: null
                  occurredAt: '2026-09-04T09:12:31.482913Z'
      responses:
        '2XX':
          description: |
            Accepted. Any 2xx marks the delivery `DELIVERED` and stops retries.
        default:
          description: |
            Anything that is not 2xx is a failed attempt: the delivery is
            retried with exponential backoff (60 s after the first failure,
            doubling, capped at 6 h) up to 15 attempts, after which it is
            marked `DEAD` and stops retrying — contact support to have it
            replayed. A 3xx is **not followed**: redirects are refused, so a
            redirect is recorded as a failed attempt and the signed body is
            never replayed at the `Location` host.

  transactionReversed:
    post:
      operationId: onTransactionReversed
      summary: Money moved backwards
      description: |
        A deliberately distinct event name for a `REVERSAL` transaction: money
        that moved and then moved back — a payout the bank returned, or a
        chargeback / recall / fraud claim on a settled payin.

        It is **not** a `REFUND`: a refund is client-initiated and draws
        against a refundable budget; a reversal is inbound and nobody asked us
        for it. The event name differs precisely so that an unprepared client
        fails loudly at integration time instead of dropping the most
        financially significant message the platform sends into an `else`
        branch.

        Every transition of the reversal row — its creation and its later
        resolution alike — arrives under this same name. `parentTransactionId`
        points at the movement being compensated; its direction tells you which
        kind of reversal this is (a compensated `PAYIN` is a chargeback, a
        compensated `PAYOUT` is a returned payout).
      parameters:
        - name: X-PS-Signature
          in: header
          required: true
          schema:
            type: string
        - name: X-PS-Event
          in: header
          required: true
          schema:
            type: string
            const: transaction.reversed
        - name: X-PS-Delivery
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CallbackPayload'
            examples:
              chargeback:
                summary: A chargeback on a settled payin
                value:
                  event: transaction.reversed
                  transactionId: tx_7hK2mNpQrStUvWxYzAbCdEf
                  parentTransactionId: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ
                  type: REVERSAL
                  status: COMPLETED
                  method: CARD
                  amount: 25.00
                  currency: EUR
                  settledAmount: 25.00
                  settledCurrency: EUR
                  provider: EXAMPLEPSP
                  userRef: user-10427
                  clientTxRef: order-8891
                  errorCode: null
                  errorMessage: null
                  occurredAt: '2026-09-18T14:03:02.117400Z'
      responses:
        '2XX':
          description: Accepted.
        default:
          description: Retried with backoff, as for `transaction.status_changed`.

components:

  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer psk_…` — the brand API key. The preferred form.

        The key is a **server-side secret**: it authorises money movement and
        must never reach a browser. It is issued to you during onboarding and
        shown exactly once; only a SHA-256 of it is stored, so it cannot be
        recovered afterwards — a lost key is replaced, never retrieved.

        A key that is unknown, malformed or revoked, and a suspended account,
        all fail closed with the same `401`, so a caller cannot probe which of
        them was the reason.
    headerApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        `X-Api-Key: psk_…` — accepted as an alternative to the bearer header,
        for clients whose HTTP stack reserves `Authorization`. The value must
        still carry the `psk_` prefix.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        **Required on every POST.** Generate it once per business intent —
        before the call, and durably — and reuse it on every retry of that
        intent. A key minted after a successful response can never help you.

        Same key + same payload = replay: `200` with the stored transaction.
        Same key + different payload = `409 IDEMPOTENCY_CONFLICT`.

        Unique per brand; keys are compared exactly after trimming surrounding
        whitespace.
      schema:
        type: string
        minLength: 1
        maxLength: 64
        pattern: '^[A-Za-z0-9_-]{1,64}$'
        examples: [order-8891-payin-01H9]

  responses:

    TransactionCreated:
      description: |
        The operation was created. The body is the transaction as it stands
        after the PSP call — read `status`, and `redirectUrl` /
        `paymentInstructions` if the end user has something left to do.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/TransactionView'
          examples:
            pendingRedirect:
              summary: Payin awaiting the end user on a hosted page
              value:
                id: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ
                type: PAYIN
                status: PENDING
                method: CARD
                provider: EXAMPLEPSP
                amount: 25.00
                currency: EUR
                settledAmount: null
                settledCurrency: null
                userRef: user-10427
                clientTxRef: order-8891
                country: DE
                redirectUrl: https://psp.example.com/pay/9fA2bC7dE1
                paymentInstructions: null
                destinationMasked: null
                parentTransactionId: null
                error: null
                createdAt: '2026-09-04T09:12:04.771200Z'
                completedAt: null
            payToAddress:
              summary: Crypto payin — instructions instead of a redirect
              value:
                id: tx_2sV8yT4nRmZqB6hJkLwXeCpF
                type: PAYIN
                status: PENDING
                method: CRYPTO
                provider: EXAMPLEPSP
                amount: 50.00
                currency: EUR
                settledAmount: null
                settledCurrency: null
                userRef: user-10427
                clientTxRef: null
                country: null
                redirectUrl: null
                paymentInstructions:
                  payAddress: exampleaddr9fA2bC7dE1
                  payAmount: 50.00
                  payCurrency: USDC
                  network: EXAMPLENET
                  qrData: 'examplechain:exampleaddr9fA2bC7dE1?amount=50.00'
                  memo: null
                  expiresAt: '2026-09-04T09:42:04.771200Z'
                destinationMasked: null
                parentTransactionId: null
                error: null
                createdAt: '2026-09-04T09:12:04.771200Z'
                completedAt: null
            declined:
              summary: The PSP definitively refused — terminal
              value:
                id: tx_5mW1pQ6rXyZtN8cVbGhJkLdS
                type: PAYIN
                status: FAILED
                method: CARD
                provider: EXAMPLEPSP
                amount: 25.00
                currency: EUR
                settledAmount: null
                settledCurrency: null
                userRef: user-10427
                clientTxRef: order-8892
                country: DE
                redirectUrl: null
                paymentInstructions: null
                destinationMasked: null
                parentTransactionId: null
                error:
                  code: CARD_DECLINED
                  message: The issuer declined this card
                createdAt: '2026-09-04T09:13:41.002100Z'
                completedAt: '2026-09-04T09:13:41.180400Z'
            inDoubt:
              summary: Ambiguous provider failure — never terminal, reconciled later
              value:
                id: tx_8nB3kR7wQzXcT2vMfHgLpYdA
                type: PAYOUT
                status: IN_DOUBT
                method: BANK_WIRE
                provider: EXAMPLEPSP
                amount: 120.00
                currency: EUR
                settledAmount: null
                settledCurrency: null
                userRef: user-10427
                clientTxRef: withdrawal-4412
                country: DE
                redirectUrl: null
                paymentInstructions: null
                destinationMasked: DE00…0000
                parentTransactionId: null
                error:
                  code: PROVIDER_UNREACHABLE
                  message: Provider call failed — status will be reconciled
                createdAt: '2026-09-04T09:20:11.410300Z'
                completedAt: null

    TransactionReplayed:
      description: |
        Idempotent replay: this `Idempotency-Key` and payload were already
        seen, and the body is the **stored** transaction from the first call —
        including any status changes it has undergone since. Treat it as
        success.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/TransactionView'

    BadRequest:
      description: |
        The request was refused **before any transaction row existed**, so
        nothing was created, no money moved, and the `Idempotency-Key` is still
        free. That is deliberate throughout: every check that can be made
        without calling the PSP is made first, so a refusal costs the client
        nothing to correct and retry.

        The full code catalogue is in `integration-guide.md` § "Errors".
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            validation:
              summary: Bean validation on the request body
              value:
                status: 400
                code: VALIDATION_ERROR
                message: Request validation failed
                details:
                  - 'currency: three uppercase letters'
                  - 'userRef: must not be blank'
                timestamp: '2026-09-04T09:12:04.771200Z'
            missingCustomerFields:
              summary: The routed PSP needs customer data this request does not carry
              value:
                status: 400
                code: MISSING_CUSTOMER_FIELDS
                message: The provider requires customer fields this request does not carry
                details: [EMAIL]
                timestamp: '2026-09-04T09:12:04.771200Z'
            amountOutOfRange:
              summary: Outside the configured limits for this route
              value:
                status: 400
                code: AMOUNT_OUT_OF_RANGE
                message: Amount is outside the accepted range
                details: ['minimum: 10.00', 'maximum: 5000.00']
                timestamp: '2026-09-04T09:12:04.771200Z'

    Unauthorized:
      description: |
        Missing, malformed, revoked or unknown API key — or a suspended brand
        or client. All of them answer identically and deliberately.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            invalidKey:
              value:
                status: 401
                code: UNAUTHORIZED
                message: Invalid API key
                details: []
                timestamp: '2026-09-04T09:12:04.771200Z'

    Forbidden:
      description: |
        Authenticated, but not permitted to perform this operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            forbidden:
              value:
                status: 403
                code: FORBIDDEN
                message: Insufficient permissions
                details: []
                timestamp: '2026-09-04T09:12:04.771200Z'

    NotFound:
      description: |
        No such transaction **for this brand**. An id belonging to another
        brand is indistinguishable from one that does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            notFound:
              value:
                status: 404
                code: NOT_FOUND
                message: Transaction not found
                details: []
                timestamp: '2026-09-04T09:12:04.771200Z'

    IdempotencyConflict:
      description: |
        This `Idempotency-Key` was already used by this brand with a
        **different** payload. That is a client bug — a key was reused across
        two distinct intents. Nothing was created.

        It is also what you get if the stored row was written by a newer
        fingerprint recipe than the running build knows (a rollback, or a
        mixed-version deploy). The platform cannot prove the payloads are
        equal, so it fails closed rather than answering your retry with some
        other request's transaction.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            conflict:
              value:
                status: 409
                code: IDEMPOTENCY_CONFLICT
                message: This Idempotency-Key was already used with a different request payload
                details: []
                timestamp: '2026-09-04T09:12:04.771200Z'

    UnsupportedMediaType:
      description: The request body was not `application/json`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    InternalError:
      description: |
        Unexpected platform error. **Not a decline** — the operation may or may
        not have been executed. Retry with the same `Idempotency-Key`, or read
        the transaction back, before assuming anything.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            internal:
              value:
                status: 500
                code: INTERNAL_ERROR
                message: Unexpected error
                details: []
                timestamp: '2026-09-04T09:12:04.771200Z'

  schemas:

    Money:
      type: number
      description: |
        A decimal money amount. Sent as a JSON number (a JSON string parses
        too); returned as a JSON number that preserves the stored scale
        (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits.

        A client that does arithmetic on these should read them as decimal
        strings from the raw body rather than through a `double` — and must
        never re-serialize a callback body it intends to verify.
      exclusiveMinimum: 0
      multipleOf: 0.01
      examples: [25.00]

    CustomerDetails:
      type: object
      description: |
        End-user data forwarded to the PSP, **in-flight only** — the platform
        keeps no end-user PII at rest and holds no end-user accounts.

        Which of these are *required* depends on the routed provider, the
        method and the currency. Read `requiredCustomerFields` from
        `GET /v1/payment-methods` and build the form from it; a missing one is
        refused with `400 MISSING_CUSTOMER_FIELDS`, listing the field names in
        `details`.

        Blank strings count as absent.
      additionalProperties: false
      properties:
        email:
          type: [string, "null"]
          format: email
          maxLength: 254
          examples: [alex@example.com]
        firstName:
          type: [string, "null"]
          maxLength: 64
        lastName:
          type: [string, "null"]
          maxLength: 64
        phone:
          type: [string, "null"]
          maxLength: 32
        dateOfBirth:
          type: [string, "null"]
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: |
            `yyyy-MM-dd`. The pattern only checks the shape; a shape-valid but
            impossible date (`2000-13-45`) is refused with
            `400 INVALID_CUSTOMER_FIELD` and `details: ["DATE_OF_BIRTH"]`
            rather than surfacing later as a confusing PSP-side rejection.
          examples: ['1990-04-17']
        addressLine1:
          type: [string, "null"]
          maxLength: 128
        addressLine2:
          type: [string, "null"]
          maxLength: 128
        city:
          type: [string, "null"]
          maxLength: 128
        postalCode:
          type: [string, "null"]
          maxLength: 16
        state:
          type: [string, "null"]
          maxLength: 128
        ipAddress:
          type: [string, "null"]
          maxLength: 45
          description: The end user's IP address, as a risk signal. IPv6-length.
          examples: [203.0.113.10]
        newCustomer:
          type: [boolean, "null"]
          description: |
            True when this is the end user's **first** transaction with the
            brand. An optional PSP risk/attribution signal only the client can
            know; omitted = unknown, and adapters fall back to their
            returning-customer default.

    PayinRequest:
      type: object
      additionalProperties: false
      required: [amount, currency, method, userRef]
      properties:
        amount:
          $ref: '#/components/schemas/Money'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO-4217, uppercase.
          examples: [EUR]
        method:
          type: string
          minLength: 1
          maxLength: 32
          pattern: '^[A-Z0-9_]+$'
          description: |
            A payment method token from `GET /v1/payment-methods`. The set is
            **not fixed by the platform** — it is whatever the brand's
            configured adapters drive (`CARD`, `BANK_WIRE`, `BANK_TRANSFER`,
            `CRYPTO`, …). Never hard-code it.
          examples: [CARD]
        provider:
          type: [string, "null"]
          maxLength: 64
          description: |
            Pin the operation to one provider code. Omitted = the platform
            routes by the brand's method rules, which is the normal case.
          examples: [EXAMPLEPSP]
        userRef:
          type: string
          minLength: 1
          maxLength: 64
          description: |
            Your end-user reference. The platform holds no user accounts, so
            this is the only identity a transaction carries. Echoed on every
            view and callback.
          examples: [user-10427]
        clientTxRef:
          type: [string, "null"]
          maxLength: 64
          description: |
            Your own reference for this operation, echoed back everywhere.
            Note that this is **not** the idempotency mechanism — it is not
            checked for uniqueness. Use `Idempotency-Key` for that.
          examples: [order-8891]
        country:
          type: [string, "null"]
          pattern: '^[A-Z]{2}$'
          description: |
            ISO-3166-1 alpha-2. Required when `requiredRequestFields.payin`
            for the routed option contains `country`; otherwise it only
            narrows routing.
          examples: [DE]
        returnUrl:
          type: [string, "null"]
          maxLength: 1024
          description: |
            Where the PSP sends the end user back after the hosted page.
            Required when `requiredRequestFields.payin` contains `returnUrl` —
            several adapters cannot open a hosted page without one, and
            omitting it is refused with `400 MISSING_REQUEST_FIELDS` rather
            than costing you a `FAILED` transaction.
          examples: [https://merchant.example.com/checkout/return]
        customer:
          $ref: '#/components/schemas/CustomerDetails'

    PayoutRequest:
      type: object
      additionalProperties: false
      required: [amount, currency, method, userRef]
      properties:
        amount:
          $ref: '#/components/schemas/Money'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          examples: [EUR]
        method:
          type: string
          minLength: 1
          maxLength: 32
          pattern: '^[A-Z0-9_]+$'
          description: |
            A method token from `GET /v1/payment-methods` whose row has
            `supportsPayouts: true`. A PSP's payin and payout method sets are
            often different.
          examples: [BANK_TRANSFER]
        provider:
          type: [string, "null"]
          maxLength: 64
        userRef:
          type: string
          minLength: 1
          maxLength: 64
          examples: [user-10427]
        clientTxRef:
          type: [string, "null"]
          maxLength: 64
          examples: [withdrawal-4412]
        country:
          type: [string, "null"]
          pattern: '^[A-Z]{2}$'
          examples: [DE]
        destination:
          type: [string, "null"]
          maxLength: 256
          description: |
            The account being paid — an IBAN, a domestic account number, a
            wallet account, a crypto address. **What it is** for a given
            (method, currency) is published as `destinationKind` on
            `GET /v1/payment-methods`; label your form from that rather than
            assuming a shape.

            Stored encrypted under the brand key; only `destinationMasked`
            (head + tail) is readable afterwards.

            Omit it **only** for a method flagged `hostedPayout`, where the PSP
            collects the destination on its own page and answers with a
            `redirectUrl`. Any other method without one is refused
            (`400 DESTINATION_REQUIRED`); a hosted method *with* one is also
            refused (`400 DESTINATION_NOT_ACCEPTED`), because the adapter would
            drop it while the platform had already encrypted and stored
            whatever you sent.
          examples: [DE00000000000000000000]
        bankCode:
          type: [string, "null"]
          maxLength: 16
          pattern: '^[0-9][0-9 -]*$'
          description: |
            The routing code that completes `destination` where the account
            number does not route itself — a UK sort code on Faster Payments.
            Digits, optionally separated by spaces or dashes as a human would
            type them (`12-34-56`); the adapter normalizes the separators away.

            Send it exactly when `requiresDestinationBankCode` is true for this
            (method, currency). Missing where required is
            `400 DESTINATION_BANK_CODE_REQUIRED`; present on a hosted method is
            `400 DESTINATION_NOT_ACCEPTED`, since half a destination is still a
            destination.

            Stored encrypted and **never echoed back** — a sort code names a
            branch, not an account, so there is nothing in it worth displaying.
          examples: [12-34-56]
        returnUrl:
          type: [string, "null"]
          maxLength: 1024
          description: Where the end user returns from a hosted payout page.
        customer:
          $ref: '#/components/schemas/CustomerDetails'

    RefundRequest:
      type: object
      additionalProperties: false
      required: [transactionId]
      properties:
        transactionId:
          type: string
          minLength: 1
          maxLength: 40
          description: |
            Public id of the **COMPLETED payin** to refund. Anything else —
            another type, another status, another brand — is refused.
          examples: [tx_4bQ9xR2mKpLvN7sTfWgHdYcZ]
        amount:
          description: |
            Omitted = the full remaining refundable amount (the payin's settled
            amount, less every refund and reversal already claimed against it).
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: "null"

    TransactionType:
      type: string
      enum: [PAYIN, PAYOUT, REFUND, REVERSAL]
      description: |
        * `PAYIN` — money in, created by `POST /v1/payins`.
        * `PAYOUT` — money out, created by `POST /v1/payouts`.
        * `REFUND` — client-initiated return of a completed payin.
        * `REVERSAL` — PSP-initiated compensation (returned payout,
          chargeback, recall, fraud claim, or a won chargeback moving the money
          back). The platform creates these on its own; a client can only
          observe them.

    TransactionStatus:
      type: string
      enum: [CREATED, PENDING, PROCESSING, IN_DOUBT, COMPLETED, FAILED, CANCELLED, EXPIRED]
      description: |
        * `CREATED` — the row exists, the PSP has not answered yet. Almost
          never observed by a client, since a POST returns only after the PSP
          call; it survives only across a crash between persisting the row and
          applying the result.
        * `PENDING` — waiting on the end user (a hosted page, or funds sent to
          a quoted address).
        * `PROCESSING` — the PSP is executing it. Nothing for the client to do
          but wait for a callback.
        * `IN_DOUBT` — an ambiguous provider failure: the PSP may or may not
          have accepted the operation. **Never terminal.** The platform
          reconciles it by querying the PSP, or a late webhook resolves it.
          Do not re-send the operation; retry the same `Idempotency-Key` or
          poll `GET /v1/transactions/{publicId}`.
        * `COMPLETED` — settled. Terminal and final.
        * `FAILED` — the PSP refused. Terminal.
        * `CANCELLED` — cancelled before completion. Terminal and final.
        * `EXPIRED` — the platform's housekeeping guess that an abandoned
          `PENDING` payin will never be paid. Terminal, but it is *our* guess
          rather than the PSP's answer, so a late PSP truth outranks it:
          `EXPIRED` may still become `COMPLETED` or `FAILED`.

        The other exception to terminality: for some methods a `PAYIN` that
        `FAILED` on a PSP answer carries a provider-declared **provisional
        window** during which the PSP may still revise it to `COMPLETED` (slow
        open-banking rails do this). It is narrow on three axes at once —
        which adapter, which method, and for how long — and a client learns of
        it only by receiving the callback.

    ErrorView:
      type: [object, "null"]
      description: The last error recorded on the transaction, if any.
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            The provider's or the platform's error code. `PROVIDER_UNREACHABLE`
            is the platform's own, and means the call was ambiguous — not that
            it was declined.
          examples: [CARD_DECLINED]
        message:
          type: [string, "null"]
          examples: [The issuer declined this card]

    PaymentInstructions:
      type: [object, "null"]
      description: |
        "Send X to address Y" data for payins with no redirect (crypto and
        other pay-to-address rails). Present only on `PAYIN` transactions, and
        only where the provider answered with instructions instead of a
        `redirectUrl`.
      required: [payAddress, payAmount, payCurrency, network, qrData, memo, expiresAt]
      properties:
        payAddress:
          type: string
          description: Where the end user must send funds.
        payAmount:
          type: [number, "null"]
          description: |
            The amount to send **in `payCurrency`**, which is often not the
            transaction currency.
        payCurrency:
          type: [string, "null"]
          examples: [USDC]
        network:
          type: [string, "null"]
          examples: [TRON]
        qrData:
          type: [string, "null"]
          description: Payload to render as a QR code, if the provider supplied one.
        memo:
          type: [string, "null"]
          description: |
            Destination tag / memo, where the rail needs one. Sending without
            it typically loses the funds.
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: When the quoted address or rate stops being valid.

    TransactionView:
      type: object
      description: |
        The client-facing view of a transaction. Every property is always
        present; absent values are `null` rather than omitted.
      required:
        - id
        - type
        - status
        - method
        - provider
        - amount
        - currency
        - settledAmount
        - settledCurrency
        - userRef
        - clientTxRef
        - country
        - redirectUrl
        - paymentInstructions
        - destinationMasked
        - parentTransactionId
        - error
        - createdAt
        - completedAt
      properties:
        id:
          type: string
          description: |
            The public id — opaque, non-sequential, `tx_` + 24 URL-safe base64
            characters. The only id a client ever sees.
          examples: [tx_4bQ9xR2mKpLvN7sTfWgHdYcZ]
        type:
          $ref: '#/components/schemas/TransactionType'
        status:
          $ref: '#/components/schemas/TransactionStatus'
        method:
          type: string
          examples: [CARD]
        provider:
          type: [string, "null"]
          description: |
            The provider code that served the transaction. Null if the
            provider configuration behind it has since been removed.
          examples: [EXAMPLEPSP]
        amount:
          $ref: '#/components/schemas/Money'
        currency:
          type: string
          examples: [EUR]
        settledAmount:
          type: [number, "null"]
          description: |
            What actually settled, which may differ from `amount` — a PSP
            settling in another currency, or a partial settlement. Null until
            the transaction settles. This, not `amount`, is what the refundable
            remainder is computed from.
        settledCurrency:
          type: [string, "null"]
        userRef:
          type: string
          examples: [user-10427]
        clientTxRef:
          type: [string, "null"]
          examples: [order-8891]
        country:
          type: [string, "null"]
          examples: [DE]
        redirectUrl:
          type: [string, "null"]
          description: |
            Where to send the end user. Present on payins whose PSP answered
            with a hosted page, and on **hosted payouts**, where it is the page
            on which the recipient supplies the destination. Null otherwise.
        paymentInstructions:
          $ref: '#/components/schemas/PaymentInstructions'
        destinationMasked:
          type: [string, "null"]
          description: |
            Head + tail mask of a payout destination. Null on payins, refunds
            and hosted payouts (where the platform never learns the
            destination). The full value is never returned.
          examples: [DE00…0000]
        parentTransactionId:
          type: [string, "null"]
          description: |
            The public id of the transaction this one derives from: the payin a
            refund draws against, or the movement a reversal compensates. Null
            on payins and payouts.
        error:
          $ref: '#/components/schemas/ErrorView'
        createdAt:
          type: string
          format: date-time
          examples: ['2026-09-04T09:12:04.771200Z']
        completedAt:
          type: [string, "null"]
          format: date-time
          description: Stamped when the transaction reached a terminal status.

    RequiredCustomerFields:
      type: object
      description: |
        Customer fields the routed PSP demands for this option, per operation
        family. Values are the platform's canonical field vocabulary and map
        one-to-one onto `CustomerDetails` properties: `EMAIL`, `FIRST_NAME`,
        `LAST_NAME`, `PHONE`, `DATE_OF_BIRTH`, `ADDRESS_LINE1`,
        `ADDRESS_LINE2`, `CITY`, `POSTAL_CODE`, `STATE`, `IP_ADDRESS`.

        Treat an unrecognised value as a field you cannot collect and surface
        it rather than dropping it — the vocabulary can grow.
      required: [payin, payout]
      properties:
        payin:
          type: array
          items: { type: string }
        payout:
          type: array
          items: { type: string }

    RequiredRequestFields:
      type: object
      description: |
        Request-level (non-customer) fields this option requires, per
        direction. Currently only `country` and `returnUrl`. Published for the
        same reason as `requiredCustomerFields` — a payment form must be buildable
        from this endpoint alone — and a missing one is
        `400 MISSING_REQUEST_FIELDS`.
      required: [payin, payout]
      properties:
        payin:
          type: array
          items: { type: string }
        payout:
          type: array
          items: { type: string }

    PaymentMethodView:
      type: object
      description: |
        One payment option this brand currently accepts: a provider, a method,
        a currency and an optional country, with its limits and its demands.
      required:
        - provider
        - providerName
        - method
        - currency
        - country
        - minAmount
        - maxAmount
        - requiredCustomerFields
        - requiredRequestFields
        - supportsPayins
        - supportsPayouts
        - hostedPayout
        - requiresDestinationBankCode
        - destinationKind
        - supportsRefunds
        - supportsPartialRefunds
      properties:
        provider:
          type: string
          description: Provider code — what you may send as `provider` on a request.
          examples: [EXAMPLEPSP]
        providerName:
          type: string
          description: |
            Your brand's own display label for this provider, set as part of
            your routing configuration. This is what belongs on the option you
            show the end user; `provider` is the machine code.
          examples: [Cards (EU)]
        method:
          type: string
          examples: [CARD]
        currency:
          type: string
          examples: [EUR]
        country:
          type: [string, "null"]
          description: |
            The country this route is scoped to, or null for country-agnostic.
        minAmount:
          type: [number, "null"]
          description: Inclusive lower bound; null = no lower bound.
        maxAmount:
          type: [number, "null"]
          description: Inclusive upper bound; null = no upper bound.
        requiredCustomerFields:
          $ref: '#/components/schemas/RequiredCustomerFields'
        requiredRequestFields:
          $ref: '#/components/schemas/RequiredRequestFields'
        supportsPayins:
          type: boolean
          description: |
            Whether **this option** can take a payin — per method and per route
            direction, not per provider. Filter your payment form on this before
            rendering: offering a payin the resolver would refuse only produces
            an error the end user cannot act on.
        supportsPayouts:
          type: boolean
          description: Whether this option can send a payout.
        hostedPayout:
          type: boolean
          description: |
            True = a payout over this method takes **no** `destination`; the
            PSP collects it on a hosted page and the answer carries a
            `redirectUrl`. This is how a card payout happens without a PAN ever
            reaching the merchant — never build a card form of your own for one.
        requiresDestinationBankCode:
          type: boolean
          description: |
            True = a payout over this method **and currency** needs `bankCode`
            beside `destination`. Per (method, currency), because that pair is
            what picks the bank rail: the same method can be SEPA in EUR and
            Faster Payments in GBP.
        destinationKind:
          type: [string, "null"]
          description: |
            **What** a payout destination over this method and currency is, so
            a payment form can label its box: `IBAN`, `BANK_ACCOUNT`,
            `WALLET_ACCOUNT`, `CRYPTO_ADDRESS` or `HOSTED`. Null when the
            routed PSP takes no payout destination over this method — read it
            together with `supportsPayouts`, which says whether the brand
            offers the withdrawal at all.

            Treat an unrecognised value as "some account we cannot describe"
            and use neutral wording. The set grows as rails are added, and a
            client that falls back to naming a shape the platform did not name
            is the bug this field exists to prevent.
          examples: [IBAN]
        supportsRefunds:
          type: boolean
        supportsPartialRefunds:
          type: boolean
          description: |
            False = this provider can only ever perform **one full refund** of
            a payin; anything else is `400 PARTIAL_REFUND_UNSUPPORTED`.

    CallbackPayload:
      type: object
      description: |
        The body of an outbound callback. Snapshotted at enqueue time, so it
        reports the transaction as it was when the transition happened.

        **Delivery order is not guaranteed** per transaction — two deliveries
        retrying with independent backoff can invert. Apply callbacks by
        `occurredAt`, discarding anything older than the newest you already
        applied to that transaction, and compare instants rather than strings.
      required:
        - event
        - transactionId
        - parentTransactionId
        - type
        - status
        - method
        - amount
        - currency
        - settledAmount
        - settledCurrency
        - provider
        - userRef
        - clientTxRef
        - errorCode
        - errorMessage
        - occurredAt
      properties:
        event:
          type: string
          enum: [transaction.status_changed, transaction.reversed]
          description: |
            Identical to the `X-PS-Event` header. Ignore events you do not
            know rather than failing — but do not silently funnel
            `transaction.reversed` into a `status_changed` handler.
        transactionId:
          type: string
          examples: [tx_4bQ9xR2mKpLvN7sTfWgHdYcZ]
        parentTransactionId:
          type: [string, "null"]
          description: |
            The transaction this one derives from — the refunded payin, or the
            movement a reversal compensates. Present on every payload that has
            a parent, not only on reversals.
        type:
          $ref: '#/components/schemas/TransactionType'
        status:
          $ref: '#/components/schemas/TransactionStatus'
        method:
          type: string
        amount:
          $ref: '#/components/schemas/Money'
        currency:
          type: string
        settledAmount:
          type: [number, "null"]
        settledCurrency:
          type: [string, "null"]
        provider:
          type: [string, "null"]
        userRef:
          type: string
        clientTxRef:
          type: [string, "null"]
        errorCode:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]
        occurredAt:
          type: string
          format: date-time
          description: |
            When the transition happened. **The ordering key** — arrival order
            is not.
          examples: ['2026-09-04T09:12:31.482913Z']

    ErrorResponse:
      type: object
      description: |
        The uniform error envelope. `code` is the stable, machine-readable
        part — branch on it, never on `message`, which is prose and may change.

        A browser navigating to an API URL (`Accept: text/html`) gets a small
        styled error page instead of this body; an API client sending
        `Accept: application/json` — or no `Accept` preference for HTML —
        always gets the JSON.
      required: [status, code, message, details, timestamp]
      properties:
        status:
          type: integer
          description: Repeats the HTTP status code.
          examples: [400]
        code:
          type: string
          description: |
            Stable machine code. The full catalogue reachable from `/v1` is in
            `integration-guide.md` § "Errors".
          examples: [MISSING_CUSTOMER_FIELDS]
        message:
          type: string
          description: Human-readable explanation, in English. Not a contract.
        details:
          type: array
          description: |
            Zero or more supporting strings whose meaning depends on `code` —
            the missing field names for `MISSING_CUSTOMER_FIELDS` and
            `MISSING_REQUEST_FIELDS`, `"field: message"` pairs for
            `VALIDATION_ERROR`, the bounds for `AMOUNT_OUT_OF_RANGE`, the
            remaining refundable amount for `REFUND_EXCEEDS_REMAINING`.
          items:
            type: string
        timestamp:
          type: string
          format: date-time
          description: When the platform produced the error.
