Appearance
Transfers
A transfer is one movement of money: fiat in and token out (buy), or token in and fiat out (sell). One POST books it and hands you the payment instructions. One GET tracks it.
Providers
GET /v2/ramp/providers?fiat=NGN&token=CNGN&chainId=56&side=buyWho can execute this corridor right now, and what their flow needs from the user. All four query parameters are optional filters. No credential.
json
{
"providers": [
{
"provider": "busha",
"displayName": "Busha",
"fiat": "NGN",
"token": "CNGN",
"chainIds": [56, 8453],
"sandboxChainIds": [],
"sides": ["buy"],
"limits": [
{ "chainId": 56, "currency": "NGN", "min": "700", "max": "500000000" },
{ "chainId": 8453, "currency": "NGN", "min": "700", "max": "500000000" }
],
"sandboxLimits": [],
"requires": {
"customerDetails": true,
"payoutBankAccount": true,
"destinationWallet": true
}
}
]
}| Field | Meaning |
|---|---|
provider | The id you pass as provider on every other call. |
chainIds | Chains the provider can settle the token on today. Read from the provider's live network switches, so it can change without notice. Do not hardcode it. |
sandboxChainIds | Chains its test account supports, when this deployment has credentials for one. Empty in production. |
sides | Which of buy and sell are on. They are switched independently, so a corridor can be one-sided. |
limits | How much one transfer can move, per chain. See Amount limits. |
sandboxLimits | The same for sandboxChainIds. The test account sets its own, usually lower. |
requires.customerDetails | The user must be registered before a transfer. See Customers. |
requires.payoutBankAccount | A sell needs a bank account. Use banks below. |
requires.destinationWallet | A buy needs a wallet to deliver to. |
An empty list means nobody can run that corridor right now. That is also what a provider that has been switched off looks like: a POST /transfers against it is a 403.
Amount limits
Each entry in limits is the per-transfer range for one chain:
json
{ "chainId": 56, "currency": "NGN", "min": "700", "max": "500000000" }currency is the unit amount takes on that side: the fiat on a buy, the token on a sell. So a buy entry is in NGN and a sell entry is in CNGN. min and max are human decimal strings, and null means that end is open. Limits differ by chain because the provider's network fees and floors do.
They come from the provider's own published figures and can change without notice, so read them when you build the amount screen rather than hardcoding them. A couple of things to know:
- Inward rounding. Both ends are rounded to the cent, the minimum up and the maximum down, so any amount inside the range clears the published bounds.
- Buy minimum includes fees. On a buy the minimum covers the token the provider has to deliver plus its network fee, converted at the current price. The bank-collection fee comes on top, so an amount right at the minimum can still come back
provider_refused. Leave a little room. - Corridor figures, not personal ones. The provider can still cap a person lower based on their verification tier. That shows up as
kyc_requiredorprovider_refusedon the create. - No entry, no published bound. A chain missing from
limitshas no published figure. The provider still decides, and refuses in its own words.
POST /transfers holds a new transfer to the same figures before it calls the provider. An amount outside them gets 422 amount_out_of_range with the entry that applied. See Ramp errors.
Banks
Sell side only.
GET /v2/ramp/banks?provider=busha
POST /v2/ramp/banks/resolveThe first returns { "banks": [{ "code": "044", "name": "Access Bank" }, …] } for the payout bank picker. It is cached on our side for 15 minutes.
The second confirms an account before anyone commits to it:
json
{ "provider": "busha", "bankCode": "044", "accountNumber": "0123456789" }json
{ "accountName": "ADA OBI" }accountNumber is 6 to 12 digits. Show accountName to the user and make them confirm it. An account the bank does not recognise is a 422 provider_refused with the provider's wording. Neither call needs a wallet or a proof; they name no person.
Create a transfer
POST /v2/ramp/transfersQuote and execute in one step. On a sell it also registers the payout recipient. Returns 201 with the transfer and its claim token.
Buy:
json
{
"provider": "busha",
"side": "buy",
"fiat": "NGN",
"token": "CNGN",
"chainId": 56,
"amount": "50000",
"wallet": "0xYourWallet",
"proof": { "nonce": "0x…", "issuedAt": 1756400000000, "signature": "0x…" },
"intentKey": "5f3a9c2e-1b7d-4e8f-9a6c-0d2b4e6f8a1c",
"email": "ada@example.com",
"acceptedTerms": true,
"expectedTargetAmount": "49450.34"
}Sell adds payout:
json
{
"provider": "busha",
"side": "sell",
"fiat": "NGN",
"token": "CNGN",
"chainId": 56,
"amount": "50000",
"wallet": "0xYourWallet",
"proof": { "…": "…" },
"intentKey": "7c1e4b9a-2d3f-4a5b-8c6d-9e0f1a2b3c4d",
"acceptedTerms": true,
"expectedTargetAmount": "49700.00",
"payout": {
"bankCode": "044",
"accountNumber": "0123456789",
"accountName": "ADA OBI"
}
}| Field | Notes |
|---|---|
provider, fiat, token, chainId, side | Must match a capability the provider advertises. Anything else is a 403. |
amount | Human decimal string in the side's source currency: fiat on a buy, token on a sell. Must be positive and inside the chain's amount limits. |
wallet | Required on both sides. Buy: where the token is delivered. Sell: the wallet the user sends from, and the refund address. The customer record for this wallet is attached automatically. |
proof | Proof of control for wallet, bound to chainId. See Customers. |
intentKey | Required. 12 to 64 characters, A-Z a-z 0-9 _ -. A UUID is fine. Identifies this creation attempt; see Retries. |
email | Optional. Status emails go here. Falls back to the email on the customer record, so a returning user does not retype it. |
acceptedTerms | true once the user accepted the provider's terms in your UI. |
expectedTargetAmount | Optional. What your offer screen promised the user would receive. If the executable quote has moved more than 1% from it, the call stops with 409 quote_drift instead of executing a materially different price. Omit it to accept whatever the quote says. |
payout | Sell only. bankCode, accountNumber and accountName from the banks step. Add providerRecipientId when retrying after a quote_drift so the same bank account is not registered twice. |
The response
json
{
"transfer": {
"id": "cm1x…",
"providerTransferId": "TRF_…",
"provider": "busha",
"sandbox": false,
"side": "buy",
"fiat": "NGN",
"token": "CNGN",
"chainId": 56,
"sourceAmount": "50000",
"targetAmount": "49450.34",
"rateLabel": "₦1.0011 / cNGN",
"feeLabel": "₦300.20",
"status": "AWAITING_FUNDS",
"providerStatus": "pending",
"retryOfId": null,
"payIn": {
"type": "temporary_bank_account",
"recipient_details": {
"bank_name": "Providus Bank",
"account_number": "9901234567",
"account_name": "Busha / Ada Obi"
},
"expires_at": "2026-09-05T12:30:00.000Z"
},
"payOut": {
"type": "address",
"network": "BSC",
"address": "0xYourWallet"
},
"hasEmail": true,
"timeline": [
{ "status": "AWAITING_FUNDS", "providerStatus": "pending", "at": "2026-09-05T12:00:00.000Z" }
],
"expiresAt": "2026-09-05T12:30:00.000Z",
"createdAt": "2026-09-05T12:00:00.000Z"
},
"claimToken": "b3JkZXItY2xhaW0tdG9rZW4…"
}The parts that matter:
payInis what the user has to do. On a buy it is the one-off bank account to pay into, underrecipient_details. On a sell it is{ "type": "address", "address": "0x…", "network": "BSC" }, the deposit address to send the token to. Show it with copy buttons and the amount.targetAmountis what the user receives, after the provider's fee.rateLabelandfeeLabelare display strings for the receipt.expiresAtis when the payment window closes. Stop showing the payment details after it. Money sent in the last seconds can still land, which is why the transfer itself stays watchable for a while longer (see Expiry).providerTransferIdis the provider's own reference. Support conversations run on it, so put it on the receipt.claimTokencomes back exactly once. Store it with the id. It is the only thing that reads this transfer back in full.
Retries
intentKey makes the create idempotent for one attempt. If the response is lost, send the same body with the same key. If the transfer was booked you get it back with a fresh claim token, since the first token may never have reached you. If the first attempt is still in flight you get a 502 saying so; wait a moment and send it again. A genuinely new attempt, because the user changed the amount or started over, gets a new key.
Without this, a retry after a timeout books a second transfer with its own live bank details, and nothing ties the two together. That is why the key is mandatory.
Price drift
The offer the user picked was priced when they typed the amount. Minutes can pass while they fill in details, and a sell is quoted before the payout channel is known. So the executable quote can differ from the offer. Inside 1% we execute. Beyond it the call stops:
json
{
"error": {
"code": "quote_drift",
"message": "The price moved while you were setting this up",
"expectedTargetAmount": "49450.34",
"actualTargetAmount": "48900.10",
"rateLabel": "₦1.0225 / cNGN",
"feeLabel": "₦300.20",
"providerRecipientId": "RCP_…"
}
}Show the new terms. If the user accepts, resend the same body without expectedTargetAmount (or with the new one), the same intentKey, and on a sell with payout.providerRecipientId copied from the error. Nothing was booked yet.
Read a transfer
GET /v2/ramp/transfers/{id}
X-Ramp-Claim: <claimToken>Returns { "transfer": … } in the same shape as the create response, plus retryIds (see Stalled payouts). A wrong id and a wrong token answer identically with 404, so the id alone reveals nothing.
While the transfer is still moving, a read that finds our stored state more than 8 seconds old re-checks the provider before answering. Polling every 3 seconds, which is what the app does, therefore costs the provider at most one call every 8 seconds and stays correct if a webhook was lost. Once the transfer is settled the read is served from storage.
Without the claim token
POST /v2/ramp/transfers/list { "wallet", "chainId", "proof", "first": 20 }
POST /v2/ramp/transfers/{id}/wallet { "wallet", "chainId", "proof" }These read by wallet instead of by claim token, for an activity list and its detail screen. A wallet address is public and the history behind it isn't, so both need proof of control for wallet on chainId. A missing or bad proof is a 401 with one of the proof_of_control_* reasons, and a missing chainId is a 400.
Both answer only for chainId. The same address can be a different Safe, or nobody's contract at all, on another chain, so a proof on one chain never reads another chain's transfers. Call once per chain you care about.
A successful call also returns an activityToken. Send it back as X-Wallet-Activity and you can leave proof out, so polling doesn't open the wallet every few seconds. It's bound to that wallet and chain and lasts 12 hours. When it lapses you get the 401 again: sign once and carry on.
json
{ "transfers": [ … ], "activityToken": "eyJ2…" }The list is newest first. first is the page size (default 50, max 200). It's a summary view: on both payIn and payOut, account names and bank codes are dropped and account numbers are cut to their last four digits. /transfers/{id}/wallet returns one transfer in full, with the complete pay-in instructions, the same as a claim-token read. Wrong id, wrong wallet and wrong chain all answer 404.
GET /v2/ramp/transfers/{id} no longer takes ?wallet=. Without X-Ramp-Claim it's a 400.
Statuses
AWAITING_FUNDS → PROCESSING → SENDING → COMPLETED
↘ FAILED → REFUNDED
→ CANCELLED
AWAITING_FUNDS → EXPIREDstatus | Meaning |
|---|---|
AWAITING_FUNDS | Waiting for the user to pay the bank account (buy) or send the token (sell). |
PROCESSING | The provider has the funds and is converting them. |
SENDING | Converted; the outgoing leg is on its way. |
COMPLETED | Delivered. On a buy, payOut.blockchain_hash carries the transaction. Settled. |
FAILED | The provider could not deliver. Usually followed by REFUNDED. |
REFUNDED | The provider returned the funds. Settled. |
CANCELLED | Cancelled at the provider. Settled. |
EXPIRED | The payment window closed with nothing received. |
providerStatus is the provider's own word for the same moment (funds_received, funds_converted, outgoing_payment_sent, …) and is what the timeline records at every change. Branch on status; show providerStatus in the timeline if you like.
Statuses are forward-only. A late webhook cannot move a transfer backwards, and a settled transfer (COMPLETED, REFUNDED, CANCELLED) never changes again.
FAILED and EXPIRED are not settled. A failed transfer usually becomes REFUNDED once the money is back, and an expired one can still become COMPLETED if a payment made near the deadline surfaces late. Keep polling both until you see a settled state, or stop after a reasonable time and rely on the email.
Expiry
The provider's payment window is expiresAt, typically 30 minutes. Hide the payment details when it passes. Textile marks the transfer EXPIRED 10 minutes after that, to give a last-second payment time to show up, and keeps re-checking expired transfers with the provider for three days in case one does. A transfer nobody is polling is re-checked by a background sweep every five minutes, so a closed tab does not leave it stuck.
Emails
If the create carried an email, or the customer record has one, the user gets three messages: the payment instructions at creation (with a link that reopens the transfer in any browser), a "we have your funds" note when the provider confirms receipt, and the terminal result. hasEmail on the transfer tells you whether they are going out. No email means no messages; the claim token is then the only way back to a pending transfer, so keep it.
Stalled payouts
Occasionally Busha converts the funds and then never sends the last leg. The transfer sits at status: "SENDING" with providerStatus: "funds_converted", and the money is parked on the person's Busha balance. Busha has no retry for this, but a new transfer can be created from that balance to the original destination. We call that re-initiating the payout. It's Busha only today.
Automatic retries
You usually don't have to do anything. Five minutes into a stall, a background job re-initiates the payout itself, as long as the balance covers targetAmount. It keeps trying for six hours. Each try re-reads the balance first, so nothing is sent unless the money is still there.
The retry is a new transfer. The original keeps its own row and status, because Busha may still complete it. To follow the money:
retryIdson the original lists its retries, oldest first. It's onGET /transfers/{id}and/transfers/{id}/wallet, not on the list.retryOfIdon the retry points back at the original.- The original's claim token reads the retry. The system never hands out a token for a retry it made, so the one you already hold opens it:
GET /transfers/{retryId}with the sameX-Ramp-Claim.
So the polling loop is: if the original is stalled and retryIds isn't empty, switch to the last id in it. Show the two as one transfer. If the create carried an email, the person also gets the usual emails for the retry.
When an automatic try doesn't send, the original's timeline gets a note that starts with automatic payout retry failed: followed by the reason, usually a balance that doesn't cover the amount. That's your cue to check for the manual retry below. A retry that stalls in turn is never retried automatically. When the money is on the balance and we can't send it ourselves, because the try bounced or the stalled transfer is itself a retry, the person gets one email asking them to send it again.
Manual retry
POST /v2/ramp/transfers/{id}/retry-payout/check
POST /v2/ramp/transfers/{id}/retry-payoutThis is what the app's "Send the payout again" button calls. Both take { wallet, chainId, proof } and require the wallet that made the transfer. A claim token isn't enough here, since this moves money. check is read-only and says whether a retry is on offer:
json
{
"eligible": true,
"stuckSince": "2026-09-05T12:20:00.000Z",
"stuckForMs": 1500000,
"balance": { "available": "49450.34", "currency": "NGN" },
"required": { "amount": "49450.34", "currency": "NGN" },
"covers": true
}A manual retry is offered once the transfer has sat at funds_converted for 20 minutes, no earlier retry is in flight, and the person has a Busha profile with a known destination. Otherwise eligible is false, reason says why, balance is null and covers is false:
reason | Means |
|---|---|
not_stuck | Not at SENDING / funds_converted. Nothing to retry. |
too_soon | Stalled for less than 20 minutes. waitMs says how much longer. |
retry_in_flight | A retry exists and hasn't failed, cancelled or expired. Follow it instead. |
no_profile | No Busha profile on the transfer, so no balance to pay from. |
no_destination | No bank recipient (sell) or address and network (buy) on record. |
balance is in the payout currency: the fiat on a sell, the token on a buy. covers is true when it holds at least required.amount. Offer the button only when eligible and covers are both true, and show the balance so the person can see the money is there.
retry-payout runs every check again and, if they pass, books a new transfer that draws the whole balance to the original destination. It answers 201 with that transfer and its own claim token. The original's claim token reads it too. A refusal is 422 provider_refused with a sentence you can show, and nothing is sent. Two clicks on the same stall land on the same Busha reference, so a double submit can't pay twice.
Support lookup
POST /v2/ramp/support/transfer { "reference": "TRF_…" }One transfer by either reference, ours or the provider's, for a support desk that has neither the claim token nor the wallet. The reference is the credential, so the view is masked: email, account number and account name are partially hidden; amounts, statuses, references and on-chain addresses are in full. An unknown reference and someone else's reference answer the same 404.