Cash out and buy with fiat
RecoilPay's fiat ramps move value between a user's wallet and their bank account:
- Off-ramp (cash out): the user's crypto is locked in escrow, a solver pays them fiat by bank transfer, and the escrow releases to the solver when the user confirms.
- On-ramp (buy): the solver's crypto is locked in escrow for the user, the user pays the solver by bank transfer, and the escrow releases to the user when the solver confirms. If the solver stays silent, the user gets it anyway.
Both run on AttestedEscrow (on Solana, the attested_escrow program), whose attestor can only release a lock to one of its two named parties. Solvers are independent and compete on rate, fee, track record and speed.
Chains: Base Sepolia (84532), Polygon Amoy (80002), BNB Chain Testnet (97) and Solana devnet (9000000002, see On Solana). Asset: USDC. Corridor: Nigerian naira (NGN) over NIP bank transfers (rail ng.nip). Read which chains have a fiat escrow from GET /api/v1/chains (fiat_escrow, fiat_bond) rather than hardcoding them.
Want to build it rather than read about it? Add fiat to the drop-in UI, build a cash-out flow or build a purchase flow. Each ends with complete, tested code.
There are three ways in, the same as for swaps. Use the highest one you can: the flows have several signed steps, encrypted bank details and an identity check, and the kit does all of it.
With the drop-in UI
The widget and the React component already handle both directions. A user types cash out 50 USDC on Base Sepolia to NGN or buy ₦20,000 of USDC on Base Sepolia into the same field as a swap. The component then shows the offer, collects bank details or runs the identity check, and tracks the trade to the end.
Two things are yours to do:
-
Let users come back. A cash-out waits on the user to confirm the money arrived, and a purchase waits on them to pay. Store the trade id when one starts and pass it back on the next visit:
Started Ended Resume with Widget events recoilpay-cash-out-started,recoilpay-purchase-startedrecoilpay-cash-out-ended,recoilpay-purchase-endedAutomatic: the widget remembers the trade in localStorageReact props onCashOutStarted,onPurchaseStartedonCashOutEnded,onPurchaseEndedresumeCashOut,resumePurchase -
Allow the identity provider if you send a Content-Security-Policy. A user's first purchase opens Dojah's web widget, which loads
https://widget.dojah.io/widget.jsand frameshttps://identity.dojah.io. See the widget's CSP table.
On Solana, the widget finds Solana wallets itself and lists them in its picker, and fires recoilpay-solana-wallet when one connects. With the React component, add the Solana half to the wallet you pass, as in On Solana.
With the headless SDK
Each direction has its own session in @recoilpay/intent-core, separate from the swap session, because it collects bank details or runs an identity check, and can take a while. Parse with the intent session as usual, then hand the resolved request over:
import { createIntentSession, createOfframpSession, createOnrampSession, viemWallet } from '@recoilpay/intent-core';
const session = createIntentSession({ hfAccessToken, wallet });
await session.run(sentence);
const state = session.getState();
// state.phase === 'offramp' → state.offramp is a resolved OfframpRequest
// state.phase === 'onramp' → state.onramp is a resolved OnrampRequest
A fiat sentence is checked against the chains in the registry that have a fiat_escrow, and their tokens (fiatSupportedSet), not against what swap solvers list: a chain can run fiat with no swap solver on it. A chain without one is refused with "Cash-outs aren't available on … yet" (or "Purchases …").
Cash out
const offramp = createOfframpSession({ wallet: viemWallet(walletClient) });
await offramp.quote(state.offramp!);
// offramp.getState().preview: payAmount, receiveAmount, fee, rate, paymentWindowSecs
await offramp.confirm({ rail: 'ng.nip', bankCode: '058', account: '0123456789', name: 'Ada Obi' });
// switch chain → Permit2 approval (first time) → seal bank details → open → sign the lock
// phase: 'awaitingFiat', then 'awaitingConfirmation' once the solver says it paid
await offramp.confirmReceived(); // or: await offramp.dispute('Nothing arrived')
- Show the user
payeeSalt(on the state once the trade opens) and let them keep it. With their bank details it proves which account the solver was told to pay. - Before asking the wallet to sign the lock, the session checks the lock names the asset, amount, chain, escrow, solver and bank details the user chose (
checkLock). If you build your own flow, do the same.
Buy
const onramp = createOnrampSession({ wallet: viemWallet(walletClient) });
await onramp.quote(state.onramp!);
// preview: payAmount (fee included), receiveAmount, fee, rate, confirmWindowSecs, limits
await onramp.buy();
// phase 'needsKyc' → a first purchase: run the identity check below, then buy() again
// phase 'awaitingPayment' → show onramp.getState().payTo and trade.payment_reference
await onramp.paymentSent(); // the user says they paid
// Paystack-collected accounts (payTo.bankName is set): call checkPayment() instead
The identity check, when phase is needsKyc:
await onramp.startKyc(); // wallet signs; state.kyc.widget has Dojah's settings
// open Dojah with state.kyc.widget and state.kyc.reference (intent-react exports openDojah)
await onramp.kycSubmitted(); // when the user finishes the widget
// phase: 'checkingKyc' → 'kycReview' (manual review) or back to 'quoted'
On Solana
Solana devnet works with the same sessions. The wallet needs a Solana half: solanaAddress, signSolanaMessage and signSolanaTransaction. solanaStandardWallet builds it from any Wallet Standard wallet (Phantom, Solflare, Backpack), with no extra dependency:
import { createOfframpSession, solanaStandardWallet, viemWallet } from '@recoilpay/intent-core';
const solana = solanaStandardWallet(wallet, account); // a Wallet Standard wallet and one of its accounts
const offramp = createOfframpSession({ wallet: { ...viemWallet(walletClient), ...solana } });
// "cash out 2 USDC on solana to NGN" → offramp.quote(…) → offramp.confirm(payee), as above
- The user needs no SOL and makes no approval. A cash-out's lock is one Solana transaction: the user signs it, and the solver's fill-wallet pays its fee and sends it. Releases are paid by the solver too, including opening the user's USDC account.
- The session checks the transaction before the wallet sees it. It rebuilds the lock instruction from what the user chose and refuses a transaction holding anything else. It also refuses one the wallet changed while signing, such as with an added priority fee, since the relay would refuse it too.
- Messages are signed by the Solana wallet (
signSolanaMessage): the same text as on EVM, ed25519, base58. - If the user has only a Solana wallet, pass an
IntentWalletwhose EVM members throw. Nothing on Solana calls them.
Both sessions have resume(tradeId) for picking a trade back up after a reload. subscribe() streams state as everywhere else in the SDK. The phases are documented on OfframpPhase and OnrampPhase.
The API
Everything the kit does goes over these public routes. As with swaps, there is no API key. Each action authorises its caller: a user by signing a message with their wallet, a solver with its operator key.
They're in the OpenAPI spec under the fiat and kyc tags. Request bodies are camelCase; trades come back snake_case, like /api/v1/chains. Errors are { "error": "CODE", "message": "…" }.
| Method | Path | Who | Purpose |
|---|---|---|---|
POST | /api/v1/fiat/quotes | anyone | Rank solvers' offers for an amount |
POST | /api/v1/fiat/trades | user | Open a cash-out with sealed bank details |
POST | /api/v1/fiat/trades/{id}/lock | user | Submit the signed lock for relay: the Permit2 signature, or on Solana the signed transaction |
POST | /api/v1/fiat/trades/{id}/lock-transaction | user | Solana: the lock transaction for the user to sign |
POST | /api/v1/fiat/trades/{id}/funded | anyone | Report a lock sent from the user's own wallet |
POST | /api/v1/fiat/onramp-trades | user | Open a purchase; locks the solver's crypto |
GET | /api/v1/fiat/trades/{id} | anyone | A trade, with pay_to while a purchase is payable |
POST | /api/v1/fiat/trades/{id}/payment-sent | fiat payer | "I've paid", with the reference |
POST | /api/v1/fiat/trades/{id}/check-payment | anyone | Ask the payment provider about a purchase now |
POST | /api/v1/fiat/trades/{id}/confirm | fiat receiver | The money arrived: release the escrow |
POST | /api/v1/fiat/trades/{id}/dispute | fiat receiver | The money didn't arrive: send to review |
POST, GET | /api/v1/fiat/trades/{id}/claim | user | After settlement: the payout never arrived |
POST, GET | /api/v1/fiat/trades/{id}/evidence | either party | Evidence for a dispute or claim |
POST | /api/v1/kyc/sessions | user | Start an identity check |
GET | /api/v1/kyc/sessions/{reference} | anyone | An identity check's state |
POST | /api/v1/kyc/sessions/{reference}/check | anyone | Ask the provider for its verdict now |
The fiat payer is the solver on a cash-out and the user on a purchase; the fiat receiver is the other one.
Quotes
curl -s "$RECOIL_API/api/v1/fiat/quotes" -H 'content-type: application/json' -d '{
"direction": "offramp",
"chain": "eip155:84532",
"asset": "USDC",
"fiatCurrency": "NGN",
"rail": "ng.nip",
"assetAmount": "50000000"
}'
On Solana devnet, chain is solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1. For a purchase, send "direction": "onramp" and fiatAmount (what the user pays, fee included, in kobo) instead of assetAmount.
The response ranks offers best first:
{
"quotes": [{
"quoteId": "…", "solverId": "solver-…",
"rate": "1500", "assetAmount": "50000000",
"fiatCurrency": "NGN", "rail": "ng.nip",
"fiatGross": "7500000", "fiatFee": "27500", "fiatNet": "7472500",
"paymentWindowSecs": 600, "expiry": 1791370000, "score": 0.93,
"payeeKey": "0x04…"
}],
"totalConsidered": 3,
"limits": { "kycTier": "none", "perTrade": "75000000", "perDay": "150000000" },
"overTradeLimit": 0
}
Fiat amounts are in minor units (kobo). payeeKey is the solver's key for sealing bank details on a cash-out; it's null on a purchase. limits are the caps that apply in this direction. Quotes don't know the wallet, so on-ramp is priced as for a verified user, and the real tier is checked when the trade opens. limits: null means the corridor is closed in this direction.
Cash-out, step by step
- Seal the bank details to the winning quote's
payeeKeywithsealPayeefrom@recoilpay/intent-core. It returns{ sealed, commitment, salt }. Give the user thesaltto keep. Use the SDK's function rather than writing your own: the envelope format and the commitment must match the solver's tooling exactly. - Open the trade:
POST /api/v1/fiat/tradeswith{ quoteId, assetAmount, userAddress, payee: { sealed, commitment } }. The response is{ trade, lock }:lock.typedDatais a Permit2PermitWitnessTransferFromnaming the escrow, the solver as counterparty, and the trade. - Check, then sign the lock. Verify that
locknames your token, amount, chain and escrow before the user signs it. The user's wallet needs a Permit2 allowance for the token first, the same one-time approval as for swaps. - Submit it:
POST /api/v1/fiat/trades/{id}/lockwith{ signature }. RecoilPay's relay sendslockForand the trade moves toawaiting_fiat. - Poll
GET /api/v1/fiat/trades/{id}. When the solver reports the payment, the state becomesawaiting_confirmationwith apayment_reference. - The user answers with a signed message,
POST …/confirm { signature }orPOST …/dispute { reason, signature }.
On Solana, steps 2 to 4 differ. userAddress is the user's Solana address, and lock in the response holds the lock's fields instead of typed data: program, tradeId (keccak256 of the trade id), lock, vault, mint, amount, funder, counterparty (the solver's Solana identity), feePayer (the solver's fill-wallet) and deadline. Check them, then:
POST /api/v1/fiat/trades/{id}/lock-transactionreturns{ message, lock }.messageis a legacy transaction message (base64), paid for byfeePayer, on a fresh blockhash.- Check that the message holds exactly the escrow's
lockinstruction for those fields and nothing else.checkSolanaLockMessagein@recoilpay/intent-coredoes this. - Have the user's wallet sign those bytes as a transaction (Wallet Standard
solana:signTransaction), and take the user's signature from the result. POST …/lock { signature, message }within a minute or so, before the blockhash expires. The relay adds the fill-wallet's signature and sends it. If it's too late, fetch a new transaction.
Purchase, step by step
- Verify the wallet once. Make a reference with
newKycReference()(rpkyc-plus 32 hex characters), have the wallet signkycStartMessage(wallet, reference), andPOST /api/v1/kyc/sessions { wallet, reference, signature }. The response'swidgetholds Dojah's public settings for opening its widget with that reference. When the user finishes,POST /api/v1/kyc/sessions/{reference}/checkuntilstateisverifiedorrejected(pendingmeans manual review). The verdict comes from Dojah, server to server, never from the browser. - Open the purchase: choose a
requestId(16–64 of[a-z0-9]), have the wallet signfiatOnrampOpenMessage(requestId, quoteId, fiatAmount, wallet), andPOST /api/v1/fiat/onramp-trades { quoteId, fiatAmount, userAddress, requestId, signature }. The trade id isft-on-<requestId>, so a retried request can never open a second trade.201: the solver's crypto is locked, andpay_tosays where to pay:bankCode,account,name, plusbankNamefor a Paystack collection account.202: the lock was sent but isn't confirmed yet, sopay_toisnull. Poll the trade; never show payment details until the trade isawaiting_fiat.
- The user pays the exact
fiat_minor_unitsfrom an account in their own name, withpayment_referencein the description, beforedeadline_at. - Report it: sign
fiatPaymentSentMessage(tradeId, payment_reference)andPOST …/payment-sent { paymentReference, signature }. For a Paystack collection account, callPOST …/check-paymentinstead; the aggregator asks Paystack and settles the trade if the payment is there and the sender's name matches the verified name. - Poll until
settled_to_fiat_payer, which means the USDC went to the user.
Refusals to handle when opening a purchase: KYC_REQUIRED (verify first), ONRAMP_IN_PROGRESS (one purchase per person at a time), SOLVER_UNAVAILABLE (the solver can't cover it; re-quote) and LOCK_FAILED (nothing was locked; nothing to pay).
What users sign
Every user action is an EIP-191 personal_sign over one of these messages (on Solana, an ed25519 signature over the same UTF-8 text, base58-encoded, as Wallet Standard solana:signMessage gives it), each naming the trade, so a signature given for one action can't be replayed as another. Build them with the exported functions. The aggregator compares them byte for byte, against test vectors shared with the SDK.
| Action | Function | Message |
|---|---|---|
| Confirm receipt | fiatConfirmMessage(id) | RecoilPay: I confirm I received the fiat payment for trade {id}. |
| Dispute | fiatDisputeMessage(id, reason) | RecoilPay: I dispute trade {id}. Reason: {reason} |
| Say "paid" | fiatPaymentSentMessage(id, ref) | RecoilPay: I have sent the fiat payment for trade {id}. Reference: {ref} |
| Open a purchase | fiatOnrampOpenMessage(…) | RecoilPay: I want to buy with quote {quoteId}, paying {fiatAmount} in minor units, into wallet {wallet}. Request: {requestId} |
| Late claim | fiatClaimMessage(id, reason) | RecoilPay: I claim the fiat payment for trade {id} never arrived. Reason: {reason} |
| Reveal bank details | fiatRevealMessage(id) | RecoilPay: I reveal the bank details I gave for trade {id}. |
| Statement | fiatStatementMessage(id, text) | RecoilPay: My statement on trade {id}: {text} |
| Start identity check | kycStartMessage(wallet, ref) | RecoilPay: verify my identity for wallet {wallet}. Session: {ref} |
Reasons, references and statements must be printable ASCII. EVM wallet addresses in messages are lowercase; Solana addresses stay exactly as written, since base58 is case-sensitive.
Trade states and clocks
state | Waiting on | When the clock runs out |
|---|---|---|
quoted | The funder to lock (10 min) | failed: nothing moved |
awaiting_fiat | The fiat payer: solver 15 min, user 30 min | refunded_to_funder |
awaiting_confirmation | The fiat receiver: solver 30 min, user 6 h | Solver silent: settled_to_fiat_payer. User silent: disputed |
disputed | An admin | No clock |
settled_to_fiat_payer | — | Terminal: escrow released to the fiat payer |
refunded_to_funder | — | Terminal: escrow returned to the funder |
failed | — | Terminal: nothing locked |
deadline_at on the trade is when the current clock runs out. The rule is whoever the clock is waiting on loses, with one exception: a user's silence never releases their own crypto. An off-ramp user who doesn't confirm goes to review instead.
Late claims. For 72 hours after a cash-out settles, the user can say the payout never arrived (POST …/claim { reason, signature }). The claim holds that much of the solver's bond until an admin rules, and an upheld claim pays the user from the bond. Disputes and claims take evidence through POST …/evidence: kind: "payee_reveal" with rail, bankCode, account, salt (checked against the trade's commitment; only the last four digits are kept), or kind: "statement" with text.
Limits
Counted before fees, over any 24 hours. Cash-outs count per wallet; purchases count per verified person, across all their wallets:
| Direction | Who | Per trade | Per 24 hours |
|---|---|---|---|
| Cash out | Anyone | ₦750,000 | ₦1,500,000 |
| Buy | Verified (BVN or NIN and a selfie) | ₦500,000 | ₦1,000,000 |
A verified identity can cover up to three wallets, which share its limits. The quote response carries the limits that apply (limits), so show them rather than hardcoding these.
Privacy, in short
- Bank details are sealed in the browser to the solver's key. The aggregator stores only the envelope and a salted commitment, and never sees the plaintext. Don't put them in the parsed sentence: parsing goes to Hugging Face, and the SDK's
parseIntentrefuses text that looks like an account number. - Identity: the aggregator keeps the verified legal name, sealed, and an HMAC of the ID number. It never keeps the number or the documents. The name is shown only to the solver on that user's open purchases, so it can check the sender of the transfer.