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

# Send crypto off-chain between omnibus accounts

> Submit an instant off-chain transfer between two omnibus accounts on the same platform via POST /v2/crypto/transfers, and confirm the transfer settles.

## Goal

Submit an off-chain transfer between two omnibus accounts on the same platform and confirm it settles.

For transfers that go through the blockchain, see [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain).

## Key entities and concepts

| Entity                | Description                                                                                                                              |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Your platform         | The product integrating Bastion's APIs                                                                                                   |
| Customer              | An individual or business your platform has onboarded (KYC/KYB-verified), with a funded account                                          |
| Identity              | The customer's record in Bastion                                                                                                         |
| Account               | A Bastion omnibus account linked to the identity. Must be an omnibus account for off-chain transfers.                                    |
| Crypto transfer       | An off-chain asset movement via [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) |
| `account_destination` | Structured destination object containing the recipient's `account_id`                                                                    |

Off-chain transfers require omnibus accounts. Both the sender and recipient must have omnibus accounts on the same platform. If your platform uses segregated wallets, see [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain) instead.

## Flow overview

1. Confirm the customer's identity is verified and they have sufficient balance.
2. Submit [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) with `account_destination`.
3. Track transfer status via webhooks or polling. Off-chain transfers settle immediately.
4. Show the completed transfer and updated balances in your app.

***

## Prerequisites

* The customer has a **verified identity** (KYC or KYB) in Bastion.
* The customer has an **omnibus account** with balance in a supported asset (e.g., USDC).
* The recipient also has an **omnibus account on the same platform**.
* You have an **API key** and have **registered your webhook**.

***

## Step 1 — confirm identity, account, and balance

Before sending, confirm the customer can transact and has sufficient balance.

**Get the identity**

```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/identities/{identity_id}
Authorization: Bearer YOUR_API_KEY
```

Check that:

* `status` is `ACTIVE`
* `allowed_to_transact_fungibles` is `true`

**Get balances**

```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/accounts/{account_id}/balances
Authorization: Bearer YOUR_API_KEY
```

**Response:**

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "balances": [
      {
        "symbol": "USDC",
        "total": "975000000",
        "available": "975000000",
        "decimals": 6,
        "type": "OMNIBUS",
  			"chain": "CHAIN_UNSPECIFIED"
      }
    ]
  }
  ```
</CodeGroup>

Omnibus balances are chain-agnostic. The `type` field is `OMNIBUS` and `chain`is `CHAIN_UNSPECIFIED`. `contract_address` is omitted.

Confirm enough available balance in the `available` field for the asset you intend to send. Your UI should surface clear errors when a transfer cannot complete (e.g., insufficient balance).

***

## Step 2 — submit off-chain transfer

Call [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) to submit an off-chain transfer from the customer's account to another account on the same platform.

**Request**

```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
POST /v2/crypto/transfers
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
```

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "request_id": "66f3b0b2-a25b-4e9d-a94b-fbcfa0720312",
    "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
    "currency_symbol": "USDC",
    "amount": "25.00",
    "account_destination": {
      "account_id": "48jK9mLp2rSTuvW3xYzAaBbCcDd"
    }
  }
  ```
</CodeGroup>

No `chain` field is needed. The transfer is a ledger operation, not a blockchain transaction.

**Request fields:**

| Field                            | Type   | Required | Description                                                                      |
| -------------------------------- | ------ | -------- | -------------------------------------------------------------------------------- |
| `request_id`                     | string | Yes      | Unique UUID. Used as idempotency key. Reuse on retries.                          |
| `account_id`                     | string | Yes      | Sender's Bastion account ID                                                      |
| `currency_symbol`                | string | Yes      | Asset to send (e.g., `USDC`)                                                     |
| `amount`                         | string | Yes      | Amount as a human-readable decimal (e.g., `"25.00"`)                             |
| `account_destination.account_id` | string | Yes      | Recipient's Bastion account ID. Must be an omnibus account on the same platform. |

**Response**

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "id": "3Z9YxKXb7M5rI8nU2oW0qS4tXeE",
    "request_id": "66f3b0b2-a25b-4e9d-a94b-fbcfa0720312"
  }
  ```
</CodeGroup>

Guidance:

* Use a unique `request_id` as an idempotency key. `request_id` must be a UUID. If you retry the same transfer after a transient error, reuse the same `request_id`.
* Both sender and recipient must be omnibus accounts on the same platform. If the destination account is not found, not an omnibus account, or belongs to a different platform, the request returns HTTP 400.

***

## Step 3 — track transfer status

Track the transfer until it reaches a terminal state. You can use webhooks (recommended) and polling. Off-chain transfers settle immediately, so you will typically receive a terminal status within seconds.

### Webhook — transfer status

Configure a webhook endpoint in the Bastion dashboard to receive transfer events. Bastion delivers a `submit_cryptocurrency_transfer_v2` webhook on every status change. Your handler should be idempotent and use `request_id` to correlate events.

**Sender webhook (confirmed):**

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "id": "3Z9YxKXb7M5rI8nU2oW0qS4tXeE",
    "type": "submit_cryptocurrency_transfer_v2",
    "data": {
      "id": "3Z9YxKXb7M5rI8nU2oW0qS4tXeE",
      "request_id": "66f3b0b2-a25b-4e9d-a94b-fbcfa0720312",
      "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
      "currency_symbol": "USDC",
      "amount": "25.00",
      "destination_type": "ACCOUNT",
      "destination_address": "",
      "destination_chain": "CHAIN_UNSPECIFIED",
      "destination_account_id": "48jK9mLp2rSTuvW3xYzAaBbCcDd",
      "status": "CONFIRMED",
      "created_at": "2025-12-05T20:56:56Z",
      "updated_at": "2025-12-05T20:56:57Z",
      "transaction_hash": "",
      "completed_at": "2025-12-05T20:56:57Z",
      "failure_details": null,
      "events": []
    }
  }
  ```
