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

# Simulate a transfer (sandbox only)

> Decides how a transfer ends, so that you can test your webhook handler and fulfilment without moving money. It exists on the sandbox only. On live the call is rejected with HTTP `403`, which you should prove during your go-live checks.

Choose a `scenario`:

| `scenario` | What it simulates |
| --- | --- |
| `success` | The customer sent the right amount and the transfer landed. |
| `failed` | The transfer failed. |
| `abandoned` | The customer never paid and left the session. |

When the GTBank sandbox is the active transfer processor, `success` creates a sandbox virtual account if the payment has none, funds it, and returns `awaiting_transfer` with the account details. The payment then completes through the same webhook path as production, which makes it a faithful test of your handler. In every other case the payment is moved straight to the chosen terminal status and the matching webhook (`payment.completed` or `payment.failed`) is emitted.

The payment must still be `pending` or `processing`. Simulating a payment that has already finished is rejected with `400`.




## OpenAPI

````yaml /openapi.yaml post /payment/checkout/charge/bank-transfer/simulate
openapi: 3.1.0
info:
  title: Vestrapay Payments API
  version: '1.0'
  summary: >-
    Initialise payments, collect them through hosted or custom checkout, and
    verify the result from your server.
  description: >
    The Vestrapay Payments API lets your server create a payment, lets your
    customer pay it, and lets your server confirm the outcome before it fulfils
    an order.


    ## How a payment flows


    1. **Initialise** (`POST /payment/initialize`, secret key). Your server
    creates a pending transaction and receives a `reference`, an `accessCode`
    and a hosted `checkoutUrl`.

    2. **Collect.** Send the customer to the `checkoutUrl`, or build your own
    pay screen with the **Checkout** endpoints, which use the `accessCode`
    rather than your secret key. Four channels are available: `card`,
    `bank_transfer`, `bank_payment` and `ussd`.

    3. **Confirm** (`GET /payment/verify/{reference}`, secret key). Fulfil only
    when `data.status` is `success` and `data.amount` matches the order.
    Webhooks and the browser callback are signals. Verify is the confirmation.


    ## Environments and servers


    Sandbox and live are separate environments with separate keys, data,
    services and webhook endpoints. A sandbox reference cannot be verified on
    live, and the reverse.


    | Environment | API base URL | Checkout host | Merchant dashboard |

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

    | Sandbox | `https://api.sandbox.vestrapay.app/v1` |
    `https://checkout.sandbox.vestrapay.app` |
    `https://merchant.sandbox.vestrapay.app` |

    | Live | `https://api.vestrapay.com/v1` | `https://checkout.vestrapay.com` |
    `https://merchant.vestrapay.com` |


    Keys begin `sk_test_` for the sandbox and `sk_live_` for live. A key sent to
    the wrong host is rejected with `invalid_key_environment`. Live payments are
    refused until your business has passed KYB.


    ## Authentication


    | Scheme | Header | Used for |

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

    | Secret key | `x-api-key: sk_…` or `Authorization: Bearer sk_…` |
    Initialise and verify. Server-side only. Never ship it to a browser or
    mobile app. |

    | Access code | `x-access-code: …` (or the `access_code` query parameter) |
    Every Checkout endpoint. Safe to hand to the customer's browser, because it
    is scoped to one payment and expires after 60 minutes. |


    ## Amounts


    Amounts are **decimal strings in major units**. `"100.00"` is one hundred
    naira. Never send kobo, and never send a formatted value such as `"₦1,000"`.
    Compare amounts as strings or decimals, never as binary floats. NGN is the
    supported currency.


    ## Response envelope


    Every JSON response uses one of two envelopes.


    ```json

    { "status": "success", "data": { } }

    ```


    ```json

    { "status": "failed", "error": "invalid_credentials", "message": "The
    provided credentials are invalid." }

    ```


    Branch on `error`, which is stable. The `message` is for people and may
    change wording. The only unwrapped responses are the checkout event stream
    (`text/event-stream`) and webhook bodies, which are `{ "event", "data",
    "timestamp" }`. Log the `X-Request-Id` response header with every failure
    and quote it to support.


    ## Splitting a payment


    Services, sub-accounts and splits are configured in the merchant dashboard,
    and each service has a **service key**. To divide a particular payment, pass
    `distribution` on initialise as a map of service key to an absolute amount
    in the same unit as `amount`. Whatever you do not allocate settles to your
    primary settlement account. Verify returns a `split` summary once the
    payment has been allocated.


    ## Statuses at a glance


    | Where | Values |

    | --- | --- |

    | Payment (initialise, verify, status) | `pending`, `processing`, `success`,
    `failed`, `abandoned` |

    | Card charge (`data.status`) | `fingerprint_required`, `3ds_required`,
    `processing`, `success`, `failed` |

    | Transfer / USSD charge (`data.status`) | `awaiting_transfer`,
    `awaiting_ussd`, plus the processing states above |

    | Bank payment charge (`data.status`) | `processing` (with `redirectUrl`
    when the bank supplies one). The event stream may emit
    `checkout.awaiting_bank_authorization`. |


    Only `success` on verify means paid.


    ## Errors and retries


    | HTTP status | Meaning | Retry? |

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

    | 400 | Invalid request, or the current state does not allow it. | No. Fix
    it first. |

    | 401 | Credentials are missing, malformed, expired or wrong. | No. |

    | 403 | Authenticated but not allowed, for example KYB not approved or live
    not enabled. | No. |

    | 404 | The thing does not exist in this environment or for your account. |
    No. |

    | 409 | Conflict with existing state. | No. |

    | 410 | The session or access code has expired. | No. Initialise again. |

    | 422 | Too many attempts on this payment. | No. Initialise again. |

    | 502, 503 | A bank or processor dependency is unavailable. | Yes, with
    exponential backoff. |


    Verify is read-only, so retry it on network failure or a 5xx response.
    Initialise creates a new reference on every call, so a retried initialise
    can create a second pending payment. Carry your own order ID in `metadata`
    so that you can recognise and ignore duplicates.


    ## Not in this reference


    Managing services, sub-accounts, splits, partner users, settlements, payment
    links, webhooks and KYB is done in the merchant dashboard and is covered in
    the guides. This reference documents the Payments API only.
  contact:
    name: Vestrapay developer support
    email: tech@vestrapay.com
    url: https://vestrapay.com
