Skip to content

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=buy

Who 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", "sell"],
      "requires": {
        "customerDetails": true,
        "payoutBankAccount": true,
        "destinationWallet": true
      }
    }
  ]
}
FieldMeaning
providerThe id you pass as provider on every other call.
chainIdsChains 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.
sandboxChainIdsChains its test account supports, when this deployment has credentials for one. Empty in production.
sidesWhich of buy and sell are on. They are switched independently, so a corridor can be one-sided.
requires.customerDetailsThe user must be registered before a transfer. See Customers.
requires.payoutBankAccountA sell needs a bank account. Use banks below.
requires.destinationWalletA 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.

Banks

Sell side only.

GET  /v2/ramp/banks?provider=busha
POST /v2/ramp/banks/resolve

The 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/transfers

Quote 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"
  }
}
FieldNotes
provider, fiat, token, chainId, sideMust match a capability the provider advertises. Anything else is a 403.
amountHuman decimal string in the side's source currency: fiat on a buy, token on a sell. Must be positive.
walletRequired 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.
proofProof of control for wallet, bound to chainId. See Customers.
intentKeyRequired. 12 to 64 characters, A-Z a-z 0-9 _ -. A UUID is fine. Identifies this creation attempt; see Retries.
emailOptional. Status emails go here. Falls back to the email on the customer record, so a returning user does not retype it.
acceptedTermstrue once the user accepted the provider's terms in your UI.
expectedTargetAmountOptional. 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.
payoutSell 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:

  • payIn is what the user has to do. On a buy it is the one-off bank account to pay into, under recipient_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.
  • targetAmount is what the user receives, after the provider's fee. rateLabel and feeLabel are display strings for the receipt.
  • expiresAt is 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).
  • providerTransferId is the provider's own reference. Support conversations run on it, so put it on the receipt.
  • claimToken comes 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. 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

GET  /v2/ramp/transfers/{id}?wallet=0xYourWallet
POST /v2/ramp/transfers/list      { "wallet": "0xYourWallet", "first": 20 }

Both read on behalf of a wallet with no signature, for an activity list and its detail screen. Because anyone who knows an address could ask the same thing, this view is masked: a payout account number is cut to its last four digits. The full view needs the claim token. The list is newest first; first is the page size.

Statuses

AWAITING_FUNDS → PROCESSING → SENDING → COMPLETED
                                       ↘ FAILED → REFUNDED
                            → CANCELLED
AWAITING_FUNDS → EXPIRED
statusMeaning
AWAITING_FUNDSWaiting for the user to pay the bank account (buy) or send the token (sell).
PROCESSINGThe provider has the funds and is converting them.
SENDINGConverted; the outgoing leg is on its way.
COMPLETEDDelivered. On a buy, payOut.blockchain_hash carries the transaction. Settled.
FAILEDThe provider could not deliver. Usually followed by REFUNDED.
REFUNDEDThe provider returned the funds. Settled.
CANCELLEDCancelled at the provider. Settled.
EXPIREDThe 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 a provider converts the funds and then never sends the last leg: the transfer sits at providerStatus: "funds_converted" with the money parked on the person's provider balance. The provider has no retry, but a new transfer can be created from that balance to the original destination. That is what these two calls do.

POST /v2/ramp/transfers/{id}/retry-payout/check
POST /v2/ramp/transfers/{id}/retry-payout

Both take { wallet, chainId, proof } and require the wallet that made the transfer. check is read-only and answers whether a retry is on offer:

json
{
  "eligible": false,
  "reason": "too_soon",
  "stuckSince": "2026-09-05T12:20:00.000Z",
  "stuckForMs": 600000,
  "waitMs": 600000,
  "balance": { "available": "49450.34", "currency": "NGN" },
  "required": { "amount": "49450.34", "currency": "NGN" },
  "covers": true
}

A 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 provider profile with a known destination. reason is one of not_stuck, too_soon, retry_in_flight, no_profile, no_destination. covers says the parked balance covers the amount.

retry-payout re-runs every check and, if they pass, creates a new transfer from the balance to the original destination. It answers 201 with that transfer and its own claim token. The new transfer carries retryOfId pointing at the original, and the original keeps its row. Show them as one story.

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.