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

> ## Agent Instructions
> Money is always a decimal string at the currency's precision (for example "29.00"), paired with an ISO 4217 currency field. Never use minor units.
> Build against the sandbox first: base URL https://sandbox-api.bachs.io with sk_sandbox_ keys. Production is https://api.bachs.io with sk_live_ keys; going live is a key swap.
> Treat webhooks (for example collection.succeeded) as the source of truth for fulfilment, never client-side events or redirects.
> Subscriptions are created by completing a checkout for a recurring product. There is no direct create-subscription endpoint.
> IDs carry resource prefixes (cust_, prod_, sub_, chk_, inv_, ref_) and timestamps are ISO 8601 UTC.

# Virtual accounts

> Give an account a fixed bank account number. Money sent to it becomes a payment on that account.

A **virtual account** is a fixed bank account number issued to your platform or a connected account. It does not expire, and anyone can send money to it at any time. Each deposit becomes a payment on the account that received it.

An account holds one virtual account per currency. `NGN` is the only currency issued today.

These calls act on the account named in `X-Account-Id`, or on your own account when you omit it. See [Acting as an account](/connect/acting-as-an-account).

***

## Turn it on

A virtual account requires the `virtual_accounts` capability and the account representative's BVN. Virtual accounts are rolling out gradually, so contact [hello@bachs.io](mailto:hello@bachs.io) to request access for your platform or a connected account.

