Bridge
@openrampkit/adapter-bridge connects Bridge (a Stripe company).
- Deposits: Bridge opens a virtual bank account for the user. The user sends ACH, wire, SEPA, SPEI, Pix or Faster Payments to it. Bridge converts the money to USDC and sends it on chain to the destination address. A destination that is not USDC on a Bridge network gets no quote (
NO_QUOTES). - Withdrawals: the user sends USDC to Bridge. Bridge pays out to the user's US bank account (ACH or wire) or IBAN (SEPA).
- Standing deposit addresses (option
standing: true): a permanent virtual account for fiat in, and a permanent liquidation address for USDC in from another chain. See Standing deposit addresses.
Bridge needs KYC for each user. The adapter sends the user to the Bridge hosted pages for the terms of service and for KYC. Then it shows the bank details.
import { bridge } from '@openrampkit/adapter-bridge'
bridge({
apiKey: process.env.BRIDGE_API_KEY!,
webhookPublicKey: process.env.BRIDGE_WEBHOOK_PUBLIC_KEY!,
// Optional: give the Bridge customer id when your app already has one
customer: async ({ userId }) => {
const u = await db.users.get(userId)
return u.bridgeCustomerId ? { customerId: u.bridgeCustomerId } : { fullName: u.legalName, email: u.email }
},
})Access
- Bridge has no self-serve sign-up. Contact Bridge sales to get an account.
- For the sandbox, ask Bridge support for a developer account. Then make a sandbox key in the dashboard (turn on the "Sandbox" toggle). Only dashboard admins can make keys. Sandbox keys start with
sk-test. - In the sandbox, KYC links do not work and no money moves. Make the customer with
POST /v0/customers, approve it withPOST /v0/customers/{id}/simulate_kyc_approval, and give its id with thecustomerhook. The sandbox sends no payment webhooks. - Production: Bridge does KYB of your company. The steps are not in the public docs.
To get the keys, see Get provider keys.
Options
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Bridge API key. Sent as the Api-Key header. |
webhookPublicKey | string | required | The public key (PEM) of your webhook endpoint. \n escapes are accepted. |
env | 'sandbox' | 'production' | 'production' | production is https://api.bridge.xyz. sandbox is https://api.sandbox.bridge.xyz. The server checks env against livemode. |
apiUrl | string | from env | API base URL without /v0 |
developerFeePercent | string | none | Your fee in percent, for example '0.5'. Sent as developer_fee_percent on virtual accounts and transfers. Shown as the "App fee" in quotes. |
bridgeFeeBps | number | 0 | Bridge's fee in basis points, for quotes only. Bridge pricing is per contract and the API has no fee quote. Set your contract rate. |
legs | string[] | all | Offer only these legs |
withdraw | boolean | true | Add the payout legs |
customer | (user) => Promise<{ customerId?, fullName?, email? } | undefined> | none | Find the Bridge customer of a user, or give the name and email for a new KYC link. Standing addresses use its customerId. |
standing | boolean | false | Give standing deposit addresses: virtual accounts (route bank) and liquidation addresses (route convert) |
Legs
Deposit legs (fiat_onramp) deliver USDC to an address on Base, Ethereum, Arbitrum, Optimism, Polygon, Avalanche or Solana.
| Leg | Method | Currency | Endorsement | Min | Surface |
|---|---|---|---|---|---|
usd-ach | ach | USD | base | 1 | BANK_FIELDS |
usd-wire | bank_transfer | USD | base | 1 | BANK_FIELDS |
eur-sepa | sepa | EUR | sepa | 1 | BANK_FIELDS |
mxn-spei | spei | MXN | spei | 50 | BANK_FIELDS |
brl-pix | pix | BRL | pix | 10 | QR (the Pix BR Code) |
gbp-fps | faster_payments | GBP | faster_payments | 2 | BANK_FIELDS |
Payout legs (crypto_offramp) take USDC from user_wallet or address on the same chains.
| Leg | Method | Currency | Bridge rail | Min (USDC) |
|---|---|---|---|---|
payout-usd-ach | ach | USD | ach | 1 |
payout-usd-wire | bank_transfer | USD | wire | 1 |
payout-eur-sepa | sepa | EUR | sepa | 1 |
Every leg also uses REDIRECT (the Bridge ToS and KYC pages) and FORM (name and email, or the payout account).
Minimums come from the Bridge rail pages. Bridge treats a deposit under 1 USD as a microdeposit and does not convert it. Pix has a limit of 500,000 USD for each customer each month.
Regions: all countries, except the countries that Bridge does not serve (BRIDGE_DENY): DZ, BI, CN, JP, TN, the prohibited list (AF, BY, CD, CU, PS, IR, IQ, LB, LY, MM, KP, RU, SO, SS, SD, SY, VE, YE) and US-NY. The planner shows each deposit leg only to users whose local currency is the leg currency.
Quotes
- USD: 1 USD gives 1 USDC, less the fees.
- EUR, MXN, BRL and GBP:
GET /v0/exchange_rates?from={currency}&to=usd. The adapter usesbuy_rate, which includes the Bridge FX fee. The rate is kept for 30 seconds. - Payouts in EUR:
GET /v0/exchange_rates?from=usd&to=eur. - Exact output works: the adapter rounds the fiat input up to the minor unit.
- Bridge has no rate lock. The bank transfer converts at the rate of the day it arrives. So the guarantee is
estimate, and quotes expire after 10 minutes (quoteExpiresAt(10)). - Fees: the Bridge fee from
bridgeFeeBps(provider) and yourdeveloperFeePercent(app) have an amount in the quote currency. For EUR, MXN, BRL and GBP, a thirdproviderfee ("FX fee in the rate") hasamount: null, becausebuy_ratecontains it and Bridge does not say how much it is. All fees haveincluded: true.
Deposit flow
- Customer. The adapter looks for the customer: the
customerhook, then the customer or KYC link of an earlier session of the same user. - KYC details. When there is no customer and no KYC link, and the name or the email is not known, the step is
KYCwith aFORM(full legal name, email). Thesubmit_kyctransition takes the answers. - KYC link.
POST /v0/kyc_linkswithtype: 'individual', the rail endorsement andredirect_uri(the session return URL). The step isKYCwith aREDIRECTtotos_linkuntil the ToS is accepted, then tokyc_link. While Bridge reviews, the leg step is{ status: 'processing', phase: 'kyc', detail: { code: 'kyc_review' } }, so the session showsKYC. When the review ends with a step for the user, the leg can go back torequires_action. A rejected or offboarded user fails withKYC_REJECTED. - Customer check.
GET /v0/customers/{id}. The customer must beactivewith the rail endorsementapproved. Apausedordeposits_restrictedcustomer fails withPROVIDER_DECLINED. - Virtual account.
POST /v0/customers/{id}/virtual_accountswithsource.currency,destination: { currency: 'usdc', payment_rail, address }and the developer fee. The adapter keeps one virtual account for each customer, currency, chain and address, and uses it again in later sessions. - Bank details. The step is
PAYMENTwithBANK_FIELDS: amount, bank name, routing and account number, IBAN and BIC, CLABE, or sort code, beneficiary and bank address. For BRL, it is aQRof the Pix BR Code. - Status.
GET /v0/customers/{id}/virtual_accounts/{va}/historyandvirtual_account.activitywebhooks. The leg takes the first deposit after the session started that no other session took.
| Virtual account event | Leg |
|---|---|
funds_scheduled, funds_received, payment_submitted | processing, detail code settling |
in_review | processing, detail code delayed |
refund_in_flight | processing, detail code refunding |
payment_processed | succeeded, with the USDC amount and the destination transaction |
refund, refunded | refunded |
refund_failed | failed with DELIVERY_FAILED |
microdeposit, account_update, activation, deactivation | ignored |
| other | no event: the adapter logs the unknown type once, and the deposit keeps its last known stage |
The providerRef of a deposit leg is the Bridge deposit_id, once the deposit arrives.
One open deposit at a time
A virtual account is persistent. When a user has two open deposit sessions to the same address, the first deposit goes to the session that sees it first. Webhooks go to the newest session that showed the account.
Withdraw flow
- KYC, as for deposits.
- Payout account. The step is
PAYMENTwith aFORM. US accounts: first and last name, bank name, routing number (9 digits), account number, checking or savings, street, city, postal code and state. IBAN: first and last name, IBAN, BIC, street, city, postal code and country (3 letters). Thesubmit_detailstransition checks the formats and callsPOST /v0/customers/{id}/external_accounts. - Transfer.
POST /v0/transferswithamount,on_behalf_of,client_reference_id(the leg ref),source: { payment_rail, currency: 'usdc', from_address }anddestination: { payment_rail, currency, external_account_id }. When the sender address is not known, the adapter sendsfeatures.allow_any_from_address: true. - Send USDC. The step is
WALLET_TX: an ERC-20 transfer (EVM) or an SPL transfer (Solana) tosource_deposit_instructions.to_address. Thesubmit_txtransition takes the transaction hash. - Status.
GET /v0/transfers/{id}andtransferwebhooks.
| Transfer state | Leg |
|---|---|
awaiting_funds | the WALLET_TX step (or processing after submit_tx) |
in_review | processing, detail code delayed |
funds_received, payment_submitted | processing, detail code settling |
refund_in_flight | processing, detail code refunding |
payment_processed | succeeded, with receipt.final_amount in the payout currency |
refunded | refunded |
canceled, error, undeliverable, returned, refund_failed, missing_return_policy | failed with DELIVERY_FAILED |
| other | no event: the adapter logs the unknown state once, and status() keeps the last known step |
The providerRef of a payout leg is the Bridge transfer id. The USDC transaction that the wallet (or the treasury) sent is the source transaction.
The adapter maps every Bridge status with statusMap from @openrampkit/adapter. The adapter uses transfers, not liquidation addresses. A transfer is one per session, so the leg ref and the webhook match one to one.
Idempotency
Bridge needs an Idempotency-Key header on every POST. The keys are:
| Call | Key |
|---|---|
| KYC link | {sessionId}:bridge:kyc:{ref} |
| Virtual account | {sessionId}:bridge:va:{ref} |
| External account | {sessionId}:bridge:ea:{ref} |
| Transfer | {sessionId}:bridge:transfer:{ref} |
The leg ref is brg_ and 20 hex characters.
Webhooks
- Create the endpoint:
POST /v0/webhookswithurl: '{baseUrl}/webhooks/bridge',event_epoch: 'webhook_creation'and the categoriesvirtual_account.activity,transfer,kyc_linkandcustomer. Withstanding: true, addliquidation_address.drain. - Put the
public_keyfrom the answer inwebhookPublicKey. - Turn the endpoint on:
PUT /v0/webhooks/{id}withstatus: 'active'. A new endpoint isdisabled.
Verification: the X-Webhook-Signature header is t=<timestamp in ms>,v0=<base64 signature>. Bridge signs SHA-256("{t}.{raw body}") with RSA PKCS#1 v1.5 and SHA-256. The adapter refuses an event more than 10 minutes from the server time. kyc_link and customer events give no leg event: the status poll moves the KYC step.
Standing deposit addresses
With standing: true, the adapter gives standing deposit addresses. Each user gets one permanent account or address for each route. Each deposit to it becomes a deposit session.
bridge({
apiKey: process.env.BRIDGE_API_KEY!,
webhookPublicKey: process.env.BRIDGE_WEBHOOK_PUBLIC_KEY!,
standing: true,
// Required in practice: the Bridge customer of the user, from your database
customer: async ({ userId }) => ({ customerId: (await db.users.get(userId)).bridgeCustomerId }),
})
// Fiat in: a virtual account. The user pays USD by ACH or wire, and gets USDC on Base.
const account = await openramp.depositAddresses.get({
userId: user.id,
origin: { currency: 'USD' },
destination: { type: 'crypto', chain: 'eip155:8453', token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', address: user.wallet },
adapterId: 'bridge',
})
// account.address: the account number. account.bank.fields: routing number, account number, beneficiary, ...
// USDC in: a liquidation address. The user sends USDC on Arbitrum, and gets USDC on Base.
const address = await openramp.depositAddresses.get({
userId: user.id,
origin: { chain: 'eip155:42161', token: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' },
destination: { type: 'crypto', chain: 'eip155:8453', token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', address: user.wallet },
adapterId: 'bridge',
})| Route | Bridge object | Origin | Destination |
|---|---|---|---|
bank | Virtual account | USD, EUR, MXN, BRL or GBP (the deposit rails in legs) | USDC on a Bridge network |
convert | Liquidation address | USDC on a Bridge network | USDC on another Bridge network |
Pass adapterId: 'bridge' when another adapter (for example Relay) also serves convert routes: the server picks the first adapter in the list.
The customer
Bridge needs a KYC-approved customer for each user. The adapter takes the customer id from the server only: the customer hook (customerId), else the customer that a Bridge KYC of an earlier deposit session made for the same user id. The browser never gives it. With no customer, get throws 409. The customer must be active. For a virtual account, the rail endorsement must be approved (for example base for USD).
One Bridge customer belongs to one user id. The first user id that gets an address with a customer keeps it, and another user id with the same customer gets 409. So a bug in your mapping cannot credit one user with another user's deposits. Deposit sessions apply the same binding: a session of another user id with that customer fails with PROVIDER_DECLINED. The fallback reads only the record of the same app user id (from the server), never a record of another user.
Virtual accounts (fiat in)
- The adapter looks for a virtual account that an earlier attempt of the same route made (
GET /v0/customers/{id}/virtual_accounts, an account created after the route's first attempt), else makes one (POST, withsource.currency,destination: { currency: 'usdc', payment_rail, address }and the developer fee). An older account (made by a deposit session or by your app) is never reused. - Bridge's answer must name the same destination (rail, currency and address) and customer. Else
getfails with502, and no address is kept. StandingAddress.addressis the main account identifier, andStandingAddress.bank.fieldsholds the bank details (a Pix account shows its BR Code). The widget does not show bank accounts yet: showbank.fieldsin your app.- Deposits:
GET .../virtual_accounts/{va}/history, grouped bydeposit_id. A deposit makes a session once the money arrived (funds_receivedor a later stage;funds_scheduledalone is not enough). Its input is thefunds_receivedamount in the account currency. Events in another currency, of another account or of another customer are skipped and logged. - Status:
GET .../history?deposit_id={id}, with the table of Deposit flow.payment_processedsucceeds only when its currency and rail (when Bridge gives them) are USDC on the destination rail. Else the session fails withDELIVERY_FAILED.
A standing account and deposit sessions never share a Bridge virtual account. Each account has one owner, taken with one atomic claim (vaowner:{id}): the standing route, or the session flow. When Bridge answers a create with an account that the other side owns, the call fails with 409: a standing get after a deposit session to the same destination, or a deposit session to the destination of a standing account. Deposits are also claimed once (dep:{deposit_id}).
At each poll, the adapter reads the account again (GET /v0/customers/{id}/virtual_accounts/{va}). When its destination is no longer the standing destination (Bridge lets the destination change), the poll lists no deposit, and a processed deposit fails with DELIVERY_FAILED.
Liquidation addresses (USDC in)
- The adapter looks for a liquidation address of the customer for this route (
GET /v0/customers/{id}/liquidation_addresses), else makes one (POST, withchain,currency: 'usdc',destination_payment_rail,destination_currency: 'usdc',destination_address, andcustom_developer_fee_percent). Bridge refuses a second address with the same source chain, currency and destination, so the adapter looks first. It reuses only an address that an earlier attempt of the same route made. An older address for the route (made by your app) is refused with409, because its deposits may also be credited elsewhere. Each address has one owner route (laowner:{id}, an atomic claim). - Bridge's answer must name the same source chain and the same destination, with no bank account or Bridge wallet as destination. Else
getfails with502. - Deposits:
GET .../liquidation_addresses/{la}/drains. A drain makes a session once its state is pastawaiting_funds, and only with its on-chain deposit transaction (deposit_tx_hash). One transaction is one deposit (ladeptx:claim): a second drain id for the same transaction makes no session. Its input is the drain amount in USDC on the origin chain. A drain in another currency or from another chain is skipped and logged. - Status:
GET .../drains?drain_id={id}.
| Drain state | Leg |
|---|---|
awaiting_funds | no session yet |
funds_received, payment_submitted | processing, detail code settling |
in_review | processing, detail code delayed |
refund_in_flight | processing, detail code refunding |
payment_processed | succeeded, with receipt.outgoing_amount, when destination.to_address is the destination. Another address, rail or currency fails with DELIVERY_FAILED. With no destination yet, the leg stays processing. |
refunded | refunded |
undeliverable, returned, missing_return_policy, refund_failed, error, canceled | failed with DELIVERY_FAILED |
Set up a crypto return policy with Bridge. A liquidation address is reused by any sender, so the adapter sets no return_address.
Webhooks of standing addresses
The sweep polls each address (see Polling). Webhooks add to it:
- The adapter finds the account by its own index (the Bridge id of the virtual account or liquidation address), never by a ref in the body. The event's
customer_idmust be the account's customer. Else the event is ignored. - A deposit that the account does not own yet gives no leg event. The adapter keeps the event for the next poll (7 days), so a deposit that the history lists late still makes its session.
- An event of a deposit that the account owns gives a leg event with the deposit's leg ref and the Bridge
event_id. The server finds the session by its own ref index. - A deposit that a deposit session owns goes to that session.
Idempotency and data
The create calls use a key from the customer and the route (orb-sva-..., orb-sla-...), so a retry gets the same account. The adapter keeps the account record in shared with no TTL: the Bridge ids, the customer id, the route and, for a virtual account, its bank details. It keeps deposit claims for 400 days, and webhook events for 7 days.
Data
The adapter keeps only Bridge ids in shared: customer, KYC link, virtual account, external account and transfer. It also keeps the virtual account deposit instructions, to show them again on the next poll. It does not keep the user's name, email or payout account number.
Verified vs TO VERIFY
Verified against the Bridge docs and OpenAPI spec (2026-10-05): base URLs, Api-Key, Idempotency-Key on every POST, the KYC link fields and statuses, endorsement names, virtual account request and deposit instruction fields, the virtual account event types, the transfer request and states, external account bodies, the exchange rate endpoint, the webhook signature scheme, the country list and the rail minimums.
TO VERIFY: the ETAs of each rail. Bridge publishes no settlement times in the API docs.
TO VERIFY:
pixorpix_onrampas the endorsement for Pix deposits.TO VERIFY:
buy_rateis the rate the user gets whenfromconverts intoto.TO VERIFY: how to add an endorsement to a customer that Bridge approved without it. The adapter sends the user back to the KYC link.
TO VERIFY:
features.allow_any_from_addresswhen the sender address is not known.TO VERIFY: the wire payout minimum.
TO VERIFY: the refund event name. The docs table says
refundedand the OpenAPI enum saysrefund. The adapter accepts both.TO VERIFY: when Bridge fills
customer_idon a KYC link. The adapter waits for it. Standing deposit addresses (verified 2026-10-16): the virtual account list, create and history endpoints (deposit_id,limit,starting_after), the virtual accountdestinationandstatusfields, the liquidation address list and create endpoints and fields (chain,currency,destination_payment_rail,destination_currency,destination_address,custom_developer_fee_percent,state), the rule that Bridge refuses a duplicate liquidation address, the drain list endpoint (drain_id,limit,starting_after), the drain fields and states, and theliquidation_address.drainwebhook category.TO VERIFY: the endorsement that a USDC to USDC liquidation address needs. The adapter checks that the customer is
active.TO VERIFY: the drain
currencyof a USDC address. The API reference showsusdcand the guide showsusd. The adapter accepts both, and nothing else.TO VERIFY: the
currencyof apayment_processedvirtual account event. The adapter acceptsusdcor none.TO VERIFY: whether Bridge allows two virtual accounts for the same customer, currency and destination. When Bridge answers with an account of deposit sessions, the standing
getfails with409.TO VERIFY:
GET /v0/customers/{id}/virtual_accounts/{va}(the docs list "Get a Virtual Account"; the adapter readsid,status,customer_idanddestinationfrom it, as from the list).TO VERIFY: how long Bridge keeps an
Idempotency-Key. The adapter also looks for an existing account before each create.Not supported yet: payouts in MXN, BRL and GBP (the docs give no external account body for them), COP deposits, and USDT or EURC delivery. Standing addresses: USDT, EURC or multichain (
evm) liquidation addresses, and bank accounts in the widget.