servers:
  - url: https://api.sandbox.vestrapay.app/v1
    description: Sandbox. Use `sk_test_` keys. No real money moves.
  - url: https://api.vestrapay.com/v1
    description: >-
      Live. Use `sk_live_` keys. Available once KYB is approved and live mode is
      enabled.
security:
  - SecretKey: []
  - BearerAuth: []
tags:
  - name: Payments
    description: >
      Server-to-server calls made with your **secret key**.


      **Initialise** creates a pending transaction and the session your customer
      pays through. **Verify** is the canonical lookup of a payment's state and
      the only call you should trust before you fulfil an order. Both calls
      belong on your server.
  - name: Checkout
    description: >
      Customer-session calls made with the **access code** returned by
      initialise. Hosted checkout uses these for you. Call them yourself only
      when you build a custom pay screen.


      The access code is valid for 60 minutes and is scoped to a single payment.
      Choose a channel, charge it, then confirm from your server with verify.


      | Channel | Calls |

      | --- | --- |

      | Card | Create a card session, mount the processor's hosted fields,
      charge, and authenticate if 3-D Secure asks for it. |

      | Transfer | Request a one-time virtual account and show it to the
      customer. In the sandbox, simulate the outcome. |

      | Pay with bank | List banks, then start the charge for the bank the
      customer picks. |

      | USSD | Start the charge with the customer's phone number and show the
      dial string. |


      The status, verify and events calls let a pay screen show progress. They
      do not replace server-side verify.