<Steps>
  <Step title="Request access">
    Ask Bachs to enable `virtual_accounts` for the account. Include the account ID when you request access for a connected account.
  </Step>

  <Step title="Read what it needs">
    Requesting the capability puts a requirement on the account. Read `requirements.entries[]` on the account, or wait for `account.updated` to tell you it changed. See [Requirements](/connect/requirements).
  </Step>

  <Step title="Submit the representative's BVN">
    Submit it as a `persons` entry with `relationship.representative: true` and an `id_numbers` entry of type `bvn`. This is a requirements submission through the API, the same call you use for every other requirement field. See [Submitting](/connect/requirements#submitting).

    The issuing bank will not create a number without this. It has to be the BVN of the person marked as the account's representative, not any other person on the account.
  </Step>

  <Step title="Wait for approval">
    The capability moves to `active` on review. You are told through the `capability.updated` webhook, with `data.capability` set to `virtual_accounts` and `data.status` set to `active`. See [capability.updated](/guides/webhooks/events/capability-updated).
  </Step>

  <Step title="Create the number">
    Approval does not create a number by itself. Call `POST /v1/virtual-accounts` when you want one.
  </Step>
</Steps>

***

## Create the virtual account

`POST /v1/virtual-accounts` · scope `virtual_accounts:write` · [full field reference](/api-reference/virtual-accounts/object)

Name the currency. Calling this a second time with the same currency returns the account you already hold, so a retry after a timeout never leaves you with two numbers.

<CodeGroup>
  ```bash Request theme={"dark"}
  curl -X POST https://sandbox-api.bachs.io/v1/virtual-accounts \
    -H "Authorization: Bearer sk_sandbox_abc123xyz..." \
    -H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
    -H "Content-Type: application/json" \
    -d '{
      "currency": "NGN"
    }'
  ```

  ```json Response theme={"dark"}
  {
    "id": "va_8Hs2kQ4mZpXv",
    "currency": "NGN",
    "account_number": "9902847361",
    "bank_name": "Example Bank",
    "bank_code": "000",
    "status": "active",
    "created_at": "2026-09-22T09:14:02.000Z"
  }
  ```
</CodeGroup>

Omit `X-Account-Id` and the call creates or reads back your own account instead of a connected one.

<Info>
  Do not treat a network or `5xx` error on create as proof that no account was made. Call `GET /v1/virtual-accounts` before retrying, or retry the create, which returns the account you already hold rather than a second number.
</Info>

## Read it back

`GET /v1/virtual-accounts` · scope `virtual_accounts:read`

Returns the virtual account for one currency. The currency defaults to `NGN` when you omit it. You get `404` with `NOT_FOUND` when the account has no virtual account in that currency.

<CodeGroup>
  ```bash Request theme={"dark"}
  curl "https://sandbox-api.bachs.io/v1/virtual-accounts?currency=NGN" \
    -H "Authorization: Bearer sk_sandbox_abc123xyz..." \
    -H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe"
  ```

  ```json Response theme={"dark"}
  {
    "id": "va_8Hs2kQ4mZpXv",
    "currency": "NGN",
    "account_number": "9902847361",
    "bank_name": "Example Bank",
    "bank_code": "000",
    "status": "active",
    "created_at": "2026-09-22T09:14:02.000Z"
  }
  ```
</CodeGroup>

***

## Give the number out

Show `account_number` and `bank_name` together. For a connected account, the sender sees that account's business name in their banking app, not your platform's name.

Nothing is reserved in advance. There is no expected amount, and no reference for the sender to quote, so anything typed in the narration reaches you as plain text and nothing more. What identifies a deposit is the sender's own bank details, which arrive with it.

***

## Receive the deposit

A deposit arrives from a bank app, outside your integration, so the webhook is the only thing that tells you it happened. Each deposit becomes its own payment, and that payment succeeds on its own. You receive [`collection.succeeded`](/guides/webhooks/events/collection-succeeded) with `checkout_id` set to `null`, because no checkout created it.

```json collection.succeeded theme={"dark"}
{
  "id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
  "type": "collection.succeeded",
  "created_at": "2026-09-22T10:41:18.402Z",
  "organization_id": "acct_7KpQ2mNv4XbR9dLc",
  "data": {
    "charge_id": "ch_1a2b3c4d5e6f",
    "checkout_id": null,
    "reference": null,
    "status": "SUCCEEDED",
    "amount": "250000.00",
    "currency": "NGN",
    "settlement_amount": "249700.00",
    "settlement_currency": "NGN",
    "processing_fee": "300.00",
    "processing_fee_currency": "NGN",
    "fee_bearer": "merchant",
    "product_cart": null,
    "customer": { "id": null },
    "payment_method_details": {
      "type": "bank_transfer",
      "bank_transfer": {
        "sender_name": "JANE ADEYEMI",
        "sender_bank": "Guaranty Trust Bank",
        "sender_account_number": "2294879124",
        "session_id": "000013260922104115000821734502",
        "virtual_account": {
          "id": "va_8Hs2kQ4mZpXv",
          "account_number": "9902847361",
          "bank_name": "Example Bank",
          "type": "permanent",
          "expires_at": null
        }
      }
    },
    "metadata": {}
  }
}
```

`checkout_id`, `reference`, `product_cart`, and `customer.id` are `null` on every deposit. A bank transfer paid through a checkout carries all four instead.

An NGN virtual account deposit costs 1.5% of the amount received, capped at NGN 300. The fee is deducted before settlement, so the example above settles NGN 249,700 from an NGN 250,000 deposit. See [Fees](/for-you/fees) and [Processing fees](/connect/processing-fees).

The money reaches the account's payout schedule the same way as any other payment. See [Payouts](/connect/payouts).

<Note>
  **A deposit above the account's limit waits before the money reaches its balance.** An account has a limit on one deposit and a limit on a day's deposits added together. A deposit over either one needs a manual review before the money goes into the balance. [Deposit limits](/guides/payments/deposit-limits) explains how limits work.
</Note>

<Note>
  **You cannot refund a deposit.** The payment comes back with `is_refundable: false`, and a refund request against it is rejected with `400`. To return the money, send it back as a [payout](/connect/payouts). Use `payment_method_details.bank_transfer.sender_bank` and `sender_account_number` as the payout destination details.
</Note>

***

## Errors

These endpoints return the standard [error envelope](/errors). See [Virtual account errors](/api-reference/error-reference#virtual-accounts) for the possible codes and how to resolve them.

***

## Related

* [Capabilities](/connect/capabilities)
* [Requirements](/connect/requirements)
* [Acting as an account](/connect/acting-as-an-account)
* [The virtual account object](/api-reference/virtual-accounts/object)
* [The payment object](/api-reference/payments/object)
* [capability.updated](/guides/webhooks/events/capability-updated)
* [collection.succeeded](/guides/webhooks/events/collection-succeeded)
