Skip to main content

Build a purchase flow

By the end of this guide your app will let a user buy USDC with a naira bank transfer, with your own screens. The headless SDK, @recoilpay/intent-core, ranks the offers, runs the one-time identity check, opens the purchase and follows it to the end. You render it.

What the user goes through: choose how much to spend → see the offer → verify their identity (first time only) → sign → see where to pay → transfer the money → tell you they've paid → receive the USDC.

What you need:

  • A web app where users connect an EVM wallet (viem or wagmi). The user needs no crypto and no gas: buying only takes signatures.
  • About 45 minutes, including one identity check of your own to test with.
Prefer not to build screens?

The widget and React component do all of this already, including the identity check.


1. Install and connect a wallet​

npm install @recoilpay/intent-core viem

A purchase runs in an on-ramp session:

import { createOnrampSession, viemWallet, type OnrampSession } from '@recoilpay/intent-core';
import { createWalletClient, custom, type WalletClient } from 'viem';
import { baseSepolia } from 'viem/chains';

const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({ account, chain: baseSepolia, transport: custom(window.ethereum) });

export const API_URL = 'https://api.recoilpay.com';

export function startBuy(walletClient: WalletClient, apiUrl = API_URL): OnrampSession {
return createOnrampSession({ apiUrl, wallet: viemWallet(walletClient) });
}

The USDC goes to the wallet that signs. That's also the wallet the identity check covers, so purchases can't be sent to another address. If your user wants the USDC elsewhere, they send it on afterwards.

2. Find where purchases run​

The same chains as cash-outs: every EVM chain in the registry with a fiat_escrow. Solana devnet works differently; see On Solana.

export async function buyChains(apiUrl = API_URL) {
const res = await fetch(`${apiUrl}/api/v1/chains`);
const { data } = await res.json();
return data
.filter((c: any) => c.fiat_escrow && c.family !== 'solana') // EVM chains: this flow signs with viem
.flatMap((c: any) => {
const usdc = c.tokens.find((t: any) => t.symbol === 'USDC');
return usdc ? [{ chainId: c.chain_id as number, name: c.name as string, usdc: { address: getAddress(usdc.address), decimals: usdc.decimals as number } }] : [];
});
}

3. Describe the purchase​

A purchase is sized in what the user pays, in naira, fee included. The SDK works in kobo:

export function buyRequest(chain: Awaited<ReturnType<typeof buyChains>>[number], naira: string): OnrampRequest {
const corridor = FIAT_CORRIDORS.NGN;
return {
chainId: chain.chainId,
chainName: chain.name,
asset: 'USDC',
token: chain.usdc.address,
decimals: chain.usdc.decimals,
fiatAmount: parseUnits(naira, corridor.decimals), // kobo, fee included
corridor,
};
}

With the intent session, buy ₦20,000 of USDC on Base Sepolia gives you this object as state.onramp.

4. Get an offer, then buy​

const session = startBuy(walletClient);
session.subscribe((state) => render(state)); // render: your UI

await session.quote(buyRequest(chain, '20000'));

At quoted, state.preview has the offer: payAmount, receiveAmount and receiveSymbol, fee, rate, chainName, confirmWindowSecs, and limits (perTrade, perDay, already formatted). Show the limits: today ₦500,000 a purchase and ₦1,000,000 a day for a verified person.

When the user clicks Buy:

await session.buy();

The wallet signs the purchase. This costs nothing. There are two possible outcomes:

  • needsKyc: this wallet has never been verified. Go to step 5.
  • awaitingPayment: the solver's USDC is locked for the user. Go to step 6.

5. Run the identity check (first purchase only)​

Buying with a bank transfer needs a verified identity: BVN or NIN and a selfie, checked by Dojah. The session starts the check; you open Dojah's window.

await session.startKyc(); // the wallet signs once; phase becomes 'verifying'

