On-Ramp Integration Guide
This guide walks you through integrating the Zet On-Ramp API to let your users buy crypto with Nigerian Naira (NGN) via bank transfer.
Overview
Your App Zet API Flint (Provider)
│ │ │
├── POST /onramp/quote ────►│ │
│◄── Quote (fees, rate) ────┤ │
│ │ │
├── POST /onramp/initiate ─►│── Create order ─────────────►│
│◄── depositAccount ────────┤◄── Bank details ─────────────┤
│ │ │
│ (User transfers NGN │ │
│ to depositAccount) │ │
│ │ │
│ │◄── Deposit detected ─────────┤
│ │ │
│ │── Swap CNGN → Token ──►(LiFi)│
│ │── Deliver to wallet │
│ │ │
│◄── Webhook: onramp.completed ◄───────────────────────────┤
│ │ │
Step 1: Get a quote
Before initiating a transaction, get a real-time quote that shows the user exactly what they’ll pay and receive.
const quote = await fetch('https://api.zet.money/v1/onramp/quote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
amount: '50000', // NGN to spend
tokenSymbol: 'USDC', // Crypto to receive
chain: 'base', // Target chain
}),
}).then(r => r.json());
// Display to user:
// "Pay ₦50,070 → Receive 32.45 USDC"
// "Fees: Provider ₦50, Swap ₦20"
console.log(quote.data);
Quote response
{
"success": true,
"data": {
"quoteId": "qt_01H8X3xyz",
"fiatAmount": "50000",
"fiatCurrency": "NGN",
"cryptoAmount": "32.45",
"tokenSymbol": "USDC",
"chain": "base",
"rate": "1540.50",
"fees": {
"providerFee": "50",
"flatFee": "0",
"swapFee": "20",
"totalFee": "70"
},
"expiresAt": "2026-03-02T10:05:00Z"
}
}
Quotes expire in ~5 minutes. If the user doesn’t proceed in time, request a new quote.
Fee breakdown
| Fee | When | Amount |
|---|
| Provider fee | Always | 0.1% of CNGN amount |
| Flat fee | Amount > 50,000 NGN | 50 NGN |
| Swap fee | Target ≠ CNGN | 20 NGN |
Buying CNGN directly? No swap fee — cheapest option.
Step 2: Initiate the on-ramp
Once the user confirms, initiate the transaction with the quoteId:
const onramp = await fetch('https://api.zet.money/v1/onramp/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', // Zet-managed wallet
reference: `order_${orderId}`, // Your unique reference
callbackUrl: 'https://yourapp.com/webhooks/zet', // Optional per-tx override
}),
}).then(r => r.json());
Response — display these bank details to the user
{
"success": true,
"data": {
"transactionId": "txn_01H8X3def",
"reference": "order_12345",
"status": "pending",
"depositAccount": {
"bankName": "Wema Bank",
"bankCode": "035",
"accountNumber": "0123456789",
"accountName": "Zet / John Doe"
},
"amount": "50070",
"expiresAt": "2026-03-02T10:30:00Z"
}
}
The user must transfer the exact amount shown (amount) to the deposit account. Partial or excess transfers may delay processing.
Step 3: Wait for completion
Option A: Webhooks (recommended)
Register a webhook and listen for onramp.completed:
// Express.js webhook handler
app.post('/webhooks/zet', (req, res) => {
// Verify signature
const signature = req.headers['x-zet-signature'];
if (!verifyWebhook(req.body, signature, process.env.ZET_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = req.body;
switch (event) {
case 'onramp.completed':
// Crypto delivered! Update your database
await markOrderComplete(data.reference, {
cryptoAmount: data.amount,
tokenSymbol: data.tokenSymbol,
transactionHash: data.transactionHash,
});
break;
case 'onramp.failed':
// Handle failure
await markOrderFailed(data.reference, data.errorMessage);
break;
}
res.status(200).send('OK');
});
Option B: Polling
Poll the status endpoint every 30 seconds:
async function waitForOnramp(transactionId) {
while (true) {
const { data } = await fetch(
`https://api.zet.money/v1/onramp/${transactionId}`,
{ headers: { 'x-api-key': process.env.ZET_API_KEY } }
).then(r => r.json());
if (data.status === 'completed') return data;
if (data.status === 'failed') throw new Error(data.errorMessage);
await new Promise(r => setTimeout(r, 30000)); // Wait 30s
}
}
Step 4: Non-custodial flow
If you’re not using Zet-managed wallets, provide destinationAddress instead of walletId:
const onramp = await fetch('https://api.zet.money/v1/onramp/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
quoteId: quote.data.quoteId,
destinationAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f5bA16',
reference: `order_${orderId}`,
}),
}).then(r => r.json());
The flow is identical — crypto is delivered to the provided address once the bank deposit is confirmed.
Supported tokens
| Token | Chain | Swap fee |
|---|
| CNGN | Base, BSC | None |
| USDC | Base, BSC | 20 NGN |
| ETH | Base | 20 NGN |
| cbBTC | Base | 20 NGN |
| BNB | BSC | 20 NGN |
| USDT | BSC | 20 NGN |
Best practices
- Always display the quote before initiating — show fees, rate, and estimated crypto amount
- Use idempotent references — if a user refreshes, use the same
reference to avoid duplicate orders
- Set a reasonable expiry UI — quotes expire in ~5 minutes, deposit windows in ~30 minutes
- Prefer webhooks over polling — more efficient and real-time
- Handle the
onramp.failed event — notify the user and offer to retry