paths:
  /payment/checkout/charge/bank-transfer/simulate:
    post:
      tags:
        - Checkout
      summary: Simulate a transfer (sandbox only)
      description: >
        Decides how a transfer ends, so that you can test your webhook handler
        and fulfilment without moving money. It exists on the sandbox only. On
        live the call is rejected with HTTP `403`, which you should prove during
        your go-live checks.


        Choose a `scenario`:


        | `scenario` | What it simulates |

        | --- | --- |

        | `success` | The customer sent the right amount and the transfer
        landed. |

        | `failed` | The transfer failed. |

        | `abandoned` | The customer never paid and left the session. |


        When the GTBank sandbox is the active transfer processor, `success`
        creates a sandbox virtual account if the payment has none, funds it, and
        returns `awaiting_transfer` with the account details. The payment then
        completes through the same webhook path as production, which makes it a
        faithful test of your handler. In every other case the payment is moved
        straight to the chosen terminal status and the matching webhook
        (`payment.completed` or `payment.failed`) is emitted.


        The payment must still be `pending` or `processing`. Simulating a
        payment that has already finished is rejected with `400`.
      operationId: simulate-bank-transfer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulateTransferRequest'
            examples:
              success:
                value:
                  scenario: success
              failed:
                value:
                  scenario: failed
              abandoned:
                value:
                  scenario: abandoned
      responses:
        '201':
          description: The simulation was accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulateTransferResponse'
              examples:
                terminal:
                  summary: Payment moved to the chosen outcome
                  value:
                    status: success
                    data:
                      reference: VPY_TXN_ACME001
                      status: success
                      message: Simulation completed successfully
                      paidAt: '2026-05-19T10:30:00.000Z'
                      failedAt: null
                gtbankSandbox:
                  summary: GTBank sandbox transfer triggered
                  description: The payment completes when the provider's webhook arrives.
                  value:
                    status: success
                    data:
                      reference: VPY_TXN_ACME001
                      status: awaiting_transfer
                      message: >-
                        GTBank sandbox transfer triggered. Awaiting the
                        provider's webhook to confirm success.
                      accountNumber: '0012345678'
                      bankName: Guaranty Trust Bank
                      accountName: Acme Stores Limited
                      expiresAt: '2026-05-19T10:45:00.000Z'
        '400':
          description: >-
            The scenario is not supported, or the payment has already finished.
            The message reads "Transaction cannot be simulated from current
            state." for a payment that is no longer `pending` or `processing`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/CheckoutUnauthorized'
        '403':
          description: >-
            Simulation is only allowed in test mode ("Simulation is only allowed
            in test mode."). This is what you should see on live.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        - AccessCode: []
        - AccessCodeQuery: []
components:
  schemas:
    SimulateTransferRequest:
      type: object
      required:
        - scenario
      properties:
        scenario:
          type: string
          enum:
            - success
            - failed
            - abandoned
          description: >-
            How the transfer should end. `success` simulates a landed transfer,
            `failed` a failed transfer, and `abandoned` a customer who never
            paid.
    SimulateTransferResponse:
      type: object
      required:
        - status
        - data
      properties:
        status:
          type: string
          enum:
            - success
        data:
          type: object
          properties:
            reference:
              type: string
              description: The payment reference.
            status:
              type: string
              description: >-
                The resulting state. `success`, `failed` or `abandoned` when the
                payment was moved straight to that outcome, or
                `awaiting_transfer` when a GTBank sandbox transfer was triggered
                and the provider's webhook is pending.
            message:
              type: string
              description: A short description of what happened.
            paidAt:
              type:
                - string
                - 'null'
              format: date-time
              description: When the payment succeeded. Present for a `success` simulation.
            failedAt:
              type:
                - string
                - 'null'
              format: date-time
              description: When the payment failed. Present for a `failed` simulation.
            accountNumber:
              type: string
              description: >-
                The sandbox virtual account. Present only when a GTBank sandbox
                transfer was triggered.
            bankName:
              type: string
              description: Present only when a GTBank sandbox transfer was triggered.
            accountName:
              type: string
              description: Present only when a GTBank sandbox transfer was triggered.
            expiresAt:
              type: string
              format: date-time
              description: Present only when a GTBank sandbox transfer was triggered.
    ErrorEnvelope:
      type: object
      description: >-
        The envelope for every failed request. Branch on `error`, which is
        stable. Show `message` to people but never parse it.
      required:
        - status
        - error
        - message
      properties:
        status:
          type: string
          enum:
            - failed
        error:
          type: string
          description: >-
            A stable machine-readable error code, such as `invalid_credentials`
            or `payment.invalid_amount`.
          example: invalid_credentials
        message:
          type: string
          description: A human-readable explanation. Wording may change.
          example: The provided credentials are invalid.
  responses:
    CheckoutUnauthorized:
      description: >
        The access code is missing, invalid or expired. Common codes are
        `missing_access_code` and `transaction_expired`. Initialise a new
        payment if the code has expired.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: failed
            error: missing_access_code
            message: The x-access-code header is required.
  securitySchemes:
    SecretKey:
      type: apiKey
      in: header
      name: x-api-key
      description: >
        Your secret key. `sk_test_…` on the sandbox, `sk_live_…` on live.
        Server-side only.

        Copy it from **Settings → Developers** in the merchant dashboard.

        `Authorization: Bearer` with the same value is also accepted.
    BearerAuth:
      type: http
      scheme: bearer
      description: >-
        The same secret key sent as `Authorization: Bearer sk_…`. An alternative
        to `x-api-key`.
    AccessCode:
      type: apiKey
      in: header
      name: x-access-code
      description: >
        The `accessCode` returned by initialise. Scoped to one payment and valid
        for 60 minutes.

        Safe to give to the customer's browser.
    AccessCodeQuery:
      type: apiKey
      in: query
      name: access_code
      description: >-
        The same access code sent as the `access_code` query parameter. An
        alternative to `x-access-code`.

````

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