Off-Ramp Integration Guide
This guide walks you through integrating the Zet Off-Ramp API to let your users sell crypto and receive Nigerian Naira (NGN) in their bank account.
Overview
Your App Zet API Flint (Provider)
│ │ │
├── GET /onramp/banks ─────►│ │
│◄── Bank list ─────────────┤ │
│ │ │
├── POST /onramp/banks/verify ►│ │
│◄── Account name ──────────┤ │
│ │ │
├── POST /offramp/quote ───►│ │
│◄── Quote (fees, NGN amt) ─┤ │
│ │ │
├── POST /offramp/initiate ►│ │
│ │── Swap Token → CNGN ──►(LiFi)│
│ │── Send CNGN ─────────────────►│
│ │ Process NGN│
│ │ to bank ───►
│ │ │
│◄── Webhook: offramp.completed ◄──────────────────────────┤
│ │ │
Step 1: Get available banks
Fetch the list of supported Nigerian banks:
const banks = await fetch('https://api.zet.money/v1/onramp/banks', {
headers: { 'x-api-key': process.env.ZET_API_KEY },
}).then(r => r.json());
// banks.data = [
// { bankCode: "044", bankName: "Access Bank", nibssCode: "000014" },
// { bankCode: "035", bankName: "Wema Bank", nibssCode: "000017" },
// ...
// ]
Cache the bank list — it changes infrequently. Refresh it once per day.
Step 2: Verify the bank account
Before quoting, verify the user’s bank account to confirm the recipient name:
const verification = await fetch('https://api.zet.money/v1/onramp/banks/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
bankCode: '044', // Access Bank
accountNumber: '0123456789',
}),
}).then(r => r.json());
// Show to user: "Sending to John Doe at Access Bank"
console.log(verification.data.accountName); // "John Doe"
Always verify and display the account name to the user before proceeding. This prevents sending NGN to the wrong person.
Step 3: Get a quote
Request a quote with the crypto amount to sell, token, and bank details:
const quote = await fetch('https://api.zet.money/v1/offramp/quote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
amount: '100',
tokenSymbol: 'USDC',
chain: 'base',
bankCode: '044',
accountNumber: '0123456789',
}),
}).then(r => r.json());
Quote response
{
"success": true,
"data": {
"quoteId": "qt_01H8X4abc",
"cryptoAmount": "100",
"tokenSymbol": "USDC",
"chain": "base",
"fiatAmount": "148500",
"fiatCurrency": "NGN",
"rate": "1540.50",
"fees": {
"platformFee": "20",
"processingFee": "148.50",
"stampDuty": "50",
"swapFee": "20",
"totalFee": "238.50"
},
"bankAccount": {
"bankName": "Access Bank",
"bankCode": "044",
"accountNumber": "0123456789",
"accountName": "John Doe"
},
"expiresAt": "2026-03-02T10:05:00Z"
}
}
Fee breakdown
| Fee | When | Amount |
|---|
| Platform fee | Always | 20 NGN |
| Processing fee | Always | 0.1% of fiat amount (max 200 NGN) |
| Stamp duty | Amount ≥ 10,000 NGN | 50 NGN |
| Swap fee | Source ≠ CNGN | 20 NGN |
Step 4: Initiate the off-ramp
Custodial flow (Zet-managed wallet)
const offramp = await fetch('https://api.zet.money/v1/offramp/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
quoteId: quote.data.quoteId,
walletId: 'wal_01H8X3abc',
reference: `payout_${payoutId}`,
}),
}).then(r => r.json());
For custodial wallets, the crypto is automatically debited and the process begins immediately.
Non-custodial flow (user-managed wallet)
const offramp = await fetch('https://api.zet.money/v1/offramp/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
quoteId: quote.data.quoteId,
sourceAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f5bA16',
reference: `payout_${payoutId}`,
}),
}).then(r => r.json());
// User must send crypto to depositAddress
console.log(offramp.data.depositAddress); // "0xdef..."
For non-custodial, the response includes a depositAddress where the user must send the crypto. Once received, the off-ramp process begins.
Response
{
"success": true,
"data": {
"transactionId": "txn_01H8X4def",
"reference": "payout_67890",
"status": "pending",
"depositAddress": null,
"estimatedFiatAmount": "148500",
"estimatedArrival": "5-15 minutes"
}
}
Step 5: Handle completion
Listen for the offramp.completed webhook:
app.post('/webhooks/zet', (req, res) => {
const { event, data } = req.body;
if (event === 'offramp.completed') {
// NGN has been sent to the user's bank account
await notifyUser(data.reference, {
message: `₦${data.fiatAmount} sent to your bank account`,
transactionHash: data.transactionHash,
});
}
if (event === 'offramp.failed') {
// Crypto is returned to the wallet on failure
await notifyUser(data.reference, {
message: 'Off-ramp failed. Your crypto has been returned.',
error: data.errorMessage,
});
}
res.status(200).send('OK');
});
Processing timeline
| Step | Estimated Time |
|---|
| Swap Token → CNGN (if needed) | 10-30 seconds |
| CNGN → Flint deposit | 30-60 seconds |
| Flint → NGN bank transfer | 3-15 minutes |
| Total | 5-15 minutes |
Best practices
- Always verify the bank account first — show the resolved name to your user
- Display clear fee breakdown — users should know exactly what they’ll receive
- Handle failures gracefully — inform users their crypto is returned on failure
- Use webhooks — don’t poll for off-ramp status in production
- Cache bank list — refresh once per day, not on every page load