> ## Documentation Index
> Fetch the complete documentation index at: https://docs.panoflow.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a transfer

> Pays a bank account (`type: payout`) or moves money between two of the business's own accounts in the same
currency (`type: own_account`).

**Payout** (Pay → Review → Confirm it's you → Sending → Sent):
1. Pay a saved recipient with `recipient_id`, or a new account with `bank_account` (account number + bank code).
   A new account is name-enquired (or the 24-hour cache is used). If `bank_account.account_name` is sent and
   differs from the bank's answer, the request fails with `422 recipient_name_mismatch` unless
   `allow_name_mismatch: true`. `save_recipient: true` saves it as a recipient (`recipient_nickname` optional)
   and the response carries the new `recipient_id`.
2. **Step-up:** a customer session must send `X-Step-Up-Token`; `403 step_up_required` otherwise. API keys
   and agent tokens don't step up (agents are held by guardrails and ask-first approvals instead).
3. Balance (amount + fee), per-payment and daily limits and risk rules are checked, then a hold is placed
   on the amount. Only then does the transfer exist. `422 insufficient_balance`, `limit_exceeded`,
   `daily_limit_exceeded`, `amount_too_small`, `amount_too_large`, `currency_not_enabled`.
4. If the caller's payments over the approval threshold need a second person, the transfer is created as
   `pending_approval` with an `approval` (kind `transfer`, or `agent_payment` for agents) and the response is
   `201`. The hold stays in place until it is approved (→ `processing`), rejected (→ `rejected`), canceled
   or expires (→ `canceled`). `approval_threshold_exceeded` is **not** returned as an error here.
5. Otherwise the transfer is `processing` and the response is `202`: poll `GET /transfers/{id}` or wait for
   `payment.completed` / `payment.failed`. It is sent over NIP; if the bank doesn't answer in time we confirm
   the outcome with the bank, never by sending again.

**Own account** ("Transfer now"): no fee, no step-up, no approval, settles straight away and
returns `201` with `status: completed`. `destination_account_id` must be another account of this business in
the same currency (`422 currency_mismatch`).

**Agents** (OAuth tokens from the MCP server): `type` must be `payout`, `source_account_id` must be the agent's
own account and `recipient_id` is required (no raw bank accounts). Guardrails return `422 recipient_not_allowed`,
`limit_exceeded` or `daily_limit_exceeded` (concurrent payments can never take an agent over its daily
limit), `403 agent_paused`; a
payment over the agent's ask-first amount, or to a new recipient, becomes `pending_approval` with an
`agent_payment` approval and fires `agent.payment.held`. The transfer carries `agent_id`.

Fires `payment.created` for every transfer created, including `pending_approval` ones.

**Error codes:** `invalid_api_key`, `parameter_invalid`, `parameter_missing`, `resource_not_found`, `scope_missing`, `step_up_required`, `csrf_token_invalid`, `idempotency_key_reused`, `agent_paused`, `recipient_name_mismatch`, `insufficient_balance`, `limit_exceeded`, `daily_limit_exceeded`, `amount_too_small`, `amount_too_large`, `currency_not_enabled`, `currency_mismatch`, `recipient_not_allowed`, `partner_timeout`, `bank_unavailable`, `business_frozen`, `business_not_approved`, `rate_limited`.



## OpenAPI

````yaml /api-reference/openapi.yaml post /transfers
openapi: 3.1.0
info:
  title: PanoFlow API
  version: 0.3.0
  description: >-
    Identity, business, team, customer-side onboarding (KYB), accounts,
    statements, the customer transactions view,

    in-app notifications and search for the PanoFlow public API. The customer
    web app (app.panoflow.io) uses exactly these endpoints;

    it has no private API. Rules: `CONVENTIONS.md`. Behaviour:
    `docs/architecture.md`. Design/architecture conflicts:

    `docs/discrepancies/identity-accounts.md`.


    ## Authentication


    * **Customer session** (`__Host-pf_session` cookie; the web app only). The
    browser calls the API through the web app's same-origin
      proxy, `https://app.panoflow.io/api/v1/...`, which forwards to `https://api.panoflow.io/v1/...` (ADR 0004). The cookie is host-only on
      app.panoflow.io (`__Host-` prefix, Secure, HttpOnly, SameSite=Lax, Path=/, no Domain), so the browser never sends it to
      api.panoflow.io and no credentialed CORS is needed; api.panoflow.io ignores a session cookie that didn't come through the proxy.
      The session ends after 15 minutes without activity or 12 hours after sign-in, whichever comes first, and is revoked on a password or
      2FA change (ADR 0005). A session starts `pending` and becomes `active` once sign-up is finished (legal name, phone verified,
      authenticator enrolled) or, for a returning user, once 2FA is passed.
    * **CSRF.** Every state-changing request (POST, PATCH, DELETE) made with the
    session cookie sends `X-CSRF-Token` with the
      `csrf_token` of the current session. The proxy passes the header and the browser's `Origin` through unchanged and never adds a
      token itself; the API checks the token against the session and that `Origin` is `https://app.panoflow.io`, and answers 403
      `csrf_token_invalid` otherwise. Sign-up, sign-in and the other unauthenticated auth calls have no token yet, so through the proxy
      only the `Origin` check applies to them. API keys and OAuth tokens never send the header.
    * **API key** (`Authorization: Bearer pk_live_…`) and **OAuth 2.1** access
    tokens (agents, MCP) can call the business, team-read,
      onboarding-read, account, statement, transaction and search operations allowed by their scopes. They can never call `/v1/auth/*`,
      `/v1/users/me/*` or `/v1/notifications*` (session only), and agent tokens never hold `team:*`.

    ## `x-required-scope`


    * `none`: unauthenticated (sign-up, sign-in, password reset, invite links).
    Rate-limited per IP and per account.

    * `self`: any signed-in user acting on their own user, session or
    notifications. Session only.

    * `<resource>:<read|write>`: the scope an API key or token needs. For
    sessions the scope comes from the member's role:


    | Role | Scopes (this file) | Notes |

    | --- | --- | --- |

    | owner | business:read/write, team:read/write, accounts:read/write |
    Everything, including closing the business account. |

    | admin | business:read/write, team:read/write, accounts:read/write |
    Everything except changing the owner. |

    | finance | business:read, team:read, accounts:read/write | Pays up to the
    approval threshold alone. |

    | approver | business:read, team:read, accounts:read | Decides pending
    approvals; can't start payments. |

    | developer | business:read, team:read | API keys, webhooks, logs. No
    balances or payments. |

    | viewer | business:read, team:read, accounts:read | Read only. |


    Some writes are limited further by role ("**Roles:**" in the operation
    description); a disallowed role gets 403 `role_not_permitted`.


    ## Step-up


    Operations marked "**Step-up**" need `X-Step-Up-Token` from `POST
    /v1/auth/step-up` (TOTP or backup code within 5 minutes).


    ## Errors


    Errors use the shared `Error` shape, with `error.response_code` (ISO 8583,
    CONVENTIONS §7a) mapped from `error.code`. Each operation lists the

    codes it can return. Non-money resources in this file carry no
    `response_code`; `Transaction` (a view of money movements) composes
    `ResponseOutcome`.



    Generated from panobaas-contracts; only operations marked `x-audience:
    public`.
  contact:
    name: PanoFlow API
    url: https://docs.panoflow.io
    email: api@panoflow.io
servers:
  - url: https://api.panoflow.io/v1
    description: Live and sandbox (sandbox is chosen by `pk_test_` keys and test sessions).
  - url: https://api-dev-9d8b.up.railway.app/v1
    description: >-
      Development (Railway). Synthetic data and fake partners; redeployed from
      main, may be reset at any time.
security: []
tags:
  - name: Business
    description: >-
      The business (`/v1/businesses/me`): CAC record, how it appears, and the
      approval threshold for the customer-side two-person rule.
    x-displayName: Business
  - name: Accounts
    description: >-
      Main, Payroll and sub-accounts with their balances; open, rename, close,
      share details, confirmation letters and balance totals. Static virtual
      accounts are in the collections API.
    x-displayName: Accounts
  - name: Statements
    description: >-
      Monthly and custom-range account statements, generated asynchronously as
      PDF and CSV.
    x-displayName: Statements
  - name: Transactions
    description: >-
      The customer transactions view: every movement in and out of the
      business's accounts, with filters, notes, attachments, receipts and CSV
      export.
    x-displayName: Transactions
  - name: Banks
    description: >
      The bank picker and bank name enquiry. Name enquiry asks the receiving
      bank for the name on an account before

      anyone pays it or saves it as a recipient.
    x-displayName: Banks
  - name: Recipients
    description: >
      The people and businesses a business pays (Payments → Recipients). Every
      recipient is name-enquiry verified when

      it is saved. Recipients are archived, never deleted.
    x-displayName: Recipients
  - name: Transfers
    description: >
      One resource for money leaving an account: `payout` to a bank account
      outside PanoFlow (NIP), and

      `own_account` moves between the business's own accounts in the same
      currency. `reversal` transfers made by

      PanoFlow also appear here, read-only.
    x-displayName: Transfers
  - name: Batches
    description: >
      Pay many: validate a CSV (or JSON rows), then create a batch. Each valid
      row becomes its own transfer with

      `batch_id`; rows that fail validation are skipped and listed with a
      reason.
    x-displayName: Batches
  - name: Scheduled payments
    description: >
      Payments that repeat (or run once on a future date). Each run creates a
      normal transfer, checked like any other.
    x-displayName: Scheduled payments
  - name: Approvals
    description: >
      When a second person on your team must approve. One generic endpoint for
      every customer `ApprovalKind`:

      transfers and batches over the approval threshold, agent payments over an
      agent's ask-first amount, pay runs,

      role changes and limit changes.
    x-displayName: Approvals
  - name: Limits
    description: >-
      Read-only payment limits for the business (per payment, daily, approval
      threshold).
    x-displayName: Limits
  - name: Collections
    description: >-
      Header figures for the Collections screen (outstanding, overdue, collected
      this month, average days to pay) and saved customers (payers).
    x-displayName: Collections
  - name: Invoices
    description: >
      Create, send, chase, void and track invoices. VAT (7.5%) is computed
      server-side. An invoice can have its

      own account number; payments into it are recorded against the invoice
      automatically, including part payments.
    x-displayName: Invoices
  - name: Payment links
    description: >-
      Reusable pay pages at `pay.panoflow.io/<slug>` with a fixed or
      customer-entered amount. Can be paused and turned back on.
    x-displayName: Payment links
  - name: Virtual accounts
    description: >
      Static virtual accounts only: one permanent account number per customer,
      settling into one of your accounts.

      Per-invoice account numbers live on the invoice (`virtual_account`), not
      here.
    x-displayName: Virtual accounts
  - name: Inbound credits
    description: >-
      Money in. Every transfer received into an invoice account, a static
      virtual account, a pay page checkout or one of your accounts, and what it
      was matched to.
    x-displayName: Inbound credits
  - name: People
    description: >-
      Add, list, update and offboard the people you pay, and the deductions
      added to each person.
    x-displayName: People
  - name: Deduction types
    description: >-
      Deductions set up once and applied to everyone or to selected people
      (PAYE, pension, NHF, loans, advances, savings, custom levies).
    x-displayName: Deduction types
  - name: Payroll settings
    description: >-
      Pay day, payslip emails, reminders, automatic pay, the second approver and
      the account runs are paid from.
    x-displayName: Payroll settings
  - name: Pay runs
    description: >
      Prepare a month's run (computes lines), adjust who is paid and one-off
      bonuses, approve with 2FA (and a second

      approver above the threshold), then execute. Execution is asynchronous
      (202); poll the run or wait for

      `payrun.completed`.
    x-displayName: Pay runs
  - name: Payslips
    description: Payslips issued for paid lines. Download as PDF or email to the person.
    x-displayName: Payslips
  - name: OAuth
    description: >-
      OAuth 2.1 authorization server for MCP clients and other apps: metadata,
      dynamic client registration, the consent page's API (2FA at grant time),
      grants (connected apps), token and revocation endpoints. Access tokens
      live 15 minutes; refresh tokens rotate and are bound to the grant.
    x-displayName: OAuth
  - name: Webhooks
    description: >-
      Endpoints that receive signed events (HMAC-SHA256, `PanoFlow-Signature`),
      their signing secrets, test events, and deliveries with response codes and
      latency, retry and replay.
    x-displayName: Webhooks
  - name: Events
    description: >-
      The event log behind webhooks (30 days), replay, and the event-type
      catalogue.
    x-displayName: Events
paths:
  /transfers:
    post:
      tags:
        - Transfers
      summary: Create a transfer
      description: >-
        Pays a bank account (`type: payout`) or moves money between two of the
        business's own accounts in the same

        currency (`type: own_account`).


        **Payout** (Pay → Review → Confirm it's you → Sending → Sent):

        1. Pay a saved recipient with `recipient_id`, or a new account with
        `bank_account` (account number + bank code).
           A new account is name-enquired (or the 24-hour cache is used). If `bank_account.account_name` is sent and
           differs from the bank's answer, the request fails with `422 recipient_name_mismatch` unless
           `allow_name_mismatch: true`. `save_recipient: true` saves it as a recipient (`recipient_nickname` optional)
           and the response carries the new `recipient_id`.
        2. **Step-up:** a customer session must send `X-Step-Up-Token`; `403
        step_up_required` otherwise. API keys
           and agent tokens don't step up (agents are held by guardrails and ask-first approvals instead).
        3. Balance (amount + fee), per-payment and daily limits and risk rules
        are checked, then a hold is placed
           on the amount. Only then does the transfer exist. `422 insufficient_balance`, `limit_exceeded`,
           `daily_limit_exceeded`, `amount_too_small`, `amount_too_large`, `currency_not_enabled`.
        4. If the caller's payments over the approval threshold need a second
        person, the transfer is created as
           `pending_approval` with an `approval` (kind `transfer`, or `agent_payment` for agents) and the response is
           `201`. The hold stays in place until it is approved (→ `processing`), rejected (→ `rejected`), canceled
           or expires (→ `canceled`). `approval_threshold_exceeded` is **not** returned as an error here.
        5. Otherwise the transfer is `processing` and the response is `202`:
        poll `GET /transfers/{id}` or wait for
           `payment.completed` / `payment.failed`. It is sent over NIP; if the bank doesn't answer in time we confirm
           the outcome with the bank, never by sending again.

        **Own account** ("Transfer now"): no fee, no step-up, no approval,
        settles straight away and

        returns `201` with `status: completed`. `destination_account_id` must be
        another account of this business in

        the same currency (`422 currency_mismatch`).


        **Agents** (OAuth tokens from the MCP server): `type` must be `payout`,
        `source_account_id` must be the agent's

        own account and `recipient_id` is required (no raw bank accounts).
        Guardrails return `422 recipient_not_allowed`,

        `limit_exceeded` or `daily_limit_exceeded` (concurrent payments can
        never take an agent over its daily

        limit), `403 agent_paused`; a

        payment over the agent's ask-first amount, or to a new recipient,
        becomes `pending_approval` with an

        `agent_payment` approval and fires `agent.payment.held`. The transfer
        carries `agent_id`.


        Fires `payment.created` for every transfer created, including
        `pending_approval` ones.


        **Error codes:** `invalid_api_key`, `parameter_invalid`,
        `parameter_missing`, `resource_not_found`, `scope_missing`,
        `step_up_required`, `csrf_token_invalid`, `idempotency_key_reused`,
        `agent_paused`, `recipient_name_mismatch`, `insufficient_balance`,
        `limit_exceeded`, `daily_limit_exceeded`, `amount_too_small`,
        `amount_too_large`, `currency_not_enabled`, `currency_mismatch`,
        `recipient_not_allowed`, `partner_timeout`, `bank_unavailable`,
        `business_frozen`, `business_not_approved`, `rate_limited`.
      operationId: createTransfer
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/StepUpTokenForPayouts'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransferCreate'
            examples:
              saved_recipient:
                summary: PayReview, saved recipient
                value:
                  type: payout
                  source_account_id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
                  recipient_id: rcp_01JCS3C4DWA7N36096Q14DR9GP
                  amount:
                    amount: 48000000
                    currency: NGN
                  narration: PO-2291 haulage, October
              new_account_saved:
                summary: PayReviewNew, a new account saved as a recipient
                value:
                  type: payout
                  source_account_id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
                  bank_account:
                    account_number: '0231987745'
                    bank_code: '058'
                    account_name: ADEBAYO OGUNLEYE
                  amount:
                    amount: 85000000
                    currency: NGN
                  narration: Haulage, Kano route
                  save_recipient: true
                  recipient_nickname: Kano haulage
                  document_ids:
                    - doc_01JCV1SD53TW8JZ38AYTNJKGGS
              own_account:
                summary: Transfer tab, Main to Payroll
                value:
                  type: own_account
                  source_account_id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
                  destination_account_id: acc_01JCN5SXS5AA819X9YP981068V
                  amount:
                    amount: 240000000
                    currency: NGN
              agent:
                summary: Procurement agent over MCP
                value:
                  type: payout
                  source_account_id: acc_01JCB9Y73MY63FCH26W14WMCHW
                  recipient_id: rcp_01JC5GQAPENECFSECZP11HYGCP
                  amount:
                    amount: 18450000
                    currency: NGN
                  narration: LOS-2291 office supplies
                  reason: >-
                    Restocking printer paper, toner and dispatch labels for the
                    Wuse depot. Quote LOS-2291 matches last month's order.
      responses:
        '201':
          description: >
            Created and not processing: an `own_account` transfer that has
            completed, or a payout waiting for approval

            (`pending_approval`, with `approval`).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transfer'
              examples:
                own_account_completed:
                  $ref: '#/components/examples/TransferOwnAccountCompleted'
                pending_approval:
                  $ref: '#/components/examples/TransferPendingApproval'
        '202':
          description: >-
            Accepted; the payout is `processing`. Poll it or wait for
            `payment.completed` / `payment.failed`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transfer'
              examples:
                processing:
                  $ref: '#/components/examples/TransferProcessingNewRecipient'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/PartnerUnavailable'
        '503':
          $ref: '#/components/responses/PartnerUnavailable'
      security:
        - ApiKey: []
        - OAuth2: []
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        Required on money-moving POSTs. Same key + same body replays the first
        response; same key + different body returns 409
        `idempotency_key_reused`. Kept 7 days.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: 8c0f2b0e-5a3b-4a8e-9a77-3f3c6a1f2d10
    StepUpTokenForPayouts:
      name: X-Step-Up-Token
      in: header
      required: false
      description: >
        Step-up proof (2FA within the last 5 minutes, from `POST
        /v1/auth/step-up`). **Required when the caller is a

        customer session** and the request would send money to someone else
        (payouts, batches, scheduled payments);

        missing or stale returns `403 step_up_required`. Not used for
        `own_account` transfers, API keys or agent tokens.

        Same header as the shared `StepUpToken` parameter, which is
        unconditionally required.
      schema:
        type: string
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: >
        Optional client correlation id, up to 128 characters. It is not echoed:
        the response's `X-Request-Id` is always

        our own `req_…` id. We log your value with the request as
        `client_request_id`, so support can find a request by

        either id. A longer value is refused with 400 `parameter_invalid`.
      schema:
        type: string
        maxLength: 128
  schemas:
    TransferCreate:
      type: object
      description: >
        Body of `POST /transfers` and `POST /transfers/quote`. A `payout` takes
        exactly one of `recipient_id` or

        `bank_account`; an `own_account` transfer takes
        `destination_account_id`. Breaking either rule returns

        `400 parameter_missing` (or `parameter_unknown` for a field that doesn't
        apply) with `param` set.
      required:
        - type
        - source_account_id
        - amount
      properties:
        type:
          type: string
          enum:
            - payout
            - own_account
          description: >-
            Customers create `payout` or `own_account`; only PanoFlow creates
            `reversal`.
        source_account_id:
          type: string
          description: '`acc_…` to pay from. For agents, the agent''s own account.'
        amount:
          $ref: '#/components/schemas/Money'
          description: >-
            What the recipient receives. The fee is charged on top. Currency
            must match the source account.
        recipient_id:
          type: string
          description: '`rcp_…`, a saved recipient (payout). Required for agents.'
        bank_account:
          type: object
          description: >-
            A new account to pay (payout). Name-enquired before the transfer is
            created.
          required:
            - account_number
            - bank_code
          properties:
            account_number:
              $ref: '#/components/schemas/NubanAccountNumber'
            bank_code:
              $ref: '#/components/schemas/BankCode'
            account_name:
              type: string
              description: >
                The name the payer expects (the one shown after name enquiry).
                If it doesn't match the bank's answer

                the request fails with `422 recipient_name_mismatch` unless
                `allow_name_mismatch` is true.
        allow_name_mismatch:
          type: boolean
          default: false
          description: >-
            Pay even though `bank_account.account_name` differs from the bank's
            name.
        destination_account_id:
          type: string
          description: >-
            `acc_…`, another account of this business in the same currency
            (own_account).
        narration:
          type: string
          maxLength: 60
          description: Shows on the recipient's bank statement.
        reason:
          type: string
          maxLength: 500
          description: >-
            Why this payment is being made. Shown to the approver if it needs
            approval; agents should always send it.
        save_recipient:
          type: boolean
          default: false
          description: >-
            With `bank_account`, also save it as a recipient ("Save as a
            recipient for next time").
        recipient_nickname:
          type: string
          maxLength: 60
          description: Nickname for the recipient saved with `save_recipient`.
        document_ids:
          type: array
          maxItems: 5
          items:
            type: string
          description: >-
            `doc_…` files already uploaded (Documents API) to keep with the
            payment, e.g. the invoice.
        metadata:
          $ref: '#/components/schemas/Metadata'
    Transfer:
      type: object
      description: >
        Money leaving (or, for a `reversal`, coming back to) an account.
        Lifecycle is `MovementStatus`.

        A payout always has one source account and, once sent, one NIP session
        id.


        Carries the ISO 8583 outcome (`ResponseOutcome`, CONVENTIONS §7a). Batch
        rows and scheduled-payment runs are

        transfers, so they carry it too:


        | status | `response_code` |

        | --- | --- |

        | `pending_approval`, `scheduled`, `processing` | `09` |

        | `processing` with `status_reason: awaiting_bank_confirmation` (the
        status query can't confirm yet) | `01` |

        | `completed`, `reversed`, and a `reversal` | `00` |

        | `failed` after reaching the bank | the bank's NIP code, e.g. `07`,
        `08`, `51`, `57`, `91`, `97` |

        | `failed` before reaching the bank | ours, from the error mapping in
        errors.yaml (e.g. `51` for `insufficient_balance` on a scheduled run,
        `61` for `limit_exceeded`) |

        | `rejected`, `canceled` (never sent) | `21` |
      allOf:
        - $ref: '#/components/schemas/ResponseOutcome'
      required:
        - id
        - object
        - type
        - status
        - response_code
        - response_message
        - amount
        - fee
        - total
        - created_by
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: '`trf_…`'
        object:
          type: string
          const: transfer
          description: Always `transfer`.
        type:
          $ref: '#/components/schemas/TransferType'
        status:
          $ref: '#/components/schemas/MovementStatus'
        status_reason:
          $ref: '#/components/schemas/TransferStatusReason'
        source_account:
          $ref: '#/components/schemas/AccountSummary'
          description: >-
            Account the money left. Omitted on a `reversal` (the other side is
            not shown).
        destination_account:
          $ref: '#/components/schemas/AccountSummary'
          description: >-
            For `own_account` and `reversal`, this business's account that
            received the money.
        recipient_id:
          type: string
          description: '`rcp_…` when paid to (or saved as) a recipient.'
        counterparty_name:
          type: string
          description: >-
            Who was paid, as the UI shows it ("Kano Freight Services", "Naira ·
            Payroll").
        bank_account:
          $ref: '#/components/schemas/BankAccountRef'
          description: For payouts, the account paid, with the name-enquiry `account_name`.
        amount:
          $ref: '#/components/schemas/Money'
          description: What the recipient receives.
        fee:
          $ref: '#/components/schemas/Money'
          description: >-
            Fee charged on top (zero for own_account). Refunded with the amount
            if the transfer fails.
        total:
          $ref: '#/components/schemas/Money'
          description: Amount + fee taken from the source account.
        narration:
          type: string
          description: Shows on the recipient's bank statement.
        reason:
          type: string
          description: Why it was made (shown to approvers; sent by agents).
        nip_session_id:
          type: string
          pattern: ^\d{30}$
          description: >-
            NIP session id, present once the payout has been sent. Quote it to
            the receiving bank.
        transaction_id:
          type: string
          description: '`txn_…`, the line in the Transactions view and on the receipt.'
        failure:
          $ref: '#/components/schemas/PaymentFailure'
          description: Present when `status` is `failed`.
        approval:
          $ref: '#/components/schemas/Approval'
          description: >-
            The approval, when this transfer needed one (not on batch rows; see
            the batch).
        batch_id:
          type: string
          description: '`bat_…` when this is a row of a batch.'
        batch_line:
          type: integer
          minimum: 1
          description: The row's line number in the batch file.
        scheduled_payment_id:
          type: string
          description: '`sch_…` when this is a run of a scheduled payment.'
        scheduled_for:
          $ref: '#/components/schemas/Date'
          description: Run date, for a `scheduled` transfer.
        agent_id:
          type: string
          description: '`agt_…` when an agent created it.'
        reversal_of:
          type: string
          description: On a `reversal`, the `trf_…` it reverses.
        reversed_by:
          type: string
          description: On a `reversed` transfer, the `trf_…` of the reversal.
        document_ids:
          type: array
          items:
            type: string
          description: '`doc_…` files kept with the payment (invoice, receipt).'
        estimated_arrival_at:
          $ref: '#/components/schemas/Timestamp'
          description: Expected arrival while processing.
        timeline:
          type: array
          description: >-
            Tracking steps, oldest first. Returned on `GET /transfers/{id}`;
            omitted in lists.
          items:
            $ref: '#/components/schemas/TimelineEvent'
        created_by:
          $ref: '#/components/schemas/Actor'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        completed_at:
          $ref: '#/components/schemas/Timestamp'
        metadata:
          $ref: '#/components/schemas/Metadata'
    Money:
      type: object
      description: >-
        An amount in minor units (kobo, cents) with its currency. Never a
        decimal.
      required:
        - amount
        - currency
      properties:
        amount:
          type: integer
          format: int64
          description: Amount in minor units. ₦450,000.00 is 45000000.
          examples:
            - 45000000
        currency:
          $ref: '#/components/schemas/Currency'
    NubanAccountNumber:
      type: string
      description: 10-digit Nigerian NUBAN account number.
      pattern: ^\d{10}$
      examples:
        - '8012457739'
    BankCode:
      type: string
      description: The bank's NIBSS NIP institution code (CBN bank code).
      examples:
        - '058'
    Metadata:
      type: object
      description: >-
        Up to 20 string keys (≤ 40 chars) with string values (≤ 500 chars).
        Returned on the resource and its events.
      maxProperties: 20
      additionalProperties:
        type: string
        maxLength: 500
    ResponseOutcome:
      type: object
      description: >
        The ISO 8583 outcome of a money movement. Every transfer, batch row,
        inbound credit, pay-run line and agent

        payment includes these two fields (compose with `allOf`). By
        `MovementStatus`:

        `draft`, `pending_approval`, `scheduled`, `processing`: `09`.
        `completed`: `00`. `failed`, `rejected`: the

        bank's NIP code (e.g. `51`, `91`), or ours from the error mapping when
        it never reached the bank.

        `canceled`: `21` (no action taken; no money moved). `reversed`: keeps
        the original `00` (the money did settle);

        the reversal is its own linked movement with its own code. `01` means we
        couldn't confirm the outcome with the

        bank yet; the payment is never retried until it resolves.
      required:
        - response_code
        - response_message
      properties:
        response_code:
          $ref: '#/components/schemas/ResponseCode'
        response_message:
          type: string
          description: >-
            Standard meaning of the code (the `x-codes` text), not customer
            copy. Customer-safe wording is in `status_reason` / `error.message`.
          examples:
            - Approved or completed successfully
    TransferType:
      type: string
      enum:
        - payout
        - own_account
        - reversal
      description: >
        - `payout`: to a bank account outside PanoFlow, over NIP.

        - `own_account`: between two of this business's accounts in the same
        currency.

        - `reversal`: created by PanoFlow to reverse a completed transfer;
        read-only for customers and linked by
          `reversal_of`.
    MovementStatus:
      type: string
      description: >
        Lifecycle shared by every money-moving resource (CONVENTIONS §8). UI
        labels: completed → "Settled",

        pending_approval → "Pending approval".
      enum:
        - draft
        - pending_approval
        - scheduled
        - processing
        - completed
        - failed
        - rejected
        - canceled
        - reversed
    TransferStatusReason:
      type: string
      description: >
        Sub-state of `status` (clients must tolerate new values):


        | status | status_reason |

        | --- | --- |

        | `pending_approval` | `awaiting_approval` |

        | `scheduled` | `awaiting_run_date` |

        | `processing` | `queued` (checks and hold done, not yet sent),
        `sent_to_bank`, `awaiting_bank_confirmation` (no answer from the bank; a
        status query is running and it will not be re-sent until it answers) |

        | `completed` | `settled` |

        | `failed` | `rejected_by_receiving_bank`, `name_enquiry_failed`,
        `insufficient_balance`, `limit_exceeded`, `could_not_process` |

        | `rejected` | `rejected_by_approver` |

        | `canceled` | `canceled_by_user`, `approval_expired`,
        `schedule_skipped`, `schedule_canceled`, `batch_canceled` |

        | `reversed` | `reversed` |
      enum:
        - awaiting_approval
        - awaiting_run_date
        - queued
        - sent_to_bank
        - awaiting_bank_confirmation
        - settled
        - rejected_by_receiving_bank
        - name_enquiry_failed
        - insufficient_balance
        - limit_exceeded
        - could_not_process
        - rejected_by_approver
        - canceled_by_user
        - approval_expired
        - schedule_skipped
        - schedule_canceled
        - batch_canceled
        - reversed
    AccountSummary:
      type: object
      description: >-
        Enough of a PanoFlow account to show it ("Naira · Main · ••••6789").
        Full account in the Accounts API.
      required:
        - id
        - name
        - currency
      properties:
        id:
          type: string
          description: '`acc_…`'
        name:
          type: string
          description: >-
            Display label, e.g. "Naira · Main", "Naira · Payroll", "US dollar ·
            Main".
        currency:
          $ref: '#/components/schemas/Currency'
        account_number_last4:
          type: string
          pattern: ^\d{4}$
          description: Last four digits of the account number.
    BankAccountRef:
      type: object
      description: A bank account outside PanoFlow (a recipient or a payer).
      required:
        - account_number
        - bank_code
      properties:
        account_number:
          $ref: '#/components/schemas/NubanAccountNumber'
        bank_code:
          $ref: '#/components/schemas/BankCode'
        bank_name:
          type: string
          description: Display name of the bank.
          examples:
            - GTBank
        account_name:
          type: string
          description: Name on the account as returned by name enquiry.
    PaymentFailure:
      type: object
      description: >-
        Customer-safe failure. The money (amount and fee) is back in the source
        account when a transfer fails.
      required:
        - code
        - message
      properties:
        code:
          $ref: '#/components/schemas/PaymentFailureCode'
        message:
          type: string
          description: Safe to show the customer as is.
    Approval:
      type: object
      description: >-
        A request for a second person to approve something. The requester can
        never decide their own approval.
      required:
        - id
        - object
        - kind
        - subject_id
        - status
        - requested_by
        - requested_at
      properties:
        id:
          type: string
          description: '`apr_…`'
        object:
          type: string
          const: approval
        kind:
          $ref: '#/components/schemas/ApprovalKind'
        subject_id:
          type: string
          description: Id of the thing being approved.
        summary:
          type: string
          description: One-line human summary, e.g. "Pay ₦850,000.00 to NGOZI ADEYEMI".
        amount:
          $ref: '#/components/schemas/Money'
        status:
          $ref: '#/components/schemas/ApprovalStatus'
        reason:
          type: string
          description: Why the requester asked.
        decision_note:
          type: string
          description: Note from the person who decided.
        requested_by:
          $ref: '#/components/schemas/Actor'
        requested_at:
          $ref: '#/components/schemas/Timestamp'
        decided_by:
          $ref: '#/components/schemas/Actor'
        decided_at:
          $ref: '#/components/schemas/Timestamp'
        expires_at:
          $ref: '#/components/schemas/Timestamp'
    Date:
      type: string
      format: date
      description: Calendar date, interpreted in Africa/Lagos.
      examples:
        - '2026-11-07'
    Timestamp:
      type: string
      format: date-time
      description: RFC 3339, UTC.
      examples:
        - '2026-10-27T08:41:00Z'
    TimelineEvent:
      type: object
      description: One step in a transfer's tracking list.
      required:
        - type
        - label
        - at
      properties:
        type:
          type: string
          enum:
            - created
            - approval_requested
            - approved
            - rejected
            - scheduled
            - sent_to_bank
            - completed
            - failed
            - canceled
            - reversed
          description: What happened.
        label:
          type: string
          description: >-
            Display text, e.g. "Sent to the receiving bank", "Rejected, money
            returned".
        at:
          $ref: '#/components/schemas/Timestamp'
        actor:
          $ref: '#/components/schemas/Actor'
    Actor:
      type: object
      description: Who did something. Shown in activity, approvals and the audit log.
      required:
        - type
        - id
        - name
      properties:
        type:
          type: string
          enum:
            - user
            - staff
            - agent
            - api_key
            - system
          description: Kind of actor.
        id:
          type: string
          description: The actor's id (`usr_…`, `stf_…`, `agt_…`, `key_…`, or `system`).
        name:
          type: string
          description: Display name, e.g. "Adaeze Okafor" or "Procurement agent".
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - response_code
            - type
            - code
            - message
            - request_id
          properties:
            response_code:
              $ref: '#/components/schemas/ResponseCode'
            type:
              type: string
              enum:
                - invalid_request
                - authentication
                - permission
                - not_found
                - conflict
                - rate_limited
                - partner
                - internal
              description: >-
                Error class, fixed per `code` (errors.yaml `x-codes` `type`;
                CONVENTIONS §7).
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              description: >-
                Safe to show the customer. Never reveals screening, fraud or STR
                state.
            param:
              type: string
              description: Dot path of the offending field.
            request_id:
              type: string
              description: '`req_…`'
            doc_url:
              type: string
              format: uri
              description: Page explaining the code.
            details:
              type: object
              additionalProperties: true
              description: Machine-readable extra context for the code.
    Currency:
      type: string
      description: >-
        ISO 4217. Only NGN is live at launch; others return 422
        `currency_not_enabled`.
      enum:
        - NGN
        - USD
        - GBP
        - EUR
        - GHS
        - KES
    ResponseCode:
      type: string
      pattern: ^[0-9A-Z]{2}$
      description: >
        Two-character ISO 8583 response code, using the NIBSS NIP meanings
        (CONVENTIONS §7a). `00` is success.

        Carried on every error (mapped from `error.code`, see errors.yaml
        `x-codes`) and on every money-movement

        object (the bank's final code, or our own while it is in flight).
        Clients must tolerate codes added later.
      enum:
        - '00'
        - '01'
        - '03'
        - '05'
        - '06'
        - '07'
        - '08'
        - '09'
        - '12'
        - '13'
        - '14'
        - '15'
        - '16'
        - '17'
        - '21'
        - '25'
        - '26'
        - '30'
        - '34'
        - '35'
        - '51'
        - '57'
        - '58'
        - '61'
        - '63'
        - '65'
        - '68'
        - '91'
        - '92'
        - '94'
        - '96'
        - '97'
      x-codes:
        '12': Invalid transaction
        '13': Invalid amount
        '14': Invalid batch number
        '15': Invalid session or record id
        '16': Unknown bank code
        '17': Invalid channel
        '21': No action taken
        '25': Unable to locate record
        '26': Duplicate record
        '30': Format error
        '34': Suspected fraud
        '35': Contact sending bank
        '51': No sufficient funds
        '57': Transaction not permitted to sender
        '58': Transaction not permitted on channel
        '61': Transfer limit exceeded
        '63': Security violation
        '65': Exceeds withdrawal frequency
        '68': Response received too late
        '91': Beneficiary bank not available
        '92': Routing error
        '94': Duplicate transaction
        '96': System malfunction
        '97': Timeout waiting for response from destination
        '00': Approved or completed successfully
        '01': Status unknown, please wait for settlement report
        '03': Invalid sender
        '05': Do not honour
        '06': Dormant account
        '07': Invalid account
        '08': Account name mismatch
        '09': Request processing in progress
    PaymentFailureCode:
      type: string
      description: >
        Why a transfer failed, mapped by the bank adapter from NIP response
        codes and our own checks. Clients must

        tolerate new values.


        - `account_invalid`: the receiving bank says the account doesn't exist
        (e.g. NIP 07).

        - `account_name_mismatch`: the receiving bank rejected the account
        number and name pair.

        - `account_restricted`: the account can't receive payments (dormant,
        blocked, not permitted).

        - `receiving_bank_unavailable`: the receiving bank is unreachable (e.g.
        NIP 91) and the payment wasn't accepted.

        - `receiving_bank_rejected`: any other decline by the receiving bank.

        - `insufficient_balance`: a scheduled run or approved payment couldn't
        be covered when it ran.

        - `limit_exceeded`: a scheduled run would go over a limit when it ran.

        - `could_not_process`: everything else, including problems on PanoFlow's
        side (e.g. NIP 51) and any risk
          or compliance outcome. The message stays neutral and never reveals screening, fraud or STR state.

        The raw NIP code itself is the transfer's `response_code` (e.g. `07` →
        `account_invalid`,

        `08` → `account_name_mismatch`, `57` → `account_restricted`, `91`/`97` →
        `receiving_bank_unavailable`).

        For a risk or compliance outcome `response_code` is `05` (do not
        honour), never `34`, so it doesn't tip off.
      enum:
        - account_invalid
        - account_name_mismatch
        - account_restricted
        - receiving_bank_unavailable
        - receiving_bank_rejected
        - insufficient_balance
        - limit_exceeded
        - could_not_process
    ApprovalKind:
      type: string
      description: >-
        What an approval is for. Customers see `transfer`, `batch`, `pay_run`,
        `agent_payment`, `member_role_change` and `limit_change`; the other
        kinds are PanoFlow's own and never returned to customers.
      enum:
        - transfer
        - batch
        - pay_run
        - agent_payment
        - member_role_change
        - limit_change
        - business_approval
        - business_freeze
        - business_unfreeze
        - payment_reversal
        - ledger_adjustment
        - recon_resolution
        - fee_refund
        - staff_role_change
        - staff_super_admin
        - str_filing
        - pricing_change
        - risk_rule_change
        - feature_flag_change
        - partner_secret_rotation
    ApprovalStatus:
      type: string
      enum:
        - pending
        - approved
        - rejected
        - expired
        - canceled
      description: Approval state.
    ErrorCode:
      type: string
      description: >-
        Stable machine-readable error code. Clients must tolerate codes added
        later.
      enum:
        - invalid_json
        - parameter_missing
        - parameter_invalid
        - parameter_unknown
        - invalid_api_key
        - session_expired
        - invalid_credentials
        - two_factor_required
        - two_factor_invalid
        - token_expired
        - staff_account_not_allowed
        - role_not_permitted
        - scope_missing
        - step_up_required
        - business_frozen
        - business_not_approved
        - account_closed
        - ip_not_allowed
        - agent_paused
        - test_key_on_live
        - email_not_verified
        - two_factor_enrolment_required
        - csrf_token_invalid
        - resource_not_found
        - idempotency_key_reused
        - invalid_state
        - self_approval_not_allowed
        - already_decided
        - duplicate_resource
        - version_conflict
        - insufficient_balance
        - limit_exceeded
        - daily_limit_exceeded
        - approval_threshold_exceeded
        - recipient_not_allowed
        - recipient_name_mismatch
        - name_enquiry_failed
        - account_number_invalid
        - currency_not_enabled
        - currency_mismatch
        - amount_too_small
        - amount_too_large
        - schedule_in_past
        - batch_row_invalid
        - batch_too_large
        - document_rejected
        - checks_incomplete
        - rate_expired
        - payroll_short_funds
        - webhook_url_invalid
        - password_too_weak
        - password_breached
        - scope_not_allowed
        - application_incomplete
        - account_balance_not_zero
        - verification_code_invalid
        - link_expired
        - invite_expired
        - net_pay_negative
        - notification_channel_locked
        - phone_not_verified
        - rate_limited
        - bank_unavailable
        - identity_provider_unavailable
        - registry_unavailable
        - partner_timeout
        - screening_unavailable
        - messaging_unavailable
        - internal_error
      x-codes:
        invalid_json:
          status: 400
          type: invalid_request
          response_code: '30'
          meaning: Body is not valid JSON.
        parameter_missing:
          status: 400
          type: invalid_request
          response_code: '30'
          meaning: A required field is missing; see `param`.
        parameter_invalid:
          status: 400
          type: invalid_request
          response_code: '30'
          meaning: A field has the wrong type or format; see `param`.
        parameter_unknown:
          status: 400
          type: invalid_request
          response_code: '30'
          meaning: An unknown field was sent.
        invalid_api_key:
          status: 401
          type: authentication
          response_code: '63'
          meaning: >-
            Key unknown, revoked, or its business inactive. Never treated as
            anonymous.
        session_expired:
          status: 401
          type: authentication
          response_code: '63'
          meaning: Session timed out or was revoked.
        invalid_credentials:
          status: 401
          type: authentication
          response_code: '63'
          meaning: Email or password wrong (no hint which).
        two_factor_required:
          status: 401
          type: authentication
          response_code: '63'
          meaning: Password accepted; a 2FA code is needed to finish sign-in.
        two_factor_invalid:
          status: 401
          type: authentication
          response_code: '63'
          meaning: 2FA or backup code wrong or already used.
        token_expired:
          status: 401
          type: authentication
          response_code: '63'
          meaning: OAuth access token expired; refresh it.
        role_not_permitted:
          status: 403
          type: permission
          response_code: '57'
          meaning: The caller's role can't do this.
        scope_missing:
          status: 403
          type: permission
          response_code: '57'
          meaning: The key or token lacks the scope in `details.required_scope`.
        step_up_required:
          status: 403
          type: permission
          response_code: '63'
          meaning: Send a fresh X-Step-Up-Token (2FA within 5 minutes).
        business_frozen:
          status: 403
          type: permission
          response_code: '57'
          meaning: The business is frozen; money can't move. Message stays neutral.
        business_not_approved:
          status: 403
          type: permission
          response_code: '57'
          meaning: Onboarding not approved yet; live money movement is closed.
        account_closed:
          status: 403
          type: permission
          response_code: '57'
          meaning: The account is closed.
        ip_not_allowed:
          status: 403
          type: permission
          response_code: '63'
          meaning: Request came from outside the allowed IP ranges.
        agent_paused:
          status: 403
          type: permission
          response_code: '57'
          meaning: The agent is paused by its owner; its tokens are revoked.
        test_key_on_live:
          status: 403
          type: permission
          response_code: '58'
          meaning: A pk_test_ key was used against a live-only operation.
        resource_not_found:
          status: 404
          type: not_found
          response_code: '25'
          meaning: Not found or not visible to this tenant.
        idempotency_key_reused:
          status: 409
          type: conflict
          response_code: '94'
          meaning: Same Idempotency-Key with a different body.
        invalid_state:
          status: 409
          type: conflict
          response_code: '12'
          meaning: >-
            The resource's status doesn't allow this action; see
            `details.status`.
        self_approval_not_allowed:
          status: 409
          type: conflict
          response_code: '57'
          meaning: The requester can't decide their own approval.
        already_decided:
          status: 409
          type: conflict
          response_code: '21'
          meaning: The approval was already approved, rejected or expired.
        duplicate_resource:
          status: 409
          type: conflict
          response_code: '26'
          meaning: An identical resource already exists (e.g. recipient).
        version_conflict:
          status: 409
          type: conflict
          response_code: '21'
          meaning: The resource changed since it was read; re-read and retry.
        insufficient_balance:
          status: 422
          type: invalid_request
          response_code: '51'
          meaning: Available balance can't cover amount + fee; `details.available`.
        limit_exceeded:
          status: 422
          type: invalid_request
          response_code: '61'
          meaning: Over a per-payment limit; `details.limit`.
        daily_limit_exceeded:
          status: 422
          type: invalid_request
          response_code: '61'
          meaning: Over the daily limit.
        approval_threshold_exceeded:
          status: 422
          type: invalid_request
          response_code: '09'
          meaning: >-
            Over the caller's approval threshold; a pending approval was created
            instead (see `details.approval_id`).
        recipient_not_allowed:
          status: 422
          type: invalid_request
          response_code: '57'
          meaning: Agent tried to pay a recipient outside its allowlist.
        recipient_name_mismatch:
          status: 422
          type: invalid_request
          response_code: '08'
          meaning: >-
            Name on the account doesn't match the name given and confirmation
            wasn't sent.
        name_enquiry_failed:
          status: 422
          type: invalid_request
          response_code: '25'
          meaning: The bank couldn't confirm the account name.
        account_number_invalid:
          status: 422
          type: invalid_request
          response_code: '07'
          meaning: Not a valid NUBAN for that bank.
        currency_not_enabled:
          status: 422
          type: invalid_request
          response_code: '12'
          meaning: Currency not live for this business yet.
        currency_mismatch:
          status: 422
          type: invalid_request
          response_code: '12'
          meaning: Amount currency doesn't match the account's currency.
        amount_too_small:
          status: 422
          type: invalid_request
          response_code: '13'
          meaning: Below the minimum for this payment type.
        amount_too_large:
          status: 422
          type: invalid_request
          response_code: '13'
          meaning: Above the maximum for this payment type.
        schedule_in_past:
          status: 422
          type: invalid_request
          response_code: '12'
          meaning: Scheduled date is not in the future.
        batch_row_invalid:
          status: 422
          type: invalid_request
          response_code: '30'
          meaning: One or more batch rows failed validation; `details.rows`.
        batch_too_large:
          status: 422
          type: invalid_request
          response_code: '14'
          meaning: Batch exceeds the row limit for this plan.
        document_rejected:
          status: 422
          type: invalid_request
          response_code: '30'
          meaning: Upload is the wrong type, too large, or unreadable.
        checks_incomplete:
          status: 422
          type: invalid_request
          response_code: '21'
          meaning: Can't approve; not every KYB check has passed.
        rate_expired:
          status: 422
          type: invalid_request
          response_code: '12'
          meaning: FX quote expired; get a new one.
        payroll_short_funds:
          status: 422
          type: invalid_request
          response_code: '51'
          meaning: Payroll account can't cover the run; `details.short_by`.
        webhook_url_invalid:
          status: 422
          type: invalid_request
          response_code: '30'
          meaning: Webhook URL must be https and publicly reachable.
        password_too_weak:
          status: 422
          type: invalid_request
          response_code: '30'
          meaning: Password fails the strength rules.
        password_breached:
          status: 422
          type: invalid_request
          response_code: '30'
          meaning: Password appears in a known breach.
        email_not_verified:
          status: 403
          type: permission
          response_code: '63'
          meaning: Verify the email address before continuing.
        two_factor_enrolment_required:
          status: 403
          type: permission
          response_code: '63'
          meaning: Set up two-factor authentication before doing anything else.
        staff_account_not_allowed:
          status: 401
          type: authentication
          response_code: '63'
          meaning: >-
            Sign-in with this Google account is not allowed. Not returned to
            customers.
        scope_not_allowed:
          status: 422
          type: invalid_request
          response_code: '57'
          meaning: None of the requested OAuth scopes can be granted to this client.
        application_incomplete:
          status: 422
          type: invalid_request
          response_code: '21'
          meaning: >-
            The onboarding application is missing required details; see
            `details.issues`.
        account_balance_not_zero:
          status: 422
          type: invalid_request
          response_code: '12'
          meaning: Move the balance out before closing the account.
        verification_code_invalid:
          status: 422
          type: invalid_request
          response_code: '63'
          meaning: The verification code is wrong or expired.
        link_expired:
          status: 422
          type: invalid_request
          response_code: '63'
          meaning: The link has expired; request a new one.
        invite_expired:
          status: 422
          type: invalid_request
          response_code: '15'
          meaning: The invite has expired; ask for a new one.
        net_pay_negative:
          status: 422
          type: invalid_request
          response_code: '13'
          meaning: >-
            Deductions exceed gross pay for this person; see
            `details.person_id`.
        notification_channel_locked:
          status: 422
          type: invalid_request
          response_code: '57'
          meaning: >-
            This notification channel can't be turned off (e.g. email for
            `compliance_request`); see `param`.
        phone_not_verified:
          status: 422
          type: invalid_request
          response_code: '63'
          meaning: >-
            Needs a verified phone number first (SMS notifications, SMS recovery
            codes).
        csrf_token_invalid:
          status: 403
          type: permission
          response_code: '63'
          meaning: >-
            A state-changing request made with a session cookie had a missing or
            wrong `X-CSRF-Token`, or came from another origin.
        rate_limited:
          status: 429
          type: rate_limited
          response_code: '65'
          meaning: Too many requests; wait `Retry-After` seconds.
        bank_unavailable:
          status: 503
          type: partner
          response_code: '91'
          meaning: >-
            Partner bank is down or degraded. Retry with the same
            Idempotency-Key.
        identity_provider_unavailable:
          status: 503
          type: partner
          response_code: '96'
          meaning: Identity checks provider unavailable.
        registry_unavailable:
          status: 503
          type: partner
          response_code: '96'
          meaning: >-
            CAC registry unavailable; onboarding continues as "pending
            registry".
        partner_timeout:
          status: 502
          type: partner
          response_code: '97'
          meaning: >-
            A partner (the bank, or the SMS provider) didn't answer in time. For
            payments we confirm the status with the bank before any retry.
        screening_unavailable:
          status: 503
          type: partner
          response_code: '96'
          meaning: >-
            Sanctions and PEP screening is unavailable; nothing was changed.
            Retry with the same Idempotency-Key.
        messaging_unavailable:
          status: 503
          type: partner
          response_code: '96'
          meaning: We couldn't send the email or text message right now. Retry shortly.
        internal_error:
          status: 500
          type: internal
          response_code: '96'
          meaning: Our fault. Safe to retry with the same Idempotency-Key.
  headers:
    X-Request-Id:
      description: >-
        Our request id (`req_…`), on every response. Always ours, never the
        client's `X-Request-Id`. Quote it to support.
      schema:
        type: string
    X-RateLimit-Limit:
      description: Requests allowed per minute for this key, token or session.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests left in the current window.
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix time when the window resets.
      schema:
        type: integer
    Idempotent-Replayed:
      description: >-
        `true` when this response is a replay of an earlier request with the
        same Idempotency-Key.
      schema:
        type: boolean
    Retry-After:
      description: Seconds to wait before retrying.
      schema:
        type: integer
  examples:
    TransferOwnAccountCompleted:
      summary: PaySentTransfer, Main to Payroll
      value:
        id: trf_01JCNPZX45HY43KWJRP1XPA7Z3
        object: transfer
        type: own_account
        status: completed
        status_reason: settled
        response_code: '00'
        response_message: Approved or completed successfully
        source_account:
          id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
          name: Naira · Main
          currency: NGN
          account_number_last4: '6789'
        destination_account:
          id: acc_01JCN5SXS5AA819X9YP981068V
          name: Naira · Payroll
          currency: NGN
          account_number_last4: '2214'
        counterparty_name: Naira · Payroll
        amount:
          amount: 240000000
          currency: NGN
        fee:
          amount: 0
          currency: NGN
        total:
          amount: 240000000
          currency: NGN
        created_by:
          type: user
          id: usr_01JCMZ9J92V81E5128Q6RW31FZ
          name: Adaeze Okafor
        created_at: '2026-10-28T08:30:00Z'
        updated_at: '2026-10-28T08:30:00Z'
        completed_at: '2026-10-28T08:30:00Z'
    TransferPendingApproval:
      summary: Created by a Finance member over the approval threshold
      value:
        id: trf_01JCDJ8FSSZ5AWSH8VHTPRE95B
        object: transfer
        type: payout
        status: pending_approval
        status_reason: awaiting_approval
        response_code: '09'
        response_message: Request processing in progress
        source_account:
          id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
          name: Naira · Main
          currency: NGN
          account_number_last4: '6789'
        counterparty_name: Swift Couriers Ltd
        bank_account:
          account_number: '2041188273'
          bank_code: '033'
          bank_name: UBA
          account_name: SWIFT COURIERS LIMITED
        amount:
          amount: 87500000
          currency: NGN
        fee:
          amount: 5375
          currency: NGN
        total:
          amount: 87505375
          currency: NGN
        narration: Last-mile Oct
        approval:
          id: apr_01JCW6VZSKDENC8SP3804GVA35
          object: approval
          kind: transfer
          subject_id: trf_01JCDJ8FSSZ5AWSH8VHTPRE95B
          summary: Pay ₦875,000.00 to SWIFT COURIERS LIMITED
          amount:
            amount: 87500000
            currency: NGN
          status: pending
          requested_by:
            type: user
            id: usr_01JCG0X454YG4GFDEXZR4YJ2C4
            name: Ngozi Uche
          requested_at: '2026-10-28T07:40:00Z'
          expires_at: '2026-10-29T07:40:00Z'
        created_by:
          type: user
          id: usr_01JCG0X454YG4GFDEXZR4YJ2C4
          name: Ngozi Uche
        created_at: '2026-10-28T07:40:00Z'
        updated_at: '2026-10-28T07:40:00Z'
    TransferProcessingNewRecipient:
      summary: PaySentNew, sent to a new account and saved
      value:
        id: trf_01JC3E28JT97KB6CQ643DZVMXX
        object: transfer
        type: payout
        status: processing
        status_reason: sent_to_bank
        response_code: '09'
        response_message: Request processing in progress
        source_account:
          id: acc_01JCWPQ5E6EYCNDY0YP57RCYBV
          name: Naira · Main
          currency: NGN
          account_number_last4: '6789'
        recipient_id: rcp_01JC5GQAPENECFSECZP11HYGCP
        counterparty_name: Adebayo Ogunleye
        bank_account:
          account_number: '0231987745'
          bank_code: '058'
          bank_name: GTBank
          account_name: ADEBAYO OGUNLEYE
        amount:
          amount: 85000000
          currency: NGN
        fee:
          amount: 5375
          currency: NGN
        total:
          amount: 85005375
          currency: NGN
        narration: Haulage, Kano route
        document_ids:
          - doc_01JCV1SD53TW8JZ38AYTNJKGGS
        estimated_arrival_at: '2026-10-28T08:31:00Z'
        timeline:
          - type: created
            label: Created by Adaeze Okafor
            at: '2026-10-28T08:30:00Z'
          - type: sent_to_bank
            label: Sent to the receiving bank
            at: '2026-10-28T08:30:02Z'
        created_by:
          type: user
          id: usr_01JCMZ9J92V81E5128Q6RW31FZ
          name: Adaeze Okafor
        created_at: '2026-10-28T08:30:00Z'
        updated_at: '2026-10-28T08:30:02Z'
  responses:
    BadRequest:
      description: Malformed request.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing, invalid or expired credentials.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Authenticated but not allowed (role, scope, step-up, frozen business).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Not found, or not visible to this tenant.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: State conflict (idempotency key reused, invalid state, self-approval).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unprocessable:
      description: Valid request that failed a business rule.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Too many requests.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalError:
      description: >-
        Something went wrong on our side (`internal_error`). Safe to retry with
        the same Idempotency-Key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PartnerUnavailable:
      description: >-
        A partner (bank, identity, registry, screening, messaging) is
        unavailable or didn't answer in time. Retry with the same
        Idempotency-Key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      bearerFormat: pk_live_… / pk_test_…
      description: Server API key, scoped. `pk_test_` keys hit the sandbox tenant.
    OAuth2:
      type: oauth2
      description: >-
        OAuth 2.1 with PKCE. Used by agents (MCP) and third-party apps. Access
        tokens live 15 minutes.
      flows:
        authorizationCode:
          authorizationUrl: https://app.panoflow.io/oauth/authorize
          tokenUrl: https://api.panoflow.io/v1/oauth/token
          refreshUrl: https://api.panoflow.io/v1/oauth/token
          scopes:
            accounts:read: Read balances, accounts and transactions
            transfers:read: Read payments
            transfers:write: Create payments (guardrails-checked for agents)
            recipients:read: Read recipients
            recipients:write: Create recipients (allowlist-checked for agents)
            collections:read: Read invoices, payment links and virtual accounts
            collections:write: Create invoices and payment links

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.