Skip to main content

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.

Testnet today

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.

Step-by-step guides

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:

  1. 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:

    StartedEndedResume with
    Widget eventsrecoilpay-cash-out-started, recoilpay-purchase-startedrecoilpay-cash-out-ended, recoilpay-purchase-endedAutomatic: the widget remembers the trade in localStorage
    React propsonCashOutStarted, onPurchaseStartedonCashOutEnded, onPurchaseEndedresumeCashOut, resumePurchase
  2. 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.js and frames https://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 IntentWallet whose 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": "…" }.

MethodPathWhoPurpose
POST/api/v1/fiat/quotesanyoneRank solvers' offers for an amount
POST/api/v1/fiat/tradesuserOpen a cash-out with sealed bank details
POST/api/v1/fiat/trades/{id}/lockuserSubmit the signed lock for relay: the Permit2 signature, or on Solana the signed transaction
POST/api/v1/fiat/trades/{id}/lock-transactionuserSolana: the lock transaction for the user to sign
POST/api/v1/fiat/trades/{id}/fundedanyoneReport a lock sent from the user's own wallet
POST/api/v1/fiat/onramp-tradesuserOpen a purchase; locks the solver's crypto
GET/api/v1/fiat/trades/{id}anyoneA trade, with pay_to while a purchase is payable
POST/api/v1/fiat/trades/{id}/payment-sentfiat payer"I've paid", with the reference
POST/api/v1/fiat/trades/{id}/check-paymentanyoneAsk the payment provider about a purchase now
POST/api/v1/fiat/trades/{id}/confirmfiat receiverThe money arrived: release the escrow
POST/api/v1/fiat/trades/{id}/disputefiat receiverThe money didn't arrive: send to review
POST, GET/api/v1/fiat/trades/{id}/claimuserAfter settlement: the payout never arrived
POST, GET/api/v1/fiat/trades/{id}/evidenceeither partyEvidence for a dispute or claim
POST/api/v1/kyc/sessionsuserStart an identity check
GET/api/v1/kyc/sessions/{reference}anyoneAn identity check's state
POST/api/v1/kyc/sessions/{reference}/checkanyoneAsk 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​

  1. Seal the bank details to the winning quote's payeeKey with sealPayee from @recoilpay/intent-core. It returns { sealed, commitment, salt }. Give the user the salt to keep. Use the SDK's function rather than writing your own: the envelope format and the commitment must match the solver's tooling exactly.
  2. Open the trade: POST /api/v1/fiat/trades with { quoteId, assetAmount, userAddress, payee: { sealed, commitment } }. The response is { trade, lock }: lock.typedData is a Permit2 PermitWitnessTransferFrom naming the escrow, the solver as counterparty, and the trade.
  3. Check, then sign the lock. Verify that lock names 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.
  4. Submit it: POST /api/v1/fiat/trades/{id}/lock with { signature }. RecoilPay's relay sends lockFor and the trade moves to awaiting_fiat.
  5. Poll GET /api/v1/fiat/trades/{id}. When the solver reports the payment, the state becomes awaiting_confirmation with a payment_reference.
  6. The user answers with a signed message, POST …/confirm { signature } or POST …/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:

  1. POST /api/v1/fiat/trades/{id}/lock-transaction returns { message, lock }. message is a legacy transaction message (base64), paid for by feePayer, on a fresh blockhash.
  2. Check that the message holds exactly the escrow's lock instruction for those fields and nothing else. checkSolanaLockMessage in @recoilpay/intent-core does this.
  3. Have the user's wallet sign those bytes as a transaction (Wallet Standard solana:signTransaction), and take the user's signature from the result.
  4. 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​

  1. Verify the wallet once. Make a reference with newKycReference() (rpkyc- plus 32 hex characters), have the wallet sign kycStartMessage(wallet, reference), and POST /api/v1/kyc/sessions { wallet, reference, signature }. The response's widget holds Dojah's public settings for opening its widget with that reference. When the user finishes, POST /api/v1/kyc/sessions/{reference}/check until state is verified or rejected (pending means manual review). The verdict comes from Dojah, server to server, never from the browser.
  2. Open the purchase: choose a requestId (16–64 of [a-z0-9]), have the wallet sign fiatOnrampOpenMessage(requestId, quoteId, fiatAmount, wallet), and POST /api/v1/fiat/onramp-trades { quoteId, fiatAmount, userAddress, requestId, signature }. The trade id is ft-on-<requestId>, so a retried request can never open a second trade.
    • 201: the solver's crypto is locked, and pay_to says where to pay: bankCode, account, name, plus bankName for a Paystack collection account.
    • 202: the lock was sent but isn't confirmed yet, so pay_to is null. Poll the trade; never show payment details until the trade is awaiting_fiat.
  3. The user pays the exact fiat_minor_units from an account in their own name, with payment_reference in the description, before deadline_at.
  4. Report it: sign fiatPaymentSentMessage(tradeId, payment_reference) and POST …/payment-sent { paymentReference, signature }. For a Paystack collection account, call POST …/check-payment instead; the aggregator asks Paystack and settles the trade if the payment is there and the sender's name matches the verified name.
  5. 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.

ActionFunctionMessage
Confirm receiptfiatConfirmMessage(id)RecoilPay: I confirm I received the fiat payment for trade {id}.
DisputefiatDisputeMessage(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 purchasefiatOnrampOpenMessage(…)RecoilPay: I want to buy with quote {quoteId}, paying {fiatAmount} in minor units, into wallet {wallet}. Request: {requestId}
Late claimfiatClaimMessage(id, reason)RecoilPay: I claim the fiat payment for trade {id} never arrived. Reason: {reason}
Reveal bank detailsfiatRevealMessage(id)RecoilPay: I reveal the bank details I gave for trade {id}.
StatementfiatStatementMessage(id, text)RecoilPay: My statement on trade {id}: {text}
Start identity checkkycStartMessage(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​

stateWaiting onWhen the clock runs out
quotedThe funder to lock (10 min)failed: nothing moved
awaiting_fiatThe fiat payer: solver 15 min, user 30 minrefunded_to_funder
awaiting_confirmationThe fiat receiver: solver 30 min, user 6 hSolver silent: settled_to_fiat_payer. User silent: disputed
disputedAn adminNo 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:

DirectionWhoPer tradePer 24 hours
Cash outAnyone₦750,000₦1,500,000
BuyVerified (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 parseIntent refuses 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.

→ Gift cards