# Product updates Source: https://docs.bastion.com/changelog Release notes and announcements for the Bastion platform: API changes, new endpoints, webhook updates, deprecations, and migration guidance. ## 🔎 `RFI_REQUIRED` at any onboarding step and on `SUSPENDED` identities Compliance can now raise a request-for-information (RFI) whatever the identity's onboarding step, and on a `SUSPENDED` identity. * `identity.sub_status` can move to `RFI_REQUIRED` from any `PENDING_ONBOARDING` sub-status, including `CREATED` and the IDV sub-statuses, and while `identity.status` is `SUSPENDED`. * When an RFI raised during onboarding is fulfilled, `sub_status` returns to the onboarding step the identity was at, not always to `COMPLIANCE_CHECKS_IN_PROGRESS`. On an `ACTIVE` or `SUSPENDED` identity, fulfillment clears `RFI_REQUIRED`. * As before, any change of `identity.status` (approval, rejection, suspension, reinstatement or termination) closes an open RFI and clears `RFI_REQUIRED`. The exception is a reinstatement to `RFI_REQUIRED`, which keeps the RFI open or opens one. * Bastion accepts resubmitted KYC/KYB data while an RFI is open. The RFI stays open, and no `identity_status_update` webhook fires for the resubmission. This is additive, so there's nothing to migrate. If you assume `RFI_REQUIRED` always follows `COMPLIANCE_CHECKS_IN_PROGRESS`, or that fulfillment always returns there, read `previous_sub_status` and `sub_status` on the webhook instead. See [Identities and accounts](/guides/concepts/identities-and-accounts). ## 🔎 `RFI_REQUIRED` sub-status now supported on `ACTIVE` identities Compliance can now raise a request-for-information (RFI) on an already-onboarded identity — for periodic review or additional documentation after go-live — not just during onboarding. * `identity.sub_status` can now be `RFI_REQUIRED` while `identity.status` is `ACTIVE` (previously `sub_status` was only populated during `PENDING_ONBOARDING`). * `identity_status_update` fires when the RFI is raised and again when it is fulfilled (`sub_status` returns to empty). * **No change to transaction behavior.** An `ACTIVE` identity with an open RFI keeps transacting; this is purely a compliance-visibility signal. This is additive — nothing to migrate. If you branch on `sub_status`, be aware it can now be non-empty on `ACTIVE` identities. See [Identities and accounts](/guides/concepts/identities-and-accounts). ## 🚀 API change notice: Introducing new identity and account status **TL;DR:** **Bastion is replacing fragmented compliance fields with a unified `status` model for Identities and Accounts, effective immediately, with legacy fields deprecated on July 31, 2026.** * **New fields**: `identity.status`, `identity.sub_status`, and `account.status` are now the single source of truth for lifecycle state. * **New webhook**: `identity_status_update` fires on every status transition (onboarding progress, suspensions, reinstatements, terminations) — replacing the old `compliance_check_status_update` and `account_freeze_status_update`. * **Deprecating July 31, 2026**: `kyc_status`, `kyb_status`, `account.frozen`, `account.account_status`, and the two legacy webhooks above. * **Migration**: Start consuming the new fields and webhook today. Remove legacy field reads before August 5th, 2026. If you're unsure whether a change affects you, we're happy to walk through your integration with you. ## ⚠️ 1. Deprecation notice The below fields and webhooks are now marked as deprecated and will be fully sunset on August 5th, 2026. Webhooks: * `compliance_check_status_update` * `account_freeze_status_update` API fields: * Identity: * `kyc_status` * `kyb_status` * Account: * `frozen` * `account_status` *** ## 🌟 2. What's new Bastion's new Identity and account status fields expose two capabilities: * **Identity and Account statuses** are now governed by the new `identity.status` and `account.status` fields with clear values indicating the current state of the identity and account. * **Additional onboarding-step visibility via `sub_status`.** Previously you had no field that told you which onboarding step was outstanding for an identity — you had to infer it from the absence or shape of `kyc_status`/`kyb_status`. Now `sub_status` reports it directly: `IDV_REQUESTED`, `IDV_IN_PROGRESS`, `IDV_EXPIRED`, `RFI_REQUIRED`, etc. This is purely additive — there is nothing to migrate from, just a new field to start consuming. * **Lifecycle events fire on every transition.** The legacy `compliance_check_status_update` webhook fired once at onboarding completion; suspensions and reinstatements were silent at the customer boundary. The new `identity_status_update` event fires on every state and sub-status change, including onboarding progress, compliance suspensions, manual reinstatements, and terminations. ### Relevant API guides and endpoints: * [Identities and accounts](/guides/concepts/identities-and-accounts) * [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) * [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer) * [Compliance notifications (v2)](/v2/api-reference/webhooks/notifications/compliance-notifications-v2) ## 3. Newly introduced fields ### `identity.status` | Value | Meaning | | - | - | | `PENDING_ONBOARDING` | Identity created; onboarding workflow in progress. See `sub_status` for the precise step. | | `ACTIVE` | KYC/KYB approved, identity is able to transact. | | `SUSPENDED` | Previously active; temporarily suspended by Bastion Compliance. | | `REJECTED` | KYC/KYB denied during onboarding. Identity is not able to transact or proceed with account opening. | | `TERMINATED` | Identity and all associated accounts are permanently closed. | ### `identity.sub_status` | Value | Meaning | | - | - | | `CREATED` | Identity exists; no compliance work has started yet. | | `COMPLIANCE_CHECKS_IN_PROGRESS` | KYC/KYB checks running. | | `IDV_REQUIRED` | Identity-document verification needs to be initiated. IDV will only be required for KYC / individual identities. | | `IDV_REQUESTED` | IDV link sent to the user; awaiting completion. IDV will only be required for KYC / individual identities. | | `IDV_IN_PROGRESS` | User has started but not finished IDV. IDV will only be required for KYC / individual identities. | | `IDV_EXPIRED` | IDV session expired before completion; needs to be re-requested. IDV will only be required for KYC / individual identities. | | `RFI_REQUIRED` | Request-for-information initiated by Bastion (additional documents/data). | ### `account.status` | Value | Meaning | | - | - | | `ACTIVE` | Account is cleared to transact and usable. | | `FROZEN` | Account is blocked (derived from a SUSPENDED parent identity). | | `CLOSED` | Account is permanently closed (derived from a TERMINATED parent identity). | ## 4. Newly introduced webhooks There will be a single type of event trigger for compliance notifications. * **`identity_status_update` Events:** These are sent whenever an identity (and its associated accounts) undergoes a change in status. These can happen for various reasons: * During onboarding, compliance checks are initiated for an identity upon receipt of a [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) request. The event will return updates to the status, or if an RFI is required. * Throughout the customer lifecycle post-onboarding, a change in identity status can occur. E.g., an identity could move from `ACTIVE` to `SUSPENDED`. ### Identity status update events An example `identity_status_update` event payload of an identity that has completed onboarding is as follows: ```json Example Event Payload theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYB_ACTIVE001", "type": "identity_status_update", "data": { "identity": { "id": "01HZXBUSINESSIDENTITY0001", "label": "corp_acme_001", "identity_type": "BUSINESS", "status": "ACTIVE", "sub_status": "" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "COMPLIANCE_CHECKS_IN_PROGRESS" } } ``` An example `identity_status_update` event payload of an approved identity that has been suspended is as follows: ```json Example Event Payload theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYB_ACTIVE001", "type": "identity_status_update", "data": { "identity": { "id": "01HZXBUSINESSIDENTITY0001", "label": "corp_acme_001", "identity_type": "BUSINESS", "status": "SUSPENDED", "sub_status": "" }, "affected_account_ids": [], "status_reason": null, "previous_status": "ACTIVE", "previous_sub_status": "" } } ```
## 5. Field-by-field mapping from legacy fields to new fields ### On `Identity` ([`GET /v2/identities/{id}`](/v2/api-reference/identities/get-identity)) | ❌ Legacy field | ✅ Read this instead | | - | - | | `kyc_status.kyc_passed` | `status == "ACTIVE"` | | `kyb_status.kyb_passed` | `status == "ACTIVE"` | | `kyc_status.kyc_failure_reason` / `kyb_status.kyb_failure_reason` | `status` (`REJECTED`, `SUSPENDED`, `TERMINATED`) | | `kyc_status.jurisdiction_check_passed` / `kyb_status.jurisdiction_check_passed` | `status == "ACTIVE"` | | `kyc_status.jurisdiction_check_failure_reason` / `kyb_status.jurisdiction_check_failure_reason` | `status` | ### On `Account` ([`GET /v2/accounts/{id}`](/v2/api-reference/accounts/get-account)) | ❌ Legacy field | ✅ Read this instead | | - | - | | `frozen` (bool) | `status == "FROZEN"` | | `account_status` *(already marked `deprecated=true`)* | `status` | ### Fields that are **not** going away (don't migrate these) | Field | Why it stays | | - | - | | `Identity.fiat_operations_enabled` | Independent fiat-rails entitlement. An identity can be `ACTIVE` but fiat-disabled. | | `Identity.type` (formerly `identity_type`) | This is an unrelated rename; `type` is already the correct field to read. | | `Account.type` (formerly `account_type`) | Same — unrelated rename. | ## 6. API examples ### [`GET /v2/identities/{id}`](/v2/api-reference/identities/get-identity) — Individual (KYC) example #### ❌ Old shape ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity": { "id": "id_01HX3K…", "type": "INDIVIDUAL", "label": "user-42", "account_ids": ["acct_01HX4Q…"], "fiat_operations_enabled": true, "kyc_status": { "kyc_passed": true, "kyc_failure_reason": null, "jurisdiction_check_passed": true, "jurisdiction_check_failure_reason": null, "allowed_to_transact_fungibles": true } } } ``` #### ✅ New shape ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity": { "id": "id_01HX3K…", "type": "INDIVIDUAL", "label": "user-42", "account_ids": ["acct_01HX4Q…"], "fiat_operations_enabled": true, "status": "ACTIVE", "sub_status": null } } ```
#### ✅ New shape — mid-onboarding, IDV outstanding ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity": { "id": "id_01HX3K…", "type": "INDIVIDUAL", "label": "user-42", "account_ids": [], "fiat_operations_enabled": false, "status": "PENDING_ONBOARDING", "sub_status": "IDV_REQUESTED" } } ```
#### ✅ New shape — suspended after compliance review ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity": { "id": "id_01HX3K…", "type": "INDIVIDUAL", "label": "user-42", "account_ids": ["acct_01HX4Q…"], "fiat_operations_enabled": false, "status": "SUSPENDED", "sub_status": null } } ```
> During the deprecation window, the legacy `kyc_status` / `kyb_status` blocks will continue to appear in responses; their `*_failure_reason` fields will now be **derived from the new identity state** (e.g., `"identity is currently suspended"`) so they stay in sync. Treat them as read-only shadows — do not branch on them. ### [`GET /v2/accounts/{id}`](/v2/api-reference/accounts/get-account) #### ❌ Old shape ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "account": { "id": "acct_01HX4Q…", "identity_id": "id_01HX3K…", "type": "SEGREGATED", "frozen": false, "account_status": "ACTIVE" } } ```
#### ✅ New shape ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "account": { "id": "acct_01HX4Q…", "identity_id": "id_01HX3K…", "type": "SEGREGATED", "identity_status": "ACTIVE", "identity_sub_status": null, "status": "ACTIVE" } } ``` > `Account.status` is the source of truth. `Account.identity_status` / `Account.identity_sub_status` are convenience mirrors of the parent Identity — handy if you don't want a second round-trip. ## 7. Migration guidance 1. **Today** — Add handlers for `identity_status_update`. You can keep your existing `compliance_check_status_update` / `account_freeze_status_update` handlers in place during this deprecation period. 2. **In your API reads** — Switch your "is this identity allowed to transact?" check to: ```javascript theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} identity.status == "ACTIVE" ``` Switch your "is this account usable?" check to: ```javascript theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} account.status == "ACTIVE" ```
3. **For onboarding UIs** — Use `identity.sub_status` to drive your user-facing onboarding step indicator instead of inferring it from `kyc_status` presence/absence. 4. **For failure messaging** — The canonical source will be `identity.status`. The legacy `*_failure_reason` will still be supported during the deprecation window. 5. **Before deprecation on August 5th, 2026** — Remove all reads of `kyc_status`, `kyb_status`, `frozen`, and `account_status`. Stop subscribing to `compliance_check_status_update` and `account_freeze_status_update`.
Questions? Reach out to your Bastion integration contact for assistance.
## 🚀 Release summary - KYC schema updates ## ✨ What's new **KYC schema updates:** * New required fields have been added to `/v2/identities/{identity_id}/compliance` * The `account_purpose` enum values have also been updated * **Action required:** * Collect additional required information from end users during onboarding * Update any code that uses `/v2/identities/{identity_id}/compliance` to always include the required fields and enum values | **New Field** | **Type** | **Required** | | - | - | - | | `occupation` | enum | Yes | | `source_of_funds` | enum | Yes | | `nationality` | string (ISO 3166-1 alpha-2 country code) | Yes | | `country_of_birth` | string (ISO 3166-1 alpha-2 country code) | Yes | | `additional_id_number` | string | Conditionally required based on identity jurisdiction | View [Submit Compliance Data API →](/v2/api-reference/compliance/submit-compliance-data) ## 🚀 Release summary – Account type rename, gas sponsorship, and deposit improvements > **TL;DR:** We've renamed two account type values: `LEDGER` is now `OMNIBUS` and `ONCHAIN` is now `SEGREGATED`. You'll need to update your integration before June 24, 2026. On the new features side: gas fees on EVM transfers are now sponsored by Bastion, deposits have a redesigned webhook with Solana support, and we've added a Deposit History API. We also shipped a couple of reliability fixes to crypto transfers. If you're unsure whether a change affects you, we're happy to walk through your integration with you. *** ### ⚠️ Deprecation notice **Account type values renamed: `LEDGER` → `OMNIBUS`, `ONCHAIN` → `SEGREGATED`** **What changed** The `account_type` enum values used when creating and reading accounts via the **Accounts API** (`/v2/accounts`) have been renamed to better reflect their meaning: * `LEDGER` is now `OMNIBUS`: for pooled, ledger-based custody accounts * `ONCHAIN` is now `SEGREGATED`: for individually isolated, on-chain wallet accounts **Transition period** Both the old and new values are currently accepted and returned. The legacy values (`LEDGER`, `ONCHAIN`) will be removed on **June 24, 2026**. **Action required** Update any code that sends or parses `account_type` to use the new values. If you use generated clients, regenerate from the latest schema. *** ### ✨ What's new * **EVM gas sponsorship:** Your users can now send on Ethereum, Polygon, Base, and Optimism without ever holding ETH or native gas tokens. Bastion sponsors gas fees automatically across all supported EVM chains, and for most integrations, it costs you nothing. Every API customer gets \$100 in gas credits, and you're only billed if you exceed that. **Already using the Crypto Transfers API on EVM chains? You're basically done.** If you're calling `/v2/crypto/transfers`, gas sponsorship is enabled automatically, no new integration work needed. Just set your `destination_chain` to any supported EVM chain (`ETHEREUM_MAINNET`, `POLYGON_MAINNET`, `BASE_MAINNET`, `OPTIMISM_MAINNET`) and we handle the rest. * **New `deposit_v2` webhook event:** We've redesigned the wallet deposit notification webhook with a cleaner payload structure, and it now supports Solana in addition to EVM chains. Key changes: * **Solana support:** Deposit webhooks now fire for SOL and SPL token deposits on Solana wallets. If you already handle deposit webhooks, you'll automatically start receiving them for Solana. * New `source_type` field: `ON_CHAIN` for external chain deposits, `ACCOUNT` for internal P2P transfers received by the destination account * New `dedup_key` field for idempotent event handling * Consistent `snake_case` field naming throughout [View Webhooks reference →](/v2/api-reference/webhooks/notifications/deposit-notifications) Example `deposit_v2` payload: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "type": "deposit_v2", "data": { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "dedup_key": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn::0", "account_id": "3BX5HRAVXz9PgBwnlL2zKR4tYdB", "account_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "source_type": "ON_CHAIN", "source_address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "currency_symbol": "USDC", "amount": "123.44", "destination_chain": "SOLANA_MAINNET", "transaction_hash": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn", "status": "CONFIRMED", "confirmed_at": "2026-03-05T15:45:14Z", "created_at": "2026-03-05T15:45:14Z" } } ``` * **Deposit history API:** Two new endpoints are available for querying deposit history: * [`GET /v2/crypto/deposits`](/v2/api-reference/cryptocurrency-deposits/list-cryptocurrency-deposits): paginated list of deposits, filterable by chain, currency, and time range. * [`GET /v2/crypto/deposits/{deposit_id}`](/v2/api-reference/cryptocurrency-deposits/get-cryptocurrency-deposit): fetch a single deposit by ID. Deposits are persisted as they flow through the system going forward. *** ### ⚡ Improvements * **EVM gas estimation:** Fixed gas estimation logic that was causing premature transaction cancellations on EVM chains. * **Confirmation timeout extended from 1 min to 5 min:** Applies to the **Crypto Transfers API** (`/v2/crypto/transfers`). Provides a wider window for transactions to confirm before auto-cancellation. * **Gov ID upload removed from KYC flow:** KYC submissions via the Submit Compliance Data API no longer require gov ID images, reducing the integration surface for identity verification. * `gov_id_image_front` and `gov_id_image_back` have been removed from the KYC submission payload. * KYB is not affected. * **Crypto Transfers (v2): Structured destination format:** The Crypto Transfers API (`/v2/crypto/transfers`) now accepts a structured `address_destination` object (`{ "address", "chain" }`) as an alternative to the existing `destination_address` + `destination_chain` fields. Both formats remain supported; new integrations should prefer `address_destination`. ## 📘 Learn more Find deeper documentation and technical details on each endpoint: * [Accounts API](/v2/api-reference/accounts/overview) * [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) * [Deposit Notifications](/v2/api-reference/webhooks/notifications/deposit-notifications) * [Submit Cryptocurrency Transfers](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) ## 🚀 Release summary - Solana gas sponsorship Your users can now send on Solana without ever touching SOL. Bastion sponsors gas fees automatically, and for most integrations, it costs you nothing. Every API customer gets \$100 in gas credits, and you're only billed if you exceed that. This is the first chain in our gas sponsorship rollout. **Already using the Crypto Transfers API with Solana? You're basically done.** If you're calling `/v2/crypto/transfers`, just set `destination_chain: SOLANA_MAINNET` — no new integration work needed. For deeper technical details, check out [Submit Cryptocurrency Transfers](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer).
## 🚀 Release summary - API enhancements and reliability improvements > **TL;DR:** KYC/KYB ID requirement change, faster identity-based queries, and more reliable webhook delivery. *** ### ⚠️ Breaking change #### Back of ID requirement for KYC and KYB (effective immediately) **Summary** [Submit Compliance Data API](/v2/api-reference/compliance/submit-compliance-data) now conditionally requires the **back of the ID depending on the document type**. **What changed** * **KYC (individuals) and KYB (Beneficial Owners)** will now require back of the ID depending on the document type | Document type | Front required | Back required | | - | - | - | | DOCUMENT\_TYPE\_PASSPORT | Yes | No | | DOCUMENT\_TYPE\_DRIVER\_LICENSE | Yes | Yes | | DOCUMENT\_TYPE\_NATIONAL\_ID | Yes | Yes | | DOCUMENT\_TYPE\_RESIDENCE\_PERMIT | Yes | Yes | **Impact** * Requests that omit required fields now **fail validation**. **Action required** * Update payloads and mappings to always include the required fields. * When Submitting Compliance Data with ID type = DOCUMENT\_TYPE\_DRIVER\_LICENSE, DOCUMENT\_TYPE\_NATIONAL\_ID or DOCUMENT\_TYPE\_RESIDENCE\_PERMIT document type, include gov\_id\_image\_back (same format as gov\_id\_image\_front). *** ### ✨ What's new **Identity-based transfer queries** — The [List Cryptocurrency Transfers API](/v2/api-reference/cryptocurrency-transfers/list-cryptocurrency-transfers) now supports querying by `identity_Id`, returning all transfers across an identity's accounts in a single call—no more iterating through each account separately. **Identity filtering on ListAccounts** — The [List Accounts](/v2/api-reference/accounts/list-accounts) endpoint now accepts an optional `identity_Id` parameter to retrieve only accounts belonging to a specific identity. *** ### ⚡ Improvements * **Webhook notification retries** — Failed webhook deliveries are now automatically retried with persistent tracking, improving reliability for event-driven integrations. * **Consistent error responses** — [`GET /v2/transaction-limits/policies/{id}`](/v2/api-reference/transaction-limits/get-transaction-limit-policy) now returns `404` when a policy isn't found (previously `500`). Conversion lookups follow the same pattern. Unsupported HTTP methods now properly return `405`. *** ### 🧰 Fixes * Corrected gRPC message size limits for document downloads, preventing failures when processing larger identity verification images.
## 🚀 Release summary – API and platform contract updates **TL;DR:** This release includes several **breaking changes** that may require updates to your integration: * KYC/KYB schema enforcement (effective immediately) * JSON fields standardized to `snake_case` (transition period until **March 6, 2026**) * Banking payment instruction payload flattened * Stablecoin endpoint **renamed** from `/stablecoins/transfers` to `/conversions` (old path no longer supported) Plus: higher API key limits, clearer HTTP semantics, richer CryptoTransfer lifecycle, and performance improvements. If you’re unsure whether a change affects you, we’re happy to walk through your integration with you. ## ⚠️ Breaking changes ### 1. KYC/KYB schema enforcement (effective immediately) **What changed** * KYC/KYB submissions must now include a **front image of government ID** and **region code** on all addresses. * Legacy `kycLevel` and `kybLevel` fields have been removed from the schema. **Impact** * Requests that omit required fields or send removed fields now **fail validation**. **Action required** * Update payloads and mappings to always include the required fields. * Stop referencing `kycLevel` and `kybLevel` anywhere in your integration. ### 2. JSON field standardization to `snake_case` **What changed** * All JSON field names are now standardized to **`snake_case`** (for example, `identity_type`, `request_id`, `account_type`) to align with Protocol Buffers. **Transition period** * **Now – March 6, 2026:** Both `camelCase` and `snake_case` field names are accepted. * **After March 6, 2026:** Only `snake_case` field names are accepted. **Impact** * Requests using `camelCase` field names (for example, `identityType`, `requestId`, `kycStatus`) **after March 6, 2026** will fail validation. * Responses now return field names in `snake_case`. **Action required** * Update all request and response handling to use `snake_case`. * Common examples: * `identityType` → `identity_type` * `requestId` → `request_id` * `accountType` → `account_type` * `kycStatus` → `kyc_status` * `fiatOperationsEnabled` → `fiat_operations_enabled` * If you use generated clients, regenerate from the latest schema. ### 3. Banking payment instruction payload flattening **What changed** * Banking payment instruction JSON has been **flattened**; fields previously nested under `payment_instructions` are now top-level. **Before (nested):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "36zeVf290384Gw97eS7w5Aa2huL", "instructions": { "country": "US", "fiatCurrencySymbol": "USD", "name": "Johny Doe", "paymentMethod": "WIRE", "bank_account_details": { ... } } } ``` **After (flattened):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "36zeVf290384Gw97eS7w5Aa2huL", "country": "US", "fiat_currency_symbol": "USD", "name": "Johny Doe", "payment_method": "WIRE", "bank_account_details": { ... } } ``` **Impact** * Code that builds or parses the old nested `payment_instructions` object will break. **Action required** * Update request builders and response parsers to use the flattened structure. * Regenerate clients from the latest schema if you use codegen. ### 4. On-/off-ramp endpoint rename (`/stablecoins/transfers` → `/conversions`) **What changed** * The on-/off-ramp endpoint path has been **renamed** to `/conversions` to align with the unified Conversion lifecycle. **Impact** * Requests to `/stablecoins/transfers` now **fail**. `/conversions` is the required path going forward. **Action required** * Update all integrations to call `/conversions`. * If relevant, rename any internal references from "stablecoin transfers" to "conversions". * We can help confirm that all traffic is on `/conversions`. *** ## ✨ What’s new ### 1. Increased API key limits * API key limit increased to **50 keys per workspace**, making it easier to use per-service / per-environment keys, rotate keys more frequently, and separate vendor access. No changes are required for existing keys. ### 2. Enhanced CryptoTransfer lifecycle **What changed** * Crypto transfers now expose a richer lifecycle with more detailed statuses and events. **Statuses** * `ACCEPTED` – Validated, awaiting submission. * `SUBMITTED` – Submitted on-chain, waiting for confirmations. * `CONFIRMED` – Confirmed on-chain (terminal success). * `FAILED` – Failed (terminal; see `failure_details`). * `DROPPED` – Dropped (terminal). **Events** * `submitted` event when a transfer is first accepted and submitted. * Additional reconciliation events during confirmation. * Clearer `failed` events with `failure_details`. **Impact** * Any code that maps statuses or consumes webhooks may see new values and events. **Action required** * Ensure status mappings and webhook handlers cover all enum values and new events. *** ## ⚡ Improvements * **HTTP semantics:** Create operations now return **`201 Created`** instead of `200 OK`. If you assert on exact status codes, update checks to accept `201`. * **Read performance:** Read queries now route to read replicas for better performance. For strict write-then-read flows, consider a small retry or delay to handle occasional replication lag. *** ## 📘 Learn more Find deeper documentation and technical details on each endpoint: * [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) * [Create Payment Instructions](/v2/api-reference/payment-instructions/create-payment-instructions) * [Submit Stablecoin Conversion](/v2/api-reference/conversions/submit-conversion) * [Submit Cryptocurrency Transfers](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer)
## 🚀 Release summary - v2 cryptocurrency transfers **TL;DR:** Introducing an upgraded asynchronous cryptocurrency transfer API with parallel transaction support, built-in idempotency, and enhanced status tracking across supported blockchain networks. ## ✨ What's new The V2 Cryptocurrency Transfers API delivers a non-blocking, asynchronous architecture that enables faster integrations and supports concurrent transaction submissions - a significant improvement over our previous sequential processing model. ### Asynchronous transfer submission The new submit endpoint returns immediately after accepting your request, allowing your application to continue processing without waiting for blockchain confirmation. Transfers proceed through multiple stages - asset pricing checks, compliance verification, and blockchain confirmation - while you poll for status updates at your convenience via the GET endpoint. This separation makes GET the authoritative place to retrieve transfer status. And soon, we’ll be introducing webhook-based status notifications so you can receive updates automatically without polling. **Submit Request:** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl --location --request POST 'https://api.bastion.io/v2/crypto/transfers' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "requestId": "903c9e51-51af-4597-9174-0a93ca80dbac", "accountId": "36NxLzj97KjakwizwUt2erT3c1S", "destinationChain": "BASE_SEPOLIA", "currencySymbol": "ETH", "amount": "0.001", "destinationAddress": "0x6F4268e34965Fb49ED87b2DA5a82cbb06044b6C4" }' ``` **Submit Response (202 Accepted):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "36NypbAj5Ok4BCepBbMhZDEmf3C", "requestId": "903c9e51-51af-4597-9174-0a93ca80dbac" } ``` **Get Transfer Response:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "36NypbAj5Ok4BCepBbMhZDEmf3C", "requestId": "903c9e51-51af-4597-9174-0a93ca80dbac", "accountId": "36NxLzj97KjakwizwUt2erT3c1S", "accountAddress": "0xf655d368bc4D6f0004BD1550Bc45ce8260A1aA3c", "currencySymbol": "ETH", "amount": "0.001", "destinationAddress": "0x6F4268e34965Fb49ED87b2DA5a82cbb06044b6C4", "destinationChain": "BASE_SEPOLIA", "status": "CONFIRMED", "createdAt": "2025-12-04T15:18:28Z", "updatedAt": "2025-12-04T15:18:30Z", "transactionHash": "0xa23f89e5893a5ac73592e9cc81a9ff3bbb5c60d59dd09e37f2d03c4c824beb5e", "completedAt": "2025-12-04T15:18:30Z", "failureDetails": null, "events": [] } ``` ### Parallel transaction support For EVM-based chains, you can now submit multiple cryptocurrency transfers concurrently without worrying about transaction ordering. We handle EVM nonce serialization internally, ensuring all parallel submissions are processed correctly and confirmed on-chain in the proper sequence. Non-EVM chains already support parallel transaction submission by design, so this improvement primarily benefits EVM transfers. ### Built-in idempotency Every request requires a unique `requestId` (UUID) that serves as an idempotency key. If you submit the same `requestId` multiple times (whether due to network retries or concurrent submission bugs), we return the existing `transferId`, guaranteeing that only one transfer is ever created. Otherwise, if the transfer was already processed, repeated submissions return a `409 Conflict - DUPLICATE_TRANSACTION` error. ## 📘 Learn more Find deeper documentation and technical details on each endpoint: * [Submit Cryptocurrency Transfer →](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) * [Get Cryptocurrency Transfer →](/v2/api-reference/cryptocurrency-transfers/get-cryptocurrency-transfer) * [List Cryptocurrency Transfers →](/v2/api-reference/cryptocurrency-transfers/list-cryptocurrency-transfers) * [Supported Assets →](/v2/api-reference/assets/list-supported-assets)
## 🚀 Release summary - Tx limits and request signing **TL;DR:** Introducing secure request signing, compliance‑gated account creation, and new transaction limits to keep your platform interactions safe and reliable. *** ### ✨ What’s new Request signing, improved onboarding compliance, and new transaction limits deliver more secure and controlled API usage without disrupting existing integrations. * **Request signing** — You can now submit a public key to Bastion and use it to sign API requests. When an API key is paired with a public key, every call must include a signature to authenticate successfully. This feature is available in both Sandbox and Production environments. * **Account creation changes** — New accounts can only be created after completing successful compliance checks. This ensures all new users meet our security and regulatory standards before onboarding. * **Transaction limits** — Cryptocurrency transfer transaction limits are now *optionally* configurable to enhance security and help mitigate fraud. Transaction limit configuration in the Bastion Dashboard
*** ### 📘 Learn more Find deeper documentation and technical details on each feature here: * [Request Signing - Overview →](/v2/api-reference/authentication/request-signing) * [Request Signing - JWT Example →](/v2/api-reference/authentication/jwt-generation-example) * [Account Creation Flow →](/v2/api-reference/accounts/overview) * [Transaction Limits Docs →](/v2/api-reference/transaction-limits/overview) *** > **Released:** October 8, 2025 > > **Author:** Bastion Platform Team
# KYC and KYB requirements Source: https://docs.bastion.com/guides/compliance/kyc-requirements Personal, address, and business information Bastion requires to verify individual (KYC) and business (KYB) customers through the Compliance API. ## Overview To use Bastion's custodial wallets and access on/off-ramp conversions, customers must complete Know Your Customer (KYC) or Know Your Business (KYB) verification. Your platform collects this information and submits it to Bastion via the Compliance API. *** ## Individual customers (KYC) ### Personal information | Information | Field | | - | - | | Legal first name | `first_name` | | Middle name (if applicable) | `middle_name` | | Legal last name | `last_name` | | Date of birth (YYYY-MM-DD) | `date_of_birth` | | Email address | `email_address` | | Phone number (E.164 format) | `phone_number` | | Social Security Number (SSN) or ITIN for US residents and equivalent ID number in other jurisdictions | `tax_id_number` | | Nationality (ISO 3166-1 alpha-2) | `nationality` | | Country of birth (ISO 3166-1 alpha-2) | `country_of_birth` | | Additional government / national ID number (conditionally required) | `additional_id_number` | ### Address | Information | Field | | - | - | | Street address | `street_line1` | | Apartment, suite, etc. (if applicable) | `street_line2` | | City | `city` | | State or region code | `region_code` | | Postal/ZIP code | `postal_code` | | Country code (e.g., `US`) | `country` | ### Additional context | Information | Field | | - | - | | IP address at time of onboarding | `ip_address` | | Intended use of the account | `account_purpose` | | Source of funds (enum) | `source_of_funds` | | Occupation / industry category (enum) | `occupation` | *** ## Business customers (KYB) ### Business information | Information | Field | | - | - | | Legal business name | `name` | | Employer Identification Number (EIN) | `tax_id` | | Business registration number | `registration_number` | | Business email address | `email_address` | | Company website URL | `website` | | Description of business activities | `description` | | Legal entity type (e.g., `LLC`, `CORPORATION`) | `business_type` | | NAICS industry classification code | `business_industry_code` | | Date the business was incorporated | `date_of_incorporation` | ### Registered address | Information | Field | | - | - | | Street address | `street_line_1` | | City | `city` | | State or region code | `region_code` | | Postal/ZIP code | `postal_code` | | Country code (e.g., `US`) | `country` | ### Compliance information | Information | Field | | - | - | | Origin of business funds (e.g., `BUSINESS_LOANS`, `EQUITY_FUNDING`) | `source_of_funds` | | Intended use of the account (e.g., `TREASURY_MANAGEMENT`) | `account_purpose` | | Whether the business is a DAO | `is_dao` | | Whether the business transmits funds on behalf of customers | `transmits_customer_funds` | | Description of compliance/AML procedures | `compliance_screening_explanation` | ### Formation document | Information | Field | | - | - | | Type of document (`DOCUMENT_TYPE_FORMATION`) | `type` | | Country where the business was incorporated | `issuing_country` | | Date the document was issued | `issued_date` | | Copy of formation document (base64-encoded) | `file_data` | ### Ownership document | Information | Field | | - | - | | Type of document (`DOCUMENT_TYPE_OWNERSHIP`) | `type` | | Country that issued the document | `issuing_country` | | Date the document was issued | `issued_date` | | Copy of ownership document (base64-encoded) | `file_data` | ### Ultimate beneficial owners You must provide at least one beneficial owner. Each owner requires: #### Personal information | Information | Field | | - | - | | Legal first name | `first_name` | | Legal last name | `last_name` | | Date of birth (YYYY-MM-DD) | `birth_date` | | Email address | `email` | | Phone number (E.164 format) | `phone` | | Tax identification number (SSN for US) | `tax_identification_number` | | Title or role (e.g., `CEO`) | `control_person_title` | | Date relationship was established | `relationship_established_at` | #### Address | Information | Field | | - | - | | Street address | `street_line_1` | | City | `city` | | State or region code | `region_code` | | Postal/ZIP code | `postal_code` | | Country code (e.g., `US`) | `country` | #### Ownership & control information | Information | Field | | - | - | | Has at least 25% ownership of the business | `has_ownership` | | Is a control person (manages or directs the business) | `has_control` | | Is authorized to sign on behalf of the business | `is_signer` | #### Government ID | Information | Field | | - | - | | Type of ID (e.g., `DOCUMENT_TYPE_PASSPORT`) | `type` | | Country that issued the ID | `issuing_country` | | Date the ID was issued | `issued_date` | | Front image of ID (base64-encoded) | `file_data` | *** ## Document types When submitting identity documents, use the following document type enums: ### Individual identity documents (KYC) | Document type | Description | Requirements | | - | - | - | | `DOCUMENT_TYPE_PASSPORT` | Provide a valid, non-expired, color copy of the passport. The full photo page must be clearly visible. | Only the front of the ID is required. | | `DOCUMENT_TYPE_NATIONAL_ID` | Provide a valid, non-expired, color copy of a National ID document. | Both the front and back of the ID are required. | | `DOCUMENT_TYPE_DRIVER_LICENSE` | Provide a valid, non-expired, color copy of a Driver License. | Both the front and back of the ID are required. | | `DOCUMENT_TYPE_RESIDENCE_PERMIT` | Provide a valid, non-expired, color copy of a Residence Permit. | Both the front and back of the ID are required. | ### Business documents (KYB) | Document type | Description | | - | - | | `DOCUMENT_TYPE_FORMATION` | Provide a valid, signed copy of a document confirming the entity's legal existence and authority to operate, such as Articles of Incorporation, Certificate of Formation, Certificate of Organization, or equivalent corporate registration document issued by a government authority. | | `DOCUMENT_TYPE_OWNERSHIP` | Provide a current document evidencing the entity's ownership structure, such as a shareholder chart or entity organization chart. The document should clearly show all direct and, where applicable, indirect owners and their percentage ownership, sufficient to identify all ultimate beneficial owners. | | `DOCUMENT_TYPE_SOURCE_OF_FUNDS` | Provide a recent document that demonstrates where the business's funds are coming from, such as bank statements, tax returns, or audited financial statements. The documentation should clearly show the origin of the funds. | **Note:** For beneficial owners and authorized representatives in KYB submissions, use the individual identity document types (`DOCUMENT_TYPE_PASSPORT`, `DOCUMENT_TYPE_NATIONAL_ID`, `DOCUMENT_TYPE_DRIVER_LICENSE` or `DOCUMENT_TYPE_RESIDENCE_PERMIT`). *** ## Submitting compliance data Submit KYC/KYB data via the Compliance API: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/compliance ``` See the [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) or [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer) quickstarts for complete examples. *** ### Additional resources * [Submit Compliance Data API](/v2/api-reference/compliance/submit-compliance-data) * [Get Identity API](/v2/api-reference/identities/get-identity) # Onboard to Bastion for production access Source: https://docs.bastion.com/guides/compliance/onboard-to-bastion How to onboard with Bastion for production: contacting sales, going through compliance due diligence, and activating production access after approval. ## Bastion production onboarding To onboard with Bastion and gain access to a production account, contact your dedicated sales representative or reach out to us directly at [sales@bastion.com](mailto:sales@bastion.com). Our team initiates the compliance due diligence process, a critical step to ensure a secure and compliant partnership. The compliance due diligence process involves reviewing your organization's information to meet regulatory requirements. Our team guides you through the necessary steps and provides all required documentation. ### Compliance due diligence Bastion is a regulated money transmitter and therefore requires a compliance review of its customers. We typically request company information, ownership details, KYC for control persons, AML and sanctions policies, risk controls, and security attestations. ### Timeline Most reviews finish within 1-4 business days after you submit all materials. You can integrate in sandbox while we review, and production activates after approval. # Supported business industry codes Source: https://docs.bastion.com/guides/compliance/supported-business-industry-codes Full list of NAICS industry codes accepted for Bastion business (KYB) customers when submitting compliance data during identity onboarding. ## NAICS codes | Code | Industry description | | - | - | | 111120 | Oilseed and Grain Farming | | 111219 | Vegetable and Melon Farming | | 111336 | Fruit and Tree Nut Farming | | 111421 | Greenhouse, Nursery, and Floriculture Production | | 111998 | Other Crop Farming | | 112111 | Cattle Ranching and Farming | | 112210 | Hog and Pig Farming | | 112310 | Poultry and Egg Production | | 112410 | Sheep and Goat Farming | | 112519 | Aquaculture | | 112990 | Other Animal Production | | 113110 | Timber Tract Operations | | 113210 | Forest Nurseries and Gathering of Forest Products | | 113310 | Logging | | 114119 | Fishing | | 114210 | Hunting and Trapping | | 115113 | Support Activities for Crop Production | | 115210 | Support Activities for Animal Production | | 115310 | Support Activities for Forestry | | 211130 | Oil and Gas Extraction | | 212114 | Coal Mining | | 212290 | Metal Ore Mining | | 212390 | Nonmetallic Mineral Mining and Quarrying | | 213113 | Support Activities for Mining | | 221122 | Electric Power Generation, Transmission and Distribution | | 221210 | Natural Gas Distribution | | 221320 | Water, Sewage and Other Systems | | 236118 | Residential Building Construction | | 236210 | Nonresidential Building Construction | | 237130 | Utility System Construction | | 237210 | Land Subdivision | | 237310 | Highway, Street, and Bridge Construction | | 237990 | Other Heavy and Civil Engineering Construction | | 238191 | Foundation, Structure, and Building Exterior Contractors | | 238211 | Building Equipment Contractors | | 238391 | Building Finishing Contractors | | 238991 | Other Specialty Trade Contractors | | 311119 | Animal Food Manufacturing | | 311212 | Grain and Oilseed Milling | | 311314 | Sugar and Confectionery Product Manufacturing | | 311422 | Fruit and Vegetable Preserving and Specialty Food Manufacturing | | 311511 | Dairy Product Manufacturing | | 311612 | Animal Slaughtering and Processing | | 311710 | Seafood Product Preparation and Packaging | | 311811 | Bakeries and Tortilla Manufacturing | | 311999 | Other Food Manufacturing | | 312111 | Beverage Manufacturing | | 312230 | Tobacco Manufacturing | | 313110 | Fiber, Yarn, and Thread Mills | | 313240 | Fabric Mills | | 313310 | Textile and Fabric Finishing and Fabric Coating Mills | | 314120 | Textile Furnishings Mills | | 314999 | Other Textile Product Mills | | 315120 | Apparel Knitting Mills | | 315250 | Cut and Sew Apparel Manufacturing | | 315990 | Apparel Accessories and Other Apparel Manufacturing | | 316110 | Leather and Hide Tanning and Finishing | | 316210 | Footwear Manufacturing | | 316990 | Other Leather and Allied Product Manufacturing | | 321113 | Sawmills and Wood Preservation | | 321211 | Veneer, Plywood, and Engineered Wood Product Manufacturing | | 321918 | Other Wood Product Manufacturing | | 322120 | Pulp, Paper, and Paperboard Mills | | 322299 | Converted Paper Product Manufacturing | | 323120 | Printing and Related Support Activities | | 324110 | Petroleum and Coal Products Manufacturing | | 325180 | Basic Chemical Manufacturing | | 325220 | Resin, Synthetic Rubber, and Artificial Synthetic Fibers and Filaments Manufacturing | | 325320 | Pesticide, Fertilizer, and Other Agricultural Chemical Manufacturing | | 325412 | Pharmaceutical and Medicine Manufacturing | | 325510 | Paint, Coating, and Adhesive Manufacturing | | 325611 | Soap, Cleaning Compound, and Toilet Preparation Manufacturing | | 325998 | Other Chemical Product and Preparation Manufacturing | | 326111 | Plastics Product Manufacturing | | 326220 | Rubber Product Manufacturing | | 327120 | Clay Product and Refractory Manufacturing | | 327212 | Glass and Glass Product Manufacturing | | 327310 | Cement and Concrete Product Manufacturing | | 327410 | Lime and Gypsum Product Manufacturing | | 327999 | Other Nonmetallic Mineral Product Manufacturing | | 331110 | Iron and Steel Mills and Ferroalloy Manufacturing | | 331210 | Steel Product Manufacturing from Purchased Steel | | 331313 | Alumina and Aluminum Production and Processing | | 331410 | Nonferrous Metal (except Aluminum) Production and Processing | | 331511 | Foundries | | 332111 | Forging and Stamping | | 332215 | Cutlery and Handtool Manufacturing | | 332311 | Architectural and Structural Metals Manufacturing | | 332410 | Boiler, Tank, and Shipping Container Manufacturing | | 332510 | Hardware Manufacturing | | 332613 | Spring and Wire Product Manufacturing | | 332710 | Machine Shops; Turned Product; and Screw, Nut, and Bolt Manufacturing | | 332812 | Coating, Engraving, Heat Treating, and Allied Activities | | 332999 | Other Fabricated Metal Product Manufacturing | | 333111 | Agriculture, Construction, and Mining Machinery Manufacturing | | 333248 | Industrial Machinery Manufacturing | | 333310 | Commercial and Service Industry Machinery Manufacturing | | 333413 | Ventilation, Heating, Air-Conditioning, and Commercial Refrigeration Equipment Manufacturing | | 333517 | Metalworking Machinery Manufacturing | | 333611 | Engine, Turbine, and Power Transmission Equipment Manufacturing | | 333618 | Other General Purpose Machinery Manufacturing | | 334118 | Computer and Peripheral Equipment Manufacturing | | 334210 | Communications Equipment Manufacturing | | 334310 | Audio and Video Equipment Manufacturing | | 334413 | Semiconductor and Other Electronic Component Manufacturing | | 334511 | Navigational, Measuring, Electromedical, and Control Instruments Manufacturing | | 334610 | Manufacturing and Reproducing Magnetic and Optical Media | | 335131 | Electric Lighting Equipment Manufacturing | | 335220 | Household Appliance Manufacturing | | 335311 | Electrical Equipment Manufacturing | | 335999 | Other Electrical Equipment and Component Manufacturing | | 336110 | Motor Vehicle Manufacturing | | 336211 | Motor Vehicle Body and Trailer Manufacturing | | 336390 | Motor Vehicle Parts Manufacturing | | 336412 | Aerospace Product and Parts Manufacturing | | 336510 | Railroad Rolling Stock Manufacturing | | 336611 | Ship and Boat Building | | 336999 | Other Transportation Equipment Manufacturing | | 337122 | Household and Institutional Furniture and Kitchen Cabinet Manufacturing | | 337214 | Office Furniture (including Fixtures) Manufacturing | | 337920 | Other Furniture Related Product Manufacturing | | 339112 | Medical Equipment and Supplies Manufacturing | | 339999 | Other Miscellaneous Manufacturing | | 423120 | Motor Vehicle and Motor Vehicle Parts and Supplies Merchant Wholesalers | | 423220 | Furniture and Home Furnishing Merchant Wholesalers | | 423310 | Lumber and Other Construction Materials Merchant Wholesalers | | 423490 | Professional and Commercial Equipment and Supplies Merchant Wholesalers | | 423520 | Metal and Mineral (except Petroleum) Merchant Wholesalers | | 423620 | Household Appliances and Electrical and Electronic Goods Merchant Wholesalers | | 423710 | Hardware, and Plumbing and Heating Equipment and Supplies Merchant Wholesalers | | 423840 | Machinery, Equipment, and Supplies Merchant Wholesalers | | 423990 | Miscellaneous Durable Goods Merchant Wholesalers | | 424110 | Paper and Paper Product Merchant Wholesalers | | 424210 | Drugs and Druggists' Sundries Merchant Wholesalers | | 424310 | Apparel, Piece Goods, and Notions Merchant Wholesalers | | 424490 | Grocery and Related Product Merchant Wholesalers | | 424590 | Farm Product Raw Material Merchant Wholesalers | | 424690 | Chemical and Allied Products Merchant Wholesalers | | 424720 | Petroleum and Petroleum Products Merchant Wholesalers | | 424820 | Beer, Wine, and Distilled Alcoholic Beverage Merchant Wholesalers | | 424990 | Miscellaneous Nondurable Goods Merchant Wholesalers | | 425120 | Wholesale Electronic Markets and Agents and Brokers | | 441110 | Automobile Dealers | | 441227 | Other Motor Vehicle Dealers | | 441330 | Automotive Parts, Accessories, and Tire Stores | | 449110 | Furniture Stores | | 449129 | Home Furnishings Stores | | 449210 | Electronics and Appliance Stores | | 444180 | Building Material and Supplies Dealers | | 444240 | Lawn and Garden Equipment and Supplies Stores | | 445110 | Grocery Stores | | 445298 | Specialty Food Stores | | 445320 | Beer, Wine, and Liquor Stores | | 456110 | Health and Personal Care Stores | | 457110 | Gasoline Stations | | 458110 | Clothing Stores | | 458210 | Shoe Stores | | 458320 | Jewelry, Luggage, and Leather Goods Stores | | 459120 | Sporting Goods, Hobby, and Musical Instrument Stores | | 459210 | Book Stores and News Dealers | | 455110 | Department Stores | | 455219 | Other General Merchandise Stores | | 459310 | Florists | | 459410 | Office Supplies, Stationery, and Gift Stores | | 459510 | Used Merchandise Stores | | 459999 | Other Miscellaneous Store Retailers | | 454113 | Electronic Shopping and Mail-Order Houses | | 445132 | Vending Machine Operators | | 454390 | Direct Selling Establishments | | 481111 | Scheduled Air Transportation | | 481211 | Nonscheduled Air Transportation | | 482111 | Rail Transportation | | 483112 | Deep Sea, Coastal, and Great Lakes Water Transportation | | 483212 | Inland Water Transportation | | 484110 | General Freight Trucking | | 484220 | Specialized Freight Trucking | | 485119 | Urban Transit Systems | | 485210 | Interurban and Rural Bus Transportation | | 485310 | Taxi and Limousine Service | | 485410 | School and Employee Bus Transportation | | 485510 | Charter Bus Industry | | 485999 | Other Transit and Ground Passenger Transportation | | 486110 | Pipeline Transportation of Crude Oil | | 486210 | Pipeline Transportation of Natural Gas | | 486990 | Other Pipeline Transportation | | 487110 | Scenic and Sightseeing Transportation, Land | | 487210 | Scenic and Sightseeing Transportation, Water | | 487990 | Scenic and Sightseeing Transportation, Other | | 488190 | Support Activities for Air Transportation | | 488210 | Support Activities for Rail Transportation | | 488390 | Support Activities for Water Transportation | | 488490 | Support Activities for Road Transportation | | 488510 | Freight Transportation Arrangement | | 488999 | Other Support Activities for Transportation | | 492110 | Couriers and Express Delivery Services | | 492210 | Local Messengers and Local Delivery | | 493190 | Warehousing and Storage | | 513130 | Newspaper, Periodical, Book, and Directory Publishers | | 513210 | Software Publishers | | 512199 | Motion Picture and Video Industries | | 512290 | Sound Recording Industries | | 516120 | Radio and Television Broadcasting | | 515210 | Cable and Other Subscription Programming | | 517111 | Wired Telecommunications Carriers | | 517112 | Wireless Telecommunications Carriers (except Satellite) | | 517410 | Satellite Telecommunications | | 517810 | Other Telecommunications | | 518210 | Data Processing, Hosting, and Related Services | | 519290 | Other Information Services | | 521110 | Monetary Authorities – Central Bank | | 522130 | Depository Credit Intermediation | | 522298 | Nondepository Credit Intermediation | | 522390 | Activities Related to Credit Intermediation | | 523150 | Securities and Commodity Contracts Intermediation and Brokerage | | 523210 | Securities and Commodity Exchanges | | 523999 | Other Financial Investment Activities | | 524128 | Insurance Carriers | | 524298 | Agencies, Brokerages, and Other Insurance Related Activities | | 525190 | Insurance and Employee Benefit Funds | | 525910 | Other Investment Pools and Funds | | 531190 | Lessors of Real Estate | | 531210 | Offices of Real Estate Agents and Brokers | | 531390 | Activities Related to Real Estate | | 532111 | Automotive Equipment Rental and Leasing | | 532289 | Consumer Goods Rental | | 532310 | General Rental Centers | | 532490 | Commercial and Industrial Machinery and Equipment Rental and Leasing | | 533110 | Lessors of Nonfinancial Intangible Assets (except Copyrighted Works) | | 541199 | Legal Services | | 541213 | Accounting, Tax Preparation, Bookkeeping, and Payroll Services | | 541310 | Architectural, Engineering, and Related Services | | 541490 | Specialized Design Services | | 541512 | Computer Systems Design and Related Services | | 541618 | Management, Scientific, and Technical Consulting Services | | 541720 | Scientific Research and Development Services | | 541810 | Advertising, Public Relations, and Related Services | | 541690 | Other Professional, Scientific, and Technical Services | | 551114 | Management of Companies and Enterprises | | 561110 | Office Administrative Services | | 561210 | Facilities Support Services | | 561311 | Employment Services | | 561499 | Business Support Services | | 561599 | Travel Arrangement and Reservation Services | | 561611 | Investigation and Security Services | | 561790 | Services to Buildings and Dwellings | | 561990 | Other Support Services | | 562111 | Waste Collection | | 562211 | Waste Treatment and Disposal | | 562998 | Remediation and Other Waste Management Services | | 611110 | Elementary and Secondary Schools | | 611210 | Junior Colleges | | 611310 | Colleges, Universities, and Professional Schools | | 611420 | Business Schools and Computer and Management Training | | 611519 | Technical and Trade Schools | | 611699 | Other Schools and Instruction | | 611710 | Educational Support Services | | 621111 | Offices of Physicians | | 621210 | Offices of Dentists | | 621399 | Offices of Other Health Practitioners | | 621498 | Outpatient Care Centers | | 621511 | Medical and Diagnostic Laboratories | | 621610 | Home Health Care Services | | 621999 | Other Ambulatory Health Care Services | | 622110 | General Medical and Surgical Hospitals | | 622210 | Psychiatric and Substance Abuse Hospitals | | 622310 | Specialty Hospitals | | 623110 | Nursing Care Facilities | | 623210 | Residential Disability and Mental Health Facilities | | 623311 | Assisted Living Facilities for the Elderly | | 623990 | Other Residential Care Facilities | | 624190 | Individual and Family Services | | 624230 | Community Food and Housing Services | | 624310 | Vocational Rehabilitation Services | | 624410 | Child Day Care Services | | 711190 | Performing Arts Companies | | 711219 | Spectator Sports | | 711310 | Promoters of Performing Arts and Sports | | 711410 | Agents and Managers | | 711510 | Independent Artists, Writers, and Performers | | 712120 | Museums and Historical Sites | | 713110 | Amusement Parks and Arcades | | 713290 | Gambling Industries | | 713990 | Other Amusement and Recreation Industries | | 721199 | Traveler Accommodation | | 721211 | RV Parks and Recreational Camps | | 721310 | Rooming and Boarding Houses | | 722310 | Special Food Services | | 722410 | Drinking Places | | 722511 | Restaurants and Other Eating Places | | 811198 | Automotive Repair and Maintenance | | 811210 | Electronic and Precision Equipment Repair | | 811310 | Industrial Machinery Repair | | 811411 | Personal and Household Goods Repair | | 812199 | Personal Care Services | | 812210 | Death Care Services | | 812320 | Dry Cleaning and Laundry Services | | 812990 | Other Personal Services | | 813110 | Religious Organizations | | 813211 | Grantmaking and Giving Services | | 813319 | Social Advocacy Organizations | | 813410 | Civic and Social Organizations | | 813930 | Business and Professional Organizations | | 814110 | Private Households | | 921140 | Executive and Legislative Government Support | | 922190 | Justice and Public Safety Activities | | 923130 | Administration of Human Resource Programs | | 924110 | Environmental Quality Programs | | 925120 | Housing and Community Development | | 926110 | Economic Program Administration | | 927110 | Space Research and Technology | | 928110 | National Security and International Affairs | # Supported chains and assets Source: https://docs.bastion.com/guides/concepts/chains-and-assets Blockchains, stablecoins, and base assets Bastion supports across EVM and SVM mainnets and testnets, with chain IDs and block explorers. Bastion supports multiple blockchain networks across different chain families, enabling cross-chain stablecoin custody, transfers, and conversions. ## Supported mainnets The following mainnet networks are fully supported in production: ### EVM chains | Chain | Chain ID | Label | Base Asset | Decimals | Block Explorer | | - | - | - | - | - | - | | Ethereum | 1 | Ethereum | ETH | 18 | etherscan.io | | Polygon | 137 | Polygon | POL | 18 | polygonscan.com | | Base | 8453 | Base Mainnet | ETH | 18 | basescan.org | | Optimism | 10 | Optimism | ETH | 18 | optimistic.etherscan.io | ### SVM chains | Chain | Label | Base Asset | Decimals | Block Explorer | | - | - | - | - | - | | Solana | Solana Mainnet | SOL | 9 | explorer.solana.com | ## Supported testnets The following testnet networks are available for development and testing: ### EVM testnets | Chain | Chain ID | Label | Base Asset | Block Explorer | | - | - | - | - | - | | Ethereum Sepolia | 11155111 | Sepolia | ETH | sepolia.etherscan.io | | Polygon Amoy | 80002 | Polygon Amoy | POL | amoy.polygonscan.com | | Base Sepolia | 84532 | Base Sepolia | ETH | sepolia.basescan.org | | Optimism Sepolia | 11155420 | Optimism Sepolia | ETH | sepolia-optimism.etherscan.io | ### SVM testnets | Chain | Label | Base Asset | Block Explorer | | - | - | - | - | | Solana Devnet | Solana Devnet | SOL | explorer.solana.com | ## Chain identifiers Each chain is referenced using a standardized enum value: | Mainnet Enum | Testnet Enum | | - | - | | `ETHEREUM_MAINNET` | `ETHEREUM_TESTNET` | | `POLYGON_MAINNET` | `POLYGON_AMOY` | | `BASE_MAINNET` | `BASE_SEPOLIA` | | `OPTIMISM_MAINNET` | `OPTIMISM_SEPOLIA` | | `SOLANA_MAINNET` | `SOLANA_DEVNET` | ## Chain families Bastion groups blockchains into families based on their underlying virtual machine architecture: | Chain Family | Identifier | Description | | - | - | - | | EVM | `CHAIN_FAMILY_EVM` | Ethereum Virtual Machine compatible chains | | SVM | `CHAIN_FAMILY_SVM` | Solana Virtual Machine compatible chains | ## Supported stablecoins Bastion provides support for the following stablecoins. | Asset | Ethereum | Polygon | Base | Optimism | Solana | | - | - | - | - | - | - | | USDC | Yes | Yes | Yes | Yes | Yes | | USDT | Yes | No | No | No | No | ## On/off-ramp conversions support Custody and crypto transfers work on every chain above. Conversions work on a subset, and the subset depends on the direction and jurisdiction. | Conversion | Chains | Stablecoins | | - | - | - | | US on-ramp | Ethereum, Solana, Base | USDC | | US off-ramp | Ethereum, Solana, Base | USDC, and USDT on Ethereum | | International off-ramp | Ethereum, Solana, Base, Polygon | USDC | Optimism supports custody and transfers only. For payout countries, currencies, and rails supported for off-ramps, see [Supported off-ramp payout countries](/guides/getting-started/supported-off-ramp-payout-countries). ## Base assets and gas Bastion sponsors gas by default on all supported chains. Your customers do not need to hold native tokens to send transfers; Bastion covers transaction fees automatically. Gas sponsorship is opt-out, not opt-in. If you need to disable it, contact your Bastion account team. If gas sponsorship is disabled, wallets will need to hold the chain's native asset to cover network fees: | Asset | Symbol | Decimals | Chains | | - | - | - | - | | Ether | ETH | 18 | Ethereum, Base, Optimism | | POL | POL | 18 | Polygon | | Solana | SOL | 9 | Solana | Base assets are not available for conversions or other value-transfer services. Bastion's custody and conversion products are built around stablecoins. ## Wallet address support Bastion automatically generates wallet addresses for supported chain families: | Chain Family | Address Format | Example | | - | - | - | | EVM | 40 hex characters with `0x` prefix | `0x742d35Cc6634C0532925a3b844Bc9e7595f...` | | SVM | Base58 encoded public key | `7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKT...` | Each account receives addresses for both EVM and SVM chain families, enabling multi-chain operations. ## Environment behavior Chain availability is environment-dependent: | Environment | Available Chains | | - | - | | Production | Mainnets and Testnets | | Sandbox / Development | Testnets only | Mainnet chains are only accessible in the production environment. Development and sandbox environments use corresponding testnets. ## Coming soon ### USDT for international off-ramps In development. USDT is supported for US off-ramps on Ethereum today. ### Avalanche Avalanche C-Chain support is currently in development: | Chain | Chain ID | Label | Base Asset | Decimals | Status | | - | - | - | - | - | - | | Avalanche C-Chain | 43114 | Avalanche | AVAX | 18 | In development | | Avalanche Fuji | 43113 | Avalanche Fuji | AVAX | 18 | In development | ## API chain parameters When making API calls, use the chain enum values: ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "chain": "SOLANA_MAINNET" } ```
# Crypto transfers and deposits Source: https://docs.bastion.com/guides/concepts/crypto-transfers How on-chain and off-chain crypto transfers move between Bastion accounts, their status lifecycle, and how deposit webhooks credit recipient balances. A crypto transfer moves digital assets from one Bastion account to a destination. Transfers operate on Bastion-custodied accounts and are submitted through a single endpoint ([`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer)). If you're not familiar with how Bastion custody works, start with [Crypto custody](/guides/concepts/custody) before continuing. ## Transfer modes There are two ways to send crypto from a Bastion account. Which modes are available depends on the account type and where the funds are going. | Transfer mode | What it does | Destination field | Account types | Settlement | | - | - | - | - | - | | On-chain | Sends assets through the blockchain to a wallet address (external wallet, exchange, or Bastion deposit address). Gas fees sponsored by Bastion. | `address_destination` (wallet address + chain) | Segregated or omnibus | After block confirmation | | Off-chain | Moves assets instantly on Bastion's internal ledger, with no blockchain transaction. No gas fees, no chain selection. | `account_destination` (recipient's account ID) | Omnibus to omnibus only (same platform) | Instant | If your platform uses segregated wallets, all transfers are on-chain. If your platform uses omnibus accounts, you can use either mode depending on the destination. ## Asynchronous processing Crypto transfers are processed asynchronously. Submitting a transfer with [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) returns `202 Accepted` with `{ id, request_id }`. The `id` is the transfer identifier, and `request_id` is the idempotency key you supplied. Track the transfer as it moves through Bastion's processing queue by polling [`GET /v2/crypto/transfers/{transfer_id}`](/v2/api-reference/cryptocurrency-transfers/get-cryptocurrency-transfer) with that `id`, or by listening for `submit_cryptocurrency_transfer_v2` webhooks. A transfer moves through `ACCEPTED` → `SUBMITTED` → `CONFIRMED`, with `FAILED`, `DROPPED`, and `CANCELED` as terminal error states. Each `submit_cryptocurrency_transfer_v2` webhook includes `id`, `request_id`, and `status`. Deduplicate updates on `id` plus `status` and keep your handler idempotent, since deliveries can repeat. ## Deposits A deposit is the receiving side of a transfer. Crypto can arrive at a Bastion account from an external wallet, an exchange, or another account on the same platform. Bastion detects the arrival, runs compliance screening, and credits the recipient's balance. Deposits are delivered to your platform via `deposit_v2` webhooks. The `source_type` field indicates where the deposit came from: | Source type | Origin | When it occurs | | - | - | - | | `ON_CHAIN` | External wallet or exchange | An on-chain transaction is confirmed on the blockchain | | `ACCOUNT` | Another account on the same platform (omnibus only) | An off-chain transfer completes between two omnibus accounts | Deposits from on-chain transfers include `source_address`, `destination_chain`, and `transaction_hash`. Deposits from off-chain transfers include `source_account_id`, and the chain and hash fields are empty (`CHAIN_UNSPECIFIED`, empty string). ## How transfers and deposits relate A transfer and a deposit are two sides of the same movement. When a transfer completes, two events fire: * The **sender** receives a `submit_cryptocurrency_transfer_v2` webhook * The **recipient** receives a `deposit_v2` webhook These are separate events on separate accounts. Your platform may handle one or both depending on whether the sender, the recipient, or both are on your platform. ## Transaction limit policies Your platform can define limits on crypto transfers to manage risk. Transaction limit policies are configured through the Bastion Admin Dashboard and apply to transfers submitted via the crypto transfers endpoint. Policies are defined by four properties: | Property | Description | | - | - | | Scope | Who the policy applies to: all identities in your app (app-scoped), or a specific identity (identity-scoped) | | Evaluation window | What transactions are considered: the current transaction only (per-transaction), or the cumulative value over the past week | | Threshold type | What happens when the limit is breached: block the transaction and send a webhook (hard), or send a webhook without blocking (soft) | | Threshold amount | The minimum amount (in USD) that triggers the policy | Up to four policies may apply to a given identity, one per combination of evaluation window and threshold type. When both an app-scoped and identity-scoped policy exist with the same evaluation window and threshold type, the identity-scoped policy takes precedence. For full configuration details, see [Transaction limit policies](/v2/api-reference/transaction-limits/overview). ## Omnibus withdrawal amount limits Separate from transaction limit policies, Bastion checks on-chain withdrawals from omnibus accounts against per-token minimum and maximum amounts. Bastion rejects an out-of-bounds withdrawal at submission with HTTP `400` and error code `INVALID_INPUT`, with the token's limits in the error details. Off-chain transfers between accounts are not subject to these limits. See [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain#omnibus-withdrawal-amount-limits) for the error shape. ## Related documentation * **Concepts** * [Custody](/guides/concepts/custody) * [Identity and account](/guides/concepts/identities-and-accounts) * [Supported chains and assets](/guides/concepts/chains-and-assets) * **Getting started** * [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain) * [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain) * [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits) # Crypto custody Source: https://docs.bastion.com/guides/concepts/custody How Bastion's custody signs transactions in AWS Nitro Enclaves without exposing private keys, across segregated and omnibus accounts, with sponsored gas. Bastion's embedded custody enables platforms to create and manage wallets for their users without handling private keys. Every wallet is backed by secure hardware, with transactions signed on-demand within AWS Nitro Enclaves. You own the user experience. Bastion handles key management, transaction signing, gas sponsorship, and multi-chain support. If you're not familiar with identities and accounts, start with [Identities and accounts](/guides/concepts/identities-and-accounts) before continuing. ## Account types Bastion supports two custody models. Your platform uses one or both depending on your use case. | Account type | How assets are held | Balance model | Key characteristic | | - | - | - | - | | Segregated (`SEGREGATED`) | Each customer gets their own on-chain wallet addresses | Chain-specific (e.g., USDC on Solana, USDC on Base tracked separately) | Customers have direct on-chain addresses across multiple chains | | Omnibus (`OMNIBUS`) | Customer funds are pooled in a custody vault. Bastion tracks individual balances via an internal ledger. | Chain-agnostic (e.g., USDC as a single balance regardless of chain) | Supports instant, fee-free off-chain transfers between accounts on the same platform | Each account gets its own dedicated cryptographic keys and blockchain wallet address. Funds are not commingled and are independently verifiable on-chain. Funds are pooled in a custody vault and tracked per account on a secure double-entry ledger. Transactions settle off-chain instantly; those that must go on-chain are routed automatically. Both account types are fully custodial under Bastion's US regulated licensing. Your platform interacts with the same APIs regardless of account type. ## Balances Balances are returned in atomic units as strings. Use the `decimals` field to convert to a human-readable amount (e.g., `"975000000"` with `decimals: 6` = 975.00 USDC). The balance response shape differs by account type: **Segregated accounts** return chain-specific balances with `type`, `chain`, and `contract_address` fields. A customer with USDC on both Solana and Base will see two separate balance entries. **Omnibus accounts** return a single chain-agnostic balance with `type: OMNIBUS` and `chain: CHAIN_UNSPECIFIED`. In both cases, the `available` field reflects the amount the customer can use for transfers or conversions. The `total` field may differ from `available` if funds are held for a pending operation. ## Gas sponsorship Bastion sponsors gas fees by default on all supported chains. Your customers do not need to hold native tokens (SOL, ETH, etc.) to send transfers. Gas sponsorship is opt-out, not opt-in. Contact your Bastion account team to disable. ## What you can do with custody Bastion custody is a prerequisite for all financial operations on the platform. With custodied accounts, your platform can: * Submit crypto transfers (on-chain and off-chain). See [Crypto transfers](/guides/concepts/crypto-transfers). * Process fiat-to-stablecoin conversions (on-ramps and off-ramps). See [Stablecoin conversions](/guides/concepts/stablecoin-conversions). * Receive incoming crypto deposits from external wallets or other accounts. * Query balances, transaction history, and transfer status in real time. * Receive webhook notifications for transfers, deposits, and account events. ## Related documentation * **Concepts** * [Identity and account](/guides/concepts/identities-and-accounts) * [Crypto transfers](/guides/concepts/crypto-transfers) * [Stablecoin conversions](/guides/concepts/stablecoin-conversions) * [Supported chains and assets](/guides/concepts/chains-and-assets) * **Getting started** * [Work with omnibus accounts](/guides/quickstarts/work-with-omnibus-accounts) * [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain) * [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain) * [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits) # Identities and accounts Source: https://docs.bastion.com/guides/concepts/identities-and-accounts How Bastion models customers as Identities (KYC/KYB anchor) and Accounts (wallets and balances), including status fields, sub-statuses, and permissions. Bastion uses a two-tier hierarchy to manage customers and their financial operations: **Identities** at the top level and **Accounts** beneath them. This separation provides flexibility for compliance, multi-wallet scenarios, and organizational structures. ## Identity An **Identity** represents a verified individual or business entity in the Bastion system. It serves as the compliance and regulatory anchor for all financial activity. | Aspect | Description | | - | - | | Purpose | Acts as the subject of KYC (Know Your Customer) or KYB (Know Your Business) verification | | Compliance | Stores verification status, jurisdiction information, and regulatory checks | | Ownership | Can own multiple Accounts for different purposes | | Types | `INDIVIDUAL` for personal customers, `BUSINESS` for corporate entities | Key properties of an Identity: | Property | Description | | - | - | | `identity_id` | Unique identifier assigned by Bastion | | `type` | Either `INDIVIDUAL` or `BUSINESS` | | `status` | Current compliance status (`ACTIVE`, `PENDING_ONBOARDING`, `REJECTED`, `SUSPENDED`, `TERMINATED`) | | `sub_status` | Finer-grained detail within a `status`. Populated during `PENDING_ONBOARDING` (compliance running, IDV steps, RFI), and can also carry `RFI_REQUIRED` on an `ACTIVE` or `SUSPENDED` identity when Compliance raises a post-onboarding request for information. Empty/unspecified otherwise. | | `fiat_operations_enabled` | Whether the identity has passed compliance and can perform fiat operations (on/off-ramp conversions) | | `allowed_to_transact_fungibles` | Whether the identity can use crypto transfers. Automatically set to `true` once sanctions and jurisdiction checks pass. | Each identity must have a unique email address. Bastion does not allow the same email address to be used across multiple identities for stablecoin conversions. ### `identity.status` values | Value | Meaning | | - | - | | `PENDING_ONBOARDING` | Identity created; onboarding workflow in progress. See `sub_status` for the precise step. | | `ACTIVE` | KYC/KYB approved, identity is able to transact. | | `SUSPENDED` | Previously active; temporarily suspended by Bastion Compliance. | | `REJECTED` | KYC/KYB denied during onboarding. Identity is not able to transact or proceed with account opening. | | `TERMINATED` | Identity and all associated accounts are permanently closed. | ### `identity.sub_status` values | Value | Meaning | | - | - | | `CREATED` | Identity exists; no compliance work has started yet. | | `COMPLIANCE_CHECKS_IN_PROGRESS` | KYC/KYB checks running. | | `IDV_REQUIRED` | Identity-document verification needs to be initiated. IDV will only be required for KYC / individual identities. | | `IDV_REQUESTED` | IDV link sent to the user; awaiting completion. IDV will only be required for KYC / individual identities. | | `IDV_IN_PROGRESS` | User has started but not finished IDV. IDV will only be required for KYC / individual identities. | | `IDV_EXPIRED` | IDV session expired before completion; needs to be re-requested. IDV will only be required for KYC / individual identities. | | `RFI_REQUIRED` | Request-for-information initiated by Bastion (additional documents/data). Bastion can raise it at any step of `PENDING_ONBOARDING`, and on an `ACTIVE` or `SUSPENDED` identity. | Every sub-status except `RFI_REQUIRED` only appears while `status` is `PENDING_ONBOARDING`. `RFI_REQUIRED` can also be set on an `ACTIVE` or `SUSPENDED` identity when Compliance requests additional information after onboarding. An `ACTIVE` identity keeps transacting while the RFI is open, and `sub_status` clears once it is fulfilled. #### How an RFI interacts with other changes * **During onboarding**, Bastion can raise an RFI at any step. When it is fulfilled, `sub_status` returns to the step the identity was at, or to the step it has reached since, for example `IDV_REQUIRED`. * **Bastion accepts resubmitted KYC/KYB data** while an RFI is open. `sub_status` stays `RFI_REQUIRED` until the RFI is fulfilled, and no `identity_status_update` webhook fires for the resubmission. * **Any change of `status`** (approval, rejection, suspension, reinstatement or termination) closes an open RFI and clears `RFI_REQUIRED`. The exception is a reinstatement that Bastion makes with `sub_status` `RFI_REQUIRED`: it keeps an open RFI open, or opens a new one. ## Account An **Account** belongs to an Identity and is the operational unit for financial activity. This is where balances live, transactions originate, and wallet addresses are managed. | Aspect | Description | | - | - | | Purpose | Holds balances and initiates transactions | | Wallets | Each account receives wallet addresses for supported blockchains | | Flexibility | An Identity can have multiple Accounts (e.g., for different currencies, use cases, or sub-entities) | | Isolation | Balances and transaction history are isolated per Account | Key properties of an Account: | Property | Description | | - | - | | `account_id` | Unique identifier for the account | | `identity_id` | The parent Identity this account belongs to | | `account_type` | Either `SEGREGATED` or `OMNIBUS`. See [Account types](/guides/concepts/custody#account-types). | | `addresses` | Wallet addresses mapped to supported chains | | `account_status` | Current account status (`ACTIVE`, `FROZEN`, `CLOSED`) | ### `account.status` values | Value | Meaning | | - | - | | `ACTIVE` | Account is cleared to transact and usable. | | `FROZEN` | Account is blocked (derived from a SUSPENDED parent identity). | | `CLOSED` | Account is permanently closed (derived from a TERMINATED parent identity). | ## How identities and accounts relate An Identity owns one or more Accounts. Each Account has its own balances, wallet addresses, and transaction history, isolated from other Accounts under the same Identity. An identity record owns one or more accounts, each with its own balances, wallets, and transactions ## Account types Every account is one of two types. `SEGREGATED` accounts hold their own on-chain wallet addresses with chain-specific balances. `OMNIBUS` accounts pool funds in a custody vault, and Bastion tracks each account's balance on an internal ledger. Both are fully custodial under Bastion's US regulated licensing, and your platform uses the same APIs either way. See [Custody](/guides/concepts/custody#account-types) for how each model holds assets and how balances are returned. ## Common patterns for multiple accounts | Pattern | Description | | - | - | | Multi-currency | Separate accounts for different fiat currency settlements | | Segregated funds | Isolate customer funds from operational funds | | Sub-entities | A business Identity with accounts for each department or subsidiary | | Testing | Maintain separate accounts for sandbox and production | ## Compliance An Identity must pass compliance verification (KYC for individuals, KYB for businesses) before accounts can be created or financial operations can begin. Identity lifecycle: create identity, submit compliance, await approval, create accounts, transact * Identity `status` must be `ACTIVE` before creating an account. * `allowed_to_transact_fungibles` must be `true` before submitting crypto transfers. * `fiat_operations_enabled` must be `true` before initiating on/off-ramp conversions. * Compliance requirements vary by jurisdiction and identity type. Business identities typically require more documentation (articles of incorporation, beneficial owner information). * Some operations may require additional verification even after initial approval. For step-by-step onboarding instructions, see [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) or [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer). ## Related documentation * **Concepts** * [Custody](/guides/concepts/custody) * [Crypto transfers](/guides/concepts/crypto-transfers) * [Stablecoin conversions](/guides/concepts/stablecoin-conversions) * **Getting started** * [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) * [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer) * **API reference** * [Accounts](/v2/api-reference/accounts/overview) * [Identities](/v2/api-reference/identities/overview) # Stablecoin on-ramp and off-ramp conversions Source: https://docs.bastion.com/guides/concepts/stablecoin-conversions How Bastion converts between fiat and stablecoins: virtual accounts for on-ramps, payment instructions for off-ramps, quotes, and conversion fees. ## What is a conversion? A conversion moves money between fiat and stablecoins. Stablecoins are held in Bastion custody. Fiat moves over traditional banking rails through Bastion's partner bank. Conversions operate on Bastion identities and accounts. If those are new to you, start with [Identities and accounts](/guides/concepts/identities-and-accounts). | Direction | What happens | | - | - | | On-ramp | A customer wires USD to Bastion, and USDC is credited to their account | | Off-ramp | Stablecoin is debited from a customer's account, and fiat is paid into a bank account | ## Who can convert The account holder must be a U.S. person. Their account must be in Bastion custody, tied to an approved identity that has passed KYC or KYB and sanctions checks. Where the money can go is a separate question. An off-ramp can pay out to a bank account in the United States or in 60+ other countries. On-ramps only work from a U.S. bank account. See [Jurisdiction overview](/guides/getting-started/jurisdiction-overview). ## Two kinds of bank account Money in and money out use different objects, and they belong to different parties. | | On-ramp (money in) | Off-ramp (money out) | | - | - | - | | What you use | Virtual account | Payment instructions | | Whose bank account | An account at Bastion's partner bank, assigned to your customer | The recipient's own bank account | ### Virtual account A virtual account is a deposit destination at Bastion's partner bank, dedicated to one customer, asset, chain, and destination wallet. Each one has its own account number, so an incoming wire reaches the right customer on the account number alone. Creating one returns the bank details your customer needs to send the wire. A customer can have several, one per asset and chain they want to receive, and each can be reused for any number of deposits. ### Payment instructions Payment instructions are a saved bank account to pay out to. You register one per bank account and reuse it across withdrawals. They belong to an identity rather than to a specific account. Each one declares whose account it is. First-party means the customer's own bank account; third-party means someone else's. US off-ramps are first-party only. International off-ramps allow both, and the recipient can be a person or a business. ## Quote A quote is a priced conversion: the exchange rate, the fees, the total debited from the customer's wallet, and the smallest and largest amounts the corridor accepts. International off-ramps are always priced by a quote, and each conversion references the quote it came from. US off-ramps are not quoted, because they convert one-for-one and carry a fixed wire fee. A quote is an estimate rather than a lock. The rate applied at settlement can differ from the rate quoted. ## Fees Two fees can appear on a conversion. The platform fee is the cost of running the transaction. The developer fee is a markup you set and collect, available on international off-ramps only. The fee model decides who absorbs them: | Fee model | Effect | | - | - | | Deducted | Fees come out of the amount sent, so the recipient receives less than that amount | | On top | Fees are added to it, so the customer's wallet is debited more and the recipient receives the full amount | US off-ramps are always deducted. International off-ramps support either, set per integration.
# Stablecoin issuance concepts Source: https://docs.bastion.com/guides/concepts/stablecoin-issuance How Bastion-issued stablecoins are minted from USD wires and redeemed back to USD, including mint destination addresses, memo IDs, and redemption bank accounts. ## Overview | Type | Direction | What happens | | - | - | - | | **Mint** | USD → Stablecoin | You send a wire (with memo ID) and receive stablecoin in a registered mint destination address | | **Redemption** | Stablecoin → USD | You send stablecoin to a redemption address; Bastion burns it and sends a wire payout to your registered bank account | *** ## Eligibility Reach out to your Bastion representative to gain access to Issuance API flows. * An approved **Business** identity. * Note that Issuance is not supported for **Individual** identities at this moment. *** ## Core concepts ### Mint destination address A **mint destination address** is a blockchain address that you register so it can receive newly minted stablecoins for an identity. When you create a mint address, you provide the on-chain address, the chain, a label, and the identity ID. * Linked to an **identity** * Required before creating **mint instructions**. Mint instructions reference a mint destination address by ID *** ### Mint instructions When you create mint instructions, Bastion returns **mint instructions**: the bank account details where you must wire funds and a **memo ID** that must be included in the wire. | Field | Description | | - | - | | `memo_id` | **Required memo**. Must be included in the wire. Bastion uses the memo ID provided on the wire to identify the sender and intent of the mint request | | `bank_name` | Receiving bank name | | `bank_account_number` | Account number for the wire (Bastion's Stablecoin Bank Account) | | `bank_routing_number` | Routing number | | `bank_beneficiary_name` | Beneficiary on the account | The **memo\_id** links the incoming wire to the mint. Without it, funds cannot be matched to a mint request. Mint instructions are tied to one **identity** and one **mint destination address**. You must register that mint destination address before creating mint instructions. *** ### Bank account A **bank account** is a saved bank account that you provide to Bastion. You use a saved bank account when creating the redemption instructions covered below. * Linked to an **identity** * Create once per bank account destination * An identity can have multiple bank accounts for different banks or purposes * Required before creating redemption instructions *** ### Redemption instructions **Redemption instructions** define where Bastion sends USD when you redeem stablecoins. They are tied to one **bank account** and one **identity**, and they produce a **redemption address** on a specific chain. * The **redemption address** is the on-chain address where you send stablecoins to initiate a redemption * When you send a Bastion Issued Stablecoin to that address, Bastion burns the stablecoins and wires the equivalent USD to the bank account tied to the redemption instructions *** ## Mint flow (USD -> stablecoin) High-level flow to mint Bastion Issued Stablecoin: 1. **Create a mint destination address:** Call [Create Mint Destination Address](/v2/api-reference/issuance/create-mint-destination-address) and provide a wallet address to receive minted stablecoins 2. **Create mint instructions:** Call [Create Mint Instructions](/v2/api-reference/issuance/create-mint-instructions) and provide the mint destination address. Save the returned wire deposit details and unique memo ID 3. **Send the wire:** Send a USD wire from your bank to the account in the mint instructions, including the **memo ID** in the wire details. 4. **Monitor status** — Via webhook or polling on [List Mints](/v2/api-reference/issuance/list-mints) until status `COMPLETED`. Bastion mints stablecoin to the specified destination wallet *** ## Redemption flow (stablecoin -> USD) High-level flow to redeem Bastion Issued Stablecoin: 1. **Register a bank account:** Call [Create Bank Account](/v2/api-reference/issuance/create-bank-account) and provide destination bank account details 2. **Create redemption instructions:** Call [Create Redemption Instructions](/v2/api-reference/issuance/create-redemption-instructions) and receive the **redemption address** 3. **Send stablecoin to the redemption address:** Send stablecoin from your wallet to the **redemption address**. This will initiate a redemption request 4. **Monitor status** — Via webhook or polling on [List Redemptions](/v2/api-reference/issuance/list-redemptions) until status `COMPLETED`. Bastion sends USD to the specified bank account. *** # Create an account in the Bastion Dashboard Source: https://docs.bastion.com/guides/dashboard/creating-accounts Step-by-step guide to creating a Bastion account under an approved identity in the Dashboard, viewing its wallet addresses, and checking its balances. ## Overview An **account** is an on-chain wallet associated with an identity. Accounts hold crypto assets, receive transfers, and are used to send cryptocurrency. Each identity can have multiple accounts. *** ### Prerequisites * A Bastion identity that has completed KYC / KYB checks *** ### Steps #### 1. Navigate to an approved identity From the Bastion Dashboard sidebar, select **Identities** and select the approved identity. #### 2. Select "Create Account" Select the **Create Account** button in the top-right corner of the page. ![Creating Accounts screenshot 1](https://docs-cdn.bastion.com/0cf9e22d9671a393a414e9fe0e4faa7d6bfb0a882a79139727fd31750f355255.png) #### 3. Account created The created account appears, showing the associated wallet addresses. * Provide the associated wallet addresses to a sender to receive supported cryptocurrencies ![Creating Accounts screenshot 2](https://docs-cdn.bastion.com/82291a68812c5100200bf81d4a2d3711e1a60b48c8a46b50e5835083f32c3b5a.png) #### 4. View account balances Select the account row to surface the account balance modal on the right. ![Creating Accounts screenshot 3](https://docs-cdn.bastion.com/73adc5ab4d4df96e241b74c42316791741cb2895beff3f43f29e4ae4ef6f52c5.png) # Create an identity in the Bastion Dashboard Source: https://docs.bastion.com/guides/dashboard/creating-identities Step-by-step guide to creating individual and business identities in the Bastion Dashboard, adding internal labels, and submitting KYC or KYB data. ## Overview An **identity** is the foundational record in Bastion representing an end user (individual person or business entity). Every account, transaction, and stablecoin operation is linked to an identity. You must create an identity before you can create accounts or perform any transactions. *** ### Steps #### 1. Navigate to identities From the Bastion Dashboard sidebar, select **Identities**. This opens the identities list view showing all existing identities in your organization. ![Creating Identities screenshot 1](https://docs-cdn.bastion.com/14176476f5e00288b323596f337c35f8dd32932893ad2225943a42b2a6744972.png) #### 2. Select "Create new Identity" Select the **Create new Identity** button at the top of the page. #### 3. Complete identity creation Provide the requested information and select **Create Identity**. ![Creating Identities screenshot 2](https://docs-cdn.bastion.com/7af8fcced95fa1a9e14436f09404806b2e0ee34ef2c05a4386a96940de4158e1.png) * Select the type of identity you are creating: * **Individual** — Represents a single person (supported globally) * **Business** — Represents a company or organization (currently supported in the US only) * Add a label (optional) * You can assign a label to help identify this record internally. Labels are alphanumeric and may include hyphens (e.g., `user-12345` or `acme-corp`). ### **Do not include personally identifiable information (PII)** in the label field. Use an internal reference ID instead. #### 4. Submit KYC / KYB Select the **Submit KYC** button on the right-most corner of the created identity. ![Creating Identities screenshot 3](https://docs-cdn.bastion.com/75e1af032c158b64c1deec710836bb067fddab52e8b4bafad34b9a404ba63673.png) A popup modal opens. Provide all requested fields and select **Submit KYC / KYB**. * **Sandbox only**: A **Prefill Sample Data** button is available to prefill dummy identity details ![Creating Identities screenshot 4](https://docs-cdn.bastion.com/f7c076650127ad6779a71c60c843c293d6f7201b7d602018d7df640a25621f46.png) #### 5. Identity approved Once the identity is approved, select the identity row to navigate into the identity page. ![Creating Identities screenshot 5](https://docs-cdn.bastion.com/e36c5f602eea581fe435479cb0fc4e934cef6a8aa85a2d70159eefb00212fde7.png) # Get access to the Bastion Dashboard Source: https://docs.bastion.com/guides/dashboard/getting-access How to request access to the Bastion Dashboard in the sandbox and production environments by contacting your Bastion representative. Reach out to your Bastion representative to gain access to Bastion Dashboard in sandbox or production. # Mint Bastion-issued stablecoins from the Dashboard Source: https://docs.bastion.com/guides/dashboard/mint-bastion-stablecoin Register a mint destination address, create mint instructions, send a USD wire with the memo ID, and track the mint in the Bastion Dashboard. ## Overview This guide walks you through the process of minting stablecoins using Bastion's Dashboard. Minting converts fiat currency (USD) into stablecoins that are delivered to a specified blockchain address. *** ### How minting works The minting process involves several steps: 1. Create a mint destination address - Register the blockchain address where minted stablecoins will be sent 2. Create mint instructions - Set up the minting configuration linking the identity, destination address, and payment details 3. Send a wire transfer - Initiate the mint by sending USD to the provided bank account with the memo ID 1. **Sandbox only**: You can simulate a wire transfer fiat deposit for testing purposes 4. Monitor the mint - Track the status of your mint request *** ### Prerequisites * A Bastion **Business** identity that has completed KYB * Note that Issuance is not supported for **Individual** identities at this moment * Reach out to your Bastion representative to gain access to Issuance flows *** ### Steps #### 1. Register a mint destination address First, register the blockchain address where minted stablecoins will be delivered. From the dashboard, navigate to an approved identity → **Mint** tab and select **Create Mint Instructions**. A popup modal opens. ![Mint Bastion Stablecoin screenshot 1](https://docs-cdn.bastion.com/e07a6c98f4ff8aefb0a59e2db69b0745445ec9372760c4765656c13abedfa53c.png) Select **Add New** and provide the following information before selecting **Create Destination Address**: * The chain of the destination address * The destination address to receive minted stablecoins * A label for the address ![Mint Bastion Stablecoin screenshot 2](https://docs-cdn.bastion.com/efce77746520402fbd25dcc15158d88ea862d5897bab896913624c51dcb3971b.png) #### 2. Create mint instructions From the Bastion Dashboard sidebar, select **Issuance** → **Mint Instructions**. Once the destination address has been created, you return to the Create Mint Instruction modal. Complete the form and select **Create Mint Instructions**. ![Mint Bastion Stablecoin screenshot 3](https://docs-cdn.bastion.com/9455039cd254837165d7f344724a810aeb6826e3b043bf14695869b647fc4db8.png) #### 3. Submit wire transfer to the mint instruction Select the created mint instruction to view the details. ![Mint Bastion Stablecoin screenshot 4](https://docs-cdn.bastion.com/47fd680646c0048780d0a219fc43ee2737e4aba020c3b26489c60b4606beae7d.png) To mint stablecoins, send a wire transfer to the bank account provided in the mint instructions response. **Critical requirements:** * **Include the memo ID**: The wire transfer **must** include the `memo_id` in the memo/reference field. Without this, Bastion cannot process the mint. * **Use the correct bank details**: Send funds to the exact bank account details provided in `bank_account_details`. * **Supported currency**: Only USD wire transfers are currently supported. Once the wire transfer is received and verified, Bastion will automatically mint the equivalent amount of stablecoins and send them to your registered mint destination address. #### Initiate test mints in sandbox In sandbox, you can simulate a wire transfer by selecting the **Simulate Fiat Deposit** button. ![Mint Bastion Stablecoin screenshot 5](https://docs-cdn.bastion.com/b4dd9e8e372024160ce769a880ae6962e3f8b8ee3ca81dd797a3009bb6f0f5ae.png) ![Mint Bastion Stablecoin screenshot 6](https://docs-cdn.bastion.com/b495f9372586f60a086ba670e271b38844e2799cafbd03a5a01d06d0415abe59.png) #### 4. Monitor mint status Navigate to **History** -> **Mints** to view the history and status of all mint requests. ![Mint Bastion Stablecoin screenshot 7](https://docs-cdn.bastion.com/9b589c0b66ea702f442459bbe7465dfd2e365052d873ff1c6123f02b87a30799.png) Once a mint is in completed status, Bastion also delivers the minted stablecoin to the specified destination wallet. # Redeem Bastion-issued stablecoins from the Dashboard Source: https://docs.bastion.com/guides/dashboard/redeem-bastion-stablecoin Register a bank account, create redemption instructions, send stablecoins to the redemption address, and track the USD wire payout in the Dashboard. ## Overview This guide walks you through the process of redeeming Bastion-issued stablecoins using Bastion's Dashboard. Redemption converts stablecoins back into fiat currency (USD) that is sent to a registered bank account via wire transfer. *** ### How redemption works The redemption process involves several steps: 1. **Register a bank account** - Add the bank account where redeemed funds will be sent 2. **Create redemption instructions** - Set up the redemption configuration with bank account and blockchain details 3. **Send stablecoins to the redemption address** - Initiate redemption by transferring stablecoins 4. **Monitor the redemption** - Track the status of your redemption request *** ### Prerequisites * An approved **Business** identity created for the redeeming entity * Note that Issuance is not supported for **Individual** identities at this moment * Bank account details for receiving USD wire transfers * Stablecoins in a wallet on a supported chain *** ### Steps #### 1. Register a bank account First, register the bank account where redeemed funds will be sent. From the dashboard, navigate to an approved identity → **Bank** tab and select **New Bank Account**. A popup modal opens. Provide all requested bank account information and select **Create Bank Account**. ![Redeem Bastion Stablecoin screenshot 1](https://docs-cdn.bastion.com/1add5f2f8054d676971a56e1e980b1b37e7efcd4b6bcb3eebf37cbc965b6ee55.png) #### 2. Create redemption instructions Once a bank account is saved, navigate to **Redeem** and select **Create Redemption Instructions**. Provide all requested information. ![Redeem Bastion Stablecoin screenshot 2](https://docs-cdn.bastion.com/22874cdfd388f6e60b0b7a399e9974b5dc63b91429c62927de30f53711be47c5.png) #### 3. Send stablecoin to the redemption address Once the redemption instruction has been created, Bastion returns a unique redemption address. ![Redeem Bastion Stablecoin screenshot 3](https://docs-cdn.bastion.com/ce7db2db95a8be2de8d40c952a7d0ab99bef5902ba4f9b02e751ce74e959b300.png) To redeem stablecoins, send them to the redemption address provided in the redemption instructions response. **Critical requirements:** * **Use the correct address**: Send stablecoins to the exact `address` returned in Step 2. * **Correct chain**: Ensure you're sending on the same blockchain network specified in the redemption instructions. * **Correct stablecoin**: Send the stablecoin type specified in `stablecoin_symbol`. Once the stablecoin transfer is received and verified: 1. Bastion burns the stablecoins 2. Bastion calculates the equivalent USD amount 3. Bastion initiates a wire transfer to your registered bank account #### 4. Monitor redemption status Navigate to **History** -> **Redemptions** to view the history and status of all redemption requests. ![Redeem Bastion Stablecoin screenshot 4](https://docs-cdn.bastion.com/bbe2f46b43ad177c353982e7cc1db8f961b9399767bfde7cd250b590254ddbf3.png) Processing times: * **Wire transfer**: 1-3 business days for domestic US wires # Send crypto from the Bastion Dashboard Source: https://docs.bastion.com/guides/dashboard/send-crypto Transfer USDC, SOL, and other supported crypto assets on-chain from a Bastion account to a supported blockchain address using the Dashboard's Transfer flow. ## Overview The **Send Crypto** feature allows you to transfer cryptocurrency from a Bastion account to a supported blockchain address. Transfers are submitted on-chain and settled according to the selected network. *** ### Prerequisites * A Bastion account with a sufficient balance of the asset you want to send * The recipient's blockchain address and target chain *** ### Steps From the Bastion Dashboard sidebar, select **Identities** → navigate into the approved identity. Select the **Transfer** button in the top-right corner of the source account that you wish to send cryptocurrency from. Transfer button in the top-right corner of a source account in the Bastion Dashboard A **Transfer Cryptocurrency** modal opens. Provide the required details and submit the transfer. **Required information:** * Destination Chain * Currency (USDC, SOL, etc.) * Amount: Asset's standard unit (e.g., enter `10` to send 10 USDC or 0.5 to send 0.5 SOL). * Destination Address Transfer Cryptocurrency modal with destination chain, currency, amount, and destination address fields Navigate to the **History** tab and select **Transfers**. You can view a history of all submitted crypto transfers along with their statuses. Transfers list under the History tab showing submitted crypto transfers and their statuses # Sandbox access and test API keys Source: https://docs.bastion.com/guides/getting-started/api-access-prerequisites How to request access to the Bastion sandbox, create test API keys from the sandbox dashboard, and authenticate against a full-featured replica of the API. ## Bastion sandbox The Bastion sandbox is a secure, isolated testing environment where you can experiment with our API without impacting live production data. It provides a full-featured replica of our production API, allowing you to: * **Test API integrations:** Validate your application's interaction with Bastion's APIs in a risk-free setting. * **Explore features:** Discover and understand the capabilities of our platform, from managing accounts and identities to stablecoin transfers and webhooks. * **Build a POC:** Quickly develop and validate proof-of-concept integrations using realistic test data, allowing for efficient building and debugging. The sandbox environment supports all core API functionalities, including: * **Account management:** Create, list, and retrieve accounts, and check account balances. * **Identity services:** Manage individual and business identities, including compliance data submission (KYB/KYC) and payment instruction creation. * **Stablecoin transfers:** Initiate and track stablecoin transfers. * **Transaction signing (coming soon):** Test signing raw transactions and data. * **Webhooks:** Configure and test webhooks for real-time event notifications. *** ## Access sandbox The sandbox is a test environment you can access by contacting sales. Once you have access, you receive a unique and confidential test API key and can sign in to the Bastion sandbox dashboard. To gain access, contact us at [sales@bastion.com](mailto:sales@bastion.com). Our team reviews your request and responds with access to the sandbox, along with additional instructions and terms of use. *** ## Sandbox dashboard Once you have access to the sandbox environment, go to [the Bastion sandbox dashboard](https://dashboard.sandbox.bastion.com/). Bastion sandbox dashboard *** ## Sandbox APIs Our sandbox API base URL is `https://api.sandbox.bastion.io`. The API consumes and produces the following media type: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} application/json ``` ### Create a sandbox API key 1. Go to the `API keys` tab on the left, and select `Create new API Key`. 2. A small pop-up window appears, prompting for an API key name. Create new API key dialog in the sandbox dashboard 3. Once you provide an API key name, select `Create new API key`. A secret test API key is displayed for you to save. Make sure to store this test secret key. You cannot retrieve it afterward. Newly created secret API key shown in the sandbox dashboard Your API key is unique to your organization. Do not share it with unauthorized individuals or expose it in client-side code, public repositories, or configuration files. *** ## Pass the sandbox API key in request headers Once you have your API key, include it in the request headers for every API call. Follow these instructions to pass the key in the Authorization header: 1. Set the Authorization header in your HTTP requests. 2. Use the Bearer scheme followed by a space and the API key. The header should look like this: ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} Authorization: Bearer ``` 3. Replace `` with the actual API key provided to you and add it to the request. For example, if you are using cURL to make a request to our API, you can include the Authorization header as follows: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -H "Authorization: Bearer " https://api.sandbox.bastion.io/v2/accounts ``` ### Stablecoin transfer API call example Here's an example of submitting a stablecoin transfer using cURL: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST \ https://api.sandbox.bastion.io/v2/stablecoins/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "request_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "amount": "100.00", "on_behalf_of": "your_account_id", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "currency": "USDC", "payment_rail": "ETHEREUM_SEPOLIA" }, "destination": { "currency": "USDC", "payment_rail": "ETHEREUM_SEPOLIA" } }' ``` *** ## Conclusion By following these steps, you can obtain a sandbox API key and pass it in request headers to authenticate your API calls. If you have any questions or encounter issues, contact our team for assistance. To make the most of the API, familiarize yourself with the API documentation and explore the available endpoints and functionalities. Get in touch with our sales team at [sales@bastion.com](mailto:sales@bastion.com) to get started! # Bastion platform overview Source: https://docs.bastion.com/guides/getting-started/bastion-platform-overview Overview of the Bastion stablecoin platform: white-label issuance, custody, on-ramps and off-ramps, and the APIs and dashboards that power them. ## Onboard to Bastion To onboard to Bastion, reach out to [sales@bastion.com](mailto:sales@bastion.com) or refer to [Onboarding to Bastion](/guides/compliance/onboard-to-bastion). *** ## Platform overview Bastion covers the end-to-end stablecoin journey to help you issue, hold, and move stablecoins. ### Issuance Bastion's white-label stablecoin issuance enables institutions and enterprises to launch their branded stablecoins. You delegate the majority of regulatory, operational, and technological overhead to us. You capture the vast majority of the yield on reserves, control your network, and capture the economics on dollars flowing through your ecosystem. We handle reserve management, liquidity management, compliance monitoring, and much more, so you focus on building the distribution & liquidity. Our issuance APIs and dashboard are not public at this time. To get access, reach out at [sales@bastion.com](mailto:sales@bastion.com). #### API With the issuance APIs, you can: * Convert fiat to your stablecoin (mint) * Convert your stablecoin to fiat (redeem) * Whitelist wallets, manage mint/redemption instructions, and track supply * Receive real-time status updates through webhook notifications #### Dashboard Our web dashboard provides real-time visibility into circulating supply, mint and redemption volumes, holders, transactions, and chain activity. You can initiate mint and redemption requests via guided flows and manage registered destination addresses. All actions mirror the publicly documented Issuance APIs for seamless UI and API parity. ![Bastion issuance overview diagram](https://docs-cdn.bastion.com/57a3213af417ca07abb8cc0319a45f05a0529be82d6ffdc6eba2d83e421c19b3.png) *** ### Custody & wallets Bastion's embedded custody APIs enable you to create and manage custodial wallets for your users, without handling private keys. Every wallet is backed by secure hardware, and transactions are signed on-demand within AWS Nitro Enclaves. You own the user experience. We handle key management, transaction signing, gas sponsorship, and multi-chain support. #### API With the Custody APIs, you can: * Create accounts with automatically provisioned wallets across chains (ex: Base, Solana) * Submit stablecoin and cryptocurrency transfers on behalf of your users * Query balances, transaction history, and transaction status * Sponsor gas fees so your users never need to hold native tokens * Receive real-time status updates through webhook notifications #### Dashboard Our web dashboard provides admin controls on your organization and app transactions. You can manage API keys, configure webhooks, and set transaction limits. ![Bastion custody and wallets overview diagram](https://docs-cdn.bastion.com/ec2932296e24a6cd19d64308a940a9aeab2c5c35d621aa57bf22c65875a8d7a4.png) ### On & off-ramp Bastion's on and off-ramp APIs enable seamless conversion between fiat currency and stablecoins. Your users can deposit fiat and receive stablecoins in their wallet, or convert stablecoins back to fiat and withdraw to their bank account. We handle banking integrations, payment processing, and transaction monitoring. You focus on building the user experience. #### API With the On & Off-Ramp APIs, you can: * Convert fiat to stablecoins (on-ramp) and stablecoins to fiat (off-ramp) * Register and manage bank account payment instructions for withdrawals * Query conversion status and transaction history * Receive real-time status updates through webhook notifications *** ## Try Bastion APIs Ready to start building? Here's how to get up and running: 1. **Get sandbox access** — Request API credentials for our sandbox environment. See [Sandbox access](/guides/getting-started/api-access-prerequisites) for details. 2. **Get your API key** — Generate an API key from the Bastion Dashboard to authenticate your requests. 3. **Explore the use case guides** — Follow step-by-step tutorials for common workflows: * [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) * [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer) * [Top up a stablecoin balance (on-ramp)](/guides/quickstarts/on-ramp) * [Withdraw stablecoin to a bank account (off-ramp)](/guides/quickstarts/off-ramp) * [Send crypto](/guides/quickstarts/send-crypto) 4. **Review security** — Understand how Bastion protects your assets and data. See [Security](/guides/security/overview). For detailed setup instructions, see [Getting started with Bastion](/guides/getting-started/api-access-prerequisites). # Jurisdiction overview Source: https://docs.bastion.com/guides/getting-started/jurisdiction-overview Where Bastion supports custody, stablecoin issuance, and fiat on-ramps and off-ramps by customer jurisdiction, including US state-level exceptions. Bastion's services vary by jurisdiction. This page covers where Bastion can serve customers: which services are available based on a customer's location, where off-ramp funds can be sent, and which jurisdictions are restricted entirely. Coverage is actively expanding. If you have a use case in a jurisdiction that is not listed, contact us at [sales@bastion.com](mailto:sales@bastion.com) to evaluate potential support. This page is about eligibility. For integration detail, see: * [Supported off-ramp payout countries](/guides/getting-started/supported-off-ramp-payout-countries) for the countries an off-ramp can pay out to * [Supported chains and assets](/guides/concepts/chains-and-assets) for stablecoin and blockchain coverage * [KYC & KYB requirements](/guides/compliance/kyc-requirements) for what customers must provide to be verified ## Service availability by customer location | Customer location | Crypto custody | Stablecoin issuance | On-ramp | Off-ramp | | - | - | - | - | - | | United States | Yes | Yes | Yes | Yes | | International | Yes | No | No | No | | Restricted / Sanctioned | No | No | No | No | Off-ramp availability here means who can initiate one. Where the money can land is a different matter, covered in [Supported off-ramp payout countries](/guides/getting-started/supported-off-ramp-payout-countries). ## United States ### Crypto custody Custody is available across the U.S., with limited state-level licensing exceptions. For customers that need full coverage across all U.S. states, Bastion works with partners and structuring options to address those edge cases. State-level exceptions: * New York * Texas * Guam * Puerto Rico ### Stablecoin issuance Bastion Platforms Trust Company, LLC is chartered as a limited purpose trust company by the New York State Department of Financial Services to engage in virtual currency business. Its application to issue stablecoins is pending; once approved, stablecoin issuance will be available across the U.S. Contact us at [sales@bastion.com](mailto:sales@bastion.com) if you are interested in issuing your own white-label stablecoin. ### On-ramp / off-ramp Fiat on-ramp and off-ramp are available to U.S. customers, except those whose principal address is in New York. The exclusion follows the customer's principal address, not the payout destination. A New York customer cannot off-ramp to any bank account, domestic or international. Both directions also depend on custody. An on-ramp credits a Bastion-custodied account and an off-ramp debits one, so the custody state exceptions above apply first. ## International If you have a use case in a jurisdiction that is not listed, contact us at [sales@bastion.com](mailto:sales@bastion.com) to evaluate potential support. ### Crypto custody Crypto custody is supported for customers based in the United States and in the following countries through U.S.-issued wallets: * Brazil (BR) * Colombia (CO) * Holy See (VA) * Mexico (MX) * Peru (PE) * United Kingdom (GB) * Uruguay (UY) * Viet Nam (VN) * Zambia (ZM) ### Stablecoin issuance Stablecoin issuance is currently not available outside the United States. ### On-ramp / off-ramp Fiat on-ramp and off-ramp are not available to end users outside the United States. The account holder must be a US-based customer in both directions. ## Where off-ramp funds can be sent An off-ramp always debits a US-based customer's account, but the money does not have to stay in the United States. Payouts can go to: | Destination | Currency | Notes | | - | - | - | | United States | USD | Wire only, and only to a bank account the customer owns | | 60+ other countries | Local currency | Local payment rails. The recipient may be the customer or a third party, and may be an individual or a business | Payouts cannot be sent to a bank account in a restricted jurisdiction. For the full list of supported destination countries, the currency and payment rail for each, see [Supported off-ramp payout countries](/guides/getting-started/supported-off-ramp-payout-countries). On-ramps have no international equivalent. Fiat can only be deposited from a U.S. bank account. ## Restricted jurisdictions Bastion prohibits users in, and services and transactions involving, the following jurisdictions. These are blocked both as customer locations and as payout destinations. | Country code | Country name | | - | - | | CU | Cuba | | IR | Iran | | KP | Korea, Democratic People's Republic of (North Korea) | | UA | Ukraine (Regions: Crimea, Donetsk, Luhansk) | | MM | Myanmar (Burma) | | BY | Belarus |
# Sandbox testing and simulation labels Source: https://docs.bastion.com/guides/getting-started/sandbox-testing-and-simulations Use Bastion sandbox simulation labels to force KYC, KYB, IDV, and conversion outcomes, and simulate on-ramp deposits during integration testing. Bastion provides `simulation labels` that your team can use to simulate specific product flows in **sandbox**. Use simulation labels during your testing efforts and UAT integration. Simulation labels are comma separated, and you can add your own labels alongside them (e.g. `sim-onboarding-result:approved,customer-42,sim-offramp:payout-refunded`). Bastion ignores labels it doesn't recognize. Set the labels in the identity's `label` field when you call [`POST /v2/identities`](/v2/api-reference/identities/create-identity). They then apply to everything that identity does, including onboarding and conversions. To test a conversion outcome, put the onboarding label and the conversion label on the same identity, because the identity must be onboarded before it can convert. Simulation labels have no effect in production. Labels that set the same outcome don't combine. For example, if an identity has two `sim-onboarding-result` labels, or two payout labels, only one applies. Labels for different steps, such as an onboarding result and a payout label, work together. ## Onboarding and KYC/KYB simulation For sandbox KYC and KYB, you can use the `label` field when calling [`POST /v2/identities`](/v2/api-reference/identities/create-identity) to simulate KYC outcomes and submit dummy KYC/KYB information. Once [`POST /v2/identities/{identity_id}/compliance`](/v2/api-reference/compliance/submit-compliance-data) completes, the identity transitions to the state indicated by the simulation label. | Scenario | Simulation label | | - | - | | Simulate an approved onboarding with identity\_status = `ACTIVE` | `sim-onboarding-result:approved` | | Simulate a rejected onboarding with identity\_status = `REJECTED` | `sim-onboarding-result:rejected` | | Simulate a pending onboarding that requires IDV checks, with identity\_substatus = `IDV_REQUIRED` | `sim-onboarding-result:idv-required` | | Simulate the full IDV lifecycle resolving to an approved KYC result, with identity\_status = `ACTIVE`, from this single label alone (no separate IDV check call). | `sim-onboarding-result:idv-approved` | | Simulate the full IDV lifecycle resolving to a rejected KYC result, with identity\_status = `REJECTED`, from this single label alone (no separate IDV check call). | `sim-onboarding-result:idv-rejected` | **Example:** [`POST /v2/identities`](/v2/api-reference/identities/create-identity) ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "label": "sim-onboarding-result:approved", "identityType": "IDENTITY_TYPE_INDIVIDUAL" } ``` ## Identity verification (IDV) simulation For sandbox IDV checks, you can use the `labels` field when calling [`POST /v2/identities/{identityId}/id_verification`](/v2/api-reference/identity-verification/create-identity-verification-check) to simulate IDV outcomes. Note that `labels` is an array. | Scenario | Simulation label | | - | - | | The IDV check is created and subsequently transitions to `APPROVED` status. | `sim-idv-check-result:approved` | | The IDV check is created and subsequently transitions to `REJECTED` status. | `sim-idv-check-result:rejected` | **Example:** [`POST /v2/identities/{identityId}/id_verification`](/v2/api-reference/identity-verification/create-identity-verification-check) ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "redirect_url": "https://www.google.com", "reason": "ONBOARDING", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "labels": ["sim-idv-check-result:approved"] } ``` ## On-ramp and off-ramp conversion simulations ### Simulate an on-ramp deposit Sandbox Virtual Accounts can't receive real deposits. Call `POST /v2/simulation/on-ramp-deposit` to simulate one and start the on-ramp flow a real deposit triggers. See [Test the on-ramp in sandbox](/guides/quickstarts/on-ramp#test-the-on-ramp-in-sandbox) for the full steps. ### Simulate conversion outcomes By default, a sandbox conversion follows the happy path to `COMPLETED`. To test how your integration handles failures and returns, add one of these labels to the identity before starting the conversion. **On-ramp** | What you're testing | Label | Result | | - | - | - | | The customer deposits less than the minimum | `sim-onramp:below-minimum-deposit` | The conversion ends in `FAILED`. | **Off-ramp** | What you're testing | Label | Result | | - | - | - | | The customer's USDC transfer to Bastion fails on-chain | `sim-ramp:onchain-transfer-fail` | The conversion ends in `FAILED` before any payout is sent. Applies to segregated accounts on EVM chains, not omnibus accounts. | | The payout is rejected when it's created, for example because the recipient's bank details are malformed | `sim-offramp:payout-rejected` | The conversion ends in `FAILED`. No payout is sent. | | The recipient's bank returns a US payout | `sim-offramp:payout-bank-bounce` | The conversion ends in `RETURNED`. Applies to US off-ramps. For international off-ramps, use `sim-offramp:payout-refunded`. | | An international payout is returned and the funds are refunded | `sim-offramp:payout-refunded` | The conversion ends in `RETURNED`. Applies to international off-ramps. | | An international payout takes a while to arrive | `sim-offramp:payout-delay-` | The conversion stays in `PAYOUT_INITIATED` for the given duration, then ends in `COMPLETED`. Write the duration as a number and a unit (`s`, `m`, or `h`), for example `sim-offramp:payout-delay-90s` or `sim-offramp:payout-delay-5m`. If Bastion can't read the duration, it ignores it and the conversion follows the happy path. Applies to international off-ramps. | Use one payout label per identity. The payout labels (`sim-offramp:payout-rejected`, `sim-offramp:payout-bank-bounce`, `sim-offramp:payout-refunded`, and `sim-offramp:payout-delay-`) don't combine: if an identity has more than one, only one of them applies. **Example:** [`POST /v2/identities`](/v2/api-reference/identities/create-identity) for an identity whose international off-ramps are returned ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "label": "sim-onboarding-result:approved,sim-offramp:payout-refunded", "identityType": "IDENTITY_TYPE_INDIVIDUAL" } ``` # Sandbox vs. production environments Source: https://docs.bastion.com/guides/getting-started/sandbox-vs-production-environments How Bastion's sandbox and production environments differ in base URLs, credentials, asset behavior, chain support, and when to use each one. Bastion provides two separate environments to support your development workflow and production operations. Each environment serves a distinct purpose with different behaviors around data persistence, compliance verification, and financial operations. *** ## Environment overview | Aspect | Sandbox | Production | | - | - | - | | **Base URL** | `https://api.sandbox.bastion.io` | `https://api.bastion.com` | | **Purpose** | Testing, integration development, and evaluation | Live customer traffic and real transactions | | **Assets** | No real fiat or crypto assets | Real fiat and crypto on supported networks | | **Access** | Contact [sales@bastion.com](mailto:sales@bastion.com) for access | Requires completion of onboarding and compliance approval | | **Audience** | Engineers and integrators building proof-of-concepts | Customers ready for real-money flows | ### When to use each environment | Environment | Use cases | | - | - | | **Sandbox** | Exploring APIs and integration patterns before moving real funds
Building and iterating on client applications using simulated data
Validating core flows end-to-end: account creation, conversions, transfers, webhook handling
Testing failure scenarios and retry logic without financial risk | | **Production** | Running live user flows with real conversions and transfers
Meeting audit, reporting, and uptime requirements for business-critical use cases
Performing final integration validation with real data and assets | *** ## Authentication & API access Both environments use the same authentication model (API keys + optional JWT signing), but credentials are environment-specific and cannot be shared between sandbox and production. For implementation details, see [API authentication](/guides/security/api-authentication). *** ## Chains, assets, and on-chain behavior ### Network support | Environment | Networks | Examples | Real value | | - | - | - | - | | **Sandbox** | Testnets only | Solana Devnet, Base Sepolia, Ethereum Sepolia | No mainnet transfers | | **Production** | Mainnets + testnet access | Solana, Base, Ethereum (mainnets available) | Full mainnet coverage with real settlements | ### Behavioral differences | Aspect | Testnet (Sandbox) | Mainnet (Production) | | - | - | - | | **Confirmation times** | May not match mainnet speeds | Production-grade performance | | **Transaction fees** | Often negligible or free | Real network fees apply | | **Provider integration** | Some third-party providers are testnet-only | All providers wired to mainnet | For complete chain and asset details, see [Supported chains and assets](/guides/concepts/chains-and-assets). *** ## Compliance and identity verification Compliance requirements differ significantly between environments to enable efficient testing while maintaining production security. ### Compliance comparison | Aspect | Sandbox | Production | | - | - | - | | **Verification process** | Test-mode checks on non‑production identities | Full regulated KYC/KYB verification | | **Typical approval timing** | Near-instant for all identity types | **KYC:** In seconds; **KYB:** 3-5 business days | | **Vendor integration** | Vendor sandbox/UAT only | Real sanctions screening and identity verification | | **Document processing** | Intended for fake/demo documents | Actual document verification required | | **Ongoing monitoring** | Best-effort logging | Full transaction monitoring and required controls | | **Audit trail** | For testing only | Suitable for regulatory audits and reporting | ### Production compliance requirements **Full regulated identity verification:** * End users and businesses provide real documents and information * Reviews can result in approvals, rejections, or requests for additional information * Identity decisions affect account limits, transaction eligibility, and ongoing monitoring For detailed compliance requirements and field specifications, see the [KYC requirements](/guides/compliance/kyc-requirements) and [Compliance](/v2/api-reference/compliance/overview) guides. *** ## Fiat operations and settlement Fiat conversion behavior differs fundamentally between environments. ### Fiat operations comparison | Aspect | Sandbox | Production | | - | - | - | | **Funding** | Fully simulated, no real money | Real wire transfers | | **Settlement** | Instant or simulated delays | Real banking rails (same-day to 1 business day for US wires) | | **Deposit instructions** | Mocked bank account details | Real bank account for wire deposits | | **Wire requirement** | No actual wire needed | User must send real wire transfer | | **Balances** | Synthetic movements only | Real asset balances | | **Webhook events** | Fire as they would in production | Production-grade event delivery | ### Example flows | Step | Sandbox | Production | | - | - | - | | **1. Submit conversion** | Receive mocked deposit instructions | Receive real bank deposit instructions | | **2. Funding** | No actual wire required → Auto-completes after delay | User sends wire with memo → Settles per banking schedule | | **3. Result** | Stablecoin balance updates for testing | Stablecoin issued, real asset in account | *** ## Data persistence and reliability | Aspect | Sandbox | Production | | - | - | - | | **Persistence** | Not guaranteed for long‑term storage; data may be cleared periodically | Permanent storage with backup and disaster recovery | | **Purpose** | Short-lived test data and disposable accounts | Long-term operational and reporting needs | | **Suitability** | Not for storing financial or identity records | Designed for regulatory and audit requirements | | **Availability** | Testing environment, may have maintenance windows | Production-grade uptime and monitoring | | **Audit compliance** | Not suitable for financial records | Full transaction history with immutable records | | **Data retention** | No guarantees | Data retained per regulatory requirements | **Need help?** Contact support at [try@bastion.io](mailto:try@bastion.io) or reach out to your sales representative. # Supported off-ramp payout countries Source: https://docs.bastion.com/guides/getting-started/supported-off-ramp-payout-countries Countries, payout currencies, local payment networks, and settlement times for Bastion off-ramp payouts to bank accounts across 60+ jurisdictions. An off-ramp converts a customer's stablecoin balance into fiat and pays it into a bank account. Payouts are supported to the United States and 60+ other countries, listed below. The customer holding the account must be a U.S. person regardless of destination. See [Jurisdiction overview](/guides/getting-started/jurisdiction-overview) for eligibility and restricted jurisdictions. ## Countries and currencies Each country pays out in the currency shown, over the local payment network listed. T+0 means the same business day and T+1 the next business day, excluding weekends and local bank holidays. Where two networks are shown, Bastion uses the faster one when it is available. | Country | Payout currency | Payment network | Settlement | | - | - | - | - | | Argentina | ARS | Transferencias 3.0 / COELSA | Real-time / T+1 | | Australia | AUD | BECS | T+0 / T+1 | | Austria | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Belgium | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Bolivia | BOB | RTGS | T+0 | | Brazil | BRL | Pix | Real-time | | Bulgaria | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Canada | CAD | EFT | T+1 | | Chile | CLP | TEF | T+1 | | Colombia | COP | PSE / ACH | Real-time / T+1 | | Costa Rica | CRC | SINPE | Real-time | | Croatia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Cyprus | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Czechia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Denmark | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Dominican Republic | DOP | LBTR | T+0 | | Ecuador | USD | ACH | T+0 | | Egypt | EGP | ACH | T+0 | | El Salvador | USD | ACH | T+0 | | Estonia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Finland | EUR | SEPA Instant / SEPA | Real-time / T+1 | | France | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Germany | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Ghana | GHS | GhIPSS | Real-time | | Greece | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Guatemala | GTQ | ACH | T+0 | | Honduras | HNL | ACH | T+0 | | Hungary | EUR | SEPA Instant / SEPA | Real-time / T+1 | | India | INR | IMPS / NEFT | Real-time | | Indonesia | IDR | BI-FAST / Artajasa | Real-time / T+1 | | Ireland | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Israel | ILS | ZAHAV | T+1 | | Italy | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Jamaica | JMD | ACH | T+1 | | Japan | JPY | Zengin | T+0 | | Jordan | JOD | ACH | T+1 | | Kenya | KES | PesaLink | Real-time / T+3 | | Latvia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Lithuania | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Luxembourg | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Malaysia | MYR | DuitNow | Real-time / T+1 | | Malta | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Mexico | MXN | SPEI | Real-time | | Netherlands | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Norway | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Pakistan | PKR | IBFT | Real-time | | Peru | PEN | ACH | T+1 | | Philippines | PHP | InstaPay / PESONet | Real-time / T+1 | | Poland | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Portugal | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Qatar | QAR | QPS | T+1 | | Romania | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Saudi Arabia | SAR | Mada | T+0 | | Singapore | SGD | FAST / MEPS | Real-time | | Slovakia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Slovenia | EUR | SEPA Instant / SEPA | Real-time / T+1 | | South Africa | ZAR | RTC / EFT | T+1 | | South Korea | KRW | KFTC / FirmBanking | Real-time | | Spain | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Sweden | EUR | SEPA Instant / SEPA | Real-time / T+1 | | Switzerland | EUR | SEPA | Real-time / T+1 | | Thailand | THB | PromptPay | Real-time / T+1 | | United Arab Emirates | AED | NPSS | Real-time | | United Kingdom | GBP | Faster Payments / BACS / CHAPS | Real-time / T+1 | | United States | USD | Wire | Same business day | SEPA countries pay out in EUR, including those with their own national currency. Payouts cannot be sent to a bank account in a restricted jurisdiction. ## Settlement timing The times above are each network's intended settlement time. Actual delivery depends on the receiving bank's own processing, including compliance screening, and can run slower than the network's target. Payouts are submitted to the local network during business hours in the destination country, 9am to 4pm local time. A payout submitted after the day's cutoff enters the next business day's cycle, so a late Friday send on a T+0 corridor typically arrives Monday. ## Who can receive a payout International payouts can go to the customer's own bank account or to someone else's. US payouts can only go to a bank account the customer owns. Most countries accept both individual and business recipients. Jordan, Pakistan, Guatemala, El Salvador, Honduras, and South Africa accept individual recipients only. ## Amount limits Each country's local payment network sets a minimum payout amount. For most countries it is equivalent to roughly USD 1, with a few higher. Bastion does not add a minimum of its own. Minimums are returned on every quote, denominated in the destination country's currency. Fees are charged separately from the minimum, so the smallest amount a customer can send is the minimum plus the fee. Where a country caps payout size, the maximum is returned on the quote as well. ## Integration requirements The payment network above is the scheme funds settle over. The value your integration sends on a request is separate, and both it and the required bank and recipient fields are listed in [Off-ramp payout countries and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields). To build the flow, see [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account) or [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account).
# MCP server for LLMs and AI coding tools Source: https://docs.bastion.com/guides/llms/mcp Connect the Bastion documentation MCP server to Claude, Cursor, and VS Code so your AI tools can search the docs and API reference while you build. The Bastion documentation site hosts a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server so AI-powered tools (like Claude, Cursor, VS Code, and other MCP-compatible editors) can search the Bastion docs and API reference directly while you build. ## MCP server URL The Bastion MCP server is available at the `/mcp` path of the documentation site: ``` https://docs.bastion.com/mcp ``` The MCP server exposes the same content as the published documentation, including the [API reference](/v2/api-reference/authentication/overview) generated from our OpenAPI specification. If the docs require sign-in, use the `/authed/mcp` path instead. ## Connect from any page Every page on this site has a contextual menu in the top-right corner with one-click options to connect the MCP server to your tools: * **Copy MCP server URL** — copies the URL above to your clipboard. * **Copy MCP install command** — copies an `npx add-mcp` command that installs the server. * **Connect to Cursor** / **Connect to VS Code** — installs the server directly in your editor. ## Manual setup Add the server with the Claude Code CLI: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} claude mcp add --transport http bastion https://docs.bastion.com/mcp ``` Verify the connection with `claude mcp list`. 1. Open the [Connectors](https://claude.ai/settings/connectors) page in your Claude settings. 2. Select **Add custom connector**. 3. Add the server. Name: `Bastion`, URL: `https://docs.bastion.com/mcp`. 4. Select **Add**. In a chat, use the attachments (**+**) button to select the Bastion server. 1. Open the command palette (Cmd + Shift + P, or Ctrl + Shift + P on Windows) and search for **Open MCP settings**. 2. Select **Add custom MCP** to open `mcp.json`. 3. Add the server: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mcpServers": { "bastion": { "url": "https://docs.bastion.com/mcp" } } } ``` Create a `.vscode/mcp.json` file and add the server: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "servers": { "bastion": { "type": "http", "url": "https://docs.bastion.com/mcp" } } } ``` ## What you can do Once connected, ask your AI tool questions about Bastion and it will pull answers from the live documentation. For example: * "How do I onboard an individual customer with Bastion?" * "What fields does the Submit Cryptocurrency Transfer endpoint require?" * "Which chains and assets does Bastion support?" Because the MCP server includes our OpenAPI-generated [API reference](/v2/api-reference/authentication/overview), your tools can look up endpoints, parameters, and response schemas without leaving your editor. # Mint Bastion-issued stablecoins with the Issuance API Source: https://docs.bastion.com/guides/quickstarts/mint-bastion-issued-stablecoins End-to-end quickstart to mint Bastion-issued stablecoins: register a mint destination address, create mint instructions, wire USD, and track the mint. ## Mint Bastion-issued stablecoins This guide walks you through the process of minting stablecoins using Bastion's Issuance API. Minting converts fiat currency (USD) into stablecoins that Bastion delivers to a specified blockchain address. ### Overview The minting process involves several steps: 1. **Create a mint destination address** - Register the blockchain address where Bastion sends minted stablecoins 2. **Create mint instructions** - Set up the minting configuration linking the identity, destination address, and payment details 3. **Send a wire transfer** - Initiate the mint by sending USD to the provided bank account with the memo ID 4. **Monitor the mint** - Track the status of your mint request ### Prerequisites Before you begin, ensure you have: * A valid API key with issuance permissions * An approved **Business** identity created for the minting entity * Note that Issuance is not supported for **Individual** identities at this moment * A blockchain wallet address on a supported chain (Solana Mainnet, Solana Devnet, or Base Sepolia) ### Step 1: create a mint destination address First, register the blockchain address where Bastion delivers minted stablecoins. #### Request ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST https://api.prod.bastion.com/v2/issuance/mint-addresses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "550e8400-e29b-41d4-a716-446655440000", "address": "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV", "chain": "SOLANA_DEVNET", "label": "Treasury Wallet", "identity_id": "id_123456789" }' ``` #### Request parameters | Parameter | Type | Required | Description | | - | - | - | - | | `request_id` | string (UUID) | Yes | Idempotency key in UUID format | | `address` | string | Yes | The blockchain wallet address to receive minted stablecoins | | `chain` | enum | Yes | Blockchain network: `SOLANA_DEVNET` | | `label` | string | Yes | A human-readable label for the address | | `identity_id` | string | Yes | The identity ID that owns this mint address | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "mda_abc123def456", "address": "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV", "chain": "SOLANA_DEVNET", "label": "Treasury Wallet", "identity_id": "id_123456789" } ``` ### Step 2: create mint instructions Create mint instructions that specify how Bastion processes mints, including the destination address and payment details. #### Request ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST https://api.prod.bastion.com/v2/issuance/mint-instructions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "identity_id": "id_123456789", "request_id": "660e8400-e29b-41d4-a716-446655440001", "stablecoin_symbol": "USDX", "mint_destination_address_id": "mda_abc123def456", "payment_rail": "WIRE", "fiat_currency_symbol": "USD", "label": "Primary Mint Instructions" }' ``` #### Request parameters | Parameter | Type | Required | Description | | - | - | - | - | | `identity_id` | string | Yes | The identity ID for this mint instruction | | `request_id` | string (UUID) | Yes | Idempotency key in UUID format | | `stablecoin_symbol` | string | Yes | Symbol of the stablecoin to mint (e.g., `USDX`). Confirm the symbol to be used with your Bastion representative | | `mint_destination_address_id` | string | Yes | ID of the mint destination address created in Step 1 | | `payment_rail` | enum | Yes | Payment method (currently only `WIRE` is supported) | | `fiat_currency_symbol` | enum | Yes | Fiat currency (currently only `USD` is supported) | | `label` | string | No | A human-readable label for the instruction | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mint_instructions": { "id": "mi_xyz789abc123", "identity_id": "id_123456789", "request_id": "660e8400-e29b-41d4-a716-446655440001", "stablecoin_symbol": "USDX", "chain": "SOLANA_DEVNET", "mint_destination_address_id": "mda_abc123def456", "payment_rail": "WIRE", "fiat_currency_symbol": "USD", "memo_id": "BSTN-MI-12345", "bank_account_details": { "bank_name": "Partner Bank", "account_holder_name": "Bastion Treasury Services", "account_number": "123456789012", "routing_number": "021000021", "type": "checking", "address": { "street_line_1": "123 Financial District", "city": "New York", "postal_code": "10004", "country": "US", "region_code": "NY" } }, "label": "Primary Mint Instructions" } } ``` > **Important:** Save the `memo_id` and `bank_account_details` from the response. You'll need these to initiate the wire transfer. ### Step 3: initiate the mint (wire transfer) To mint stablecoins, send a wire transfer to the bank account provided in the mint instructions response. **Critical requirements:** * **Include the memo ID**: The wire transfer **must** include the `memo_id` from Step 2 in the memo/reference field. Without this, Bastion cannot process the mint. * **Use the correct bank details**: Send funds to the exact bank account details provided in `bank_account_details`. * **Supported currency**: Only USD wire transfers are currently supported. Once Bastion receives and verifies the wire transfer, it automatically mints the equivalent amount of stablecoins and sends them to your registered mint destination address. #### Initiating test mints in sandbox In sandbox you can simulate a wire transfer via the [Simulate Fiat Deposit endpoint](/v2/api-reference/simulation/simulate-fiat-deposit). Call this endpoint with the request body below to trigger a test mint. The response returns the ID of the triggered mint. #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "amount": "1234", "memo_id": "BSTN-MI-12345", "currency": "USD", "payment_rail": "WIRE" } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mint_id": "mint_001abc" } ``` ### Step 4: monitor mint status #### List all mints ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET "https://api.prod.bastion.com/v2/issuance/mints?identity_id=id_123456789&limit=10" \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Query parameters | Parameter | Type | Required | Description | | - | - | - | - | | `status` | enum | No | Filter by status: `ACCEPTED`, `SUBMITTED`, `COMPLETED`, `DENIED`, `FAILED`, `CANCELLED` | | `limit` | integer | No | Max results per page (1-25, default: 10) | | `cursor` | string | No | Pagination cursor for next page | | `identity_id` | string | No | Filter by identity ID | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mints": [ { "id": "mint_001abc", "amount": "10000.00", "chain": "SOLANA_DEVNET", "mint_address_id": "mda_abc123def456", "status": "COMPLETED", "external_tx_id": "5wHu2hC7rnP3YqKf...", "identity_id": "id_123456789", "stablecoin_symbol": "USDC" } ], "cursor": "eyJsYXN0X2lkIjoibWludF8wMDFhYmMifQ==" } ``` #### Get a specific mint ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/mints/mint_001abc \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "mint_001abc", "amount": "10000.00", "chain": "SOLANA_DEVNET", "mint_address_id": "mda_abc123def456", "status": "COMPLETED", "external_tx_id": "5wHu2hC7rnP3YqKf...", "identity_id": "id_123456789", "stablecoin_symbol": "USDC" } ``` ### Mint status values | Status | Description | | - | - | | `ACCEPTED` | Wire transfer received and mint request accepted | | `SUBMITTED` | Mint transaction submitted to the blockchain | | `COMPLETED` | Stablecoins successfully minted and delivered | | `DENIED` | Mint request was denied (compliance or other reasons) | | `FAILED` | Mint transaction failed | | `CANCELLED` | Mint request was cancelled | ### Additional endpoints #### List mint destination addresses ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET "https://api.prod.bastion.com/v2/issuance/mint-addresses?identity_id=id_123456789" \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Get mint destination address ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/mint-addresses/mda_abc123def456 \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### List mint instructions ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET "https://api.prod.bastion.com/v2/issuance/mint-instructions?identity_id=id_123456789" \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Get mint instructions ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/mint-instructions/mi_xyz789abc123 \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Supported chains | Chain | Enum value | | - | - | | Solana Devnet | `SOLANA_DEVNET` | | Base Sepolia (Currently not available) | `BASE_SEPOLIA` | ### Error handling | HTTP status | Description | | - | - | | `400` | Bad request - Check request parameters | | `401` | Unauthorized - Invalid or missing API key | | `404` | Resource not found | | `409` | Conflict - Resource already exists (check idempotency key) | | `500` | Internal server error | ### Webhooks Configure webhooks to receive real-time notifications when mint status changes. See the [Webhooks documentation](/v2/api-reference/webhooks/overview) for setup instructions. ### Best practices 1. **Use unique request IDs**: Always generate a new UUID for each `request_id` to ensure idempotency. 2. **Verify bank details**: Double-check the bank account details before initiating wire transfers. 3. **Include memo ID**: Always include the exact `memo_id` in your wire transfer memo field. 4. **Monitor status**: Use webhooks or polling to track mint progress. 5. **Test on Devnet**: Use Solana Devnet or Base Sepolia for testing before moving to production. # Off-ramp: Withdraw stablecoin to a bank account Source: https://docs.bastion.com/guides/quickstarts/off-ramp Compare US and international off-ramp flows: destinations, pricing, quotes, third-party recipients, supported chains, and which quickstart to follow. An off-ramp converts a customer's stablecoin balance into fiat and pays it into a bank account. There are two ways to do it. Both use the same endpoint (`POST /v2/conversions`), but they differ in where the money can land, what details you collect, and how pricing works. | | US | International | | - | - | - | | **Where the money lands** | A bank account in the United States | A bank account in one of 60+ other countries | | **What the recipient receives** | USD, one-for-one with the USDC converted | The local currency, converted at the market rate | | **Who can receive it** | The customer's own bank account only | The customer's own account, or someone else's. An individual or a business | | **What you collect** | Account number and routing number for a wire | Varies by country. In some countries the customer also picks their bank from a list you fetch from Bastion | | **Pricing** | Submit the amount directly. A flat wire fee is taken out of it, so the recipient receives slightly less | Request a quote first. It returns the exchange rate, the fees, and the total cost, and you reference that quote when you submit | | **Charging your own fee** | Not available | You can add your own fee, set per destination country. It appears as a separate line on the quote so you can show customers a breakdown | | **Stablecoins and chains** | USDC on Ethereum, Solana, and Base. USDT on Ethereum | USDC on Ethereum, Solana, Base, and Polygon | Both follow the same status lifecycle: `INITIATED` → `PROCESSING` → `PAYOUT_INITIATED` → `PAYOUT_IN_PROGRESS` → `COMPLETED`, with `FAILED` and `RETURNED` as terminal errors. **The customer holding the account must be a US person either way.** What differs is where the money goes, not who can hold the account. A US customer can send to their own US bank account, to their own bank account abroad, or to someone else's bank account abroad. See [Jurisdiction overview](/guides/getting-started/jurisdiction-overview) for eligibility, state-level exceptions, and restricted jurisdictions. **Integrating US payouts only:** follow the US quickstart. It is the shorter of the two. You collect wire details once per bank account and submit conversions directly. **Integrating international payouts:** follow the international quickstart. You make two extra calls: one to price the conversion and one to look up banks in the countries that need it. The bank details you collect depend on the destination country. **Integrating both:** the identity, account, and balance steps are identical. Branch on the destination country when you register bank details, and skip the pricing step for US payouts. | Quickstart | What it covers | | - | - | | [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account) | USDC to USD by wire, to the customer's own bank account | | [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account) | USDC to local currency across 60+ countries, to the customer or to someone else | Country coverage, the details required for each country, and which countries need a bank lookup are in [Off-ramp payout countries and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields). # Off-ramp: Withdraw stablecoin internationally Source: https://docs.bastion.com/guides/quickstarts/off-ramp/international-bank-account Withdraw USDC to a bank account in 60+ countries via off-ramp conversion: quote pricing, institution lookup, payment instructions, and status tracking. ## Goal Withdraw USDC from a customer's Bastion account to a bank account outside the United States, using an off-ramp conversion. The recipient may be the customer (first-party) or someone else (third-party), and may be an individual or a business. **Scope:** USDC to local fiat via international payout rails in 60+ countries, first-party and third-party recipients. The customer holding the account must be a U.S. person; only the payout destination is international. To withdraw to a U.S. bank account, see [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account). ### Key entities | 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 account linked to the identity that holds balances and transacts | | Corridor | A destination country plus payment method combination (e.g. Mexico via SPEI) | | Quote | A priced conversion, including FX rate, fees, and corridor limits. Required on every international off-ramp | | Institution | A receiving bank in the destination country, identified by `bank_id` | | Payment instructions | The recipient's bank account details, registered once and reusable | | Conversion | A value change between assets (e.g. USDC on Solana to MXN in a bank account) via `POST /v2/conversions` | ### Flow overview The steps to withdraw funds from a stablecoin balance to an international bank account are: 1. Confirm identity, account, and balance 2. Get a quote 3. Look up institutions, if the corridor requires a bank selection 4. Register payment instructions 5. Submit the conversion 6. Track status 7. Show updated balance ### Prerequisites * Customer has a verified identity (KYC or KYB) with `fiat_operations_enabled = true` * Customer has an on-chain account with a USDC balance on a supported chain * You have an API key and a registered webhook * The destination corridor is supported. See [Payout corridors and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields) International off-ramps are supported on Ethereum, Solana, Base, and Polygon. See [Supported chains and assets](/guides/concepts/chains-and-assets). ### Step 1 – Confirm identity, account, and balance Before quoting, confirm the customer can transact and read their current balance. You need the balance to validate the withdrawal after quoting, and to support full-balance withdrawals. **Get the identity** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/identities/{identity_id} Authorization: Bearer YOUR_API_KEY ``` Verify these fields: | Field | Required value | | - | - | | `status` | `ACTIVE` | | `fiat_operations_enabled` | `true` | **Get balances** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "750000000", "available": "750000000", "decimals": 6 } ] } ``` Balances are returned in base units. Divide by `10 ^ decimals` to get the decimal amount: `750000000` with `decimals: 6` is `750.000000` USDC. Quotes and conversions take decimal strings, not base units. Confirm `available` is greater than zero here. The sufficiency check happens in Step 2, once the quote returns the all-in debit. ### Step 2 – Get a quote Every international off-ramp must reference a quote. The quote returns the FX rate, the fee breakdown, the all-in debit from the customer's wallet, and the corridor's amount limits. Query the quote in the direction that matches what the customer entered: | Customer enters | Use | | - | - | | A USDC amount to send | `source_amount` | | A fiat amount for the recipient to receive | `destination_amount` | | Their full wallet balance ("send max") | `source_total_max` | **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/conversions/quotes?source_currency=USDC&source_chain=SOLANA_MAINNET&destination_currency=MXN&destination_country=MX&payment_method=SPEI&source_amount=500.00 Authorization: Bearer YOUR_API_KEY ``` **Response** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "qt_123", "expires_at": "2026-06-23T20:00:00Z", "locked": false, "fee_model": "ON_TOP", "source": { "amount": "500.00", "total": "506.50", "currency": "USDC", "chain": "SOLANA_MAINNET", "payment_method": "BLOCKCHAIN" }, "destination": { "amount": "8620.00", "currency": "MXN", "payment_method": "SPEI", "country": "MX" }, "exchange_rate": "17.24", "fees_total": "6.50", "fees_total_currency": "USDC", "fees": [ { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" }, { "type": "DEVELOPER", "amount": "4.00", "currency": "USDC", "fee_percent": "0.80" } ], "limits": { "min_amount": "18.00", "max_amount": "", "currency": "MXN" } } ``` **Query parameters** | Parameter | Required | Type | Description | | - | - | - | - | | `source_currency` | Yes | string | Source asset currency. `USDC` | | `source_chain` | Yes | string | Blockchain network. e.g. `SOLANA_MAINNET` | | `destination_currency` | Yes | string | Destination fiat currency. e.g. `MXN`, `JPY`, `EUR` | | `destination_country` | Yes | string | ISO 3166-1 alpha-2 country code. e.g. `MX`, `JP` | | `payment_method` | Yes | string | Payment rail. e.g. `SPEI`, `BANK_TRANSFER`, `SEPA` | | `source_amount` | Conditional | string | USDC amount to convert | | `destination_amount` | Conditional | string | Fiat amount the recipient receives | | `source_total_max` | Conditional | string | USDC budget to spend from the wallet, covering principal and fees. `ON_TOP` fee model only. See [Full-balance withdrawals](#full-balance-withdrawals) | | `developer_fee_percent` | Optional | string | Overrides your configured developer fee rate on this quote. Decimal string between `0` and `100` with at most 6 decimal places, e.g. `"0.80"` for 0.80%. Pass `"0.00"` to waive it | Provide exactly one of `source_amount`, `destination_amount`, or `source_total_max`. **Response fields** | Field | Description | | - | - | | `id` | Quote identifier. Pass as `quote_id` on `POST /v2/conversions` | | `expires_at` | RFC 3339 timestamp. Submitting after this time returns `QUOTE_EXPIRED` | | `locked` | Returns `false`. `locked` covers the exchange-rate guarantee only: when `false`, the rate is indicative and the rate applied at settlement may differ from the quoted rate. Bastion snapshots fee terms on the quote and copies them to the conversion at submit, regardless of `locked`. Present the quoted rate as an estimate, and show the final values from the `COMPLETED` event on the receipt | | `fee_model` | Under `ON_TOP`, `source.total = source.amount + fees_total`, and the recipient receives the full quoted destination amount. Under `DEDUCTED`, `source.total = source.amount`, fees are taken out of the principal before FX, and the recipient receives less | | `source.amount` | USDC principal that converts. Submit this value as `amount` on the conversion | | `source.total` | All-in USDC debit from the wallet. Display on the confirmation screen. Do not submit it | | `fees[]` | One entry per fee type. `PLATFORM` is the transaction cost. `DEVELOPER` is your configured markup | | `fees_total` | Sum of `fees[].amount`, denominated in `fees_total_currency` | | `exchange_rate` | Rate applied to the converting principal. Under `ON_TOP` that is `source.amount`. Under `DEDUCTED` it is `source.amount` minus `fees_total` | | `limits` | The destination country's payout minimum and maximum, set by the local payment network. An empty `max_amount` means no maximum is enforced. Both values are denominated in `limits.currency` | After the quote returns: * Verify `available` from Step 1 covers `source.total`. Under `ON_TOP` the customer pays more than the amount they entered. * Validate the amount against `limits`. If the customer entered an amount in a currency other than `limits.currency`, convert using `exchange_rate` for client-side validation, or rely on server-side validation. * `limits.min_amount` applies to the payout amount and does not include fees. A customer sending the minimum needs the minimum plus fees in their wallet. Re-fetch the quote whenever the customer's input changes, and again before showing the confirmation screen. Each quote supports one successful conversion. Retries that reuse the same `request_id` are idempotent and do not consume a second quote. > **On **`fee_percent`**:** the `DEVELOPER` fee is a percentage of `source.amount`, calculated before FX. The `fee_percent` on that line is informational: `amount` is rounded to the asset's displayed precision, so recomputing from the percentage can differ by a cent. Use `amount` for validation, ledgering, and reconciliation. #### Full-balance withdrawals Use `source_total_max` when the customer wants to empty their wallet under `ON_TOP`. There is no headroom above the balance to add fees, so Bastion solves the principal such that `source.amount + fees_total` fits inside the budget you pass. This keeps the fee arithmetic server-side, where a client-side calculation risks a rounding mismatch that fails the submit. Constraints: * `source_total_max` works only under the `ON_TOP` fee model. Under `DEDUCTED`, `source_amount` is already the all-in debit, so send-max has no meaning. Bastion rejects `DEDUCTED` send-max quotes with a `400` error. * The budget accepts at most 6 decimal places, the same precision as `source_amount`. * The budget must cover the fees with a positive principal left over. If it cannot, Bastion rejects the quote with a `400` error. * The solved `source.amount` must still meet the corridor's payout minimum. Bastion rejects a budget that solves below `limits.min_amount`. * Bastion doesn't store the budget on the quote or echo it in the response. Read `source.amount` and `source.total` from the response. ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/conversions/quotes?source_currency=USDC&source_chain=SOLANA_MAINNET&destination_currency=MXN&destination_country=MX&payment_method=SPEI&source_total_max=100.00 Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "qt_456", "expires_at": "2026-06-23T20:00:00Z", "locked": false, "fee_model": "ON_TOP", "source": { "amount": "96.72", "total": "99.99", "currency": "USDC", "chain": "SOLANA_MAINNET", "payment_method": "BLOCKCHAIN" }, "destination": { "amount": "1667.45", "currency": "MXN", "payment_method": "SPEI", "country": "MX" }, "exchange_rate": "17.24", "fees_total": "3.27", "fees_total_currency": "USDC", "fees": [ { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" }, { "type": "DEVELOPER", "amount": "0.77", "currency": "USDC", "fee_percent": "0.80" } ] } ``` Submit the returned `source.amount` as `amount`. The returned `source.total` is less than or equal to `source_total_max`; rounding can leave a small remainder in the wallet. **Precision:** pass the full on-chain balance, not the figure your UI displays. If the wallet holds `50128456` base units (`50.128456` USDC) and your UI rounds it to `50.12`, treat a "send max" tap as intent to withdraw `50.128456` and pass that as `source_total_max`. Passing `50.12` leaves the remainder stranded and the balance will not reach zero. ### Step 3 – Look up institutions Some corridors require the customer to choose the receiving institution, and reject payment instructions submitted without a `bank_id`. Others accept any bank. Check [Payout corridors and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields) for your corridor. If it does not require a bank selection, skip to Step 4. **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/institutions?country=KR¤cy=KRW&payment_method=BANK_TRANSFER Authorization: Bearer YOUR_API_KEY ``` **Response** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "country": "KR", "currency": "KRW", "payment_method": "BANK_TRANSFER", "all_institutions_supported": false, "institutions": [ { "bank_id": "FW4qdFBLoU4WFWerI-0SBbAl3PfolkxLruCNVofPmrUoaf7Op-MouPDqqV04mQDri34", "name": "Korea Development Bank" }, { "bank_id": "tFxhEg6Cvb7sOMaTGt_EerlvmQzElLJI1DAtGJOhCGhPpJpDDg", "name": "Jeju Bank" } ] } ``` When `all_institutions_supported` is `true`, the `institutions` array is empty, any bank is accepted, and `bank_id` is not required. When it is `false`, present the returned list and pass the selected `bank_id` on registration. `bank_id` is an opaque string. Do not parse or construct it. > **Caching:** institution lists change infrequently, and a 24-hour TTL is a reasonable default. For countries returning long lists, use a search field rather than a dropdown. ### Step 4 – Register payment instructions Register the recipient's bank account. The returned `payment_instructions_id` is reusable across withdrawals to the same bank account. Before building the request: 1. Determine `payment_method` from the destination country and currency 2. Check whether the corridor requires a `bank_id` (Step 3) 3. Set `beneficiary_type` to `FIRST_PARTY` for the customer's own bank account, or `THIRD_PARTY` for someone else's 4. Collect the fields for the corridor and nest them inside the `{payment_method}_details` object 5. Set exactly one of `recipient.individual` or `recipient.business`. Some corridors require `tax_id` or `phone`, and some accept individual recipients only All corridor field requirements are in [Payout corridors and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields). **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/payment-instructions Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` Third-party payout to Mexico via SPEI: ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "b7e2c1a0-3f4d-4a9b-8c12-1d6e5f8a2b34", "payment_method": "SPEI", "country": "MX", "fiat_currency_symbol": "MXN", "account_name": "Maria's BBVA Account", "beneficiary_type": "THIRD_PARTY", "bank_account_details": { "name": "BBVA Bancomer", "spei_details": { "clabe": "626899294090851234" } }, "recipient": { "individual": { "first_name": "Maria", "last_name": "Lopez", "email": "maria.lopez@email.com", "tax_id": "HEGJ8427157M9", "address": { "street_line_1": "Av. Insurgentes Sur 1234", "city": "Mexico City", "postal_code": "03100", "country": "MX", "region_code": "CDMX" } } } } ``` **Field notes** | Field | Description | | - | - | | `request_id` | UUID idempotency key. Reuse the same `request_id` on retries to avoid creating duplicate payment instructions | | `account_name` | User-facing label for the saved payment method | | `beneficiary_type` | `FIRST_PARTY` or `THIRD_PARTY` | | `bank_account_details.name` | The receiving institution's name, not the recipient's name. Required on all corridors | | `{payment_method}_details` | Corridor-specific bank fields. The key matches the payment method, e.g. `spei_details`, `bank_transfer_details` | | `recipient.individual` | `first_name`, `last_name`, `email`, and `address` required. `middle_name` optional | | `recipient.business` | `business_name`, `email`, and `address` required | | `recipient.*.address` | The recipient's address, not the bank's | Bastion derives the account holder's name and address from the `recipient` object before forwarding to the payment provider. Do not put them inside `bank_account_details`. Corridors requiring a bank selection differ only inside `bank_account_details`. For Korea via `BANK_TRANSFER`, that object is: ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "name": "Korea Development Bank", "bank_transfer_details": { "account_number": "1234567890", "bank_id": "FW4qdFBLoU4WFWerI-0SBbAl3PfolkxLruCNVofPmrUoaf7Op-MouPDqqV04mQDri34" } } ``` **Response** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw", "payment_method": "SPEI", "country": "MX", "fiat_currency_symbol": "MXN", "account_name": "Maria's BBVA Account", "beneficiary_type": "THIRD_PARTY" } ``` Store the `id` as `payment_instructions_id`. Reference it on the conversion and reuse it for future withdrawals to this bank account. ### Step 5 – Submit the conversion Submit a conversion that debits USDC from the customer's account and pays out local fiat. Set `quote_id` to the `id` from Step 2, and set `amount` to the quoted `source.amount`. Do not submit `source.total` or the raw wallet balance. Under `ON_TOP`, Bastion adds fees on top of `amount`, so submitting the all-in figure double-counts them. **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/conversions Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "c8f4b2e0-5093-4d7b-af26-8e9c301b4d65", "quote_id": "qt_123", "amount": "500.00", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "SOLANA_MAINNET" }, "destination": { "currency": "MXN", "payment_method": "SPEI", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw" } } ``` Fee terms are set on the quote and inherited through `quote_id`. This endpoint does not accept `fee_model` or `developer_fee_percent`. To change a fee term, request a new quote and submit that `quote_id`. `payment_purpose` is required and must describe the nature of the payout. See [Payment purposes](/v2/api-reference/conversions/payment-purposes) for valid values and how to choose one. `destination.payment_method` and `destination.currency` must match the `payment_method` and `fiat_currency_symbol` on the registered payment instructions. An expired quote returns `QUOTE_EXPIRED`. A quote that has already been used, or was priced for a different corridor, is also rejected. In each case, request a new quote and resubmit. **Response - INITIATED** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3Gb7G2J33qhpt0hUSSJm7ZnttAC", "request_id": "c8f4b2e0-5093-4d7b-af26-8e9c301b4d65", "quote_id": "qt_123", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "amount": "500.00", "type": "OFF_RAMP", "status": "INITIATED", "payment_purpose": "PERSONAL_REMITTANCE", "fee_model": "ON_TOP", "source": { "amount": "500.00", "total": "506.50", "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "SOLANA_MAINNET" }, "destination": { "amount": "8620.00", "currency": "MXN", "payment_method": "SPEI", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw", "country": "MX" }, "exchange_rate": "17.24", "fees_total": "6.50", "fees_total_currency": "USDC", "fees": [ { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" }, { "type": "DEVELOPER", "amount": "4.00", "currency": "USDC", "fee_percent": "0.80" } ], "created_at": "2026-03-01T15:00:00Z", "updated_at": "2026-03-01T15:00:00Z", "completed_at": "" } ``` ### Step 6 – Track conversion status Track the conversion via webhooks (recommended) or polling until it reaches a terminal state. #### Option A: Webhooks Configure a webhook endpoint in the Bastion Dashboard to receive conversion events. Each event carries the conversion object in `data`, using the same shape as the submit response. See [Conversion notifications](/v2/api-reference/webhooks/notifications/conversions-notifications) for the full schema. The `exchange_rate` and `destination.amount` on `COMPLETED` reflect settlement and may differ from the quoted values. On `FAILED` and `RETURNED`, the event carries `failure_reason` with a human-readable description of what went wrong. Log it, and use it to decide between prompting the customer to correct bank details and routing to support. Do not display it to the customer verbatim. On `RETURNED`, also read `refund_details`: * `refund_amount` — USDC credited back to the customer's account * `deposit_id` — internal identifier for the cross-app credit (useful for support and reconciliation) * `failure_details.type` — structured category (`bank_rejected`, `expired`, `data_missing`, `other`, or `payout_returned`) for routing and analytics * `failure_details.description` — provider detail; matches `failure_reason` when both are set When present, `refund_details.transaction_hash` is the on-chain hash of the cross-app refund credit from Bastion back to the customer. **Status progression** `INITIATED` to `PROCESSING` to `PAYOUT_INITIATED` to `PAYOUT_IN_PROGRESS` to `COMPLETED` Handle statuses: | Status | Action | | - | - | | `INITIATED`, `PROCESSING` | Show a processing indicator; do not allow double-spend | | `PAYOUT_INITIATED`, `PAYOUT_IN_PROGRESS` | Show that funds are on their way to the destination bank; update the USDC balance | | `COMPLETED` | Mark the withdrawal complete, refresh balances, and display the final `exchange_rate` and `destination.amount` | | `FAILED` | Show an error, allow retry, or direct to support. Read `failure_reason` | | `RETURNED` | Read `failure_reason` and `refund_details` (amount, `deposit_id`, `failure_details`). If `failure_details.type` is `bank_rejected`, prompt the customer to verify the recipient's bank details and register corrected payment instructions before retrying. For `expired`, have the customer start a new withdrawal with a fresh quote. For `data_missing`, `other`, or `payout_returned`, route to support and use `failure_details.description` to decide next steps | For canonical status definitions and recovery guidance, see [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview). > **Webhook reliability:** failed deliveries are retried with exponential backoff (15 s base interval, 10 min max). Deduplicate using `data.id` and `data.status`. If no webhook arrives within a reasonable window, poll `GET /v2/conversions/{conversion_id}`. #### Option B: Polling Poll `GET /v2/conversions/{conversion_id}` as a fallback. The response matches the submit response, including `quote_id`, `fee_model`, `source.total`, `fees[]`, and `destination.country`. ### Step 7 – Show updated balance After the conversion completes, refresh balances and show the withdrawal in the customer's history. ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` UI recommendations: * Decrease the visible USDC balance by `source.total`, which includes fees * Display the USDC debited, the fiat delivered, the destination currency, and the status * Show the destination with masked bank account details * On the receipt, show the `exchange_rate` and `destination.amount` from the `COMPLETED` event rather than the quoted values ### Implementation notes | Recommendation | Details | | - | - | | Quote before every submit | Re-fetch when input changes and before the confirmation screen. Handle `QUOTE_EXPIRED` by re-quoting and resubmitting with the new `quote_id` | | Check `source.total`, not the entered amount | Under `ON_TOP` the customer pays more than they entered. Verify the balance covers `source.total` before submitting | | Convert base units carefully | Balances are returned in base units; quotes and conversions take decimal strings. Use the full on-chain precision for full-balance withdrawals | | Use idempotent request IDs | Provide a unique `request_id` per conversion and per payment instruction registration. Reuse it on retries | | Treat fee amounts as authoritative | Use `fees[].amount` for validation and reconciliation. `fee_percent` is for display | | Validate against corridor limits | `limits.min_amount` is always populated and excludes fees. An empty `max_amount` means no maximum | | Handle paused corridors | Bastion can pause a corridor. Quotes, payment instruction registrations, and new submits then return `422` `CORRIDOR_UNAVAILABLE`; ask the customer to try later. A retried `request_id` returns the existing conversion | | Handle failures clearly | Treat `FAILED` and `RETURNED` as errors requiring customer messaging and potential manual review. Both carry `failure_reason`; `RETURNED` also carries `refund_details` for the credited amount and structured failure metadata | | Mirror in your database | Store `identity_id`, `account_id`, `payment_instructions_id`, and `quote_id` alongside your internal records so webhooks route to the right customer | | Reuse payment instructions | `payment_instructions_id` is reusable across withdrawals to the same bank account | | Confirm the recipient | For third-party payouts, show the recipient's name and masked account details on the confirmation screen so the customer can catch entry errors before submitting | ### What's next * [Payout corridors and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields) * [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account) * [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview) * [Jurisdiction overview](/guides/getting-started/jurisdiction-overview)
# Off-ramp: Withdraw stablecoin to a US bank account Source: https://docs.bastion.com/guides/quickstarts/off-ramp/withdraw-to-a-bank-account Withdraw USDC to a customer's own US bank account via wire: register payment instructions, submit the conversion, and track it to completion. ## Goal Withdraw USDC from a customer's Bastion account to their own US bank account in USD, using an off-ramp conversion. **Scope:** USDC to USD, wire only, first-party only (the customer withdraws to a bank account they own). To pay out to a bank account outside the United States, or to a third party, see [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account). ### Key entities | 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 and a linked bank account | | Identity | The customer's record in Bastion | | Account | A Bastion account linked to the identity that holds balances and transacts | | Conversion | A value change between assets (e.g. USDC on Solana to USD in a bank account) via `POST /v2/conversions` | | Payment instructions | Bank account details (wire instructions) used to pay USD out to the customer's bank | ### Flow overview The steps to withdraw funds from a stablecoin balance to a US bank account are: 1. Confirm identity, account, and balance 2. Register payment instructions 3. Submit the conversion 4. Track status 5. Show updated balance ### Prerequisites * Customer has a verified identity (KYC or KYB) with `fiat_operations_enabled = true` * Customer has an on-chain account with a USDC balance on a supported chain * You have an API key and a registered webhook * The customer's principal address is not in New York. See [Jurisdiction overview](/guides/getting-started/jurisdiction-overview) US off-ramps are supported on Ethereum, Solana, and Base. See [Supported chains and assets](/guides/concepts/chains-and-assets). ### Step 1 – Confirm identity, account, and balance Before allowing a withdrawal, confirm the customer can transact and has sufficient balance. **Get the identity** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/identities/{identity_id} Authorization: Bearer YOUR_API_KEY ``` Verify these fields: | Field | Required value | | - | - | | `status` | `ACTIVE` | | `fiat_operations_enabled` | `true` | **Get balances** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "750000000", "available": "750000000", "decimals": 6 } ] } ``` Balances are returned in base units. Divide by `10 ^ decimals` to get the decimal amount: `750000000` with `decimals: 6` is `750.000000` USDC. Conversions take decimal strings, not base units. Verify `available` covers the withdrawal amount. Fees are deducted from that amount rather than added to it, so the customer's total debit equals the amount you submit. ### Step 2 – Register payment instructions Register the customer's bank account as payment instructions. The returned `payment_instructions_id` is reusable across withdrawals to the same bank account. **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/payment-instructions Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "a3b1c4d2-e5f6-4a89-b0c1-d2e3f4a5b6c7", "payment_method": "WIRE", "country": "US", "fiat_currency_symbol": "USD", "account_name": "John's Chase Account", "beneficiary_type": "FIRST_PARTY", "bank_account_details": { "name": "Chase", "wire_details": { "account_number": "123321", "routing_number": "321123", "type": "CHECKING", "address": { "street_line_1": "270 Park Ave", "city": "New York", "postal_code": "10017", "country": "US", "region_code": "NY" } } }, "recipient": { "individual": { "first_name": "John", "last_name": "Doe", "email": "john.doe@example.com", "address": { "street_line_1": "100 Biscayne Blvd", "city": "Miami", "postal_code": "33132", "country": "US", "region_code": "FL" } } } } ``` **Field notes** | Field | Description | | - | - | | `request_id` | UUID idempotency key. Generate a unique UUID for each registration, and reuse the same `request_id` on retries to avoid creating duplicate payment instructions | | `account_name` | User-facing label for the saved payment method (e.g. "John's Chase Account") | | `beneficiary_type` | `FIRST_PARTY`. US off-ramps pay out only to a bank account the customer owns | | `bank_account_details.name` | Required. The bank's legal name (e.g. "Chase") | | `wire_details.type` | `CHECKING` or `SAVINGS` | | `wire_details.address` | The bank's address, not the customer's | | `recipient.individual.first_name` | Recipient's first name | | `recipient.individual.middle_name` | Optional | | `recipient.individual.last_name` | Recipient's last name | | `recipient.individual.email` | Required on all registrations | | `recipient.individual.address` | The recipient's address, not the bank's | **Response** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw", "payment_method": "WIRE", "country": "US", "fiat_currency_symbol": "USD", "account_name": "John's Chase Account", "beneficiary_type": "FIRST_PARTY", "bank_account_details": { "name": "Chase", "wire_details": { "account_number": "123321", "routing_number": "321123", "type": "CHECKING", "address": { "street_line_1": "270 Park Ave", "city": "New York", "postal_code": "10017", "country": "US", "region_code": "NY" } } }, "recipient": { "individual": { "first_name": "John", "last_name": "Doe", "email": "john.doe@example.com", "address": { "street_line_1": "100 Biscayne Blvd", "city": "Miami", "postal_code": "33132", "country": "US", "region_code": "FL" } } } } ``` Store the `id` as `payment_instructions_id` so you can reference it on the conversion request and reuse it for future withdrawals to this bank account. ### Step 3 – Submit the conversion Submit a conversion that debits USDC from the customer's account and sends USD to their bank. **Request** ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/conversions Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "amount": "500.00", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET" }, "destination": { "currency": "USD", "payment_method": "WIRE", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw" } } ``` `payment_purpose` is required and must describe the nature of the payout. See [Payment purposes](/v2/api-reference/conversions/payment-purposes) for valid values and how to choose one. `destination.payment_method` and `destination.currency` must match the `payment_method` and `fiat_currency_symbol` on the registered payment instructions. **Response - INITIATED** ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GTrcGxI4dvxsVzWxIRPOpQp3S1", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "amount": "500.00", "type": "OFF_RAMP", "status": "INITIATED", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "amount": "500.00", "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET" }, "destination": { "amount": "486.00", "currency": "USD", "payment_method": "WIRE", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw" }, "exchange_rate": "1.0", "fee_model": "DEDUCTED", "fees_total": "14.00", "fees_total_currency": "USDC", "fees": [ { "amount": "14.00", "currency": "USDC", "type": "PLATFORM" } ], "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:00:00Z", "completed_at": "" } ``` The `PLATFORM` fee is the wire processing cost. It is deducted from `amount` before payout, so `destination.amount` is `amount` minus `fees_total`. > **Tip:** use a unique `request_id` (UUID) as an idempotency key, and reuse the same `request_id` on retries. The `payment_instructions_id` can be reused across withdrawals to the same bank account. ### Step 4 – Track conversion status Track the conversion via webhooks (recommended) or polling until it reaches a terminal state. #### Option A: Webhooks Configure a webhook endpoint in the Bastion Dashboard to receive conversion events. Each event carries the conversion object in `data`, using the same shape as the submit response. ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GuXD1oNPTfqc9sIsbmbnqr6rx5", "type": "conversion_notification_update", "data": { "id": "3GTrcGxI4dvxsVzWxIRPOpQp3S1", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "amount": "500.00", "type": "OFF_RAMP", "status": "COMPLETED", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "amount": "500.00", "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET" }, "destination": { "amount": "486.00", "currency": "USD", "payment_method": "WIRE", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw" }, "exchange_rate": "1.0", "fee_model": "DEDUCTED", "fees_total": "14.00", "fees_total_currency": "USDC", "fees": [ { "amount": "14.00", "currency": "USDC", "type": "PLATFORM" } ], "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:45:00Z", "completed_at": "2026-04-19T15:45:00Z" } } ``` The top-level `id` is unique per delivery. The stable conversion ID is `data.id`. See [Conversion notifications](/v2/api-reference/webhooks/notifications/conversions-notifications) for the full schema. On `FAILED` and `RETURNED`, the event carries `failure_reason` with a human-readable description of what went wrong. Log it, and use it to decide between prompting the customer to correct bank details and routing to support. Do not display it to the customer verbatim. **Status progression** `INITIATED` to `PROCESSING` to `PAYOUT_INITIATED` to `PAYOUT_IN_PROGRESS` to `COMPLETED` Handle statuses: | Status | Action | | - | - | | `INITIATED`, `PROCESSING` | Show a processing indicator; do not allow double-spend | | `PAYOUT_INITIATED`, `PAYOUT_IN_PROGRESS` | Show "sending to your bank"; update the USDC balance | | `COMPLETED` | Mark the withdrawal complete, refresh balances, and show it as settled | | `FAILED` | Show an error, allow retry, or direct to support. Read `failure_reason` | | `RETURNED` | The receiving bank rejected the payout. Prompt the customer to verify their bank details. Read `failure_reason` | For canonical status definitions and recovery guidance, see [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview). > **Webhook reliability:** failed deliveries are retried with exponential backoff (15 s base interval, 10 min max). Deduplicate using `data.id` and `data.status`. If no webhook arrives within a reasonable window, poll `GET /v2/conversions/{conversion_id}`. #### Option B: Polling Poll `GET /v2/conversions/{conversion_id}` as a fallback. Use the returned status and fee breakdown to update your UI. ### Step 5 – Show updated balance After the conversion completes, refresh balances and show the withdrawal in your history. ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` UI recommendations: * Decrease the visible USDC balance by the amount submitted, which includes fees * Display the amount, the asset (USDC), the USD delivered, and the status * Show the destination with masked bank account details ### Implementation notes | Recommendation | Details | | - | - | | Fees are deducted, not added | `fee_model` returns `DEDUCTED`. The `PLATFORM` fee comes out of the amount you submit, so the recipient receives less than the amount. Developer fees are not available on US payouts | | Check balance first | Verify `available` covers the withdrawal before submitting, and handle insufficient funds gracefully | | No minimum withdrawal | Wire has no network minimum, and Bastion does not add one. Because the fee is deducted from the amount submitted, the practical floor is any amount that exceeds the fee | | Convert base units carefully | Balances are returned in base units; conversions take decimal strings | | Use idempotent request IDs | Provide a unique `request_id` per conversion and per payment instruction registration. Reuse it on retries | | Handle failures clearly | Treat `FAILED` and `RETURNED` as errors requiring customer messaging and potential manual review. Both carry `failure_reason` | | Mirror in your database | Store `identity_id`, `account_id`, and `payment_instructions_id` alongside your internal user records so webhooks route to the right customer | | Reuse payment instructions | `payment_instructions_id` is reusable across withdrawals to the same bank account | | Be explicit about ownership | Make clear in your UI that withdrawals go to the customer's own bank account | ### What's next * [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account) * [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview) * [Jurisdiction overview](/guides/getting-started/jurisdiction-overview)
# On-ramp: Fund a stablecoin account with a USD wire Source: https://docs.bastion.com/guides/quickstarts/on-ramp Create a Virtual Account, share wire instructions with the customer, and let Bastion auto-convert the USD deposit into USDC when the wire lands. ## Goal Fund a customer's Bastion account with USDC by converting USD wired from their bank account, using an on-ramp conversion via a Virtual Account (VA). > **Scope:** USD → USDC, Wire only, US customers only, first-party (customer wires from their own bank account). ## Key entities | Entity | Description | | - | - | | **Your platform** | The product integrating Bastion's APIs | | **Customer** | An individual or business your platform has onboarded (KYC/KYB-verified), with a Bastion account | | **Identity** | The customer's record in Bastion | | **Account** | A Bastion account linked to the identity that holds balances and transacts | | **Virtual Account (VA)** | A dedicated deposit address at Bastion's partner bank tied to a specific customer, chain, and destination wallet | | **Conversion** | A value change between assets (e.g., USD wired in → USDC on Ethereum) auto-created when a deposit lands | *** ## Flow overview The steps to fund a stablecoin balance from a bank account are: 1. Create a Virtual Account 2. Share deposit instructions with the customer 3. Customer wires USD 4. Track conversion status 5. Show balance Unlike off-ramps, you do not call [`POST /v2/conversions`](/v2/api-reference/conversions/submit-conversion). Bastion auto-creates the conversion the moment it detects the incoming wire. *** ## Prerequisites * Customer has a **verified identity** (KYC or KYB) with `fiat_operations_enabled = true` * Customer has an active Bastion account on a supported chain * You have an API key and registered webhook *** ## Step 1 – create a virtual account Create a Virtual Account for the customer. Each VA links to a specific chain, asset, and destination wallet; create one VA per chain and asset you want to support. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/virtual-accounts Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "source": { "banking_country": "US", "currency": "USD" }, "destination": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz" } } ``` **Field notes** | Field | Description | | - | - | | `source.banking_country` | Determines the banking partner. `US` for US customers | | `source.currency` | Fiat currency the VA accepts. `USD` for US Wire | | `destination.chain` | Blockchain network for USDC delivery (e.g. `ETHEREUM_MAINNET`) | | `destination.account_id` | Customer's Bastion account that receives USDC | **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "va_3Cb0rR32xNl2GAHFjJc4EHEcdPw", "identity_id": "36RNhc8l8oQLLUS2aJaXZaUbwnC", "status": "ACTIVE", "source": { "banking_country": "US", "currency": "USD" }, "destination": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz" }, "deposit_instructions": { "bank_name": "Partner Bank", "bank_address": "123 Banking St, New York, NY 10001", "bank_account_number": "9876543210", "bank_routing_number": "021000000", "bank_beneficiary_name": "John Doe", "bank_beneficiary_address": "99 Main St, New York, NY 10001", "payment_method": "WIRE", "currency": "USD" }, "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:00:00Z" } ``` Store the `id` as `virtual_account_id` so you can reference this VA later. You can retrieve it again at any time from the [`GET /v2/identities/{identity_id}/virtual-accounts`](/v2/api-reference/virtual-accounts/list-virtual-accounts) list endpoint. *** ## Step 2 – share deposit instructions with the customer Present the `deposit_instructions` from Step 1 to the customer so they can initiate a wire from their bank. **Fields to surface in your UI** | Field | Label for the customer | | - | - | | `bank_name` | Bank name | | `bank_address` | Bank address | | `bank_account_number` | Account number | | `bank_routing_number` | Routing number (ABA) | | `bank_beneficiary_name` | Beneficiary name | | `bank_beneficiary_address` | Beneficiary address | The `bank_account_number` is unique to this VA. The customer does not need to include a memo or reference. The account number alone routes the deposit to the correct VA. *** ## Step 3 – customer wires USD The customer initiates a domestic wire from their bank to the deposit instructions shared in Step 2. When the wire lands at Bastion's banking partner, Bastion detects it and auto-creates a conversion record. No API call is required from your platform at this step. Testing in sandbox? See [Test the on-ramp in sandbox](/guides/quickstarts/on-ramp#test-the-on-ramp-in-sandbox). *** ## Step 4 – track conversion status Once Bastion detects the wire, it creates the conversion and fires webhooks as the conversion progresses. You learn the `conversion_id` from the first webhook. ### Option A: webhooks **Example event (INITIATED)** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "cv_334455", "request_id": "auto-generated-uuid", "amount": "500.00", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "currency": "USD", "payment_method": "WIRE", "amount": "500.00" }, "destination": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "amount": "" }, "type": "ON_RAMP", "status": "INITIATED", "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:00:00Z", "completed_at": "", "exchange_rate": "1.0", "fees_total": "14.00", "fees_total_currency": "USDC", "fees": [ { "amount": "14.00", "currency": "USDC", "type": "PLATFORM" } ] } ``` **Example event (COMPLETED)** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "cv_334455", "request_id": "auto-generated-uuid", "amount": "500.00", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "payment_purpose": "PERSONAL_REMITTANCE", "source": { "currency": "USD", "payment_method": "WIRE", "amount": "500.00" }, "destination": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "amount": "486.00" }, "type": "ON_RAMP", "status": "COMPLETED", "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:45:00Z", "completed_at": "2026-04-19T15:45:00Z", "exchange_rate": "1.0", "fees_total": "14.00", "fees_total_currency": "USDC", "fees": [ { "amount": "14.00", "currency": "USDC", "type": "PLATFORM" } ] } ``` **Status progression** The on-ramp progression is: `INITIATED` → `PROCESSING` → `PAYOUT_INITIATED` → `PAYOUT_IN_PROGRESS` → `COMPLETED` **Handle statuses:** | Status | Action | | - | - | | `INITIATED`, `PROCESSING` | Show "funding your account" indicator. | | `PAYOUT_INITIATED`, `PAYOUT_IN_PROGRESS` | Show "delivering USDC"; USDC is on the way to the customer's wallet. | | `COMPLETED` | Mark on-ramp complete, refresh balances, show as settled. | | `FAILED` | Show error, direct to support. | > **Webhook reliability:** Failed deliveries are retried with exponential backoff (15 s base interval, 10 min max). Deduplicate using `id` + `status`. If no webhook arrives within a reasonable window, poll [`GET /v2/conversions/{conversion_id}`](/v2/api-reference/conversions/get-conversion). ### Option B: polling If webhook delivery fails, list conversions by account to find the record: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/conversions?account_id={account_id}&type=ON_RAMP Authorization: Bearer YOUR_API_KEY ``` Then poll [`GET /v2/conversions/{conversion_id}`](/v2/api-reference/conversions/get-conversion) for status updates. *** ## Step 5 – show updated balance After the conversion completes, refresh balances and show the on-ramp in the customer's history. **Refresh balances** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` **UI recommendations:** * Increase visible USDC balance by `destination.amount` from the completion webhook * Display USD wired (gross), fees, USDC delivered (net), and status * Show source (masked bank account details from the wire) *** ## Fees `fees_total` on the conversion reflects the incoming wire fee only. Bastion deducts the fee from the wired USD amount before delivering USDC. Example: customer wires \$500 → `fees_total = 14.00` → `destination.amount = 486.00` USDC delivered. The `fees` array is populated from the moment the conversion is created (when the wire lands). Developer fees are not supported yet. *** ## Implementation notes | Recommendation | Details | | - | - | | **One VA per chain** | Create a separate VA for each chain (Ethereum, Solana, Base) you want to support | | **VAs are reusable** | The same VA accepts multiple deposits. Don't create a new VA per transaction | | **Learn conversion ID from webhook** | You don't create the conversion; the `id` arrives in the first webhook | | **Map IDs to your users** | Store `identity_id`, `account_id`, and `virtual_account_id` alongside your internal user records so webhooks route to the right customer | | **Handle failures clearly** | Treat `FAILED` as errors requiring customer messaging | *** ## Test the on-ramp in sandbox Sandbox Virtual Accounts can't receive real deposits. To test the on-ramp end to end, simulate a deposit instead. Bastion then runs the same on-ramp flow a real deposit triggers. **How a simulated deposit differs from a real one** * No USDC is delivered, so the account balance doesn't change. Use simulation to test status tracking and webhook handling, not funded balances. * The simulation endpoint is available in sandbox only. Call the simulation endpoint with the customer's account and the `id` of the on-ramp VA from Step 1. ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST https://api.sandbox.bastion.io/v2/simulation/on-ramp-deposit Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "amount": "500.00", "virtual_account_id": "va_3Cb0rR32xNl2GAHFjJc4EHEcdPw" } ``` The response returns the `request_id` of the on-ramp conversion the deposit starts. ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "7f3e2a10-5b8c-4d9e-a1f2-3c4d5e6f7a8b" } ``` Poll the lookup endpoint with the `request_id` until it returns the conversion. The response includes the conversion's `id`. ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET https://api.sandbox.bastion.io/v2/conversions/by-request-id/{request_id} Authorization: Bearer YOUR_API_KEY ``` From here the conversion behaves like a real one: webhooks fire as it moves through each status, and you can poll [`GET /v2/conversions/{conversion_id}`](/v2/api-reference/conversions/get-conversion) with its `id`. Track its status as described in Step 4. Balances don't change in sandbox, so skip the balance updates. To test failure paths, such as a deposit below the minimum, add the matching label to the identity before simulating. See [On-ramp and off-ramp conversion simulations](/guides/getting-started/sandbox-testing-and-simulations#on-ramp-and-off-ramp-conversion-simulations). # Onboard a business customer Source: https://docs.bastion.com/guides/quickstarts/onboard-a-business-customer Onboard a business to Bastion end-to-end: create a business identity, submit KYB compliance data, track status webhooks, and open a transacting account. ## Goal Onboard a business customer to Bastion so they can hold digital assets and use conversions and transfers. ## Key entities | Entity | Description | | - | - | | **Your platform** | The product integrating Bastion's APIs | | **Business customer** | A business your platform onboards and verifies | | **Identity** | The record of a business customer in Bastion, used for KYB and permissions | | **Account** | A unique operational unit tied to an Identity. Each Account has its own wallet address per supported blockchain and is used to perform transactions | *** ## Flow overview Follow these steps to onboard a business customer and have them transact: 1. Create identity 2. Submit KYB 3. Track status 4. Create account 5. Start transacting ### Simulation for sandbox testing Refer to [sandbox testing and simulations](/guides/getting-started/sandbox-testing-and-simulations) for a guide on how to use simulation labels to simulate the onboarding checks to facilitate testing. *** ## Prerequisites * You have access to your dashboard * You've created an API key * You've registered a webhook *** ## Step 1 – create a business identity Create a business identity in Bastion when your customer's business signs up or is approved in your own system. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity_type": "BUSINESS", "label": "corp_acme_001" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXBUSINESSIDENTITY0001", "identity_type": "BUSINESS", "account_ids": [], "kyb_status": { "kyb_passed": false, "jurisdiction_check_passed": false, "allowed_to_transact_fungibles": false }, "label": "corp_acme_001", "fiat_operations_enabled": false, "status": "PENDING_ONBOARDING", "sub_status": "CREATED" } ``` Store the returned `identity_id` alongside your internal business customer ID for all future calls. > **Tip:** Use `label` for a stable, non-PII identifier (e.g., your internal business ID). Do not include names, emails, or phone numbers. *** ## Step 2 – submit KYB data Submit KYB information for the business identity. Your platform collects this data in your own UI and sends it to Bastion. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/compliance Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "kyb": { "name": "Acme Robotics LLC", "tax_id": "12-3456789", "email_address": "compliance@acme.example", "address": { "street_line_1": "500 Howard St", "street_line_2": "Floor 3", "city": "San Francisco", "postal_code": "94105", "country": "US", "region_code": "CA" }, "description": "B2B SaaS robotics control software and hardware subscriptions.", "source_of_funds": "BUSINESS_REVENUE", "business_type": "LLC", "website": "https://acme.example", "is_dao": false, "transmits_customer_funds": false, "compliance_screening_explanation": "We do not transmit funds on behalf of customers; screening is limited to our own treasury and payroll counterparties.", "ultimate_beneficial_owners": [ { "first_name": "Jordan", "last_name": "Lee", "birth_date": "1985-06-11", "email": "jordan.lee@acme.example", "phone": "+14155550100", "tax_identification_number": "123-45-6789", "address": { "street_line_1": "10 Main St", "city": "Oakland", "postal_code": "94607", "country": "US", "region_code": "CA" }, "gov_id_image_front": { "file_data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ…", "issuing_country": "US", "issued_date": "2022-01-15", "type": "DOCUMENT_TYPE_DRIVER_LICENSE" }, "gov_id_image_back": { "file_data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ…", "issuing_country": "US", "issued_date": "2022-01-15", "type": "DOCUMENT_TYPE_DRIVER_LICENSE" }, "has_ownership": true, "has_control": true, "is_signer": true, "relationship_established_at": "2020-03-01", "control_person_title": "CEO" } ], "formation_document": { "file_data": "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MK…", "issuing_country": "US", "issued_date": "2018-04-01", "type": "DOCUMENT_TYPE_FORMATION" }, "ownership_document": { "file_data": "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MK…", "issuing_country": "US", "issued_date": "2019-06-10", "type": "DOCUMENT_TYPE_OWNERSHIP" }, "date_of_incorporation": "2018-04-01", "business_industry_code": "541511", "account_purpose": "TREASURY_MANAGEMENT", "registration_number": "C4567890" } } ``` Bastion processes KYB asynchronously. It updates the status over time and emits webhooks. *** ## Step 3 – track KYB status Track KYB status via * **`identity_status_update` webhooks (recommended) or** * **polling** via [`GET /v2/identities/{identity_id}`](/v2/api-reference/identities/get-identity) Typically, most KYB checks are approved within 3-5 business days once data and documents are submitted. ### Option A: webhooks Configure a webhook endpoint in the Bastion Dashboard to react to KYB status changes in real time. **Example event** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYB_ACTIVE001", "type": "identity_status_update", "data": { "identity": { "id": "01HZXBUSINESSIDENTITY0001", "label": "corp_acme_001", "identity_type": "BUSINESS", "status": "ACTIVE" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "COMPLIANCE_CHECKS_IN_PROGRESS" } } ``` Use this event to: * Mark the business as approved or failed in your system * Enable or block access to financial features ### Option B: polling Poll the identity endpoint as a fallback. ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/identities/{identity_id} Authorization: Bearer YOUR_API_KEY ``` **Response (approved)** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXBUSINESSIDENTITY0001", "identity_type": "BUSINESS", "account_ids": ["01HZXBIZACCOUNT00000001"], "kyb_status": { "kyb_passed": true, "jurisdiction_check_passed": true, "allowed_to_transact_fungibles": true }, "label": "corp_acme_001", "fiat_operations_enabled": true, "status": "ACTIVE", "sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED" } ``` **Key fields** | Field | Meaning | | - | - | | `status` | `ACTIVE`: Onboarding completed and identity is available to transact | | `sub_status` | Onboarding detail when status is `PENDING_ONBOARDING` (e.g. compliance running, IDV steps, RFI); otherwise not meaningful / often unspecified | | `allowed_to_transact_fungibles` | Whether the identity can use crypto transfers | | `fiat_operations_enabled` | Whether on/off-ramp conversions are enabled | > Only proceed to account creation after KYB passes. ### Terminal KYB statuses A KYB attempt resolves to one of two terminal statuses: | Status | Meaning | | - | - | | `ACTIVE` | KYB approved. Inform the customer of successful onboarding and proceed to downstream actions, such as account creation. | | `REJECTED` | KYB denied. The identity is not able to transact or proceed with account opening. | Any other `status` (for example `PENDING_ONBOARDING`) means the attempt is still in progress. Keep listening for `identity_status_update` events or continue polling. For the full list of identity statuses, see [Identities and accounts](/guides/concepts/identities-and-accounts). *** ## Step 4 – create an account Once KYB is approved, create an account linked to the business identity. Use a unique `request_id` (UUID) as an idempotency key. Reuse the same `request_id` on retries to avoid duplicate accounts. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/accounts Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_id": "01HZXBUSINESSIDENTITY0001", "type": "SEGREGATED" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_type": "BUSINESS", "addresses": [ { "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", "chains": ["BASE_MAINNET", "ETHEREUM_MAINNET"] }, { "address": "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK", "chains": ["SOLANA_MAINNET"] } ], "identity_id": "01HZXBUSINESSIDENTITY0001", "id": "01HZXBIZACCOUNT00000001", "type": "SEGREGATED", "frozen": false, "identity_status": "ACTIVE", "identity_sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED", "account_status": "ACTIVE" } ``` Capture the `account_id`. Use this account for: * Checking balances * On-ramp and off-ramp conversions * Crypto transfers *** ## Step 5 – verify setup Confirm the business is fully ready: 1. **Check identity status** — [`GET /v2/identities/{identity_id}`](/v2/api-reference/identities/get-identity) 2. **Check account** — Verify `account_ids` includes the new account and `account_status = ACTIVE` 3. **Check balances** (optional) — [`GET /v2/accounts/{account_id}/balances`](/v2/api-reference/accounts/get-account-balances) **Next actions:** * Top up via on-ramp (wire → stablecoin) * Send crypto via `/v2/crypto/transfers` * Withdraw via off-ramp (stablecoin → USD) *** ## Implementation notes | Recommendation | Details | | - | - | | **Wait for KYB approval** | Accounts cannot be created until `status = ACTIVE`. Always wait before enabling money movement. Most KYB checks are approved within seconds once data and documents are submitted | | **Mirror in your database** | Store `identity_id`, `account_id`, and status in your own system. Keep in sync via webhooks. | | **Separate access model from KYB** | Your own roles, permissions, and access control are separate from Bastion's KYB. Treat Bastion as the financial/compliance layer only. | # Onboard an individual customer Source: https://docs.bastion.com/guides/quickstarts/onboard-an-individual-customer Onboard an individual to Bastion end-to-end: create an identity, submit KYC data, handle optional IDV checks, and open a transacting account. ## Goal Onboard an individual customer to Bastion so they can hold digital assets and use conversions and transfers. ## Key entities | Entity | Description | | - | - | | **Your platform** | The product integrating Bastion's APIs | | **Individual customer** | A person your platform onboards and verifies | | **Identity** | The record of an individual customer in Bastion, used for KYC and permissions | | **Account** | A unique operational unit tied to an Identity. Each Account has its own wallet address per supported blockchain and is used to perform transactions | *** ## Flow overview The steps needed to onboard and transact an individual customer are: 1. Create identity 2. Submit KYC 3. Track status 1. (Conditionally required) Identity verification check 4. Create account 5. Start transacting ### Simulation for sandbox testing Refer to [sandbox testing and simulations](/guides/getting-started/sandbox-testing-and-simulations) for a guide on how to use simulation labels to simulate both the onboarding and IDV checks to facilitate testing. *** ## Prerequisites * You have access to your dashboard * You've created an API key * You've registered a webhook *** ## Step 1 – create an individual identity Create an identity in Bastion as soon as your customer signs up or when they first try to use financial features. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "label": "merchant-user-48291", "identity_type": "INDIVIDUAL" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "identity_type": "INDIVIDUAL", "account_ids": [], "label": "merchant-user-48291", "fiat_operations_enabled": false, "status": "PENDING_ONBOARDING", "sub_status": "CREATED" } ``` Store the returned `identity_id` alongside your internal customer ID for all future calls. > **Tip:** Use `label` for a stable, non-PII identifier (e.g., your internal customer ID). Do not include names, emails, or phone numbers. *** ## Step 2 – submit KYC data Submit KYC information for the identity. Your platform collects this data and sends it to Bastion. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/identities/{identity_id}/compliance Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "kyc": { "first_name": "Jane", "middle_name": "Q", "last_name": "Doe", "date_of_birth": "1990-04-20", "ip_address": "203.0.113.10", "physical_address": { "street_line_1": "123 Market St", "street_line_2": "Suite 400", "city": "San Francisco", "postal_code": "94105", "country": "US", "region_code": "CA" }, "tax_id_number": "123-45-6789", "phone_number": "+14155552671", "email_address": "jane.doe@example.com", "account_purpose": "TRADING", "source_of_funds": "EMPLOYMENT_SALARY", "occupation": "FINANCE_AND_INSURANCE", "nationality": "US", "country_of_birth": "US", "additional_id_number": "D1234567" } } ``` Bastion processes KYC asynchronously. It updates the status over time and emits webhook events, or you can poll as described below. *** ## Step 3 – track KYC status Track KYC status via * **`identity_status_update` webhooks (recommended) or** * **polling** via [`GET /v2/identities/{identity_id}`](/v2/api-reference/identities/get-identity) Typically, most KYC checks are approved within seconds once data is submitted. ### Option A: webhooks Configure a webhook endpoint in the Bastion Dashboard to react to KYC status changes in real time. Event: `identity_status_update` **Example event** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "type": "identity_status_update", "data": { "identity": { "id": "3CPgtujGnpORK9EKaXu5U9OclxU", "label": "merchant-user-48291", "identity_type": "INDIVIDUAL", "status": "ACTIVE" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "COMPLIANCE_CHECKS_IN_PROGRESS" } } ``` Use this event to: * Mark the customer as approved or failed in your system * Enable or block access to financial features ### Option B: polling Poll the identity endpoint as a fallback. ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/identities/{identity_id} Authorization: Bearer YOUR_API_KEY ``` **Response (approved)** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "identity_type": "INDIVIDUAL", "account_ids": [], "label": "merchant-user-48291", "fiat_operations_enabled": false, "status": "ACTIVE", "sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED" } ``` **Key fields** | Field | Meaning | | - | - | | `status` | `ACTIVE`: Onboarding completed and identity is available to transact | | `sub_status` | Onboarding detail when status is `PENDING_ONBOARDING` (e.g. compliance running, IDV steps, RFI); otherwise not meaningful / often unspecified | | `allowed_to_transact_fungibles` | Whether the identity can use crypto transfers | | `fiat_operations_enabled` | Whether on/off-ramp conversions are enabled | > Only proceed to account creation after `status = ACTIVE`. ### Terminal KYC statuses A KYC attempt resolves to one of two terminal statuses: | Status | Meaning | | - | - | | `ACTIVE` | KYC approved. Inform the customer of successful onboarding and proceed to downstream actions, such as account creation. | | `REJECTED` | KYC denied. The identity is not able to transact or proceed with account opening. | Any other `status` (for example `PENDING_ONBOARDING`) means the attempt is still in progress. Keep listening for `identity_status_update` events or continue polling. For the full list of identity statuses, see [Identities and accounts](/guides/concepts/identities-and-accounts). *** ## Step 3a – identity verification For a subset of identities undergoing KYC, Bastion may require you to run an additional identity verification (IDV) process. When required, Bastion emits the `identity_status_update` event. ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "type": "identity_status_update", "data": { "identity": { "id": "3CPgtujGnpORK9EKaXu5U9OclxU", "label": "merchant-user-48291", "identity_type": "INDIVIDUAL", "status": "PENDING_ONBOARDING", "sub_status": "IDV_REQUIRED" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": null } } ``` When IDV is required, create a new IDV by using the endpoint `/v2/identities/{identity_id}/id_verification`. **Request** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "redirect_url": "https://yourapp.example/idv/return", "reason": "ONBOARDING", "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXIDVCHK0000000001", "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "verification_url": "https://idv.vendor.example/session/abc123", "reason": "ONBOARDING", "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "redirect_url": "https://yourapp.example/idv/return", "status": "IN_PROGRESS", "created_at": "2026-04-20T16:00:00Z", "updated_at": "2026-04-20T16:00:00Z" } ``` Provide the returned `verification_url` to the onboarding end user to undergo IDV. Continue polling (or listening to the webhook) for the identity `status` to turn `ACTIVE` as the signal for KYC completion. * The source of truth for identity status is the identity's `status` field: `data.identity.status` on the `identity_status_update` webhook, or [GET identity](/v2/api-reference/identities/get-identity). Do not rely on the status of IDV checks. *** ## Step 4 – create an account Once `status` is `ACTIVE`, create an account linked to the identity. Use a unique `request_id` (UUID) as an idempotency key. Reuse the same `request_id` on retries to avoid duplicate accounts. **Request** ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/accounts Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "type": "SEGREGATED" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_type": "INDIVIDUAL", "addresses": [ { "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", "chains": ["ETHEREUM_MAINNET", "BASE_MAINNET"] }, { "address": "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK", "chains": ["SOLANA_MAINNET"] } ], "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "id": "01HZXBANKACCOUNTIDEXAMPLE0", "type": "SEGREGATED", "frozen": false, "identity_status": "ACTIVE", "identity_sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED", "account_status": "ACTIVE" } ``` Capture the `account_id`. Use this account for: * Checking balances * On-ramp and off-ramp conversions * Crypto transfers *** ## Step 5 – verify setup Confirm the customer is fully ready: 1. **Check identity status** — [`GET /v2/identities/{identity_id}`](/v2/api-reference/identities/get-identity) 2. **Check account** — Verify `account_ids` includes the new account and `account_status = ACTIVE` 3. **Check balances** (optional) — [`GET /v2/accounts/{account_id}/balances`](/v2/api-reference/accounts/get-account-balances) **Next actions:** * Top up via on-ramp (USD → stablecoin) * Send crypto via `/v2/crypto/transfers` * Withdraw via off-ramp (stablecoin → USD) *** ## Implementation notes | Recommendation | Details | | - | - | | **Wait for KYC approval** | Accounts cannot be created until `status = ACTIVE`. Always wait before enabling money movement. Most KYC checks are approved within seconds once data and documents are submitted. | | **Mirror in your database** | Store `identity_id`, `account_id`, and status in your own system. Keep in sync via webhooks. | | **Separate auth from KYC** | Your login, session, and device security are separate from Bastion's KYC. Treat Bastion as the financial/compliance layer only. | # Receive crypto deposits to a Bastion account Source: https://docs.bastion.com/guides/quickstarts/receive-crypto-deposits Display a customer's deposit address, receive deposit webhooks when crypto arrives, and reconcile on-chain and off-chain deposits by source_type. ## Goal Detect and handle incoming crypto deposits to a customer's Bastion account. Deposits can arrive from external wallets, exchanges, or other accounts on your platform. ## 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 an active account | | Identity | The customer's record in Bastion | | Account | A Bastion account linked to the identity, with wallet addresses per supported blockchain | | Deposit | An incoming asset credit detected by Bastion and delivered via `deposit_v2` webhook | | `source_type` | Indicates the origin of the deposit: `ON_CHAIN` for external blockchain deposits, `ACCOUNT` for off-chain transfers from another account on your platform | Bastion handles block finality verification and compliance screening internally before crediting the customer's balance. Your platform receives a single `deposit_v2` webhook when the deposit is confirmed and the balance has been credited. ## Flow overview 1. Retrieve the customer's deposit address and display it in your UI. 2. When crypto arrives, Bastion detects the deposit, runs compliance checks, and credits the balance. 3. Your platform receives a `deposit_v2` webhook. Update the displayed balance. 4. Optionally, query deposit history for the account. *** ## Prerequisites * The customer has a **verified identity** (KYC or KYB) in Bastion. * The customer has an **active account** with at least one wallet address. * You have an **API key** and have **registered your webhook**. *** ## Step 1 — retrieve and display the deposit address Get the customer's wallet address and display it in your UI so the sender knows where to send funds. **Get the account** ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id} Authorization: Bearer YOUR_API_KEY ``` **Response:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "36hv3yZpsHt7pwgBZom5SXgURYz", "request_id": "iz-sc-test-ee5d809d-07b6-4a27-b3f4-89211ac682da", "type": "SEGREGATED", "addresses": [ { "chains": [ "ETHEREUM_MAINNET", "POLYGON_MAINNET", "BASE_MAINNET", "OPTIMISM_MAINNET" ], "address": "0x1C2D1F96d946764dc7d28b963903D8AB08180DFb" }, { "chains": [ "SOLANA_MAINNET" ], "address": "G2Pt9Gq56sxNcfXJLuA5TrQpoqQL4Vdq1mM2cZABpbvN" } ], "identity_id": "36RNhc8l8oQLLUS2aJaXZaUbwnC", "identity_type": "INDIVIDUAL", "identity_status": "ACTIVE", "identity_sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED", "account_status": "ACTIVE", "frozen": false, "labels": [] } ``` Use the `addresses` array to determine which chains and addresses the customer can receive on. Each entry may map to multiple chains (e.g., one EVM address shared across Ethereum, Polygon, Base, and Optimism). See the [supported assets](/v2/api-reference/assets/list-supported-assets) endpoint for the full list of accepted assets and networks. Display the deposit address in your UI alongside a QR code, the supported chains for that address, and which assets are accepted (e.g., USDC). Remind the customer that sending unsupported assets or using an unsupported network will result in funds not appearing. *** ## Step 2 — detect incoming deposits Bastion detects deposits automatically once they are confirmed on-chain (for external deposits) or completed on the ledger (for off-chain transfers). Bastion handles compliance screening internally before crediting the balance. Your platform receives a `deposit_v2` webhook when the deposit is confirmed and the balance is credited. ### Webhook — deposit confirmed Configure a webhook endpoint in the Bastion Dashboard to receive deposit events. Bastion fires one webhook per deposit. Your handler should be idempotent; use the `dedup_key` field to avoid double-processing. **On-chain deposit (from an external wallet):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "type": "deposit_v2", "data": { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "dedup_key": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn::0", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "account_address": "G2Pt9Gq56sxNcfXJLuA5TrQpoqQL4Vdq1mM2cZABpbvN", "source_type": "ON_CHAIN", "source_address": "6XtEiZhw3EwndtHmCqEULmieuWymV3GQiNkHUPqeY5Kc", "currency_symbol": "USDC", "amount": "123.44", "destination_chain": "SOLANA_MAINNET", "transaction_hash": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn", "status": "CONFIRMED", "confirmed_at": "2026-03-05T15:45:14Z", "created_at": "2026-03-05T15:45:14Z" } } ``` **Off-chain deposit (from another account on your platform, omnibus accounts only):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "5CZ7KUDYac2SjEzqoO5cNU7wBgF", "type": "deposit_v2", "data": { "id": "5CZ7KUDYac2SjEzqoO5cNU7wBgF", "dedup_key": "5CZ7KUDYac2SjEzqoO5cNU7wBgF", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "source_type": "ACCOUNT", "source_account_id": "48jK9mLp2rSTuvW3xYzAaBbCcDd", "currency_symbol": "USDC", "amount": "123.44", "destination_chain": "CHAIN_UNSPECIFIED", "status": "CONFIRMED", "confirmed_at": "2026-03-05T15:45:14Z", "created_at": "2026-03-05T15:45:14Z" } } ``` Use `source_type` to distinguish between the two: | Source type | Origin | Key fields | | - | - | - | | `ON_CHAIN` | External wallet or exchange | `source_address`, `destination_chain`, `transaction_hash` | | `ACCOUNT` | Another account on your platform (off-chain transfer, omnibus accounts only) | `source_account_id`. `destination_chain` is `CHAIN_UNSPECIFIED`, `transaction_hash` is empty. | On receipt of a `deposit_v2` event, record the confirmed deposit in your own ledger and refresh the account balance in your UI. Bastion credits the account's on-platform balance before sending the webhook, so do not credit it again. Use `dedup_key` to avoid processing the same event more than once. > **Webhook reliability:** Failed deliveries are retried with exponential backoff (15 s base, 10 min max). Deduplicate using `data.dedup_key`. *** ## Step 3 — show the updated balance After receiving a deposit webhook, refresh balances and update your UI. **Refresh balances** ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` **Response:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "123440000", "available": "123440000", "decimals": 6, "type": "SPL_TOKEN", "chain": "SOLANA_MAINNET", "contract_address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } ] } ``` Balances are returned in atomic units as strings. Use the `decimals` field to convert to a human-readable amount (e.g., `"123440000"` with `decimals: 6` = 123.44 USDC). Only show funds as available after receiving the `deposit_v2` webhook with `status: CONFIRMED`. *** ## Step 4 — query deposit history (optional) Retrieve past deposits for an account using [`GET /v2/crypto/deposits`](/v2/api-reference/cryptocurrency-deposits/list-cryptocurrency-deposits). Note this endpoint returns crypto deposits only. On-ramp conversions (fiat → stablecoin) are not included; use the Conversions API for those. ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/crypto/deposits?account_id={account_id} Authorization: Bearer YOUR_API_KEY ``` **Query parameters:** | Parameter | Type | Required | Description | | - | - | - | - | | `account_id` | string | Yes | The Bastion account ID | | `destination_chain` | string | No | Filter by chain (e.g., `SOLANA_MAINNET`) | | `source_type` | string | No | Filter by origin: `ON_CHAIN` or `ACCOUNT` | | `currency_symbol` | string | No | Filter by asset (e.g., `USDC`) | | `transaction_hash` | string | No | Filter by transaction hash (EVM) or signature (Solana) | | `start_time` | string | No | ISO 8601 start time | | `end_time` | string | No | ISO 8601 end time | | `limit` | integer | No | Max results per page | | `cursor` | string | No | Pagination cursor | Pair `transaction_hash` with `account_id` to look up a specific on-chain transfer: ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/crypto/deposits?account_id={account_id}&transaction_hash={transaction_hash} Authorization: Bearer YOUR_API_KEY ``` Responses mirror the webhook payload structure. You can also retrieve a single deposit by ID: [`GET /v2/crypto/deposits/{deposit_id}`](/v2/api-reference/cryptocurrency-deposits/get-cryptocurrency-deposit). *** ## Implementation notes and recommendations | Recommendation | Details | | - | - | | Deduplicate on `dedup_key` | Use `data.dedup_key` to prevent double-crediting. For on-chain deposits, the key is `{transaction_hash}::{index}`. For off-chain deposits, the key equals the deposit `id`. | | Only credit on `CONFIRMED` | Only confirmed deposits trigger a webhook. Do not credit balances from any other source or status. | | Handle both source types | Your webhook handler should handle both `ON_CHAIN` and `ACCOUNT` source types. The payload structure differs slightly between the two. | | Backfill missed deposits | If your webhook handler experiences downtime, use [`GET /v2/crypto/deposits`](/v2/api-reference/cryptocurrency-deposits/list-cryptocurrency-deposits) to backfill any missed deposits. | | Unsupported assets won't appear | If a sender deposits an unsupported asset or uses an unsupported network, the balance will not appear. Bastion only processes supported assets. | | Keep deposits in sync | Store `deposit_id` and `dedup_key` in your own system to correlate Bastion deposit events with your records. | # Redeem Bastion-issued stablecoins to USD Source: https://docs.bastion.com/guides/quickstarts/redeem-bastion-issued-stablecoins End-to-end quickstart to redeem Bastion-issued stablecoins: register a bank account, create redemption instructions, send stablecoins, and track USD payout. ## Redeem Bastion-issued stablecoins This guide walks you through the process of redeeming Bastion-issued stablecoins using Bastion's Issuance API. Redemption converts stablecoins back into fiat currency (USD), which Bastion sends to a registered bank account via wire transfer. ### Overview The redemption process involves several steps: 1. **Register a bank account** - Add the bank account where Bastion sends redeemed funds 2. **Create redemption instructions** - Set up the redemption configuration with bank account and blockchain details 3. **Send stablecoins to the redemption address** - Initiate redemption by transferring stablecoins 4. **Monitor the redemption** - Track the status of your redemption request ### Prerequisites Before you begin, ensure you have: * A valid API key with issuance permissions * An approved **Business** identity created for the redeeming entity * Note that Issuance is not supported for **Individual** identities at this moment * Bank account details for receiving USD wire transfers * Stablecoins in a wallet on a supported chain ### Step 1: register a bank account First, register the bank account where Bastion sends redeemed funds. #### Request ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST https://api.prod.bastion.com/v2/issuance/bank-account \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "identity_id": "id_123456789", "bank_account_details": { "bank_name": "First National Bank", "account_holder_name": "Acme Corporation", "account_number": "9876543210", "routing_number": "021000021", "type": "checking", "address": { "street_line_1": "456 Business Ave", "street_line_2": "Suite 100", "city": "San Francisco", "postal_code": "94102", "country": "US", "region_code": "CA" } } }' ``` #### Request parameters | Parameter | Type | Required | Description | | - | - | - | - | | `identity_id` | string | Yes | The identity ID that owns this bank account | | `bank_account_details` | object | Yes | Bank account information (see below) | ##### Bank account details object | Field | Type | Required | Description | | - | - | - | - | | `bank_name` | string | Yes | Name of the bank | | `account_holder_name` | string | Yes | Name on the account | | `account_number` | string | Yes | Bank account number | | `routing_number` | string | Yes | Bank routing number (ABA) | | `type` | string | Yes | Account type (e.g., `checking`, `savings`) | | `address` | object | Yes | Bank address (see below) | ##### Address object | Field | Type | Required | Description | | - | - | - | - | | `street_line_1` | string | Yes | Street address line 1 (3-100 characters) | | `street_line_2` | string | No | Street address line 2 | | `city` | string | Yes | City (3-50 characters) | | `postal_code` | string | Yes | Postal/ZIP code (max 20 characters) | | `country` | string | Yes | ISO 3166-1 alpha-2 country code (e.g., `US`) | | `region_code` | string | Yes | ISO 3166-2 subdivision code (e.g., `CA` for California) | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "ba_def456ghi789", "identity_id": "id_123456789", "bank_account_details": { "bank_name": "First National Bank", "account_holder_name": "Acme Corporation", "account_number": "9876543210", "routing_number": "021000021", "type": "checking", "address": { "street_line_1": "456 Business Ave", "street_line_2": "Suite 100", "city": "San Francisco", "postal_code": "94102", "country": "US", "region_code": "CA" } } } ``` ### Step 2: create redemption instructions Create redemption instructions that specify how Bastion processes redemptions, including the bank account and blockchain details. #### Request ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST https://api.prod.bastion.com/v2/issuance/redemption-instructions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "770e8400-e29b-41d4-a716-446655440002", "bank_account_id": "ba_def456ghi789", "chain": "SOLANA_DEVNET", "label": "Primary Redemption", "payment_rail": "WIRE", "identity_id": "id_123456789", "stablecoin_symbol": "USDX" }' ``` #### Request parameters | Parameter | Type | Required | Description | | - | - | - | - | | `request_id` | string (UUID) | Yes | Idempotency key in UUID format | | `bank_account_id` | string | Yes | ID of the bank account created in Step 1 | | `chain` | enum | Yes | Blockchain network: `SOLANA_DEVNET` | | `label` | string | Yes | A human-readable label for the instruction | | `payment_rail` | enum | Yes | Payment method (currently only `WIRE` is supported) | | `identity_id` | string | Yes | The identity ID for this redemption instruction | | `stablecoin_symbol` | string | Yes | Symbol of the stablecoin to redeem (e.g., `USDX`) | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "ri_pqr123stu456", "address": "9vMJfxuKxXBoEa7rM7gQEpNsEHvT3AxK...", "bank_account_id": "ba_def456ghi789", "chain": "SOLANA_DEVNET", "label": "Primary Redemption", "payment_rail": "WIRE", "identity_id": "id_123456789", "stablecoin_symbol": "USDX" } ``` > **Important:** Save the `address` from the response. This is the blockchain address where you'll send stablecoins to initiate redemptions. ### Step 3: initiate the redemption (send stablecoins) To redeem stablecoins, send them to the redemption address provided in the redemption instructions response. **Critical requirements:** * **Use the correct address**: Send stablecoins to the exact `address` returned in Step 2. * **Correct chain**: Ensure you're sending on the same blockchain network specified in the redemption instructions. * **Correct stablecoin**: Send the stablecoin type specified in `stablecoin_symbol`. Once Bastion receives and verifies the stablecoin transfer, it: 1. Burns the stablecoins 2. Calculates the equivalent USD amount 3. Initiates a wire transfer to your registered bank account ### Step 4: monitor redemption status #### List all redemptions ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET "https://api.prod.bastion.com/v2/issuance/redemptions?identity_id=id_123456789&limit=10" \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Query parameters | Parameter | Type | Required | Description | | - | - | - | - | | `status` | enum | No | Filter by status: `ACCEPTED`, `SUBMITTED`, `COMPLETED`, `DENIED`, `FAILED`, `CANCELLED` | | `limit` | integer | No | Max results per page (1-25, default: 10) | | `cursor` | string | No | Pagination cursor for next page | | `identity_id` | string | No | Filter by identity ID | #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "redemptions": [ { "id": "redeem_001xyz", "amount": "5000.00", "chain": "SOLANA_DEVNET", "redemption_address_id": "ri_pqr123stu456", "from_address": "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV", "status": "COMPLETED", "deposit_external_tx_id": "3nKt9vRxQW8m...", "burn_external_tx_id": "4mLu0wSyRX9n...", "identity_id": "id_123456789", "stablecoin_symbol": "USDC" } ], "cursor": "eyJsYXN0X2lkIjoicmVkZWVtXzAwMXh5eiJ9" } ``` #### Get a specific redemption ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/redemptions/redeem_001xyz \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "redeem_001xyz", "amount": "5000.00", "chain": "SOLANA_DEVNET", "redemption_address_id": "ri_pqr123stu456", "from_address": "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV", "status": "COMPLETED", "deposit_external_tx_id": "3nKt9vRxQW8m...", "burn_external_tx_id": "4mLu0wSyRX9n...", "identity_id": "id_123456789", "stablecoin_symbol": "USDC" } ``` ### Redemption status values | Status | Description | | - | - | | `ACCEPTED` | Stablecoin deposit received and redemption request accepted | | `SUBMITTED` | Burn transaction submitted and wire transfer initiated | | `COMPLETED` | Stablecoins burned and USD wire transfer completed | | `DENIED` | Redemption request was denied (compliance or other reasons) | | `FAILED` | Redemption process failed | | `CANCELLED` | Redemption request was cancelled | ### Redemption response fields | Field | Description | | - | - | | `id` | Unique identifier for the redemption request | | `amount` | Amount of stablecoins being redeemed | | `chain` | Blockchain network for the redemption | | `redemption_address_id` | ID of the redemption instruction used | | `from_address` | Source wallet address that sent the stablecoins | | `status` | Current status of the redemption | | `deposit_external_tx_id` | Blockchain transaction ID of the stablecoin deposit | | `burn_external_tx_id` | Blockchain transaction ID of the burn | | `identity_id` | Identity that owns this redemption | | `stablecoin_symbol` | Symbol of the stablecoin being redeemed | ### Additional endpoints #### List bank accounts ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/bank-accounts \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Get bank account ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/bank-account/ba_def456ghi789 \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### List redemption instructions ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET "https://api.prod.bastion.com/v2/issuance/redemption-instructions?identity_id=id_123456789" \ -H "Authorization: Bearer YOUR_API_KEY" ``` #### Get redemption instructions ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/issuance/redemption-instructions/ri_pqr123stu456 \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Supported chains | Chain | Enum value | | - | - | | Solana Devnet | `SOLANA_DEVNET` | | Base Sepolia (Currently not supported) | `BASE_SEPOLIA` | ### Error handling | HTTP status | Description | | - | - | | `400` | Bad request - Check request parameters | | `401` | Unauthorized - Invalid or missing API key | | `404` | Resource not found | | `409` | Conflict - Resource already exists (check idempotency key) | | `500` | Internal server error | ### Webhooks Configure webhooks to receive real-time notifications when redemption status changes. See the [Webhooks documentation](/v2/api-reference/webhooks/overview) for setup instructions. ### Best practices 1. **Use unique request IDs**: Generate a new UUID for each new logical request, and reuse the same `request_id` when retrying that request so it stays idempotent. 2. **Verify bank details**: Double-check bank account information before creating bank accounts. 3. **Correct address**: Always verify you're sending to the correct redemption address on the correct chain. 4. **Test small amounts**: For initial testing, start with small redemption amounts. 5. **Monitor status**: Use webhooks or polling to track redemption progress. 6. **Test on Devnet**: Use Solana Devnet for testing before moving to production. ### Processing timeline Redemption processing times depend on several factors: * **Blockchain confirmation**: Typically minutes, depending on the chain * **Wire transfer**: 1-3 business days for domestic US wires ### Security considerations * Bank account details are stored securely and cannot be modified after creation * Each redemption instruction generates a unique blockchain address * All transactions are logged and auditable * Bastion performs compliance checks on all redemption requests # Send crypto from a Bastion account Source: https://docs.bastion.com/guides/quickstarts/send-crypto Compare on-chain and off-chain crypto transfers, when each mode applies to segregated and omnibus accounts, and which quickstart to follow for each. There are two ways to send crypto from a customer's Bastion account. Both use the same endpoint ([`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer)), but they work differently depending on where the funds are going and how your accounts are configured. | | On-chain | Off-chain | | - | - | - | | **When to use** | Sending to any wallet address (external wallet, exchange, or Bastion deposit address) | Sending to another account on your platform | | **Destination field** | `address_destination` | `account_destination` | | **What you provide** | Wallet address + chain | Recipient's Bastion account ID | | **Requires `OMNIBUS` accounts** | No - works for `SEGREGATED` accounts | Yes (both sender and recipient) | | **Blockchain transaction** | Yes | No | | **Gas fees** | Sponsored by Bastion | None | | **Settlement** | After block confirmation | Instant | | **Status lifecycle** | `ACCEPTED` → `SUBMITTED` → `CONFIRMED` / `FAILED` / `CANCELED` / `DROPPED` | `ACCEPTED` → `CONFIRMED` / `FAILED` | **If your platform uses segregated accounts**, all transfers are on-chain. Follow the on-chain quickstart. **If your platform uses omnibus accounts**, you have both options. Use on-chain for sends outside your platform, and off-chain for sends within your platform. | Quickstart | What it covers | | - | - | | [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain) | Transfers through the blockchain to any wallet address | | [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain) | Instant transfers between omnibus accounts on your platform | # Send crypto off-chain between omnibus accounts Source: https://docs.bastion.com/guides/quickstarts/send-crypto/off-chain 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:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "975000000", "available": "975000000", "decimals": 6, "type": "OMNIBUS", "chain": "CHAIN_UNSPECIFIED" } ] } ``` 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 ``` ```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" } } ``` 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** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3Z9YxKXb7M5rI8nU2oW0qS4tXeE", "request_id": "66f3b0b2-a25b-4e9d-a94b-fbcfa0720312" } ``` 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):** ```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": [] } } ``` 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):** ```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" } } ``` 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:** ```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": [] } ``` 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. | # Send crypto on-chain to any wallet address Source: https://docs.bastion.com/guides/quickstarts/send-crypto/on-chain Submit an on-chain transfer from a Bastion account to an external wallet or exchange, with Bastion-sponsored gas, and track it to confirmation via webhooks. ## Goal Submit an on-chain crypto transfer from a funded account and track it to confirmation. The destination can be an external wallet, exchange deposit address, or another Bastion account's deposit address. For instant off-chain transfers between omnibus accounts, see [Send crypto off-chain](/guides/quickstarts/send-crypto/off-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 account linked to the identity. Each account has wallet addresses per supported blockchain and is used to perform transactions. | | Crypto transfer | An on-chain asset movement via [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) | | `address_destination` | Structured destination object containing `address` and `chain` | **Gas fees:** Bastion sponsors gas by default on all supported chains. Your customers do not need to hold native tokens (SOL, ETH, etc.). Gas sponsorship is opt-out. Contact your Bastion account team to disable. **Supported chains:** Segregated accounts support Solana and EVM chains. Omnibus accounts support Solana and EVM chains for on-chain withdrawals. See [supported chains and assets](/guides/concepts/chains-and-assets) and the [supported assets](/v2/api-reference/assets/list-supported-assets) endpoint. ## 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 `address_destination`. 3. Track transfer status via webhooks or polling until it reaches a terminal state. 4. Show the completed transfer and updated balances in your app. This is an on-chain transfer flow. For instant off-chain transfers between omnibus accounts, see [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain). *** ## Prerequisites * The customer has a **verified identity** (KYC or KYB) in Bastion. * The customer has an account with **balance in a supported asset** (e.g., USDC on Solana). * You have an **API key** and have **registered your webhook**. * Verify the asset and network are supported. See [supported chains and assets](/guides/concepts/chains-and-assets). *** ## 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 (segregated account):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "975000000", "available": "975000000", "decimals": 6, "type": "SPL_TOKEN", "chain": "SOLANA_MAINNET", "contract_address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } ] } ``` **Response (omnibus account):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "975000000", "available": "975000000", "decimals": 6, "type": "OMNIBUS" } ] } ``` Balances are returned in atomic units as strings. Use the `decimals` field to convert to a human-readable amount (e.g., "`975000000`" with `decimals: 6` = 975.00 USDC). Omnibus balances are chain-agnostic. Confirm enough available balance in the `available` field before submitting. Your UI should surface a clear error if the customer does not have enough balance. *** ## Step 2 — submit on-chain transfer Call [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) to submit an on-chain transfer from the customer's account. **Request** ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/crypto/transfers Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "currency_symbol": "USDC", "amount": "25.00", "address_destination": { "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "chain": "SOLANA_MAINNET" } } ``` **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"`) | | `address_destination.address` | string | Yes | Recipient wallet address | | `address_destination.chain` | string | Yes | Destination chain enum (e.g., `SOLANA_MAINNET`) | | `priority` | string | No | Inclusion priority. Higher priority = faster confirmation, higher gas spend. Values: `PRIORITY_UNSPECIFIED`, `PRIORITY_NORMAL`, `PRIORITY_HIGH`, `PRIORITY_URGENT`. High-priority transfers may be delayed if the account has pending lower-priority transactions that must be processed first. | **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "2Y8YxKXb6L4qH7mT1nV9pR3sWcD", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac" } ``` 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`. ### Omnibus withdrawal amount limits Bastion checks on-chain withdrawals from omnibus accounts against per-token minimum and maximum amounts. If `amount` falls outside the configured bounds for the token, Bastion rejects the request at submission with HTTP `400` and error code `INVALID_INPUT`. The transfer is never created, so no webhook fires. The token's limits are included in the error `details`: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "code": "INVALID_INPUT", "message": "Invalid argument provided", "details": { "min_amount": "1", "max_amount": "10000000", "currency": "USDC" }, "retryable": false } ``` `min_amount` and `max_amount` are inclusive bounds in the token's default unit (e.g., USDC, not atomic units). Requests with `validate_only: true` return the same error. Transfers from segregated accounts and off-chain transfers to another account (`account_destination`) are not subject to these limits. ### Legacy request body (still accepted) The flat `destination_address` and `destination_chain` fields are still accepted: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "destination_chain": "SOLANA_MAINNET", "currency_symbol": "USDC", "amount": "25.00", "destination_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" } ``` Migrate to the structured `address_destination` field. *** ## Step 3 — track transfer status Track the transfer until it reaches a terminal state. You can use webhooks (recommended) and polling. For full lifecycle definitions, timeout behavior, and failure modes, see [Crypto transfers](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer). ### 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, not just terminal states. Your handler should be idempotent and use `request_id` to correlate events. **Example event (confirmed):** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "2Y8YxKXb6L4qH7mT1nV9pR3sWcD", "type": "submit_cryptocurrency_transfer_v2", "data": { "id": "2Y8YxKXb6L4qH7mT1nV9pR3sWcD", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "account_address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "currency_symbol": "USDC", "amount": "25.00", "destination_type": "ON_CHAIN", "destination_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "destination_chain": "SOLANA_MAINNET", "destination_account_id": "", "status": "CONFIRMED", "created_at": "2025-12-05T20:56:56Z", "updated_at": "2025-12-05T20:56:58Z", "transaction_hash": "3BvrQg7S77viaibgzeKr4Tn2o7LK86XbZ97wiwwKuM6BiWPQRQp1vi8GN7fBe6ZooSqA9rqgTnVQRDDAFVTuM3hF", "completed_at": "2025-12-05T20:56:58Z", "failure_details": null, "events": [] } } ``` The recipient receives a separate `deposit_v2` webhook when the transfer is credited. See [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits). Handle statuses: | Status | Type | Recommended action | | - | - | - | | `ACCEPTED` | Intermediate | Transfer received and queued. Wait for further updates. | | `SUBMITTED` | Intermediate | Transaction submitted to the blockchain. Wait for confirmation. | | `CONFIRMED` | Terminal | Transaction confirmed on-chain. Mark transfer complete, refresh balances. | | `FAILED` | Terminal | See `failure_details`; if transient, retry with new request; otherwise resolve root cause. | | `CANCELED` | Terminal | Transaction submitted but never mined; Bastion cleared the account nonce. Funds were not moved; retry with new transfer. | | `DROPPED` | Terminal | Transaction not picked up by the network (SVM only). Funds were not moved; retry with new transfer. | > **Webhook reliability:** Failed deliveries are retried with exponential backoff (15 s base, 10 min max). Deduplicate on `id` + `status`; the transfer `id` is stable across status changes, so `id` alone would drop later updates. ### 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:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "2Y8YxKXb6L4qH7mT1nV9pR3sWcD", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "account_address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "currency_symbol": "USDC", "amount": "25.00", "destination_type": "ON_CHAIN", "destination_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "destination_chain": "SOLANA_MAINNET", "destination_account_id": "", "status": "CONFIRMED", "created_at": "2025-12-05T20:56:56Z", "updated_at": "2025-12-05T20:56:58Z", "transaction_hash": "3BvrQg7S77viaibgzeKr4Tn2o7LK86XbZ97wiwwKuM6BiWPQRQp1vi8GN7fBe6ZooSqA9rqgTnVQRDDAFVTuM3hF", "completed_at": "2025-12-05T20:56:58Z", "failure_details": null, "events": [] } ``` 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 ``` Only show funds as deducted after the transfer reaches a terminal `CONFIRMED` status. Keep pending states visible for transparency. *** ## 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`, `DROPPED`, and `CANCELED` as error states that require 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. | | On-chain transfers are irreversible | Make it clear in your product that on-chain transfers cannot be reversed. Prompt customers to double-check the destination address and chain before confirming. | # Work with omnibus accounts Source: https://docs.bastion.com/guides/quickstarts/work-with-omnibus-accounts Set up omnibus custody end-to-end: create an omnibus account, fund it, read chain-agnostic balances, and move funds off-chain and on-chain. ## Goal Set up a customer with an omnibus account and use it end-to-end: create the account, fund it, read its balance, and move funds both within your platform (off-chain) and to external wallets (on-chain). If you're not familiar with the two custody models, read [Crypto custody](/guides/concepts/custody#account-types) first. ## How omnibus custody works With omnibus accounts, customer funds are pooled in a Bastion custody vault. Bastion tracks each account's balance on a secure double-entry ledger. This differs from segregated accounts, where each customer holds funds at their own dedicated on-chain addresses. | Characteristic | Omnibus (`OMNIBUS`) | | - | - | | How assets are held | Pooled in a custody vault; per-account balances tracked on Bastion's internal ledger | | Balance model | Chain-agnostic: one USDC balance regardless of chain | | Off-chain transfers | Instant, fee-free transfers between omnibus accounts on the same platform | | On-chain withdrawals | Supported on Solana and EVM chains, with gas sponsored by Bastion | Both account types are fully custodial under Bastion's US regulated licensing, and your platform uses the same APIs either way. ## Key entities | Entity | Description | | - | - | | Your platform | The product integrating Bastion's APIs | | Customer | An individual or business your platform has onboarded (KYC/KYB-verified) | | Identity | The customer's record in Bastion, used for compliance and permissions | | Account | An omnibus account linked to the identity. Its balance lives on Bastion's internal ledger. | ## Flow overview 1. Onboard the customer and wait for their identity to become `ACTIVE`. 2. Create an account with `type: OMNIBUS`. 3. Fund the account via on-ramp, crypto deposit, or off-chain transfer. 4. Check the chain-agnostic balance. 5. Move funds off-chain (within your platform) or on-chain (external withdrawals). *** ## Prerequisites * You have access to your dashboard * You've created an API key * You've registered a webhook *** ## Step 1 – onboard the customer Omnibus accounts follow the same onboarding flow as any other account. Create an identity, submit KYC or KYB data, and track status until the identity is `ACTIVE`: * [Onboard an individual customer](/guides/quickstarts/onboard-an-individual-customer) * [Onboard a business customer](/guides/quickstarts/onboard-a-business-customer) Accounts cannot be created until the identity's `status` is `ACTIVE`. *** ## Step 2 – create an omnibus account Call [`POST /v2/accounts`](/v2/api-reference/accounts/create-account) with `type` set to `OMNIBUS`. The `type` field defaults to `SEGREGATED`. You must set `type: "OMNIBUS"` explicitly to create an omnibus account. Use a unique `request_id` (UUID) as an idempotency key. Reuse the same `request_id` on retries to avoid duplicate accounts. **Request** ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/accounts Content-Type: application/json Authorization: Bearer YOUR_API_KEY ``` ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "type": "OMNIBUS" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "identity_type": "INDIVIDUAL", "addresses": [ { "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", "chains": ["ETHEREUM_MAINNET", "BASE_MAINNET"] }, { "address": "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK", "chains": ["SOLANA_MAINNET"] } ], "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "id": "01HZXBANKACCOUNTIDEXAMPLE0", "type": "OMNIBUS", "frozen": false, "identity_status": "ACTIVE", "identity_sub_status": "IDENTITY_SUB_STATUS_UNSPECIFIED", "account_status": "ACTIVE" } ``` The response returns the new account. Capture these fields: | Field | Meaning | | - | - | | `id` | The account ID. Store it alongside your internal customer record. | | `type` | `OMNIBUS`, confirming the custody model | | `addresses` | The account's deposit addresses, one entry per address with the chains it accepts. Display these to the customer for receiving crypto deposits. | | `status` | The account's status, derived from its parent identity state (`ACTIVE` when ready) | The `addresses` array contains the deposit addresses for the account. Funds sent to a deposit address are credited to the account's chain-agnostic ledger balance, regardless of which supported chain they arrive on. See [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits) for how to display deposit addresses and handle incoming deposits. If the identity has not passed compliance and jurisdiction checks, the request returns HTTP 422. *** ## Step 3 – fund the account An omnibus account can receive funds in three ways: | Funding method | How it works | | - | - | | On-ramp conversion | Convert fiat to stablecoin into the account. See [On-ramp](/guides/quickstarts/on-ramp). | | Crypto deposit | Receive crypto from an external wallet or exchange. See [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits). | | Off-chain transfer | Receive an instant transfer from another omnibus account on your platform. | In all cases, Bastion credits the balance and delivers a webhook when funds arrive. For deposits, that is the `deposit_v2` event. Off-chain deposits from another account on your platform arrive with `source_type: ACCOUNT` and `source_account_id` identifying the sender. *** ## Step 4 – check the balance ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/accounts/{account_id}/balances Authorization: Bearer YOUR_API_KEY ``` **Response:** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "balances": [ { "symbol": "USDC", "total": "975000000", "available": "975000000", "decimals": 6, "type": "OMNIBUS", "chain": "CHAIN_UNSPECIFIED" } ] } ``` Omnibus balances differ from segregated balances: * Balances are chain-agnostic. The `type` field is `OMNIBUS` and `chain` is `CHAIN_UNSPECIFIED`. A customer holding USDC has one balance entry, regardless of which chains the funds arrived on. Segregated accounts instead return one entry per chain. * `contract_address` is omitted. Balances are returned in atomic units as strings. Use the `decimals` field to convert to a human-readable amount (e.g., `"975000000"` with `decimals: 6` = 975.00 USDC). The `available` field reflects the amount the customer can use for transfers or conversions. `total` may differ from `available` if funds are held for a pending operation. *** ## Step 5 – move funds Both transfer modes use the same endpoint, [`POST /v2/crypto/transfers`](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer). The destination field determines the mode. ### Off-chain: send to another account on your platform Off-chain transfers move funds instantly on Bastion's internal ledger. There is no blockchain transaction, no gas fee, and no chain selection. Both sender and recipient must have omnibus accounts on the same platform. ```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" } } ``` Off-chain transfers settle immediately, with a status lifecycle of `ACCEPTED` → `CONFIRMED` / `FAILED`. For the full walkthrough, including webhook payloads and status handling, see [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain). ### On-chain: withdraw to an external wallet On-chain withdrawals send funds through the blockchain to any wallet address. Omnibus accounts support on-chain withdrawals on Solana and EVM chains, with gas sponsored by Bastion. Specify the destination address and chain: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "currency_symbol": "USDC", "amount": "25.00", "address_destination": { "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "chain": "SOLANA_MAINNET" } } ``` Because the omnibus balance is chain-agnostic, the customer's full `available` balance can be withdrawn to any supported chain. On-chain transfers settle after block confirmation, with a status lifecycle of `ACCEPTED` → `SUBMITTED` → `CONFIRMED` / `FAILED` / `CANCELED` / `DROPPED`. For the full walkthrough, see [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain). *** ## Step 6 – verify setup Confirm the customer is fully ready: 1. **Check identity status** — [`GET /v2/identities/{identity_id}`](/v2/api-reference/identities/get-identity) returns `status: ACTIVE` 2. **Check the account** — [`GET /v2/accounts/{id}`](/v2/api-reference/accounts/get-account) returns `type: OMNIBUS` and `status: ACTIVE` 3. **Check balances** — [`GET /v2/accounts/{account_id}/balances`](/v2/api-reference/accounts/get-account-balances) shows the funded, chain-agnostic balance *** ## Implementation notes | Recommendation | Details | | - | - | | Set `type` explicitly | `POST /v2/accounts` defaults to `SEGREGATED`. Always pass `type: "OMNIBUS"` when creating omnibus accounts. | | Use idempotent request IDs | Provide a unique `request_id` (UUID) per account creation and per transfer. Reuse it on retries to avoid duplicates. | | Display one balance per asset | Omnibus balances are chain-agnostic. Do not build per-chain balance UI for omnibus accounts. | | Check `available` before sends | Confirm sufficient `available` balance before submitting a transfer. Surface insufficient-funds errors clearly in your UI. | | Validate off-chain eligibility | Off-chain transfers require omnibus accounts on both sides, on the same platform. Ineligible destinations return HTTP 400. | | Keep records in sync | Store `identity_id`, `account_id`, and `transfer_id` in your own system and reconcile via `submit_cryptocurrency_transfer_v2` and `deposit_v2` webhooks. | ## Related documentation * [Crypto custody](/guides/concepts/custody) * [Identities and accounts](/guides/concepts/identities-and-accounts) * [Crypto transfers and deposits](/guides/concepts/crypto-transfers) * [Send crypto off-chain](/guides/quickstarts/send-crypto/off-chain) * [Send crypto on-chain](/guides/quickstarts/send-crypto/on-chain) * [Receive crypto deposits](/guides/quickstarts/receive-crypto-deposits) # API authentication overview Source: https://docs.bastion.com/guides/security/api-authentication How Bastion authenticates API requests: Bearer API keys, optional JWT request signing with ES256, key storage, and choosing the right method for your use case. ## Overview Bastion supports two authentication methods: | Method | Security Level | Use Case | | - | - | - | | **API key (Bearer token)** | Standard | Server-to-server requests | | **API key + request signing** | Enhanced | High-security operations | *** ## API keys Bastion API keys use the `bas_` prefix: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} bas_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` You can create API keys through your dashboard in the `API keys` tab: API keys page in the Bastion dashboard Include your API key as a Bearer token: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X GET https://api.prod.bastion.com/v2/accounts \ -H "Authorization: Bearer bas_your_api_key_here" ``` **Security:** * Keys stored as BLAKE2b hashes (never raw) * Full key shown only once at creation * Can be revoked immediately *** ## Request signing (enhanced authentication) For higher-security operations, add asymmetric request signing using ECDSA P-256 keys. ### Setup 1. Generate an ECDSA P-256 key pair 2. Register your public key in your Bastion Dashboard (linked to your API key) Creating an API key in the Bastion dashboard 3. Sign each request with your private key ### Signature contents Sign a JWT claim containing: | Claim | Description | | - | - | | `iat` | Timestamp (must be within 30 seconds) | | `req-method` | HTTP method | | `req-path` | Request path | | `body` | SHA-256 hash (hex-encoded) of the request body; omit for `GET`/`HEAD` | Bastion validates the signature and timestamp before processing. Requests outside the 30-second window are rejected to prevent replay attacks. Follow the [API Request Signing Guide](/v2/api-reference/authentication/request-signing) for a full overview of the API request signing process with examples. *** ## Security best practices * **Never expose API keys in client-side code** * **Store keys in secrets managers** * **Rotate API keys periodically** * **Protect private signing keys** — use HSM or secrets manager # Wallet key management Source: https://docs.bastion.com/guides/security/key-management How Bastion derives wallet private keys on demand from a non-exportable root key in AWS KMS and CloudHSM, and signs inside AWS Nitro Enclaves. ## Overview Wallet private keys are generated and managed exclusively within the Bastion signer service. These private keys never exit the secure boundary of the signer service. *** ## Key architecture Bastion uses a hierarchical key derivation model: | Layer | Location | Purpose | | - | - | - | | **Root key** | AWS KMS (CloudHSM-backed) | Master key, non-exportable | | **Enclave-shard key** | Secure Enclave (AWS Nitro) | Derived from root, used to derive wallet keys | | **Wallet keys** | Secure Enclave (AWS Nitro) | Derived from per-shard key when needed | *** ## Private key generation Wallet private keys are generated using a deterministic secure algorithm: * The secure root private key is maintained in AWS KMS backed by AWS CloudHSM and is **not exportable** * Access to the root key is strictly limited to the Bastion signer service operating within the AWS Nitro Enclave boundary * During boot, the signer service securely communicates with AWS KMS to restore a per-shard private key, stored in encrypted enclave memory * User wallet private keys are derived from this per-shard private key using per-user metadata This algorithm enables rapid yet secure generation of wallet private keys, creating them only when necessary. *** ## Private key retrieval Wallet private keys are generated **on-demand** and never stored in persistent storage: * Keys are created by the signer service within an AWS Nitro Enclave * Nitro Enclaves are isolated, highly constrained compute environments that can only communicate via a secure virtual socket * Enclaves are invisible to the host machine * Nitro Enclaves provide cryptographic attestations, enabling verification of the software running within the enclave All signer code is implemented in **Rust**, a programming language known for its safety and security. The code is cryptographically enforced as custom firmware. *** ## Key lifecycle | Phase | What Happens | | - | - | | **Generation** | Wallet key deterministically regenerated on-demand for signing | | **Usage** | Key exists briefly in enclave memory, accessible only within the enclave | | **Removal** | Key removed from enclave memory immediately after signing | During the brief period that a private key exists in memory, it is only accessible within the Nitro Enclave and hidden from external inspection. *** ## KMS access control AWS Nitro Enclave cryptographic attestation secures access to KMS keys: 1. Only secure and trusted Bastion signer software is authorized to interact with the HSM-backed root key 2. The encrypted per-shard private key can only be decrypted by secure and trusted Bastion signer software This is complemented by AWS IAM and KMS key policies that strictly limit available operations, ensuring they can only be initiated from the Bastion signer running inside AWS Nitro Enclaves. All AWS IAM and KMS key access is securely logged via AWS CloudTrail, with logs maintained in a separate, isolated AWS account. # Network security Source: https://docs.bastion.com/guides/security/network-security How Bastion protects its network with AWS VPC isolation, Cloudflare Zero Trust, a web application firewall, mTLS between services, and continuous monitoring. ## Network infrastructure ### AWS VPC Bastion uses AWS VPC to isolate its resources and create a secure network environment, minimizing exposure to external threats while facilitating secure communication. ### Cloudflare Zero Trust Cloudflare Zero Trust grants access to internal systems and networks based on user identity and context, greatly reducing the risk of unauthorized access. ### Web Application Firewall (WAF) Bastion employs a WAF to protect applications from common web threats such as SQL injection and cross-site scripting. *** ## Network controls ### Ingress and egress management Bastion meticulously manages ingress and egress traffic, ensuring that only authorized data flows in and out of the network, safeguarding sensitive information. ### Network access management Robust policies control user permissions and access to critical systems, ensuring only authorized personnel can access sensitive resources. ### Mutual TLS (mTLS) mTLS secures service-to-service communication, restricting interservice communication to predefined routes. This guarantees that network communication is trusted, secure, and well-defined. ### Continuous monitoring Bastion deploys network monitoring to detect anomalies and potential threats in real time, allowing it to respond swiftly to security incidents. # Security architecture overview Source: https://docs.bastion.com/guides/security/overview How Bastion secures the platform end-to-end: defense in depth, authenticated request flow, VPC isolation, WAF and DDoS protection, and secure hardware. ## Overview Bastion is built with security as a foundational principle. Bastion employs defense-in-depth strategies to protect your digital assets and ensure the integrity of every transaction. This document provides an overview of that security architecture. *** ## Architecture ### Defense in depth Bastion's infrastructure implements multiple layers of security controls: * **Network isolation**: All services operate within isolated network segments with strict ingress controls * **Authentication gateway**: Every API request is authenticated before reaching any backend system * **Least privilege**: Each component operates with minimal permissions required for its function #### Request flow ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} ┌──────────┐ ┌─────────────────┐ ┌───────────────┐ │ Your │ TLS │ API Gateway │ Auth │ Bastion │ │ App │────────▶│ + Auth Layer │────────▶│ Platform │ └──────────┘ └─────────────────┘ └───────────────┘ ``` All API requests pass through Bastion's authentication layer before any processing occurs. Unauthenticated or unauthorized requests are rejected at the gateway and never reach backend systems. #### Infrastructure security Bastion runs on AWS with enterprise-grade security controls: * **VPC isolation**: Services run in private subnets with no direct internet access * **WAF protection**: Web Application Firewall blocks common attack patterns * **DDoS mitigation**: AWS Shield protects against volumetric attacks * **Container hardening**: All services run as non-root with read-only filesystems *** ## Secure hardware ### AWS Nitro Enclaves Bastion's signing operations run inside **AWS Nitro Enclaves**, providing hardware-enforced isolation for cryptographic operations. **What are Nitro Enclaves?** Nitro Enclaves are isolated compute environments with: * **No persistent storage** — Secrets exist only in volatile memory * **No interactive access** — No SSH, no shell, no debugging access, even for Bastion engineers * **No external networking** — Access to the enclave is limited to a secure virtual socket interface * **Cryptographic attestation** — Hardware-signed proof that the enclave is running verified code **Why this matters for you:** Your private keys are generated and used inside this isolated environment. The keys cannot be extracted, copied, or accessed, even by Bastion. Only the enclave has access to the cryptographic material needed to sign transactions on your behalf. ### Hardware-level guarantees | Property | Guarantee | | - | - | | **Isolation** | Signing runs in a separate virtual machine with its own kernel | | **Attestation** | AWS provides cryptographic proof of enclave integrity | | **No persistence** | Keys exist only in memory during operation | | **Tamper evidence** | Any modification to the enclave code invalidates attestation | *** ## Wallet key security ### Key derivation Bastion uses a hierarchical deterministic key derivation scheme. Private keys are **never stored**. They are mathematically derived on-demand from a master secret secured in AWS Key Management Service (KMS). ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} ┌─────────────────────────────────────────────────────────────┐ │ AWS KMS │ │ Hardware Security Module (FIPS 140-2 Level 3) │ └──────────────────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Nitro Enclave │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Shard Key (derived from KMS, in-memory only) │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ Account Private Key (derived per-request) │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ Signed Transaction │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### Key security properties | Property | Description | | - | - | | **No key storage** | Private keys are derived on-demand, never written to disk | | **KMS-backed** | Root secrets protected by FIPS 140-2 Level 3 certified HSMs | | **Memory protection** | Cryptographic material is zeroed immediately after use | | **Deterministic** | Same wallet address is always derived from the same account | #### Supported cryptography | Chain Family | Algorithm | Chains | | - | - | - | | EVM | secp256k1 ECDSA | Ethereum, Polygon, Arbitrum, Base, Optimism | | SVM | Ed25519 EdDSA | Solana | *** ## API authentication ### API keys Bastion uses high-entropy API keys for authentication: ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} Authorization: Bearer bas_XXXXXXXXXX... ``` **Security properties:** | Property | Description | | - | - | | **Secure entropy** | Keys generated using secure high-entropy randomness | | **One-time display** | The full key is shown only at creation and cannot be retrieved later | | **Secure storage** | Keys are securely hashed before storage; plaintext keys are never stored | | **Instant revocation** | Revoked keys are immediately rejected | #### Best practices | Do | Don't | | - | - | | Store keys in a secrets manager | Commit keys to version control | | Use separate keys for development and production | Share keys between environments | | Rotate keys periodically | Use long-lived keys without rotation | | Revoke unused or compromised keys immediately | Leave old keys active | ### Request signing (enhanced security) For high-security use cases, Bastion supports ECDSA request signing: 1. **Generate a key pair**: Create an ECDSA P-256 key pair 2. **Register your public key**: Associate it with your API key via the Bastion Dashboard 3. **Sign each request**: Include a signature header with your requests When request signing is enabled, every API call must include a valid signature. This protects against API key theft and provides request authenticity. ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/crypto/transfers Authorization: Bearer bas_XXXXXXXXXX... Bastion-Signature: ``` *** ## Data protection ### Encryption at rest All sensitive data is encrypted using AES-256 with keys managed through AWS KMS. #### Encryption in transit All communications are protected with modern TLS. *** ## Summary | Layer | Protection | | - | - | | **Infrastructure** | VPC isolation, WAF, DDoS protection, hardened containers | | **Signing** | AWS Nitro Enclaves with hardware-level isolation | | **Keys** | Never stored; derived on-demand; KMS-backed | | **Authentication** | High-entropy API keys; optional ECDSA request signing | | **Data** | AES-256 at rest; modern TLS in transit | # Create Account Source: https://docs.bastion.com/v2/api-reference/accounts/create-account /v2/openapi.yaml post /v2/accounts Create an account for your organization. Note that accounts can only be created if the associated identity has been cleared to transact (i.e. has passed both compliance and jurisdiction checks). # Get Account Source: https://docs.bastion.com/v2/api-reference/accounts/get-account /v2/openapi.yaml get /v2/accounts/{id} Get an account belonging to your organization # Get Account Balances Source: https://docs.bastion.com/v2/api-reference/accounts/get-account-balances /v2/openapi.yaml get /v2/accounts/{account_id}/balances Get base and token asset balances for a given account # List Accounts Source: https://docs.bastion.com/v2/api-reference/accounts/list-accounts /v2/openapi.yaml get /v2/accounts List Accounts belonging to your organization # Accounts API overview Source: https://docs.bastion.com/v2/api-reference/accounts/overview Bastion accounts represent per-identity operational units with wallet addresses per chain. Covers segregated and omnibus account types and creation steps. Bastion supports both segregated on-chain accounts (`SEGREGATED`) and omnibus ledgered accounts (`OMNIBUS`). ## What is an account in Bastion? An account in Bastion represents a unique operational unit tied to an identity. Each account has its own wallet address per supported blockchain, and you use it to perform transactions. Note: you can only create accounts under identities that are `allowed_to_transact_fungibles`. ## Account creation steps 1. [Create an identity](/v2/api-reference/identities/create-identity) 2. [Submit compliance data](/v2/api-reference/compliance/submit-compliance-data) for the created identity 3. Wait until the identity has passed compliance checks. You may do this in one of two ways: 1. Poll the [get identity endpoint](/v2/api-reference/identities/get-identity) until the identity’s `allowed_to_transact_fungibles` flag is set to `true` 2. Listen for a [`compliance_check_status_update` webhook](/v2/api-reference/webhooks/notifications/compliance-notifications#compliance-check-status-update-events) that indicates that the identity’s `allowed_to_transact_fungibles` flag is set to `true` 4. [Create an account](/v2/api-reference/accounts/create-account) under the created identity ## Key traits of an account * Linked to a single identity, which holds the compliance status. * Fiat operations eligibility is determined at the identity level (`fiat_operations_enabled` on the identity). * Supports multiple chains. By default, accounts have wallets on all supported chains. # List Supported Assets Source: https://docs.bastion.com/v2/api-reference/assets/list-supported-assets /v2/openapi.yaml get /v2/assets Get all the supported assets. If chain is specified, assets supported only on that chain will be returned. # JWT generation example Source: https://docs.bastion.com/v2/api-reference/authentication/jwt-generation-example Working Python script that constructs the required claims and signs an ES256 JWT for Bastion API request signing, including body hashing and headers. If you prefer to generate JWTs programmatically within your Python application or tooling, you can use the following lightweight script. It constructs the required JWT claims and signs the token using your ES256 private key. ## Step 1: Generate your private key Before running the script, securely generate your ES256 private and public keys. For testing purposes you can use this command: ```shell theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} openssl ecparam -name prime256v1 -genkey -noout -out es256-private.pem openssl ec -in es256-private.pem -pubout -out es256-public.pem ``` ## Step 2: Script to generate a JWT ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import jwt import json import datetime import hashlib # Required inputs method = "POST" url = "/v2/accounts" body = "{\n \"request_id\": \"2de34f56-d549-5651-af61-e2316bc3c47a\",\n \"identity_id\": \"2zwoDFH1yR9ofx1DFHqNDNL2aFS\"\n}" # Loaded EC private key object (ES256) # This assumes PRIVATE_KEY is already loaded hashedBody = hashlib.sha256(body.encode()).hexdigest() # Generate timestamp timestamp = datetime.datetime.now(datetime.UTC).timestamp() # Construct claims claims = { "iat": int(timestamp), "req-method": method, "req-path": url, "body": hashedBody } # Sign JWT using ES256 token = jwt.encode(claims, PRIVATE_KEY, algorithm="ES256") ``` The resulting `token` variable contains the signed JWT string, which you can include in your API request header like so: ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} bastion-signature: ``` ## Notes * The `iat` claim must reflect the current UTC time and be within 30 seconds of server time. * The `body` must match the exact order and structure of the request body. * The script does not skip the `body` claim for `GET` or `HEAD`. # API authentication with Bearer keys Source: https://docs.bastion.com/v2/api-reference/authentication/overview Obtain a Bastion API key from the dashboard, choose between the sandbox and production environments, and pass the key as a Bearer token in requests. Authenticate your requests to the Bastion API by obtaining an API key from the Bastion Dashboard and passing it in the `Authorization` HTTP header. ## API environments Bastion provides two environments: | Environment | Base URL | Dashboard URL | | - | - | - | | Production | `https://api.prod.bastion.com` | [https://dashboard.prod.bastion.com](https://dashboard.prod.bastion.com) | | Sandbox | `https://api.sandbox.bastion.io` | [https://dashboard.sandbox.bastion.com](https://dashboard.sandbox.bastion.com) | ## Obtain an API key Contact our sales team at [sales@bastion.com](mailto:sales@bastion.com) to gain access to the Bastion platform. Our team will review your request and respond with access credentials along with additional instructions and terms of use. Once you have dashboard access, you can create API keys directly from the Bastion Dashboard: 1. Navigate to the **API keys** tab in the left sidebar. Bastion sandbox dashboard 2. Select **Create new API Key** and enter a name for the key. Create new API key dialog in the sandbox dashboard 3. Copy and securely store the generated secret key. It won't be retrievable after this step. Newly created secret API key shown in the sandbox dashboard Your API key is unique to your organization. Do not share it with unauthorized individuals or expose it in client-side code, public repositories, or configuration files. ## Pass the API key in request headers Once you have obtained your API key, include it in the HTTP request headers for every API call: 1. Set the `Authorization` header in your HTTP requests. 2. Use the `Bearer` scheme followed by a space and your API key: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} Authorization: Bearer ``` 3. Replace `` with the actual key provided to you. ### Example requests ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -H "Authorization: Bearer " \ https://api.sandbox.bastion.io/v2/accounts ``` A successful authenticated request returns a `200` response. If your key is missing or invalid, you'll receive a `401 Unauthorized` response. ## Next steps * Explore the [Sandbox access](/guides/getting-started/api-access-prerequisites) page for details on the sandbox environment and dashboard * Browse the [API reference](/v2/api-reference/authentication/overview) to see available endpoints # Request signing with JWTs Source: https://docs.bastion.com/v2/api-reference/authentication/request-signing Enable JWT request signing on a Bastion API key: upload an ES256 public key, then sign every request with iat, req-path, req-method, and body claims. ## JWT authentication To securely authenticate API requests, Bastion supports JWT-based request signing using ES256 public keys. This guide walks you through setting up your API key for JWT authentication and how to include signed tokens in your API requests. ### Step 1: Upload a public key To enable JWT authentication for your API key: 1. **Generate an ES256 (P-256 curve, SHA-256) public/private key pair.** ```shell theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} openssl ecparam -name prime256v1 -genkey -noout -out es256-private.pem openssl ec -in es256-private.pem -pubout -out es256-public.pem ``` 2. Go to your **[Bastion Dashboard](https://dashboard.prod.bastion.com/)**, find the relevant API key, and select **Add public key**. 3. In the pop-up, paste your **ES256 public key** in PEM format and select **Add public key**. > Important: Once a public key is associated with an API key, all requests using that key must include a valid JWT. Requests without a properly signed token will be rejected. ### Step 2: Sign requests with a JWT Each request must include an **ES256-signed JWT** in the request headers. The token must include the following claims: * **iat** – A Unix timestamp representing the time of the request. * Must be **valid within 30 seconds** of the server time. * Cannot be **in the future**. * **req-path** – The **full request path** and **query parameters** (e.g. `/v2/crypto/deposits?account_id=123`). * **req-method** – The **HTTP method** used (e.g. `POST`, `PUT`, `GET`, etc.). * **body** – The **request body**, if applicable. * You may omit this claim for `GET` or `HEAD` requests. * Ensure the request body is **serialized in the exact order** it is transmitted, then **hashed with SHA-256 and hex-encoded.** For a working example, see [JWT generation example](/v2/api-reference/authentication/jwt-generation-example). #### Example claims ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "iat": 1754073218, "req-path": "/v2/accounts", "req-method": "POST", "body": "61ba41c6ce45dc6947975d178faa7ed82354fdc98c02cedaeaff64cff51134e0" } ``` ### Step 3: Send the JWT in headers Include the signed JWT in the `bastion-signature` header: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} bastion-signature: ``` Here’s an example using `curl`: ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} curl -X POST https://api.prod.bastion.com/v2/accounts \ -H "Authorization: Bearer " \ -H "bastion-signature: " \ -H "Content-Type: application/json" \ -d '{ "request_id": "2de34f56-d549-5651-af61-e2316bc3c47a", "identity_id": "2zwoDFH1yR9ofx1DFHqNDNL2aFS" }' ``` 💡 Replace `` with the actual ES256 JWT, signed using your private key. ### Conclusion Once your API key is configured with a public key, **you must use JWTs for all future requests**. This approach enhances the security of your API interactions and ensures all requests are verifiably authentic. If you have questions about key generation, JWT formatting, or encounter issues, feel free to contact us at [try@bastion.io](mailto:try@bastion.io). We’re here to help! # Compliance API overview Source: https://docs.bastion.com/v2/api-reference/compliance/overview Submit KYC or KYB data to Bastion at the identity level, understand the sanctions and jurisdiction checks Bastion runs, and handle each possible outcome. Submitting compliance information is a necessary prerequisite to performing operations via Bastion. The verification process is a one-time step facilitated via the [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) endpoint. ## Identities and accounts Bastion operates on an identity-based system, where an identity can have multiple associated accounts. You submit compliance data at the identity level rather than per account. There are two types of identities: **Individual identities** – Require KYC (Know Your Customer) verification. **Business identities** – Require KYB (Know Your Business) verification. All accounts underneath a given identity inherit the *type* of identity. ## Types of checks When Bastion receives a [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) request, it performs two types of checks: * **Sanctions checks** – Determines whether the identity is a sanctioned entity or linked to one. * **Jurisdiction checks** – Confirms whether the identity is in a jurisdiction supported by Bastion. To interact with fungible assets through the Bastion platform, an identity must pass both checks. If an identity is permitted to transact in fungibles, all associated accounts can. ## Data requirements Compliance data submissions must contain specific required fields based on the identity type. Complete and accurate information ensures timely processing and compliance review. For detailed schema definitions and required fields, refer to the [Submit Compliance Data API](/v2/api-reference/compliance/submit-compliance-data). ## Compliance submission example Submitting compliance data to Bastion is a simple two-step process: 1. [Create the identity](/v2/api-reference/identities/create-identity) 2. [Submit compliance data on behalf of the identity](/v2/api-reference/compliance/submit-compliance-data) ## Submission outcomes There are three possible outcomes when submitting compliance data: 1. **Failed sanctions check** – If the identity fails the sanctions check, all associated accounts are frozen and blocked from interacting with the Bastion platform without manual intervention. 2. **Failed jurisdiction check** – If the identity passes the sanctions check but fails the jurisdiction check, its associated accounts cannot interact with fungible assets. If a jurisdiction later becomes supported, Bastion will automatically re-evaluate affected identities. 3. **Successful compliance check** – If the identity passes both sanctions and jurisdiction checks: * For **individual accounts**, they are permitted to transact with fungible assets. * For **business accounts**, they are permitted to transact with fungible assets. ## Understanding compliance status To check the compliance status of an identity, call [GET identity](/v2/api-reference/identities/get-identity). The response will include a `compliance_status` field, which will indicate whether the identity is undergoing KYC or KYB, depending on its type. This status provides the following key information: * **Sanctions check results** – Whether the identity passed or failed sanctions checks. If it failed, the response will include a reason for failure. * **Jurisdiction status** – Whether the identity passed the jurisdiction check. If it failed, the response will include a failure reason. * **Fungible transaction eligibility** – Whether the identity is permitted to transact in fungible assets. By retrieving this status, you can determine whether an identity has passed sanctions and jurisdiction checks and whether it is permitted to transact in fungibles. ## Fiat operations eligibility To determine whether an identity is eligible for fiat operations via a partner, call [GET identity](/v2/api-reference/identities/get-identity). The response includes the `fiat_operations_enabled` field: * **fiat\_operations\_enabled: true** – The identity is eligible to convert fiat to stablecoins and vice versa. * **fiat\_operations\_enabled: false** – The identity does not have access to fiat operations. ## Additional checks Bastion performs additional compliance checks on non-Bastion addresses that users interact with: * At the time of transaction submission – if Bastion determines that a transaction is at high risk of interacting with a sanctioned entity, Bastion may reject the submission. This prevents the end user from potentially violating sanctions law. * When a Bastion user receives an on-chain deposit – if Bastion determines that a deposit is at high risk of originating from a sanctioned entity, Bastion freezes the user's account and conducts a compliance review of the deposit. ## Sandbox testing notes In the sandbox environment, Bastion simulates compliance checks using specific test values to help you validate behavior without triggering live sanctions or jurisdiction checks. To test compliance outcomes, use the following conventions: ### Sanctions check To **pass**, use the business name `Corporation` (for KYB) or a personal name like John Smith (for KYC). To **fail**, use `watchlist hit` as a business name (for KYB), or use `Osama Bin Laden` (for KYC) as a personal name. ### Jurisdiction check Use an address from a Bastion-supported jurisdiction to pass. Use an unsupported region (e.g. certain countries or U.S. states) to simulate failure. **Note**: All email addresses must be unique, including for any associated beneficial owners (UBOs). # Submit Compliance Data Source: https://docs.bastion.com/v2/api-reference/compliance/submit-compliance-data /v2/openapi.yaml post /v2/identities/{identity_id}/compliance Submit compliance data for an identity in your organization. Either KYB or KYC must be included in the request body. | IdentityType | Required Compliance Data| | -------------|----------------------- | | `INDIVIDUAL` | kyc | | `BUSINESS` | kyb | Please note that only US-based businesses are supported at this time. An international KYB submission will be rejected. # Update Compliance Data Source: https://docs.bastion.com/v2/api-reference/compliance/update-compliance-data /v2/openapi.yaml patch /v2/identities/{identity_id}/compliance Update an identity's compliance data. Note that only certain KYC fields for individual identities are updatable at this time. # Get Conversion Source: https://docs.bastion.com/v2/api-reference/conversions/get-conversion /v2/openapi.yaml get /v2/conversions/{id} Get a previously submitted conversion using its ID # Get Quote Source: https://docs.bastion.com/v2/api-reference/conversions/get-quote /v2/openapi.yaml get /v2/conversions/quotes Get an indicative quote for an off-ramp conversion. Quotes are not rate-locked. # Off-ramp payout countries and required fields Source: https://docs.bastion.com/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields Per-country payout rails, bank details, bank lookups, recipient fields, and business-recipient support for Bastion off-ramps across 60+ jurisdictions. Off-ramp payouts are supported to bank accounts in the United States and 60+ other countries. Requirements vary by country: the payment rail, the bank details you collect, whether the customer must select a receiving institution, and in some cases additional recipient fields. For the end-to-end flows, see [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account) and [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account). For who is eligible to off-ramp, see [Jurisdiction overview](/guides/getting-started/jurisdiction-overview). ## Country requirements **Bank lookup** means you must fetch the receiving institution from `GET /v2/institutions` and pass the selected `bank_id`. See [Bank lookups](#bank-lookups) below. **Recipient extras** are in addition to the fields every registration requires: name (or `business_name`), `email`, and `address`. | Country | Currency | Rail | Bank lookup | Bank details | Recipient extras | Business recipients | | - | - | - | - | - | - | - | | Argentina | ARS | `COELSA` | No | `coelsa_details`: `account_number`, `account_number_type` | `tax_id`, `phone` | Yes | | Australia | AUD | `BECS` | No | `bank_transfer_details`: `account_number`, `bank_code` | None | Yes | | Bolivia | BOB | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `type` | `tax_id` | Yes | | Brazil | BRL | `PIX` | No | `pix_details`: `identifier_type`, `identifier_value` | `tax_id` | Yes | | Canada | CAD | `EFT` | Yes | `ca_eft_details`: `account_number`, `bank_id`, `institution_number`, `transit_number` | None | Yes | | Chile | CLP | `TEF` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `type` | `tax_id` | Yes | | Colombia | COP | `ACH` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `type` | `tax_id`, `phone` | Yes | | Costa Rica | CRC | `SINPE` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `tax_id`, `phone` | Yes | | Dominican Republic | DOP | `LBTR` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `type` | `tax_id`, `phone` | Yes | | Ecuador | USD | `ACH` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `tax_id`, `phone` | Yes | | Egypt | EGP | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | None | Yes | | El Salvador | USD | `ACH` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | No | | Ghana | GHS | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | None | Yes | | Guatemala | GTQ | `ACH` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | No | | Honduras | HNL | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | No | | India | INR | `IMPS_FIRC` | No | `imps_firc_details`: `account_number`, `ifsc_code` | None | Yes | | Indonesia | IDR | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | None | Yes | | Israel | ILS | `ZAHAV` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | None | Yes | | Jamaica | JMD | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `bank_code` | `phone` | Yes | | Japan | JPY | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id`, `bank_code` | `phone` | Yes | | Jordan | JOD | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | `phone` | No | | Kenya | KES | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | None | Yes | | Malaysia | MYR | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | Yes | | Mexico | MXN | `SPEI` | No | `spei_details`: `clabe` | None | Yes | | Pakistan | PKR | `IBFT` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | None | No | | Peru | PEN | `CCE` | Yes | `cce_details`: `account_number`, `bank_id`, `type` | `tax_id` | Yes | | Philippines | PHP | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | None | Yes | | Qatar | QAR | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | `tax_id` | Yes | | Saudi Arabia | SAR | `MADA` | Yes | `bank_transfer_details`: `account_number` (IBAN), `bank_id` | None | Yes | | Singapore | SGD | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | Yes | | South Africa | ZAR | `EFT` | Yes | `za_eft_details`: `account_number`, `bank_id`, `transit_number` | `phone` | No | | South Korea | KRW | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | `phone` | Yes | | Thailand | THB | `BANK_TRANSFER` | Yes | `bank_transfer_details`: `account_number`, `bank_id` | None | Yes | | United Arab Emirates | AED | `NPSS` | No | `bank_transfer_details`: `account_number` (IBAN) | None | Yes | | United Kingdom | GBP | `BANK_TRANSFER` | No | `bank_transfer_details`: `account_number` (IBAN), `bank_code`, plus at least one of `swift_or_bic_number` or `bank_id` | Business `tax_id` | Yes | | United States | USD | `WIRE` | No | `wire_details`: `account_number`, `routing_number`, `type`, `address` | None | Yes | | SEPA countries (29, listed below) | EUR | `SEPA` | No | `sepa_details`: `bank_account_number`, `swift_or_bic_number` | Sweden: business `tax_id` | Yes | Where **Business recipients** is No, the country accepts individual recipients only, and submitting `recipient.business` fails. South Korea and Jordan require `phone` for `FIRST_PARTY` recipients too. The other recipient extras apply to `THIRD_PARTY` recipients only. US payouts are first-party only: `beneficiary_type` must be `FIRST_PARTY`, and the destination must be a bank account the customer owns. Every other country accepts both `FIRST_PARTY` and `THIRD_PARTY`. ### SEPA countries All SEPA corridors pay out in EUR and share the same requirements, except that Sweden also requires `tax_id` for `THIRD_PARTY` business recipients: Austria, Belgium, Bulgaria, Croatia, Cyprus, Czechia, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Norway, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden, Switzerland. ### Why rails and details objects differ Some countries have a standardized local scheme with its own `payment_method` value, such as `SPEI` for Mexico or `PIX` for Brazil. Countries without a dedicated local rail use `BANK_TRANSFER` as the catch-all, and the `country` on the request determines which corridor applies. Rails with identical field sets share a details object. `TEF`, `SINPE`, `LBTR`, `IBFT`, `MADA`, `ZAHAV`, `NPSS`, `BECS`, and `ACH` all use `bank_transfer_details` despite having distinct `payment_method` values. Beyond the fields you collect, the rail changes nothing. Conversion submission, webhook handling, and status progression are identical everywhere. ## Bank lookups Countries marked **Yes** above require the customer to choose their receiving institution, and registration fails without a `bank_id`. Fetch the list before you render the bank field: ```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} GET /v2/institutions?country=KR¤cy=KRW&payment_method=BANK_TRANSFER Authorization: Bearer YOUR_API_KEY ``` For the response shape, when the list comes back empty, and where this fits in the flow, see [Step 3 of the international quickstart](/guides/quickstarts/off-ramp/international-bank-account#step-3-–-look-up-institutions). Two country-specific exceptions: * **Japan** requires a lookup for `bank_id` and separately requires a `bank_code`, the Zengin code, which the institutions endpoint does not return. Collect it from the customer. * **United Kingdom** needs no lookup, but does require at least one of `swift_or_bic_number` or `bank_id`. If you send `bank_id`, get it from the institutions endpoint rather than constructing it. ## Building the request Payout destinations are registered through `POST /v2/identities/{identity_id}/payment-instructions`. The country determines three things: the `payment_method`, the details object nested inside `bank_account_details`, and which recipient fields you need. Canada, which uses one of the more detailed objects: ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "payment_method": "EFT", "country": "CA", "fiat_currency_symbol": "CAD", "beneficiary_type": "THIRD_PARTY", "bank_account_details": { "name": "Royal Bank of Canada", "ca_eft_details": { "account_number": "1320993", "bank_id": "3FkR2mQpLxNvWsT4", "institution_number": "003", "transit_number": "00010" } }, "recipient": { "individual": { "first_name": "Avery", "last_name": "Tremblay", "email": "avery.tremblay@example.com", "address": { "street_line_1": "88 Queen St W", "city": "Toronto", "postal_code": "M5V 2T6", "country": "CA", "region_code": "ON" } } } } ``` Set exactly one of `recipient.individual` or `recipient.business`. Nest `tax_id` and `phone` inside whichever one you set. Both are also accepted at the top level of `recipient`, but that form is deprecated, and the nested value wins if you send both. Bastion derives the account holder's name and address from the `recipient` object before forwarding to the payment provider. Do not put them inside `bank_account_details`. ## Format requirements **Tax IDs** | Country | Format | | - | - | | Mexico | RFC, 13 alphanumeric characters. Optional | | Brazil | CPF (11 digits) for individuals, CNPJ (14 digits) for businesses | | Argentina | CUIT with check digit, e.g. `30-51731290-4` | | Ecuador | Exactly 10 digits for individual recipients | **Bank codes** | Country | Format | | - | - | | Australia | BSB, 6 digits, no hyphen, e.g. `012950` | | Japan | Zengin, `XXXX-XXX`, e.g. `0397-480` | | Jamaica | Transit code, `00000 999` | | United Kingdom | Sort code, 6 digits, no dashes, e.g. `200353` | | Canada | `institution_number` is 3 digits, e.g. `003`. `transit_number` is a 5-digit branch code | | South Africa | `transit_number` is the branch code | **Postal codes** | Country | Format | | - | - | | Argentina | Alphanumeric, e.g. `C1043AAZ` | | Canada | `A1A 1A1`, e.g. `M5V 2T6` | | El Salvador | `CP NNNN`, e.g. `CP 1101` | | Netherlands | `NNNN AA`, e.g. `1012 AB` | **Street addresses** Saudi Arabia, the United Arab Emirates, Israel, and Kenya require a physical street address. `P.O. Box` and `PO Box` are rejected in `street_line_1`. **Phone numbers** E.164 with country code, e.g. `+541143000000`. **Account identifiers** | Field | Format | | - | - | | `clabe` (Mexico) | 18 digits | | `identifier_type` (Brazil) | One of `CPF`, `CNPJ`, `EMAIL`, `PHONE_NUMBER`, `RANDOM_KEY` | | `identifier_value` (Brazil) | The PIX key itself. CPF is 11 digits, CNPJ is 14, `RANDOM_KEY` is a UUID | | `account_number_type` (Argentina) | One of `CBU`, `CVU`, `ALIAS`. CBU is 22 digits | | `type` (Peru) | `CHECKING` or `SAVINGS` | | `address` (Peru) | Same shape as `recipient.address`: `street_line_1`, `city`, `postal_code`, `country`, `region_code` |
# Conversions API overview Source: https://docs.bastion.com/v2/api-reference/conversions/overview How on-ramp and off-ramp conversions behave over time: lifecycle states, quote binding, webhook events, terminal errors, and what COMPLETED means. This page defines how a conversion behaves over time: the states it moves through, what each one means, how quotes bind to a conversion, and what to do when something fails. For what a conversion is, see [Stablecoin on/off-ramp conversions](/guides/concepts/stablecoin-conversions). For step-by-step implementation, see the quickstarts for [on-ramps](/guides/quickstarts/on-ramp) and [off-ramps](/guides/quickstarts/off-ramp). For request and response shapes, see the endpoint pages. ## Conversion types | Type | Direction | Created by | | - | - | - | | `ON_RAMP` | USD to USDC | Automatically, when a wire lands at a Virtual Account | | `OFF_RAMP` | USDC to fiat | `POST /v2/conversions` | Both types share the same lifecycle and the same webhook event type. ## Lifecycle ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} INITIATED ──→ PROCESSING ──→ PAYOUT_INITIATED ──→ PAYOUT_IN_PROGRESS ──→ COMPLETED │ │ │ │ ├─ INSUFFICIENT_BALANCE ▼ ▼ ▼ ▼ FAILED FAILED FAILED RETURNED ``` `COMPLETED`, `FAILED`, `RETURNED`, `INSUFFICIENT_BALANCE`, and `CANCELLED` are terminal. Every other state is transient. `FAILED` is reachable from `INITIATED`, `PROCESSING`, and `PAYOUT_INITIATED`. `INSUFFICIENT_BALANCE` is reachable from `INITIATED` on an off-ramp whose source balance does not cover the conversion. Once the payout is in flight the terminal error is `RETURNED` instead, because the funds left and are coming back. A successful submit call does not mean a conversion will reach payout, so handle a terminal error out of every transient state. | State | Terminal | Meaning | | - | - | - | | `INITIATED` | No | The conversion exists and has been accepted. For an off-ramp, the submit call succeeded. For an on-ramp, the incoming wire has been recognized | | `PROCESSING` | No | Bastion is converting between the stablecoin and fiat legs. The source funds are committed at this point | | `PAYOUT_INITIATED` | No | The outbound leg has been handed to the payment provider or the destination chain | | `PAYOUT_IN_PROGRESS` | No | The provider or network is delivering the funds | | `COMPLETED` | Yes | Funds have been delivered. `completed_at` is populated | | `FAILED` | Yes | The conversion could not be completed and the payout never went out. `failure_reason` describes the cause | | `INSUFFICIENT_BALANCE` | Yes | The payout never started because the source balance did not cover the conversion. Submit again after funding the account. `failure_reason` describes the shortfall | | `RETURNED` | Yes | The payout went out and was sent back, most commonly by the receiving bank rejecting it. `failure_reason` describes the cause; `refund_details` carries the credited USDC amount, cross-app credit identifiers, and structured failure metadata | | `CANCELLED` | Yes | Bastion stopped the conversion before any funds moved | ### What `COMPLETED` means `COMPLETED` means Bastion and its payment partner have delivered the funds. It does not mean the receiving bank has credited the recipient's account. Receiving banks apply their own processing, including compliance screening, so funds can appear in the recipient's account later than `completed_at`. ### Recovering from a terminal error `FAILED` means the payout never went out, so no funds are in flight. Retry if `failure_reason` points to a transient cause, otherwise route to support. `INSUFFICIENT_BALANCE` means the payout never started because the source account could not cover the conversion. Fund the account, then submit a new conversion. `RETURNED` means the payout went out and came back. Retrying against the same payment instructions will usually fail the same way, so have the customer verify the destination bank details and register corrected instructions first. Treat `failure_reason` as diagnostic. It reports what the provider or bank said, or why the source-balance check found insufficient funds. It belongs in your logs and support tooling rather than in front of a customer. ### Values populated during the lifecycle Some fields have no value until the conversion advances far enough to produce one. | Field | Behavior | | - | - | | `destination.amount` | Empty until settlement completes. US wire off-ramps are the exception and set it when the conversion is created | | `exchange_rate` | Empty until settlement completes. On an international off-ramp the settled value can differ from the rate quoted | | `completed_at` | Empty until `COMPLETED` | | `failure_reason` | Set on `FAILED`, `INSUFFICIENT_BALANCE`, and `RETURNED`. GET and webhooks populate this field from the same mapping logic | | `refund_details` | Present only when `status` is `RETURNED`. Individual keys inside may be omitted until the refund workflow completes | Show quoted figures as estimates, and use the values from the `COMPLETED` event on receipts and in your ledger. ## Quote lifecycle International off-ramps must reference a quote. US off-ramps do not use quotes, and a `quote_id` on a US conversion is rejected. A quote is created by `GET /v2/conversions/quotes`, is persisted, and carries an `expires_at`. It binds fee terms and pricing to the conversion that later references it, which is why fee fields are not accepted on submit. Three rules govern the binding: * **Single use.** A quote supports one successful conversion. Retries that reuse the same `request_id` are idempotent and do not consume a second quote. * **Expiry.** Submitting after `expires_at` returns `QUOTE_EXPIRED`. * **Consistency.** The conversion must match the quote. The `amount` must equal the quoted `source.amount`, and the destination corridor must be the one that was priced. Pricing is indicative rather than locked. Re-fetch a quote whenever the customer's input changes, and again before showing a confirmation screen, so the figures they approve are as current as possible. ## Interpreting webhook events Conversions emit a single event type, `conversion_notification_update`, on every state change. The conversion object arrives in `data`, matching the shape returned by `GET /v2/conversions/{conversion_id}`. Two identifiers, used for different things: * The top-level `id` identifies the event and is regenerated if the event is reprocessed, so don't use it as your deduplication key. * `data.id` is the conversion ID and is stable across every event for that conversion. Deduplicate business logic on `data.id` together with `data.status`. A delivery can repeat, so the same status may arrive more than once. Make your handler idempotent. Because `INITIATED` is the first event on an on-ramp, that event is also the first time you learn the conversion's ID. Route it using `account_id` and the Virtual Account it landed against. For delivery behavior, retries, and sender verification, see [Webhooks](/v2/api-reference/webhooks/overview). For payload field definitions, see [Conversion notifications](/v2/api-reference/webhooks/notifications/conversions-notifications). ## Polling Webhooks are the recommended way to track a conversion. Poll `GET /v2/conversions/{conversion_id}` as a fallback when an expected webhook has not arrived within a reasonable window. Polling returns current state, not history, and there is no endpoint that lists conversions. Persist each event as it arrives, keyed on `data.id`, and store `identity_id`, `account_id`, and `payment_instructions_id` alongside your own records. That gives you transition history, event routing, and period reconciliation without querying Bastion. # Off-ramp payment purposes Source: https://docs.bastion.com/v2/api-reference/conversions/payment-purposes Valid payment_purpose values for Bastion off-ramp conversions, with suggested display labels and guidance on choosing codes for individuals and businesses. `payment_purpose` tells the receiving bank why the money is moving. Banks in the destination country use it for their own reporting. It is required on every off-ramp to a bank account, domestic or international. For the end-to-end flows, see [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account) and [Withdraw stablecoin to an international bank account](/guides/quickstarts/off-ramp/international-bank-account). ## Using the codes * Don't display the raw values to your users. Start from the suggested labels below and map them to wording that fits your product and audience. * Retail products should offer the full list. If you want to offer a narrower set, talk to the Bastion team first. ## Choosing a purpose * Each code covers a range of related payments. Pick the closest match. * If more than one could apply, pick the one that describes the underlying obligation rather than the mechanism. A consultant who bills travel as part of their fee is `ADVISORY_FEES`, not `TRAVEL`. ## Common purposes Suitable for both individuals and businesses.
| Value | Suggested label | Covers | | - | - | - | | `OWNED_ACCOUNT_ABROAD` | Transfer to own account abroad | Moving your own funds to an account you hold in another country. | | `RENT_PAYMENT` | Rent | Rent for a residential or commercial property. | | `PROPERTY_PURCHASE` | Property purchase | Buying real estate or property. | | `UTILITY_BILLS` | Utilities | Electricity, water, gas, internet, and similar bills. | | `EDUCATION` | Education | Tuition, school fees, and other study-related costs. | | `LOAN_PAYMENT` | Loan repayment | Repaying a loan, mortgage, or other credit obligation. | | `DONATION` | Donation or gift | Charitable giving, and gifts to individuals. | | `TRAVEL` | Travel | General travel costs such as flights, tours, and trip expenses. | | `HOTEL` | Hotel or accommodation | Lodging booked and billed separately from the rest of a trip. | | `SHARES_INVESTMENT` | Stocks and securities | Buying stocks, shares, or other securities. |
## Individuals only
| Value | Suggested label | Covers | | - | - | - | | `PERSONAL_REMITTANCE` | Family support or living expenses | Financial support to family members, and personal transfers for living expenses. | | `MEDICAL_EXPENSE` | Medical expenses | Treatment, healthcare services, and medical insurance. |
## Businesses only
| Value | Suggested label | Covers | | - | - | - | | `SALARY_PAYMENT` | Payroll | Wages paid by an employer to an employee. | | `SERVICE_CHARGE` | Supplier or contractor payment | Paying a supplier or contractor for services rendered or goods delivered. | | `ADVISORY_FEES` | Professional and advisory fees | Consulting, legal, accounting, and similar advisory work. | | `ADVERTISING_EXPENSES` | Advertising and marketing | Ad spend, agency fees, and marketing services. | | `OFFICE_EXPENSES` | Office expenses | Supplies, equipment, and day-to-day operating costs. | | `MAINTENANCE_EXPENSES` | Maintenance | Upkeep and repair of property or equipment. | | `CONSTRUCTION` | Construction | Costs tied to a construction or build-out project. | | `TRANSPORTATION_FEES` | Freight and logistics | Freight, logistics, and transport charges. | | `DELIVERY_FEES` | Delivery and shipping | Shipping and last-mile delivery charges. | | `EXPORTS` | Exported goods | Payment for goods shipped across a border. | | `ROYALTY_FEES` | Royalties and licensing | Licensing fees and intellectual property royalties. | | `BUSINESS_INSURANCE` | Business insurance | Commercial insurance premiums. | | `TAX_PAYMENT` | Taxes | Payments toward a government tax obligation. | | `INTERNAL_BUSINESS_MOVEMENT` | Transfer between own business accounts | A business moving its own funds between accounts it controls, such as wallet proceeds out to its own bank. |
# Submit Conversion Source: https://docs.bastion.com/v2/api-reference/conversions/submit-conversion /v2/openapi.yaml post /v2/conversions Submit a conversion on behalf of an account # Get Cryptocurrency Deposit Source: https://docs.bastion.com/v2/api-reference/cryptocurrency-deposits/get-cryptocurrency-deposit /v2/openapi.yaml get /v2/crypto/deposits/{deposit_id} Get a specific cryptocurrency deposit by its ID. # List Cryptocurrency Deposits Source: https://docs.bastion.com/v2/api-reference/cryptocurrency-deposits/list-cryptocurrency-deposits /v2/openapi.yaml get /v2/crypto/deposits Get cryptocurrency deposits for an account, sorted by recency in descending order. # Get Cryptocurrency Transfer Source: https://docs.bastion.com/v2/api-reference/cryptocurrency-transfers/get-cryptocurrency-transfer /v2/openapi.yaml get /v2/crypto/transfers/{transfer_id} Get a cryptocurrency transfer using its ID. **Eventual Consistency** Cryptocurrency transfers involve multiple processing stages - asset pricing checks, compliance verification, and blockchain confirmation - which can take significant time to complete. For this reason, the [submit endpoint](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) is designed to be asynchronous, returning immediately after accepting your request. Due to this design, there is a brief window (typically milliseconds) where a newly submitted transfer may not yet be queryable. If you call this endpoint immediately after submission and receive a `404 Not Found` response, simply retry after a short delay. Once available, you can use this endpoint to track the transfer through all its processing stages until completion. # List Cryptocurrency Transfers Source: https://docs.bastion.com/v2/api-reference/cryptocurrency-transfers/list-cryptocurrency-transfers /v2/openapi.yaml get /v2/crypto/transfers Get previously submitted cryptocurrency transfers, sorted by recency in descending order. # Submit Cryptocurrency Transfer Source: https://docs.bastion.com/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer /v2/openapi.yaml post /v2/crypto/transfers Submit a cryptocurrency transfer on behalf of an account. Refer to the [supported assets](/v2/api-reference/assets/list-supported-assets) endpoint for a list of supported assets # Create Identity Source: https://docs.bastion.com/v2/api-reference/identities/create-identity /v2/openapi.yaml post /v2/identities Create an identity for your organization # Get Identity Source: https://docs.bastion.com/v2/api-reference/identities/get-identity /v2/openapi.yaml get /v2/identities/{id} Get an identity belonging to your organization # List Identities Source: https://docs.bastion.com/v2/api-reference/identities/list-identities /v2/openapi.yaml get /v2/identities List Identities belonging to your application # Identities API overview Source: https://docs.bastion.com/v2/api-reference/identities/overview Bastion identities represent individuals and businesses that undergo compliance verification and can own multiple accounts, each with distinct wallet addresses. An **identity** in Bastion represents a distinct entity, either an *individual* or a *business*, that undergoes compliance verification. A single identity can have multiple associated accounts, each with their own distinct wallet addresses. # Create Identity Verification Check Source: https://docs.bastion.com/v2/api-reference/identity-verification/create-identity-verification-check /v2/openapi.yaml post /v2/identities/{identity_id}/id_verification Create an identity verification check for an individual identity. # Get Identity Verification Check Source: https://docs.bastion.com/v2/api-reference/identity-verification/get-identity-verification-check /v2/openapi.yaml get /v2/identities/{identity_id}/id_verification/{id} Get an existing identity verification check by ID. # List Identity Verification Checks Source: https://docs.bastion.com/v2/api-reference/identity-verification/list-identity-verification-checks /v2/openapi.yaml get /v2/identities/{identity_id}/id_verification List all identity verification checks for an identity. # Identity verification API overview Source: https://docs.bastion.com/v2/api-reference/identity-verification/overview How Bastion signals when identity verification (IDV) is required for a KYC identity, and how to create an IDV check with a redirect URL for the customer. For a subset of identities that are undergoing KYC, Bastion may require an additional identity verification (IDV) process. When required, Bastion emits the `identity_status_update` event. ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT0000000000001", "type": "identity_status_update", "data": { "identity": { "id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "label": "user_ext_ref", "identity_type": "INDIVIDUAL", "status": "PENDING_ONBOARDING", "sub_status": "IDV_REQUIRED" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "COMPLIANCE_CHECKS_IN_PROGRESS" } } ``` When IDV is required, create a new IDV by using the endpoint `/v2/identities/{identity_id}/id_verification`. **Request** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "redirect_url": "https://yourapp.example/idv/return", "reason": "ONBOARDING", "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7" } ``` **Response** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXIDVCHK0000000001", "identity_id": "01HZX8QK9VM5T9JY8ZQXQZQXQZ", "verification_url": "https://idv.vendor.example/session/abc123", "reason": "ONBOARDING", "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "redirect_url": "https://yourapp.example/idv/return", "status": "IN_PROGRESS", "created_at": "2026-04-20T16:00:00Z", "updated_at": "2026-04-20T16:00:00Z" } ``` Provide the returned `verification_url` to the onboarding end user to undergo IDV. Continue polling (or listening to the webhook) for the identity `status` to turn to `ACTIVE` as the signal for completion of KYC. * The source of truth for identity status is the identity's `status` field, either from [GET identity](/v2/api-reference/identities/get-identity) or `data.identity.status` on the `identity_status_update` webhook. Do not rely on this endpoint's top-level `status`, which reports only the IDV check status. # Refresh Identity Verification Check URL Source: https://docs.bastion.com/v2/api-reference/identity-verification/refresh-identity-verification-check-url /v2/openapi.yaml post /v2/identities/{identity_id}/id_verification/{id}/refresh_url Refresh the verification URL for an existing identity verification check. # Get Institutions Source: https://docs.bastion.com/v2/api-reference/institutions/get-institutions /v2/openapi.yaml get /v2/institutions Returns the list of supported bank institutions for a given corridor. Required for corridors that use a bank selector. # Create Bank Account Source: https://docs.bastion.com/v2/api-reference/issuance/create-bank-account /v2/openapi.yaml post /v2/issuance/bank-account Create a bank account for a given identity # Create Mint Destination Address Source: https://docs.bastion.com/v2/api-reference/issuance/create-mint-destination-address /v2/openapi.yaml post /v2/issuance/mint-addresses Create a mint destination address for a given identity # Create Mint Instructions Source: https://docs.bastion.com/v2/api-reference/issuance/create-mint-instructions /v2/openapi.yaml post /v2/issuance/mint-instructions Create a mint instruction for a given identity # Create Redemption Instructions Source: https://docs.bastion.com/v2/api-reference/issuance/create-redemption-instructions /v2/openapi.yaml post /v2/issuance/redemption-instructions Create redemption instructions for a given identity # Get Bank Account Source: https://docs.bastion.com/v2/api-reference/issuance/get-bank-account /v2/openapi.yaml get /v2/issuance/bank-account/{id} Get a bank account by ID # Get Mint Destination Address Source: https://docs.bastion.com/v2/api-reference/issuance/get-mint-destination-address /v2/openapi.yaml get /v2/issuance/mint-addresses/{id} Get a mint destination address using its ID # Get Mint Instructions Source: https://docs.bastion.com/v2/api-reference/issuance/get-mint-instructions /v2/openapi.yaml get /v2/issuance/mint-instructions/{id} Get a mint instruction using its ID # Get Mint Request Source: https://docs.bastion.com/v2/api-reference/issuance/get-mint-request /v2/openapi.yaml get /v2/issuance/mints/{id} Get a mint request using its ID # Get Redemption Address Source: https://docs.bastion.com/v2/api-reference/issuance/get-redemption-address /v2/openapi.yaml get /v2/issuance/redemption-instructions/{id} Get a redemption address using its ID # Get Redemption Request Source: https://docs.bastion.com/v2/api-reference/issuance/get-redemption-request /v2/openapi.yaml get /v2/issuance/redemptions/{id} Get a redemption request using its ID # List Bank Accounts Source: https://docs.bastion.com/v2/api-reference/issuance/list-bank-accounts /v2/openapi.yaml get /v2/issuance/bank-accounts List all bank accounts for a given identity # List Mint Destination Addresses Source: https://docs.bastion.com/v2/api-reference/issuance/list-mint-destination-addresses /v2/openapi.yaml get /v2/issuance/mint-addresses List all mint destination addresses # List Mint Instructions Source: https://docs.bastion.com/v2/api-reference/issuance/list-mint-instructions /v2/openapi.yaml get /v2/issuance/mint-instructions List mint instructions # List Mints Source: https://docs.bastion.com/v2/api-reference/issuance/list-mints /v2/openapi.yaml get /v2/issuance/mints List mints # List Redemption Instructions Source: https://docs.bastion.com/v2/api-reference/issuance/list-redemption-instructions /v2/openapi.yaml get /v2/issuance/redemption-instructions List all redemption instructions # List Redemptions Source: https://docs.bastion.com/v2/api-reference/issuance/list-redemptions /v2/openapi.yaml get /v2/issuance/redemptions List redemptions # Issuance API overview Source: https://docs.bastion.com/v2/api-reference/issuance/overview Mint and redeem Bastion-issued stablecoins from an approved business identity: register mint destination addresses, create instructions, and burn tokens. Heads up: Stablecoin issuance is a permissioned product and must be explicitly enabled by our team before use. If you’re interested in accessing our stablecoin issuance APIs, contact us to request activation and discuss eligibility and setup. ## Prerequisites To mint or redeem, create a `BUSINESS` identity and submit compliance data for that entity. See [this page](/v2/api-reference/compliance/overview#compliance-submission-example) for more details. *** ## Mint workflow This flow enables minting stablecoin tokens to an address on a supported blockchain. At this time, the only supported blockchain is `SOLANA_DEVNET`. ### Step 1: Register a mint destination address **Endpoint**: [`POST /v2/issuance/mint-addresses`](/v2/api-reference/issuance/create-mint-destination-address) Registers an on-chain address as an eligible destination for minted tokens. Tokens cannot be minted to an address that has not been successfully registered. #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "123e4567-e89b-12d3-a456-426655440000", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "chain": "SOLANA_DEVNET", "label": "treasury hot wallet" } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "addr_30CKPHEXdXjb91BZ4FWh5DkLul4", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "chain": "SOLANA_DEVNET", "label": "treasury hot wallet" } ``` ### Step 2: Create mint instructions **Endpoint**: [`POST /v2/issuance/mint-instructions`](/v2/api-reference/issuance/create-mint-instructions) Creates a mint instruction for a given identity. This endpoint returns the following information, which you need to trigger a mint: * Details about the bank account to which funds must be transferred to trigger a mint * A `memo_id`, which must be attached to the memo field of any wire transfer sent to the returned bank account to trigger a mint #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "request_id": "123e4567-e89b-12d3-a456-426655440000", "stablecoin_symbol": "USDX", "mint_destination_address_id": "34enYhNwqK0rpKeZA1jnEzW3SFt", "payment_rail": "WIRE", "fiat_currency_symbol": "USD" } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mint_instructions": { "id": "34eo2o437kfJDk0QfKYXPmHZ4Ik", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "request_id": "123e4567-e89b-12d3-a456-426655440000", "stablecoin_symbol": "USDX", "chain": "SOLANA_DEVNET", "mint_destination_address_id": "34enYhNwqK0rpKeZA1jnEzW3SFt", "payment_rail": "WIRE", "fiat_currency_symbol": "USD", "memo_id": "4Q1EBF3HXY5WD30L", "bank_account_details": { "name": "Bastion Bank", "account_holder_name": "Bastion Bank Account Holder", "account_number": "123456789", "routing_number": "123456789", "type": "CHECKING", "address": { "street_line_1": "123 Main St", "street_line_2": "Apt 1", "city": "New York", "postal_code": "10001", "country": "US", "region_code": "NY" } } } } ``` ### Step 3: Send a wire transfer Send a wire transfer following the mint instructions generated in step 2. Alternatively, in sandbox you may simulate a wire transfer via the [Simulate Fiat Deposit endpoint](/v2/api-reference/simulation/simulate-fiat-deposit). Calling this endpoint with the request body provided below will trigger a test mint. The response includes the ID of the triggered mint. #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "amount": "1234", "memo_id": "4Q1EBF3HXY5WD30L", "currency": "USD", "payment_rail": "WIRE" } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mint_id": "mint_3xcfXpYaLMLg22TxP1ZEQ09tOq9" } ``` ### Step 4: View mint history **Endpoint**: [`GET /v2/issuance/mints`](/v2/api-reference/issuance/list-mints) List mints and their statuses. #### Sample call [`GET /v2/issuance/mints?identity_id=34ekQKpGgpmjAZ4DNu9KiTfGmxk&status=ACCEPTED`](/v2/api-reference/issuance/list-mints) #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "mints": [ { "id": "mint_3xcfXpYaLMLg22TxP1ZEQ09tOq9", "amount": "1000", "chain": "SOLANA_DEVNET", "mint_address_id": "addr_30CKPHEXdXjb91BZ4FWh5DkLul4", "status": "ACCEPTED", "external_tx_id": "", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "stablecoin_symbol": "USDX" } ], "cursor": null } ``` *** ## Redemption workflow Allows customers to redeem tokens for fiat through supported bank rails. ### Step 1: Register a bank account **Endpoint**: [`POST /v2/issuance/bank-account`](/v2/api-reference/issuance/create-bank-account) Registers a bank account with Bastion. Only registered bank accounts can receive the proceeds of any redemptions. #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "bank_account_details": { "name": "Bastion LLC", "account_holder_name": "Bastion LLC", "account_number": "123456789", "routing_number": "123456789", "type": "CHECKING", "address": { "street_line_1": "1234 Main St", "city": "San Francisco", "country": "US", "region_code": "CA", "postal_code": "94101" } } } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "34exkPlHJyLpOUtxXDdKFPXvQzh", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "bank_account_details": { "name": "Bastion LLC", "account_holder_name": "Bastion LLC", "account_number": "123456789", "routing_number": "123456789", "type": "CHECKING", "address": { "street_line_1": "1234 Main St", "street_line_2": "", "city": "San Francisco", "postal_code": "94101", "country": "US", "region_code": "" } } } ``` ### Step 2: Create redemption instructions **Endpoint**: [`POST /v2/issuance/redemption-instructions`](/v2/api-reference/issuance/create-redemption-instructions) Creates a blockchain-to-bank redemption path for a customer. Each redemption instruction is tied to a bank account and fiat payout method and provides a redemption address. Bastion initiates a redemption when it detects a deposit to this redemption address. #### Request ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "request_id": "323e4567-e89b-12d3-a456-426655440000", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "bank_account_id": "34exkPlHJyLpOUtxXDdKFPXvQzh", "chain": "SOLANA_DEVNET", "label": "default redemption", "payment_rail": "WIRE", "stablecoin_symbol": "USDX" } ``` #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "6xcfXpYaLMLg22TxP1ZEQ09tOq9", "address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", "bank_account_id": "34exkPlHJyLpOUtxXDdKFPXvQzh", "chain": "SOLANA_DEVNET", "label": "default redemption", "payment_rail": "WIRE", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "stablecoin_symbol": "USDX" } ``` ### Step 3: Deposit to the redemption address Transfer the stablecoin you would like to redeem to the redemption address generated in step 2. ### Step 4: View redemption history **Endpoint**: [`GET /v2/issuance/redemptions`](/v2/api-reference/issuance/list-redemptions) #### Sample call [`GET /v2/issuance/redemptions?identity_id=34ekQKpGgpmjAZ4DNu9KiTfGmxk&status=ACCEPTED`](/v2/api-reference/issuance/list-redemptions) #### Response ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "redemptions": [ { "id": "red_3xcfXpYaLMLg22TxP1ZEQ09tOq8", "amount": "1000", "chain": "SOLANA_DEVNET", "redemption_address_id": "raddr_6xcfXpYaLMLg22TxP1ZEQ09tOq9", "from_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "status": "ACCEPTED", "deposit_external_tx_id": "", "burn_external_tx_id": "", "identity_id": "34ekQKpGgpmjAZ4DNu9KiTfGmxk", "stablecoin_symbol": "USDX" } ], "cursor": null } ``` # Create Payment Instructions Source: https://docs.bastion.com/v2/api-reference/payment-instructions/create-payment-instructions /v2/openapi.yaml post /v2/identities/{identity_id}/payment-instructions Create a payment instruction for a given identity # Get Payment Instructions Source: https://docs.bastion.com/v2/api-reference/payment-instructions/get-payment-instructions /v2/openapi.yaml get /v2/identities/{identity_id}/payment-instructions/{payment_instructions_id} Get payment instructions by id # List Payment Instructions Source: https://docs.bastion.com/v2/api-reference/payment-instructions/list-payment-instructions /v2/openapi.yaml get /v2/identities/{identity_id}/payment-instructions List payment instructions saved for a given identity # Get Raw Transaction Source: https://docs.bastion.com/v2/api-reference/raw-transactions/get-raw-transaction /v2/openapi.yaml get /v2/crypto/raw-transactions/{raw_transaction_id} Sandbox only, on testnets; not available in production. Get a raw transaction and its current status. # Submit Raw Transaction Source: https://docs.bastion.com/v2/api-reference/raw-transactions/submit-raw-transaction /v2/openapi.yaml post /v2/crypto/raw-transactions Sandbox only, on testnets; not available in production. Sign a customer-built EVM transaction with an account's wallet and return it for you to broadcast. You supply the nonce, gas limit, and fees. # Delete Signing Key Source: https://docs.bastion.com/v2/api-reference/signing-keys/delete-signing-key /v2/openapi.yaml delete /v2/identities/{identity_id}/signing-keys/{key_id} Delete a registered device signing key for a given identity # List Signing Keys Source: https://docs.bastion.com/v2/api-reference/signing-keys/list-signing-keys /v2/openapi.yaml get /v2/identities/{identity_id}/signing-keys List the registered device signing keys for a given identity # Register Signing Key Source: https://docs.bastion.com/v2/api-reference/signing-keys/register-signing-key /v2/openapi.yaml post /v2/identities/{identity_id}/signing-keys Register a device signing key for a given identity # Simulate Fiat Deposit Source: https://docs.bastion.com/v2/api-reference/simulation/simulate-fiat-deposit /v2/openapi.yaml post /v2/simulation/fiat-deposit Simulate a fiat deposit for testing purposes. Not available in production. # Get Transaction Limit Policy Source: https://docs.bastion.com/v2/api-reference/transaction-limits/get-transaction-limit-policy /v2/openapi.yaml get /v2/transaction-limits/policies/{id} Get a transaction limit policy by ID # List Transaction Limit Policies Source: https://docs.bastion.com/v2/api-reference/transaction-limits/list-transaction-limit-policies /v2/openapi.yaml get /v2/transaction-limits/policies List transaction limit policies for your organization # Transaction limits API overview Source: https://docs.bastion.com/v2/api-reference/transaction-limits/overview Configure per-transaction, daily, and weekly transaction limit policies with hard blocks or soft webhook alerts, and see how scoped policies are evaluated. ## Transaction limit policy configuration You can create, update, enable, and disable transaction limit policies via the [Bastion admin dashboard](https://dashboard.bastion.com), as shown in the screenshots below. You can view transaction limit policies either via the Bastion admin dashboard, or via API through the [Get Transaction Limit Policy](/v2/api-reference/transaction-limits/get-transaction-limit-policy) or [List Transaction Limit Policies](/v2/api-reference/transaction-limits/list-transaction-limit-policies) endpoints. Transaction Limits screenshot 1 Transaction Limits screenshot 2 Transaction Limits screenshot 3 When you create a transaction limit policy, you define the following properties: | Name | Description | | - | - | | **Scope** | This property describes who the policy applies to. It can be set to one of the following two values:
- **App Scoped**: The policy will apply to all identities in your application.
- **Identity Scoped:** The policy will apply to a specific identity. | | **Identity ID** | The identity ID that the policy applies to. This property is only relevant for **Identity Scoped** policies. | | **Evaluation Window** | This property describes what set of transactions is taken into account when evaluating a policy. It can be set to one of the following three values:
- **Per Transaction**: Only the current transaction is used to evaluate the policy (i.e. the policy sets a maximum value for any single transaction).
- **Past Day**: The cumulative value of a given identity’s transactions over a rolling 24-hour window (including the current transaction) is used to evaluate the policy.
- **Past Week**: The cumulative value of a given identity’s transactions over a rolling 7-day window (including the current transaction) is used to evaluate the policy. | | **Threshold Type** | This property describes what behavior will be executed if the transaction limit is breached. It can be set to one of the following two values:
- **Hard**: Transactions that breach the policy will be blocked AND trigger webhook notifications (see [webhook documentation here](/v2/api-reference/webhooks/notifications/transaction-limit-notifications)).
- **Soft**: Transactions that breach the policy will trigger webhook notifications but will NOT be blocked. | | **Threshold Amount** | The minimum transacted amount that will cause a policy to be breached. | | **Threshold Currency** | The currency that the threshold amount is defined in. At the moment, only **USD** is supported. | Note that there can only be one transaction limit policy per unique combination of the following fields: `Scope`, `Evaluation Window`, `Threshold Type`, and `Identity ID`. Creating a policy that duplicates an existing combination fails with a `409 Conflict`. These four fields are also immutable once a policy is created. You can update a policy’s `Threshold Amount` after creation, and enable or disable the policy separately. ## Transaction limit policy evaluation Up to six transaction limit policies may apply to a given identity, one per unique combination of `Evaluation Window` and `Threshold Type`. In other words, an identity will be subject to at most: * One policy with `Evaluation Window = Past Week`, and `Threshold Type = Hard` * One policy with `Evaluation Window = Past Week`, and `Threshold Type = Soft` * One policy with `Evaluation Window = Past Day`, and `Threshold Type = Hard` * One policy with `Evaluation Window = Past Day`, and `Threshold Type = Soft` * One policy with `Evaluation Window = Per Transaction`, and `Threshold Type = Hard` * One policy with `Evaluation Window = Per Transaction`, and `Threshold Type = Soft` If there is both an `App Scoped` and relevant `Identity Scoped` policy with the same values for `Evaluation Window` and `Threshold Type`, then **the `Identity Scoped` policy will take precedence over the `App Scoped` policy**. As an example, consider a scenario where the following policies are set: * A policy with `Scope = App Scoped`, `Evaluation Window = Past Week`, and `Threshold Type = Hard` * A policy with `Scope = Identity Scoped`, `Identity Id = Identity123`, `Evaluation Window = Past Week`, and `Threshold Type = Hard` If `Identity123` submits a cryptocurrency transfer, it would be subject to the second policy rather than the first policy. # Create Virtual Account Source: https://docs.bastion.com/v2/api-reference/virtual-accounts/create-virtual-account /v2/openapi.yaml post /v2/identities/{identity_id}/virtual-accounts Create a virtual account for an identity to receive fiat deposits and convert to crypto # List Virtual Accounts Source: https://docs.bastion.com/v2/api-reference/virtual-accounts/list-virtual-accounts /v2/openapi.yaml get /v2/identities/{identity_id}/virtual-accounts List virtual accounts for an identity # Create Webhook Source: https://docs.bastion.com/v2/api-reference/webhooks/create-webhook /v2/openapi.yaml post /v2/webhooks Create a new webhook # Delete Webhook Source: https://docs.bastion.com/v2/api-reference/webhooks/delete-webhook /v2/openapi.yaml delete /v2/webhooks/{id} Delete a webhook by ID # List Webhooks Source: https://docs.bastion.com/v2/api-reference/webhooks/list-webhooks /v2/openapi.yaml get /v2/webhooks List all webhooks # Compliance notifications Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/compliance-notifications Deprecated compliance_check_status_update and account_freeze_status_update webhook payloads. Retained for reference; migrate to Compliance Notifications v2. Refer to [Compliance notifications (v2)](/v2/api-reference/webhooks/notifications/compliance-notifications-v2) instead. ## Introduction Bastion sends these notifications when accounts in your organization gain or lose the ability to transact for compliance reasons. Two types of events trigger compliance notifications: 1. **Compliance check status update events:** Sent when compliance checks for identities in your organization complete. As a reminder, Bastion initiates compliance checks for an identity when it receives a [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) request. 2. **Account freeze status update events:** Sent when accounts in your organization are frozen. An account is frozen when its corresponding identity fails compliance checks. Bastion's compliance team may also freeze an account at its discretion. ## Compliance check status update events An example `Compliance Check Status Update` event payload: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "compliance_check_status_update:32n4BQ5ihrJjYTtYHwedggNIFjT:2025-09-16T17:14:31Z", "type": "compliance_check_status_update", "data": { "identity_id": "32n4BQ5ihrJjYTtYHwedggNIFjT", "compliance_check_passed": true, "compliance_check_failure_reason": "", "jurisdiction_check_passed": true, "jurisdiction_check_failure_reason": "", "allowed_to_transact_fungibles": true } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique identifier for each event. It can be used to deduplicate events. | | `type` | string | The type of event. This is always `compliance_check_status_update` for compliance check status update events. | | `data.identity_id` | string | The ID of the identity whose compliance check status was updated. | | `data.compliance_check_passed` | boolean | Flag indicating whether or not the identity passed compliance checks. | | `data.compliance_check_failure_reason` | string | Optional reason for the compliance check failure. | | `data.jurisdiction_check_passed` | boolean | Flag indicating whether or not the identity passed jurisdiction checks (i.e. that the identity is in a jurisdiction supported by Bastion). | | `data.jurisdiction_check_failure_reason` | string | Optional reason for the jurisdiction check failure. | | `data.allowed_to_transact_fungibles` | boolean | Flag indicating whether or not the identity is allowed to transact fungible assets. | ## Account freeze status update events An example `Account Freeze Status Update` event payload: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "account_freeze_status_update:32n4AmrCrbTZC7jcopJXg4RYOla:2025-09-16T17:14:21Z", "type": "account_freeze_status_update", "data": { "account_id": "32n4AmrCrbTZC7jcopJXg4RYOla", "is_frozen": true, "message": "Account has been frozen - please contact Bastion support for assistance" } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique identifier for each event. It can be used to deduplicate events. | | `type` | string | The type of event. This is always `account_freeze_status_update` for account freeze status update events. | | `data.account_id` | string | The ID of the account that was frozen or unfrozen. | | `data.is_frozen` | boolean | Flag indicating whether the account was frozen or unfrozen. | | `data.message` | string | Optional message regarding the change in frozen status. | # Compliance notifications (v2) Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/compliance-notifications-v2 Reference for identity_status_update webhooks: payload fields, status and sub_status transitions during onboarding, and post-onboarding lifecycle changes. ## Introduction Bastion sends these notifications when identities and accounts in your organization gain or lose the ability to transact for compliance reasons. There is a single type of event trigger for compliance notifications: * **`identity_status_update` events:** Sent whenever an identity (and its associated accounts) undergoes a change in status. These can happen for various reasons: * During onboarding, Bastion initiates compliance checks for an identity when it receives a [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) request. The event returns updates to the status, or indicates if an RFI is required. * Throughout the customer lifecycle post-onboarding, a change in identity status can occur. For example, an identity could move from `ACTIVE` to `SUSPENDED`. ## Identity status update events An example `identity_status_update` event payload of an identity that has completed onboarding is as follows: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYB_ACTIVE001", "type": "identity_status_update", "data": { "identity": { "id": "01HZXBUSINESSIDENTITY0001", "label": "corp_acme_001", "identity_type": "BUSINESS", "status": "ACTIVE", "sub_status": "" }, "affected_account_ids": [], "status_reason": null, "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "COMPLIANCE_CHECKS_IN_PROGRESS" } } ``` An example `identity_status_update` event payload of an approved identity that has been suspended is as follows: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYB_ACTIVE001", "type": "identity_status_update", "data": { "identity": { "id": "01HZXBUSINESSIDENTITY0001", "label": "corp_acme_001", "identity_type": "BUSINESS", "status": "SUSPENDED", "sub_status": "" }, "affected_account_ids": [], "status_reason": null, "previous_status": "ACTIVE", "previous_sub_status": "" } } ``` An example `identity_status_update` event payload for an identity rejected at onboarding because its jurisdiction is not supported is as follows. Note the machine-readable `status_reason`: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "01HZXISMEVT_KYC_REJECTED01", "type": "identity_status_update", "data": { "identity": { "id": "01HZXINDIVIDUALIDENTITY01", "label": "user_ext_ref_001", "identity_type": "INDIVIDUAL", "status": "REJECTED", "sub_status": "" }, "affected_account_ids": [], "status_reason": "unsupported-jurisdiction", "previous_status": "PENDING_ONBOARDING", "previous_sub_status": "CREATED" } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique identifier for each event. It can be used to deduplicate events. | | `type` | string | The type of event. This is always `identity_status_update` for identity status transition events. | | `data.identity` | object | The updated identity, including its `id`, `label`, `identity_type`, `status`, and `sub_status`. | | `data.affected_account_ids` | array | Account IDs whose derived account status may change because of this identity transition (often empty or a small list). | | `data.status_reason` | string \| null | Machine-readable, kebab-case code for why the status changed, or `null` when no coded reason applies. The set of values may grow over time. Current values: `unsupported-jurisdiction` — the identity was rejected at onboarding because its jurisdiction is not supported; `conflict` — the identity was rejected because its tax ID is already claimed in this tenant. | | `data.previous_status` | string | The identity's lifecycle status before this transition. | | `data.previous_sub_status` | string \| null | The identity's sub-status before this transition. | # Conversion notifications Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/conversions-notifications Webhook payload reference for conversion_notification_update events on every on-ramp and off-ramp status change, including fees, refunds, and quote IDs. Conversions emit one event type, `conversion_notification_update`, on every status change. It covers both on-ramps and off-ramps. This page documents the payload. For what each status means and when it changes, see [Conversions](/v2/api-reference/conversions/overview). For delivery, retries, and sender verification, see [Webhooks](/v2/api-reference/webhooks/overview). ## Envelope | Field | Type | Description | | - | - | - | | `id` | string | Identifies this event. Use it to discard duplicate deliveries | | `type` | string | Always `conversion_notification_update` | | `data` | object | The conversion, matching what `GET /v2/conversions/{conversion_id}` returns | The two IDs do different jobs. The top-level `id` identifies the event; `data.id` identifies the conversion and stays the same across all of its events. Deduplicate your own logic on `data.id` plus `data.status`. ## Conversion object | Field | Type | Description | | - | - | - | | `id` | string | Conversion ID. Stable across all events for this conversion | | `request_id` | string | The idempotency key you sent on submit. Generated by Bastion for on-ramps | | `account_id` | string | The Bastion account debited or credited | | `identity_id` | string | The identity that owns `account_id` | | `type` | string | `ON_RAMP` or `OFF_RAMP` | | `status` | string | See [Status values](#enumerated-values) | | `amount` | string | The amount submitted, in the source currency | | `payment_purpose` | string | The declared purpose of the payout | | `quote_id` | string | The quote this conversion was priced from. Empty on US off-ramps and on-ramps, which are not quoted | | `fee_model` | string | `DEDUCTED` or `ON_TOP` | | `exchange_rate` | string | The rate used to convert. `1` when the source and destination currency match. Empty until settlement completes | | `fees_total` | string | Sum of `fees[].amount` | | `fees_total_currency` | string | Currency of `fees_total` | | `destination_currency` | string | Same as `destination.currency` | | `fees` | array | One entry per fee applied. See [Fee object](#fee-object) | | `failure_reason` | string | Human-readable description of what went wrong. Present on `FAILED`, `INSUFFICIENT_BALANCE`, and `RETURNED` | | `refund_details` | object | Refund metadata. Present only when `status` is `RETURNED`. See [Refund details object](#refund-details-object) | | `source_deposit_instructions` | object | Always `null` in this payload | | `created_at` | string | RFC 3339 timestamp | | `updated_at` | string | RFC 3339 timestamp, refreshed on each event | | `completed_at` | string | RFC 3339 timestamp. Empty until `COMPLETED` | ### Source object | Field | Type | Description | | - | - | - | | `source.currency` | string | `USD` on an on-ramp. `USDC` or `USDT` on an off-ramp | | `source.payment_method` | string | `WIRE` on an on-ramp. `BLOCKCHAIN` on an off-ramp | | `source.chain` | string | Blockchain network. Off-ramp source and on-ramp destination | | `source.amount` | string | The principal that converts | | `source.total` | string | What the wallet is actually debited, including fees. Same as `source.amount` when fees are deducted | ### Destination object | Field | Type | Description | | - | - | - | | `destination.currency` | string | `USDC` on an on-ramp. The payout currency on an off-ramp | | `destination.payment_method` | string | `BLOCKCHAIN` on an on-ramp. The payout rail on an off-ramp | | `destination.chain` | string | Destination network on an on-ramp | | `destination.payment_instructions_id` | string | The registered bank account paid out to. Empty on on-ramps | | `destination.country` | string | ISO 3166-1 alpha-2 country of the destination bank. Empty on on-ramps | | `destination.amount` | string | Amount delivered to the destination. Empty until settlement completes, except on US wire off-ramps where it is set when the conversion is created | ### Fee object | Field | Type | Description | | - | - | - | | `amount` | string | The fee charged. Authoritative for reconciliation | | `currency` | string | Currency of the fee | | `type` | string | `PLATFORM` or `DEVELOPER` | | `fee_percent` | string | The rate behind a `DEVELOPER` fee, for display. Recomputing the fee from it can differ from `amount` by a cent | ### Refund details object Present only when `status` is `RETURNED`. The object is always included on `RETURNED`; individual keys inside it may be omitted when Bastion has not populated that value yet (for example on some US domestic returns). | Field | Type | Description | | - | - | - | | `refund_amount` | string | USDC amount credited back to the customer's account | | `transaction_hash` | string | On-chain hash of the cross-app credit transfer from Bastion's intermediary account to the customer. Not the inbound refund from the payment provider | | `deposit_id` | string | Internal deposit identifier for the cross-app credit | | `failure_details` | object | Structured failure category. See [Refund failure details object](#refund-failure-details-object) | ### Refund failure details object Always present on `refund_details`. | Field | Type | Description | | - | - | - | | `type` | string | `bank_rejected`, `expired`, `data_missing`, `other`, or `payout_returned` | | `description` | string | Human-readable detail from the provider or bank. Omitted when empty. Matches `failure_reason` on `RETURNED` when both are set | ## Enumerated values | Field | Values | | - | - | | `status` | `INITIATED`, `PROCESSING`, `PAYOUT_INITIATED`, `PAYOUT_IN_PROGRESS`, `COMPLETED`, `FAILED`, `INSUFFICIENT_BALANCE`, `RETURNED` | | `type` | `ON_RAMP`, `OFF_RAMP` | | `fee_model` | `DEDUCTED`, `ON_TOP` | | `fees[].type` | `PLATFORM`, `DEVELOPER` | ## Examples ### US off-ramp USDC to USD by wire. Fees are deducted, so the recipient gets `amount` minus `fees_total`. ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GuKgYMGV0g345dqKbbgZAGcv8nZ", "type": "conversion_notification_update", "data": { "id": "112233aBcDeFgHiJkLmNoPqRsTu", "request_id": "903c9e51-51af-4597-9174-0a93ca80dbac", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "identity_id": "36hv3wAnJm8F6WqLpVxYzKcE2dR", "amount": "500.00", "type": "OFF_RAMP", "status": "COMPLETED", "source_deposit_instructions": null, "payment_purpose": "PERSONAL_REMITTANCE", "quote_id": "", "fee_model": "DEDUCTED", "source": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "amount": "500.00" }, "destination": { "currency": "USD", "payment_method": "WIRE", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw", "country": "US", "amount": "486.00" }, "exchange_rate": "1", "fees_total": "14.00", "destination_currency": "USD", "fees_total_currency": "USDC", "fees": [{ "amount": "14.00", "currency": "USDC", "type": "PLATFORM" }], "created_at": "2026-04-19T15:00:00Z", "updated_at": "2026-04-19T15:45:00Z", "completed_at": "2026-04-19T15:45:00Z" } } ``` ### International off-ramp USDC to MXN via SPEI, with a developer fee added on top. `source.total` is higher than `amount` because the fees sit on top of it. ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GuKgh0uXqWNcjz30BjEPLOC8SP", "type": "conversion_notification_update", "data": { "id": "3Gb7G2J33qhpt0hUSSJm7ZnttAC", "request_id": "c8f4b2e0-5093-4d7b-af26-8e9c301b4d65", "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz", "identity_id": "36hv3wAnJm8F6WqLpVxYzKcE2dR", "amount": "500.00", "type": "OFF_RAMP", "status": "COMPLETED", "source_deposit_instructions": null, "payment_purpose": "PERSONAL_REMITTANCE", "quote_id": "qt_123", "fee_model": "ON_TOP", "source": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "SOLANA_MAINNET", "amount": "500.00", "total": "506.50" }, "destination": { "currency": "MXN", "payment_method": "SPEI", "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw", "country": "MX", "amount": "8620.00" }, "exchange_rate": "17.24", "fees_total": "6.50", "destination_currency": "MXN", "fees_total_currency": "USDC", "fees": [ { "amount": "2.50", "currency": "USDC", "type": "PLATFORM" }, { "amount": "4.00", "currency": "USDC", "type": "DEVELOPER", "fee_percent": "0.80" } ], "created_at": "2026-03-01T15:00:00Z", "updated_at": "2026-03-01T15:42:00Z", "completed_at": "2026-03-01T15:42:00Z" } } ``` ### Returned off-ramp The payout went out and the receiving bank sent it back. `failure_reason` and `refund_details` are set; `completed_at` and `exchange_rate` are empty, and `destination.amount` is omitted, because settlement did not complete. `refund_details.transaction_hash` is the on-chain hash of the cross-app credit from Bastion's intermediary account back to the customer. It is not the inbound refund transaction from the payment provider. ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GuXD1oNPTfqc9sIsbmbnqr6rx5", "type": "conversion_notification_update", "data": { "id": "3GsMDCVsPIAONf5Suiyidb6cFdu", "request_id": "d511014b-beef-4dc0-b123-2545203dc6f3", "account_id": "3Evd01czVw292pAl9RKyw6eridtH", "identity_id": "3Evd00tQjX5mYbNcWgHsKfLpAzu", "amount": "5.05", "type": "OFF_RAMP", "status": "RETURNED", "source_deposit_instructions": null, "payment_purpose": "PERSONAL_REMITTANCE", "quote_id": "3GsMAIPChSfY6L3jN0RqIGVGVVK", "fee_model": "DEDUCTED", "failure_reason": "The payout was returned by the receiving bank.", "source": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "SOLANA_MAINNET", "amount": "5.05" }, "destination": { "currency": "JPY", "payment_method": "BANK_TRANSFER", "payment_instructions_id": "3GZKn9aTfaKHYWq5D1s3kk6Lvtv", "country": "JP" }, "exchange_rate": "", "fees_total": "1.00", "destination_currency": "JPY", "fees_total_currency": "USDC", "fees": [{ "amount": "1.00", "currency": "USDC", "type": "PLATFORM" }], "refund_details": { "refund_amount": "4.05", "transaction_hash": "5KJp7vN3mQ2xR8hW9tY4zA6bC1dE0fG2hI3jK4lM5nO6pQ7rS8tU9vW0xY1zA2bC3dE4fG", "deposit_id": "3GsRefundDep0sitIdExample123456", "failure_details": { "type": "bank_rejected", "description": "The payout was returned by the receiving bank." } }, "created_at": "2026-07-22T21:28:21Z", "updated_at": "2026-07-23T15:58:24Z", "completed_at": "" } } ``` ### On-ramp USD to USDC via a Virtual Account. There is no submit call, so the first event is where you learn the conversion's ID. Fees are deducted from the wire, so `destination.amount` is `amount` minus `fees_total`. ```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3GuKgh0uXqWNcjz30BjEPLOC8SP", "type": "conversion_notification_update", "data": { "id": "3GpbPImJsAlsghgjopjlfMCgJvq", "request_id": "mt-onramp-d83988e0-b65c-47d5-bd4f-b33f446bb92c", "account_id": "3G4jQq71B8pJS22113ZWtyOrjF6", "identity_id": "3G4jQnUvRw9sXeTbMfCyLhDkZq2", "amount": "25.00", "type": "ON_RAMP", "status": "COMPLETED", "source_deposit_instructions": null, "payment_purpose": "PERSONAL_REMITTANCE", "quote_id": "", "fee_model": "DEDUCTED", "source": { "currency": "USD", "payment_method": "WIRE", "amount": "25.00" }, "destination": { "currency": "USDC", "payment_method": "BLOCKCHAIN", "chain": "ETHEREUM_MAINNET", "payment_instructions_id": "", "amount": "11.00" }, "exchange_rate": "1", "fees_total": "14.00", "destination_currency": "USDC", "fees_total_currency": "USD", "fees": [ { "amount": "14.00", "currency": "USD", "type": "PLATFORM", "fee_percent": "" } ], "created_at": "2026-07-21T22:03:51Z", "updated_at": "2026-07-21T22:07:27Z", "completed_at": "2026-07-21T22:07:27Z" } } ``` On earlier events, `completed_at` is empty and `destination.amount` is omitted. # Deposit notifications Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/deposit-notifications Reference for deposit_v2 webhook payloads: on-chain versus internal ACCOUNT deposits, dedup_key idempotency, source and destination fields, and status. ## Introduction Bastion triggers deposit notification events whenever a Bastion-custodied wallet receives an on-chain token deposit that has been confirmed and passed compliance screening. Bastion fires one webhook per deposit event. Make your handler idempotent. Use the `dedup_key` field to avoid double-processing on retries or duplicate deliveries. *** ## Source types Event payloads include a `source_type` field that indicates the origin of the deposit. | **Source Type** | **Description** | | - | - | | `ON_CHAIN` | Deposit received from an external on-chain wallet address (e.g., a user sending USDC from their personal wallet). | | `ACCOUNT` | Deposit received from an internal Bastion-to-Bastion P2P transfer. The sender is another account within the Bastion system. | *** ## Deposit notifications ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "type": "deposit_v2", "data": { "id": "3AX4GQAUWy8OgAvmkmK1QPPK6GA", "dedup_key": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn::0", "account_id": "3BX5HRAVXz9PgBwnlL2zKR4tYdB", "account_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "source_type": "ON_CHAIN", "source_address": "8DwKActJ8swmNXGLyfdaoe6vmD2TxX19bhyE3txsmwfj", "currency_symbol": "USDC", "amount": "123.44", "destination_chain": "SOLANA_MAINNET", "transaction_hash": "4vJ9jDQkpMHcpbNSG8YRXfj7WEdNRpQ2bXkh6M3TzKVrE9Pf5C1gNwHzYuL8aJqRdmF7BhN2sKpXvWcTy6D3oAn", "status": "CONFIRMED", "confirmed_at": "2026-08-05T15:45:14Z", "created_at": "2026-08-05T15:45:14Z", "source_account_id": "", "identity_id": "3BX5GmQkTvYcJpWnRdA2LuFh8sE", "source_identity_id": "" } } ``` *** ## Recommended action When you receive a `deposit_v2` event, credit the recipient account with the deposited `amount` and `currency_symbol`. Use `dedup_key` to guard against double-crediting if the same event is delivered more than once. *** ## Field reference | **Field** | **Type** | **Description** | | - | - | - | | `id` | string | The unique KSUID identifier for this deposit record in Bastion's system. | | `type` | string | The type of the event. Always `deposit_v2` for deposit events. | | `dedup_key` | string | A unique key used for deduplication. Composed of the on-chain transaction signature and a transfer index (e.g. `{signature}::{index}`). Use this to safely handle retries or duplicate deliveries. | | `account_id` | string | The Bastion account ID of the deposit recipient. | | `identity_id` | string | The identity that owns `account_id`. Empty for deposits created before August 2026. | | `account_address` | string | The wallet address that received the deposit. | | `source_type` | string | The origin of the deposit. `ON_CHAIN` for external deposits; `ACCOUNT` for internal Bastion-to-Bastion P2P transfers. | | `source_address` | string | The sender's wallet address. | | `source_account_id` | string | The Bastion account ID of the sender. Empty when the source is not a Bastion account. | | `source_identity_id` | string | The identity that owns `source_account_id`. Empty when the source is not a Bastion account, and for deposits created before August 2026. | | `currency_symbol` | string | The symbol of the deposited asset (e.g. `USDC`). | | `amount` | string | The amount deposited as a human-readable decimal string (e.g. `"123.44"`). | | `destination_chain` | string | The chain on which the deposit was received (e.g. `SOLANA_MAINNET`). | | `transaction_hash` | string | The on-chain transaction identifier. For Solana this is the transaction signature; the field name is kept consistent with EVM conventions for familiarity. | | `status` | string | The deposit status. Always `CONFIRMED` — only confirmed deposits trigger a webhook. | | `confirmed_at` | string | ISO 8601 timestamp of when the deposit was confirmed on-chain. | | `created_at` | string | ISO 8601 timestamp of when the deposit record was created (mapped from the on-chain block timestamp). | # Transaction limit notifications Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/transaction-limit-notifications Reference for transaction_limit_policy_breached webhook payloads sent when a crypto transfer breaches a policy, including policy scope, window, and threshold. Bastion sends these notifications when identities in your organization breach transaction limit policies. See the [transaction limits page](/v2/api-reference/transaction-limits/overview) for more information about transaction limit policies. ## Transaction limit policy breached events An example `Transaction Limit Policy Breached` event payload: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "33TmBAsfebMrQi6QNE1ge1ecI70:1czONudbZwh5siu2igQJY94QkFH", "type": "transaction_limit_policy_breached", "data": { "policy": { "id": "1czONudbZwh5siu2igQJY94QkFH", "scope": "APP_SCOPED", "evaluation_window": "PER_TRANSACTION", "threshold_type": "HARD", "threshold_currency": "USD", "threshold_amount": "1234.12", "is_active": true, "created_at": "2026-09-18T12:00:00Z", "updated_at": "2026-09-18T12:30:00Z", "managed_by": "CUSTOMER", "transaction_type": "CRYPTO_TRANSFER" }, "identity_id": "33TmRnrHXVasjXfIQ2e9OBixtwM", "crypto_transfer_request_id": "33TmBAsfebMrQi6QNE1ge1ecI70", "transferred_amount": "10000.00" } } ``` | **Field** | **Type** | **Description** | | - | - | - | | id | string | Unique identifier for each event. It can be used to deduplicate events. | | type | string | The type of event. This is always `transaction_limit_policy_breached` for transaction limit policy breached events. | | data.policy.id | string | Unique identifier for the policy that was breached. | | data.policy.scope | string | Scope of the policy that was breached. Valid values are `APP_SCOPED` and `IDENTITY_SCOPED`. | | data.policy.evaluation\_window | string | Evaluation window of the policy that was breached. Valid values are `PER_TRANSACTION`, `PAST_DAY`, or `PAST_WEEK`. | | data.policy.threshold\_type | string | Threshold type of the policy that was breached. Valid values are `HARD` or `SOFT`. | | data.policy.threshold\_amount | string | Threshold amount of the policy that was breached. | | data.policy.threshold\_currency | string | Threshold currency of the policy that was breached. | | data.policy.identity\_id | string | The identity ID that the breached policy applies to. Omitted for `APP_SCOPED` policies. | | data.policy.is\_active | boolean | Indicates whether the breached policy is currently active or not. This should always be true. | | data.policy.created\_at | string | The timestamp when the breached policy was created, in RFC 3339 format. | | data.policy.updated\_at | string | The timestamp when the breached policy was last updated, in RFC 3339 format. | | data.policy.managed\_by | string | Who manages the breached policy. Valid values are `CUSTOMER` and `BASTION`. | | data.policy.transaction\_type | string | The product lane the breached policy applies to, such as `CRYPTO_TRANSFER`. | | data.identity\_id | string | The ID of the identity that breached the transaction limit policy. | | data.crypto\_transfer\_request\_id | string | The ID of the cryptocurrency transfer request that triggered the policy breach. | | data.transferred\_amount | string | The amount transferred by the identity that breached the policy over the course of the policy's evaluation window. | # Cryptocurrency transfer notifications Source: https://docs.bastion.com/v2/api-reference/webhooks/notifications/transfer-notifications Reference for cryptocurrency transfer webhook payloads: intermediate and terminal statuses, destination types, deduplication, and recommended handler actions. ## Introduction Bastion sends transaction notification events whenever a cryptocurrency transfer changes status. These events notify your app of status updates related to transfers submitted via the [Submit Cryptocurrency Transfer API](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer). Bastion sends a webhook on **every status change**, not just at the terminal state. The top-level `id` equals `data.id` (the transfer ID) and repeats across a transfer's events. Deduplicate on `id` + `status`, and use `request_id` to correlate events with your original request. Make your handler idempotent. *** ## Destination types Event payloads include a `destination_type` field that indicates where the transfer was sent. | **Destination Type** | **Description** | | - | - | | `ON_CHAIN` | Transfer was sent to an external on-chain wallet address. | *** ## Transfer statuses The `status` field reflects the current state of the transfer. Statuses fall into two categories: **intermediate** (more updates will follow) and **terminal** (no further updates). | **Status** | **Type** | **Description** | **Recommended Action** | | - | - | - | - | | `ACCEPTED` | Intermediate | Transfer received and queued for processing. | Wait for further updates. | | `SUBMITTED` | Intermediate | Transaction submitted to the blockchain. | Wait for confirmation. | | `CONFIRMED` | Terminal | Transaction mined and confirmed on-chain. | Mark transfer complete and refresh balances. | | `FAILED` | Terminal | Transfer failed before or during submission. | Show error from `failure_details`; if the cause is transient (RPC), retry with a new request; otherwise resolve the root cause before retrying. | | `CANCELED` | Terminal | Transaction was submitted but never mined; Bastion automatically cleared the account nonce. | Funds were not moved; retry by creating a new transfer request. | | `DROPPED` | Terminal | Transaction was not picked up by the network (SVM only). | Funds were not moved; retry by creating a new transfer request. | > Note: Bastion automatically cancels transfers that are not confirmed within 5 minutes of submission to prevent stale execution. *** ## Cryptocurrency transfer events ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "3B577wxiV8Y8l1DllcEPQ1CU8rR", "type": "submit_cryptocurrency_transfer_v2", "data": { "id": "3B577wxiV8Y8l1DllcEPQ1CU8rR", "request_id": "0037a041-fcb0-4565-b026-f5cd1ce3ebb4", "account_id": "3B5766avX4ECshxgU4JuiqB9sRz", "account_address": "0x56c8d09e2A17FEf5B43aEd72A0f3F9cfFAC71afA", "currency_symbol": "TEST", "amount": "0.00001", "destination_address": "0x084a39ebB91797Ec4deB1cd82959Cc2Da96fE363", "destination_chain": "BASE_SEPOLIA", "status": "CONFIRMED", "created_at": "2026-03-17T17:01:53Z", "updated_at": "2026-03-17T17:03:53Z", "transaction_hash": "0x809dd38dd05d3c2362dd99426ccaf047d5ec6d3545cb8c1e1aa7e50913d1e248", "completed_at": "2026-03-17T17:03:53Z", "failure_details": null, "events": [ { "event_name": "Transfer", "event_info": { "from": ["0x56c8d09e2A17FEf5B43aEd72A0f3F9cfFAC71afA"], "to": ["0x084a39ebB91797Ec4deB1cd82959Cc2Da96fE363"], "value": ["10000000000000"] }, "contract_address": "0x30d79b806C70463290261B5e8c332C36cD13a1D4" } ], "destination_type": "ON_CHAIN", "destination_account_id": "", "identity_id": "3B5765mKcWpXtRqYzNvAeLgJhDf" } } ``` *** ## Field reference | **Field** | **Type** | **Description** | | - | - | - | | `id` | string | The unique KSUID identifier for this transfer in Bastion's system. | | `type` | string | The type of the event. Always `submit_cryptocurrency_transfer_v2` for transfer events. | | `request_id` | string | The idempotency key provided by the caller when submitting the transfer. Use this to match events back to your original request. | | `account_id` | string | The Bastion account ID of the sender. | | `account_address` | string | The wallet address of the sender. | | `currency_symbol` | string | The symbol of the transferred asset (e.g. `USDC`). | | `amount` | string | The transfer amount as a human-readable decimal string (e.g. `"0.00001"`). | | `destination_address` | string | The recipient's on-chain wallet address. Populated for `ON_CHAIN` transfers. | | `destination_chain` | string | The destination chain (e.g. `SOLANA_MAINNET`, `BASE_SEPOLIA`). | | `status` | string | The current transfer status. See [Transfer statuses](#transfer-statuses) above for all possible values and their meanings. | | `created_at` | string | ISO 8601 timestamp of when the transfer was created. | | `updated_at` | string | ISO 8601 timestamp of the most recent status update. | | `transaction_hash` | string | The on-chain transaction identifier. Populated once the transaction has been submitted. | | `completed_at` | string | ISO 8601 timestamp of when the transfer reached a terminal status. Null if not yet complete. | | `failure_details` | object or null | Details about why the transfer failed when `status` is `FAILED`. Contains a `code` and `message` describing the failure reason. Null for all other statuses. | | `events` | array | On-chain contract event logs captured from the transaction. Only present for on-chain transfers. | | `destination_type` | string | Indicates the transfer destination type. `ON_CHAIN` for transfers to an external wallet address. | | `destination_account_id` | string | The destination Bastion account ID. Empty for `ON_CHAIN` transfers. | | `identity_id` | string | The identity that owns the sending account. Empty for transfers created before September 2025. | # Webhooks API overview Source: https://docs.bastion.com/v2/api-reference/webhooks/overview Register a webhook endpoint to receive real-time Bastion events instead of polling, and manage endpoints via the Dashboard or the webhooks API. ## Overview Instead of polling APIs for status changes, register a webhook URL and Bastion will push events to you as they happen. ### How it works 1. **Register a webhook.** Provide a URL where you want to receive events. 2. **Bastion sends events.** When something happens (conversion completed, compliance approved, etc.), Bastion POSTs a payload to your URL. 3. **You acknowledge.** Return a `2xx` response to confirm receipt. ## Managing webhooks You can register new webhook endpoints via the **Bastion admin dashboard** or the API. The HTTP endpoint must be publicly accessible and secured with a valid SSL/TLS certificate issued by a common certificate authority. ### Webhook object | Field | Type | Description | | - | - | - | | `id` | string | Unique identifier for the webhook | | `url` | string | Your endpoint that receives events | | `label` | string | Friendly name for the webhook | | `status` | string | Current status of the webhook | | `created_at` | string | When the webhook was registered | ### API endpoints | Action | Endpoint | | - | - | | List all webhooks | [`GET /v2/webhooks`](/v2/api-reference/webhooks/list-webhooks) | | Create a webhook | [`POST /v2/webhooks`](/v2/api-reference/webhooks/create-webhook) | | Delete a webhook | [`DELETE /v2/webhooks/{id}`](/v2/api-reference/webhooks/delete-webhook) | ### Create a webhook ```http theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} POST /v2/webhooks ``` **Request** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "url": "https://events.altolia.com/webhook-events", "label": "bastion-app-events" } ``` ## Security ### Firewall Bastion sends webhook notification events from a small set of IPs. To ensure your systems only process events sent by Bastion, configure a firewall to only accept requests from Bastion IPs. The set of Bastion IPs differs per environment. Configure your firewall to accept requests from **all** of the listed IPs for a given environment. | Environment | IP Allowlist | | - | - | | Sandbox | `18.208.60.184` `34.226.117.172` `52.202.50.106` | | Production | `52.87.76.4` `44.215.237.89` `44.216.216.174` | ## Event payload All events follow the same general payload format: a unique ID, the `type` of the event, and event `data` that varies by type. **Example event payload** ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "id": "00000000-0000-0000-0000-000000000000", "type": "event_type", "data": { ... } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Uniquely identifies this event. Identifier semantics are event-specific (see each event's reference page); do not assume it equals the request ID. | | `type` | string | The type of the event. This determines the schema of the event `data`. | | `data` | object | The event-specific data. | ## Event types Bastion sends webhooks for events including: * **Compliance.** Identity approved, rejected, or requires action. * **Conversions.** Status changes, deposit instructions ready. * **Transfers.** Crypto transfer confirmed or failed. * **Deposits.** Incoming blockchain transactions into your wallets. See the notification references for the full list of event types and payload schemas: [Deposits](/v2/api-reference/webhooks/notifications/deposit-notifications), [Transfers](/v2/api-reference/webhooks/notifications/transfer-notifications), and [Conversions](/v2/api-reference/webhooks/notifications/conversions-notifications). ## Best practices * **Return quickly.** Respond with `2xx` within a few seconds and process asynchronously. * **Verify the sender.** Validate the webhook sender IP to ensure authenticity. * **Handle duplicates.** Use idempotency keys. You may receive the same event more than once. * **Monitor failures.** Bastion retries failed deliveries (see below), but persistent failures may result in the webhook event being dropped. ## Retry behavior If your endpoint does not return a `2xx` response, Bastion retries delivery using exponential backoff. Retries follow an exponential backoff schedule starting at 15 seconds, with a maximum retry window of 10 minutes from event creation: `15s → 30s → 60s → 120s → 240s` (5 attempts over \~7.75 minutes) If Bastion does not receive a `2xx` response within the 10-minute window, it drops the event and does not retry. # Rotate Webhook Signing Key Source: https://docs.bastion.com/v2/api-reference/webhooks/rotate-webhook-signing-key /v2/openapi.yaml post /v2/webhooks/{id}/rotate-signing-key Generate or rotate the HMAC signing secret for a webhook endpoint. If the webhook already has a key, the existing key is moved into a 'previous' slot and remains valid for the overlap window (24H) during which deliveries carry both signatures. The new secret is returned exactly once. # Verifying Bastion webhook signatures Source: https://docs.bastion.com/v2/api-reference/webhooks/verifying-bastion-webhooks Verify Bastion webhook signatures: decode the whsign_ secret, parse the JWS JSON envelope, and validate the detached HMAC-SHA256 in Go, Node.js, or Python. Bastion signs outbound webhooks with HMAC-SHA256 under a per-endpoint secret you generate from the dashboard. The wire format is a **JWS JSON General Serialization** envelope ([RFC 7515 §7.2.1](https://www.rfc-editor.org/rfc/rfc7515#section-7.2.1)) with a **detached payload** ([RFC 7797](https://www.rfc-editor.org/rfc/rfc7797), `b64: false`). This page shows how to verify a signature in Go, Node.js, and Python, plus a no-dependency manual fallback. ## Header Signed webhooks carry one new header: | Header | Value | | - | - | | `Webhook-Signature` | JSON-encoded JWS JSON General Serialization envelope. See [Envelope shape](#envelope-shape) below. | The HTTP body remains the original JSON event; verification does not consume or transform it. Parse and act on the body exactly as you would have without signing. ## Your signing secret When you generate or rotate a signing key, the dashboard shows the secret **once**, in this form: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} whsign_3q2-7_8xQvWmRYxLfBgA0123456789abcdefABCDEF01 ``` This string is **not** the HMAC key directly. To get the key bytes: 1. Strip the `whsign_` prefix. 2. base64url-decode the remainder (RFC 4648 §5, **unpadded**) → 32 raw bytes. HMAC verification uses those **32 decoded bytes**, never the `whsign_…` string. Every snippet below shows the decode step; if you skip it, signatures will never match. Store secrets keyed by their key id (`kid`, see below) so you can verify during a rotation overlap when two keys are briefly live. ## Envelope shape The `Webhook-Signature` header value, JSON-parsed: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "signatures": [ { "protected": "", "signature": "" } ] } ``` During a rotation overlap, the `signatures` array carries **two** entries — current first, previous second. Accept the delivery if **any** entry verifies under a key you trust. ## Protected header (per signature) Each entry's `protected` field, base64url-decoded, is JSON of this shape: ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} { "alg": "HS256", "typ": "webhook+jws", "kid": "2cFp7yShQ8vN3kRtZ1wL9bGmJxY", "iat": 1714501234, "jti": "msg_2cFp9aBdE4gH6jK8mN0pQ2rS", "b64": false, "crit": ["b64"] } ``` * `alg`: always `"HS256"` today. **You MUST pin this** — see [Algorithm pinning](#algorithm-pinning). * `typ`: always `"webhook+jws"`. * `kid`: identifies which signing key produced this signature. Use it to look up the right secret in your `(kid → secret)` map. The `kid` is an opaque identifier. Do not assume a format or prefix; treat it as a string. * `iat`: issued-at, Unix seconds. **Reject deliveries whose `iat` is more than 5 minutes off your local clock.** This is your replay window. * `jti`: message id (`msg_<…>`). It **changes on retries** — do not use it as your idempotency key. Use a payload-level id from the event body instead. * `b64: false` + `crit: ["b64"]`: detached, unencoded payload (RFC 7797). The signing input is over the **raw body bytes**, not a base64-encoded copy. ## Signing input For each signature entry, the bytes that were HMAC-SHA256'd are: ```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} base64url(protected) + "." + raw_request_body_bytes ``` That is: the entry's `protected` field **exactly as it appears** (still base64url-encoded), a literal `.`, then the exact bytes of the HTTP request body. The HMAC output is base64url-encoded, **unpadded**, and compared against the entry's `signature` field. ## Algorithm pinning Pass `algorithms=['HS256']` (or your library's equivalent) to the verify call explicitly. This rejects two attacks: 1. **`alg: none` forgery.** [RFC 7518 §3.1](https://www.rfc-editor.org/rfc/rfc7518#section-3.1) defines a `none` algorithm with an empty signature. A verifier that does not pin `alg` will accept a forged envelope whose protected header sets `alg: none` and whose signature segment is empty. Pinning rejects this outright. 2. **Algorithm confusion at a future asymmetric rollout.** Bastion may eventually add asymmetric algorithms (`ES256`, `EdDSA`). We will announce any algorithm change before flipping `alg`. Until then your allow-list is `['HS256']` only. ## Receiver behavior on signature failure Return HTTP **4xx (400 or 401)** when verification fails, never **200**. Returning 200 after silently discarding a webhook: * **Suppresses our retry signal.** The dispatcher only retries on a non-2xx response. A 200 tells us the delivery succeeded, so we drop the event. * **Hides receiver misconfiguration.** If your verifier silently rejects valid signatures (a stale key after rotation, clock skew on `iat`, an unexpected `alg`), you lose webhooks without ever noticing. ## Rotation When you select **Rotate** in the dashboard, you receive a new secret immediately. For an overlap window (currently 24h) we sign each delivery with **both** keys — the new and the old appear as two entries in the `signatures` array, each with its own `kid`. Hold both `(kid → secret)` mappings during the overlap. Drop the old one once you have confirmed deliveries verifying under the new key and the overlap window has passed. *** ## Go verifier (go-jose v4) ```go theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import ( "encoding/base64" "encoding/json" "errors" "io" "net/http" "strings" "time" "github.com/go-jose/go-jose/v4" ) const tolerance = 5 * time.Minute // DecodeSecret turns the dashboard secret ("whsign_") into the raw // HMAC key bytes. Store the result keyed by kid. func DecodeSecret(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(strings.TrimPrefix(s, "whsign_")) } // keysByKid maps each kid to its DECODED secret bytes (see DecodeSecret). func Verify(r *http.Request, keysByKid map[string][]byte) ([]byte, error) { headerValue := r.Header.Get("Webhook-Signature") if headerValue == "" { return nil, errors.New("missing Webhook-Signature header") } body, err := io.ReadAll(r.Body) if err != nil { return nil, err } // Pin alg=HS256 to reject alg:none and future-algorithm confusion. sig, err := jose.ParseSigned(headerValue, []jose.SignatureAlgorithm{jose.HS256}) if err != nil { return nil, err } for _, entry := range sig.Signatures { // Enforce the iat freshness window before trusting the signature. var hdr struct { Iat int64 `json:"iat"` Kid string `json:"kid"` } if err := json.Unmarshal(entry.Protected, &hdr); err != nil { continue } if delta := time.Since(time.Unix(hdr.Iat, 0)); delta > tolerance || delta < -tolerance { continue } secret, ok := keysByKid[hdr.Kid] if !ok { continue } // DetachedVerify recomputes the HMAC over base64url(protected) + "." + body. if _, err := sig.DetachedVerify(body, secret); err == nil { return body, nil } } return nil, errors.New("no valid signature") } ``` ## Node.js (TypeScript) verifier (panva `jose`) ```tsx theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import { generalVerify } from 'jose'; const TOLERANCE_MS = 5 * 60 * 1000; // Turn the dashboard secret ("whsign_") into the raw HMAC key bytes. function decodeSecret(s: string): Uint8Array { return new Uint8Array(Buffer.from(s.replace(/^whsign_/, ''), 'base64url')); } // keysByKid maps each kid to its DECODED secret bytes (see decodeSecret). export async function verify( headers: Record, body: string, keysByKid: Record, ): Promise { const headerValue = headers['webhook-signature']; if (!headerValue) return false; let env; try { env = JSON.parse(headerValue); } catch { return false; } if (!Array.isArray(env.signatures)) return false; for (const entry of env.signatures) { let hdr; try { hdr = JSON.parse(Buffer.from(entry.protected, 'base64url').toString('utf8')); } catch { continue; } // iat freshness — check before calling the library verify. if (Math.abs(Date.now() - hdr.iat * 1000) > TOLERANCE_MS) continue; const secret = keysByKid[hdr.kid]; if (!secret) continue; try { await generalVerify({ ...env, payload: new TextEncoder().encode(body), signatures: [entry] }, secret, { algorithms: ['HS256'], }); return true; } catch { // Try the next entry (e.g. during a rotation overlap). } } return false; } ``` > The `body` passed here must be the **raw request body string**, byte-identical to what was delivered. If your framework parses JSON before you see it, capture the raw body first (e.g. an Express `verify` callback on `express.json`, or `req.text()` before parsing). ## Python verifier (Authlib) ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import base64 import json import time from authlib.jose import JsonWebSignature TOLERANCE = 5 * 60 # seconds def decode_secret(s: str) -> bytes: """Dashboard secret ('whsign_') → raw HMAC key bytes.""" raw = s.removeprefix("whsign_") return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)) def verify(headers, body, keys_by_kid): """keys_by_kid maps kid -> DECODED secret bytes (see decode_secret).""" header_value = headers.get("Webhook-Signature") if not header_value: return False try: env = json.loads(header_value) except (ValueError, TypeError): return False if not isinstance(env.get("signatures"), list): return False jws = JsonWebSignature(algorithms=["HS256"]) # algorithm pinning for entry in env["signatures"]: try: protected = json.loads( base64.urlsafe_b64decode(entry["protected"] + "=" * (-len(entry["protected"]) % 4)) ) except Exception: continue if abs(time.time() - protected["iat"]) > TOLERANCE: continue secret = keys_by_kid.get(protected["kid"]) if not secret: continue try: jws.deserialize_json( {"payload": "", "signatures": [entry]}, secret, payload=body ) return True except Exception: continue return False ``` > Authlib's handling of detached (`b64: false`) JSON-serialized JWS varies across versions. If `deserialize_json` raises on a signature you believe is valid, use the dependency-free manual verifier below — it implements the exact signing input and works on any Python. ## Manual fallback (no JOSE library) If your language's JOSE library does not handle JWS JSON General Serialization with `b64: false` natively, verify by recomputing the HMAC directly. This is the canonical recipe; every library snippet above does the same thing under the hood. ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import base64 import hmac import json import time from hashlib import sha256 TOLERANCE = 5 * 60 # seconds def b64url_decode(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def decode_secret(s: str) -> bytes: return b64url_decode(s.removeprefix("whsign_")) def verify(raw_body: bytes, header_value: str, secrets_by_kid: dict) -> bool: env = json.loads(header_value) for entry in env["signatures"]: protected = json.loads(b64url_decode(entry["protected"])) if protected["alg"] != "HS256": # algorithm pinning continue if abs(time.time() - protected["iat"]) > TOLERANCE: # iat freshness window continue secret = secrets_by_kid.get(protected["kid"]) # raw bytes via decode_secret if secret is None: continue # signing input = base64url(protected) + "." + raw body bytes signing_input = entry["protected"].encode("ascii") + b"." + raw_body expected = hmac.new(secret, signing_input, sha256).digest() got = b64url_decode(entry["signature"]) if hmac.compare_digest(got, expected): # constant-time compare return True return False # caller responds 4xx, NOT 200 ``` Use your language's constant-time comparison primitive. Never use `==` on the raw signature bytes: | Language | Primitive | | - | - | | Go | `hmac.Equal` | | Node.js | `crypto.timingSafeEqual` | | Python | `hmac.compare_digest` | A Node.js sketch of the same recipe, using only the built-in `crypto` module: ```tsx theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}} import { createHmac, timingSafeEqual } from 'crypto'; function decodeSecret(s: string): Buffer { return Buffer.from(s.replace(/^whsign_/, ''), 'base64url'); } function verify(rawBody: Buffer, headerValue: string, secretsByKid: Record): boolean { const env = JSON.parse(headerValue); for (const entry of env.signatures) { const hdr = JSON.parse(Buffer.from(entry.protected, 'base64url').toString('utf8')); if (hdr.alg !== 'HS256') continue; // algorithm pinning if (Math.abs(Date.now() / 1000 - hdr.iat) > 300) continue; // iat freshness window const secret = secretsByKid[hdr.kid]; if (!secret) continue; const signingInput = Buffer.concat([Buffer.from(entry.protected, 'ascii'), Buffer.from('.'), rawBody]); const expected = createHmac('sha256', secret).update(signingInput).digest(); const got = Buffer.from(entry.signature, 'base64url'); if (got.length === expected.length && timingSafeEqual(got, expected)) return true; } return false; // caller responds 4xx, NOT 200 } ``` ## Checklist * [ ] Read the raw request body bytes **before** any JSON parsing — verification is over the exact bytes delivered. * [ ] Decode the `whsign_…` secret to raw key bytes; HMAC with those bytes. * [ ] Pin `alg` to `HS256`. * [ ] Reject deliveries whose `iat` is more than ±5 minutes from your clock. * [ ] Compare signatures in constant time. * [ ] Accept if **any** entry in the `signatures` array verifies (rotation overlap). * [ ] Return **4xx** on failure, never 200. * [ ] Use a **payload-level** id (from the event body) for idempotency — not `jti`, which changes on retries.