</CodeGroup>

For off-chain transfers, `destination_address`, `destination_chain`, and `transaction_hash` are empty because no blockchain transaction occurs. `destination_type` is `ACCOUNT`. The `events` array is always present but empty.

**Recipient webhook (deposit credited):**

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "id": "3AX4Jpn2UzTAoM4S1V0yLl5R8Ts",
    "type": "deposit_v2",
    "data": {
      "id": "3AX4Jpn2UzTAoM4S1V0yLl5R8Ts",
      "dedup_key": "3AX4Jpn2UzTAoM4S1V0yLl5R8Ts",
      "account_id": "48jK9mLp2rSTuvW3xYzAaBbCcDd",
      "source_type": "ACCOUNT",
      "source_account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
      "currency_symbol": "USDC",
      "amount": "25.00",
      "destination_chain": "CHAIN_UNSPECIFIED",
      "status": "CONFIRMED",
      "confirmed_at": "2025-12-05T20:56:57Z",
      "created_at": "2025-12-05T20:56:57Z"
    }
  }
  ```
</CodeGroup>

For off-chain deposits, `source_type` is `ACCOUNT` and `source_account_id` identifies the sender. See [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits) for full details.

Handle statuses:

| Status      | Type         | Recommended action                                              |
| ----------- | ------------ | --------------------------------------------------------------- |
| `ACCEPTED`  | Intermediate | Transfer received and queued. Wait for further updates.         |
| `CONFIRMED` | Terminal     | Transfer complete, funds moved. Mark as done, refresh balances. |
| `FAILED`    | Terminal     | See `failure_details`; resolve root cause before retrying.      |

> **Webhook reliability:** Failed deliveries are retried with exponential backoff (15 s base, 10 min max). Deduplicate on `id` + `status` for transfer events (the transfer `id` is stable across status changes), and on `data.dedup_key` for deposit events.

### Polling — get a transfer

As a fallback, you can poll transfer status.

```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/crypto/transfers/{transfer_id}
Authorization: Bearer YOUR_API_KEY
```

**Response:**

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "id": "3Z9YxKXb7M5rI8nU2oW0qS4tXeE",
    "request_id": "66f3b0b2-a25b-4e9d-a94b-fbcfa0720312",
    "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
    "currency_symbol": "USDC",
    "amount": "25.00",
    "destination_type": "ACCOUNT",
    "destination_address": "",
    "destination_chain": "CHAIN_UNSPECIFIED",
    "destination_account_id": "48jK9mLp2rSTuvW3xYzAaBbCcDd",
    "status": "CONFIRMED",
    "created_at": "2025-12-05T20:56:56Z",
    "updated_at": "2025-12-05T20:56:57Z",
    "transaction_hash": "",
    "completed_at": "2025-12-05T20:56:57Z",
    "failure_details": null,
    "events": []
  }
  ```
</CodeGroup>

Use the returned `status` and any `failure_details` to decide what to show to the customer.

***

## Step 4 — show the updated balance

After the transfer confirms, refresh balances and show the transfer in history.

**Refresh balances**

```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/accounts/{account_id}/balances
Authorization: Bearer YOUR_API_KEY
```

**Show transfer in your UI:**

* Display amount, asset (USDC), recipient name or account identifier, and status.
* Indicate the transfer was instant (no blockchain confirmation wait).
* For the recipient side: show the sender's name or account identifier using `source_account_id` from the deposit webhook.

***

## Implementation notes and recommendations

| Recommendation                                | Details                                                                                                                                                 |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Use idempotent request IDs                    | Always provide a unique `request_id` per transfer and reuse it on retries to avoid duplicates.                                                          |
| Check balances before allowing sends          | Always check the `available` balance before submitting a transfer. Handle insufficient funds gracefully in your UI.                                     |
| Handle failed transfers clearly               | Treat `FAILED` as an error state that requires customer messaging. Do not assume a transfer will eventually succeed.                                    |
| Keep identity, account, and transfers in sync | Store `identity_id`, `account_id`, and `transfer_id` in your own system so you can correlate Bastion events with your own records.                      |
| Validate account eligibility                  | Both sender and recipient must be omnibus accounts on the same platform. Surface a clear error if the destination account is not found or not eligible. |


## Related topics

- [Send crypto on-chain to any wallet address](/guides/quickstarts/send-crypto/on-chain.md)
- [Send crypto from a Bastion account](/guides/quickstarts/send-crypto.md)
- [Crypto custody](/guides/concepts/custody.md)
- [Crypto transfers and deposits](/guides/concepts/crypto-transfers.md)
- [Receive crypto deposits to a Bastion account](/guides/quickstarts/receive-crypto-deposits.md)