When the phase is verifying, state.kyc holds widget (Dojah's public settings) and reference. Open Dojah with them. In React, openDojah from @recoilpay/intent-react does this. Without React:

export function openDojah(widget: KycWidget, reference: string, onFinished: () => void, onClosed: () => void): void {
const open = () => {
const Connect = (window as any).Connect;
const connect = new Connect({
app_id: widget.appId,
p_key: widget.publicKey,
type: 'custom',
config: { widget_id: widget.widgetId },
reference_id: reference,
onSuccess: onFinished,
onClose: onClosed,
onError: onClosed,
});
connect.setup();
connect.open();
};
if ((window as any).Connect) return open();
const script = document.createElement('script');
script.src = 'https://widget.dojah.io/widget.js';
script.onload = open;
document.head.appendChild(script);
}

const { kyc } = session.getState();
openDojah(kyc!.widget!, kyc!.reference, () => session.kycSubmitted(), () => showClosedMessage());
  • kycSubmitted() tells the session the user finished. That isn't the verdict: the aggregator asks Dojah itself, server to server. Phases: checkingKyc, and kycReview if Dojah reviews by hand (leave the page open; it carries on).
  • Also show an "I've finished" button that calls session.kycSubmitted(). Dojah sometimes moves the user to their phone, and they come back to your page when they're done.
  • When verified, the phase returns to quoted. Call session.buy() again.
  • If the user gives up, session.cancelKyc() stops waiting.

If you send a Content-Security-Policy, allow https://widget.dojah.io in script-src and https://identity.dojah.io in frame-src.

6. Show where to pay​

At awaitingPayment, and only then, the solver's USDC is locked for this user and state.payTo says where to pay:

export function payInstructions(state: OnrampState) {
const { payTo, trade } = state;
if (state.phase !== 'awaitingPayment' || !payTo || !trade) return null; // never before the lock
const corridor = FIAT_CORRIDORS[trade.fiat_currency];
return {
amount: `${corridor.symbol}${formatFiat(trade.fiat_minor_units, corridor)}`,
bank: payTo.bankName ?? corridor.banks.find((b) => b.code === payTo.bankCode)?.name ?? payTo.bankCode,
account: payTo.account,
name: payTo.name,
// A Paystack account (bankName set) is made for this purchase: no reference needed.
reference: payTo.bankName ? null : trade.payment_reference,
deadline: trade.deadline_at,
detectedAutomatically: Boolean(payTo.bankName),
};
}

Show each value with a copy button, plus a countdown to deadline (30 minutes). Tell the user to:

  • transfer the exact amount,
  • from a bank account in their own name, because the solver checks the sender against the verified name,
  • with the reference in the transfer's description, when there is one.
Never show an account before awaitingPayment

Before that phase nothing is locked for the user, so a payment could go unbacked. payInstructions returns null in every other phase.

Save the trade id now, so a user who reloads can still pay (step 8).

7. "I've paid"​

When the user has made the transfer:

if (payInstructions(state)?.detectedAutomatically) {
await session.checkPayment(); // Paystack account: asks Paystack now
} else {
await session.paymentSent(); // the wallet signs "I've paid"
}

Then the phase is awaitingConfirmation. The solver checks its account and confirms, and the phase becomes settled. If the solver doesn't answer within 30 minutes, the USDC is released to the user anyway. For a Paystack account, the aggregator also checks in the background every 30 seconds. state.notice says when a payment hasn't been seen yet.

Every phase, with the words to show:

export function describe(state: OnrampState): string {
// …see the complete code below
}

8. Come back to a purchase​

session.subscribe((s) => s.trade && localStorage.setItem('recoilpay:purchase', s.trade.id));

const saved = localStorage.getItem('recoilpay:purchase');
if (saved) await session.resume(saved);

A resumed purchase at awaitingPayment has payTo again, so the user can still pay. Clear the id at settled, refunded or failed.

settled means the trade is decided. The escrow's release transaction follows within about a minute. To show it:

export async function releaseTx(tradeId: string, apiUrl = API_URL): Promise<string> {
for (;;) {
const trade = await (await fetch(`${apiUrl}/api/v1/fiat/trades/${encodeURIComponent(tradeId)}`)).json();
if (trade.release_tx_hash) return trade.release_tx_hash;
await new Promise((r) => setTimeout(r, 5000));
}
}

Try it​

  1. Connect a wallet. It doesn't need any funds.
  2. Buy ₦1,000 of USDC on Base Sepolia. Your first purchase runs the identity check.
  3. Transfer to the account shown, then click I've paid.
This moves real naira

The USDC is testnet USDC, but the bank transfer is real. Test with a small amount, and with a solver you know.

Complete code​

Everything above in one module. It ran end to end against a local aggregator and solver before publishing, with the identity check filled by an already-completed Dojah sandbox check.

buy.ts
import {
createOnrampSession,
FIAT_CORRIDORS,
formatFiat,
viemWallet,
type KycWidget,
type OnrampRequest,
type OnrampSession,
type OnrampState,
} from '@recoilpay/intent-core';
import { getAddress, parseUnits, type WalletClient } from 'viem';

export const API_URL = 'https://api.recoilpay.com';

// ── Step 2: where purchases run (the same chains as cash-outs) ───────────
export async function buyChains(apiUrl = API_URL) {
const res = await fetch(`${apiUrl}/api/v1/chains`);
const { data } = await res.json();
return data
.filter((c: any) => c.fiat_escrow && c.family !== 'solana') // EVM chains: this flow signs with viem
.flatMap((c: any) => {
const usdc = c.tokens.find((t: any) => t.symbol === 'USDC');
return usdc ? [{ chainId: c.chain_id as number, name: c.name as string, usdc: { address: getAddress(usdc.address), decimals: usdc.decimals as number } }] : [];
});
}

// ── Step 3: describe the purchase, in naira the user pays ────────────────
export function buyRequest(chain: Awaited<ReturnType<typeof buyChains>>[number], naira: string): OnrampRequest {
const corridor = FIAT_CORRIDORS.NGN;
return {
chainId: chain.chainId,
chainName: chain.name,
asset: 'USDC',
token: chain.usdc.address,
decimals: chain.usdc.decimals,
fiatAmount: parseUnits(naira, corridor.decimals), // kobo, fee included
corridor,
};
}

export function startBuy(walletClient: WalletClient, apiUrl = API_URL): OnrampSession {
return createOnrampSession({ apiUrl, wallet: viemWallet(walletClient) });
}

// ── Step 5: open Dojah for the identity check ────────────────────────────
// React apps can use openDojah from @recoilpay/intent-react instead.
export function openDojah(widget: KycWidget, reference: string, onFinished: () => void, onClosed: () => void): void {
const open = () => {
const Connect = (window as any).Connect;
const connect = new Connect({
app_id: widget.appId,
p_key: widget.publicKey,
type: 'custom',
config: { widget_id: widget.widgetId },
reference_id: reference,
onSuccess: onFinished,
onClose: onClosed,
onError: onClosed,
});
connect.setup();
connect.open();
};
if ((window as any).Connect) return open();
const script = document.createElement('script');
script.src = 'https://widget.dojah.io/widget.js';
script.onload = open;
document.head.appendChild(script);
}

// ── Step 6: where to pay ─────────────────────────────────────────────────
export function payInstructions(state: OnrampState) {
const { payTo, trade } = state;
if (state.phase !== 'awaitingPayment' || !payTo || !trade) return null; // never before the lock
const corridor = FIAT_CORRIDORS[trade.fiat_currency];
return {
amount: `${corridor.symbol}${formatFiat(trade.fiat_minor_units, corridor)}`,
bank: payTo.bankName ?? corridor.banks.find((b) => b.code === payTo.bankCode)?.name ?? payTo.bankCode,
account: payTo.account,
name: payTo.name,
// A Paystack account (bankName set) is made for this purchase: no reference needed.
reference: payTo.bankName ? null : trade.payment_reference,
deadline: trade.deadline_at,
detectedAutomatically: Boolean(payTo.bankName),
};
}

// ── Step 7: what to show for each phase ──────────────────────────────────
export function describe(state: OnrampState): string {
const p = state.preview;
switch (state.phase) {
case 'quoting':
return state.trade ? 'Loading your purchase…' : 'Finding the best price…';
case 'quoted':
return `You pay ${p!.currencySymbol}${p!.payAmount} by bank transfer and receive ${p!.receiveAmount} ${p!.receiveSymbol} on ${p!.chainName}.`;
case 'opening':
case 'startingKyc':
case 'reporting':
return 'Confirm in your wallet…';
case 'needsKyc':
return 'Buying with a bank transfer needs a one-time identity check: BVN or NIN and a selfie.';
case 'verifying':
return 'Finish the check in the Dojah window, then choose "I\'ve finished".';
case 'checkingKyc':
return 'Checking your result…';
case 'kycReview':
return 'Your check is being reviewed by hand. Leave this open.';
case 'locking':
return "Locking the solver's USDC for you. Nothing to pay yet.";
case 'awaitingPayment':
return 'Pay the account shown, then tell us you paid.';
case 'checkingPayment':
return 'Checking for your payment…';
case 'awaitingConfirmation':
return 'Waiting for the solver to confirm. If it does not answer in time, the USDC is yours anyway.';
case 'disputed':
return 'The solver says your payment did not arrive. An admin is reviewing it; keep your bank receipt.';
case 'settled':
return 'Bought. The USDC is on its way to your wallet.';
case 'refunded':
return 'No payment was reported in time, so the purchase was cancelled.';
case 'failed':
return "The solver's USDC could not be locked. Don't pay anything for this purchase.";
case 'error':
return state.error ?? 'Something went wrong.';
default:
return '';
}
}

// ── Step 8: the release transaction, once it lands ──────────────────────
// `settled` means the trade is decided; the escrow's release follows within
// a minute. Poll the trade until it carries the transaction hash.
export async function releaseTx(tradeId: string, apiUrl = API_URL): Promise<string> {
for (;;) {
const trade = await (await fetch(`${apiUrl}/api/v1/fiat/trades/${encodeURIComponent(tradeId)}`)).json();
if (trade.release_tx_hash) return trade.release_tx_hash;
await new Promise((r) => setTimeout(r, 5000));
}
}

Next: Add fiat to the drop-in UI · How purchases work under the hood