` 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.
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.