naira-ramp
v0.8.0
Published
Unified crypto on-ramp and off-ramp SDK for Nigeria. Abstracts YellowCard and Quidax into a single API with React hooks, widgets, multi-chain support (Ethereum, Tron, Base, Optimism, BSC), and webhook handling.
Readme
naira-ramp
Unified crypto on-ramp and off-ramp SDK for Nigeria. Abstracts YellowCard and Quidax into a single API with React hooks and drop-in widgets.
Features
- Single
NairaRampclass for on-ramp and off-ramp - Supports USDT and USDC
- Auto-selects the best provider rate
- In-memory rate cache with configurable TTL
- Automatic retry with exponential backoff on network and server errors
- Transaction history with pluggable store (
MemoryStore,LocalStorageStore, or custom) - Webhook support — receive push status updates server-side with signature verification
- React hooks (
useRates,useOfframp,useOnramp) — works on web and React Native - Drop-in React widgets (
OfframpWidget,OnrampWidget) —inlineandmodalvariants,theme:'auto', close button/Escape dismiss - Sandbox mode for development without real API keys
How It Works
┌─────────────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ React Web React Native Node.js Server │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Widget │ │ Hooks │ │ handleWebhook() │ │
│ │Offramp / │ │useRates │ │ (verify + parse │ │
│ │ Onramp │ │useOfframp│ │ + fire callbacks│ │
│ └────┬─────┘ │useOnramp │ └────────┬─────────┘ │
│ │ └────┬─────┘ │ │
└───────┼───────────────────┼───────────────────────┼─────────────────┘
│ │ │ webhook POST
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ NairaRamp │
│ │
│ getRate() ──► Router (selectProvider) ──► Rate Cache (TTL) │
│ │ │
│ ┌─────┴──────┐ │
│ │ auto mode? │ │
│ └─────┬──────┘ │
│ No ◄────────┴────────► Yes │
│ (use preferred) (allSettled → best rate) │
│ │
│ offramp() ──► selectProvider ──► initiateOfframp() │
│ │ │
│ onramp() ──► selectProvider ──► initiateOnramp() │
│ │ │
│ TransactionStore │
│ (save + update) │
│ │ │
│ getTransaction() ──────────────► poll status ──► callbacks │
│ │
│ Retry: NETWORK_ERROR / 5xx → exponential backoff (×3 default) │
└──────────────────────┬──────────────────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ YellowCard │ │ Quidax │
│ │ │ │
│ HMAC-SHA256 auth│ │ Bearer token auth│
│ GET /rates │ │ GET /tickers │
│ POST /payments │ │ POST /instant_ │
│ GET /payments/ │ │ orders │
│ :id │ │ GET /instant_ │
│ GET /account- │ │ orders/:id │
│ lookup │ │ │
│ │ │ verifyBankAccount│
│ Sandbox: ✅ │ │ → returns input │
│ (sandbox URL) │ │ unchanged │
└─────────────────┘ └──────────────────┘Off-ramp flow (Crypto → NGN)
User enters amount + bank details
│
▼
verifyBankAccount() ──► YellowCard: real lookup
──► Quidax: returns input as-is
│
▼
getRate() ──► Router picks best NGN/USDT rate
│
▼
offramp() ──► initiateOfframp() ──► Transaction created
│ │
│ store.save(tx)
│ callbacks registered
▼
Poll / Webhook ──► status: pending → processing → completed
│ │
▼ ▼
onStatusChange() onSuccess(tx)On-ramp flow (NGN → Crypto)
User enters NGN amount + wallet address
│
▼
getRate() ──► Router picks lowest rate
│
▼
onramp() ──► initiateOnramp() ──► Transaction created
│ │
│ store.save(tx)
▼
Poll / Webhook ──► status: pending → processing → completed
│
onSuccess(tx)
crypto sent to walletInstallation
npm install naira-rampReact is a peer dependency (optional — only needed for hooks and widgets):
npm install reactQuick Start
import { NairaRamp } from 'naira-ramp';
const ramp = new NairaRamp({
yellowcardApiKey: process.env.YELLOWCARD_API_KEY,
yellowcardSecretKey: process.env.YELLOWCARD_SECRET_KEY,
quidaxApiKey: process.env.QUIDAX_API_KEY,
defaultProvider: 'auto', // picks best rate automatically
});
// Get current rate
const rate = await ramp.getRate({ currency: 'USDT', type: 'offramp' });
console.log(`1 USDT = ₦${rate.rate}`);
// Sell crypto (off-ramp)
const tx = await ramp.offramp({
amount: 100, // USDT
currency: 'USDT',
bankAccount: {
accountNumber: '0123456789',
bankCode: '058',
},
});Configuration
const ramp = new NairaRamp({
// YellowCard credentials (both required to enable YellowCard)
yellowcardApiKey: '...',
yellowcardSecretKey: '...',
// Quidax credentials
quidaxApiKey: '...',
// Provider selection: 'yellowcard' | 'quidax' | 'auto' (default: 'auto')
defaultProvider: 'auto',
// Rate cache TTL in ms (default: 30000)
rateCacheTtlMs: 30_000,
// Enable sandbox/mock mode (default: false)
sandbox: false,
// Transaction persistence (default: MemoryStore — in-memory, lost on refresh)
store: new LocalStorageStore(), // persist across sessions
// Retry configuration
maxRetries: 3, // max attempts after first failure (default: 3)
retryDelayMs: 500, // initial delay before first retry in ms (default: 500)
retryBackoffMultiplier: 2, // multiplier applied to delay on each retry (default: 2)
// Merchant rate spread (Quidax only, default: 0 — no adjustment)
// merchantPercent: 0.5, // adds 0.5% to onramp rate, subtracts 0.5% from offramp rate
});Retry Behaviour
The SDK automatically retries failed requests using exponential backoff. Retries are applied to getRate(), offramp(), onramp(), and getTransaction().
What gets retried:
| Error | Retried | Reason |
| ------------------------- | ------- | ----------------------------------------------------- |
| NETWORK_ERROR | ✅ Yes | Transient connectivity issue |
| PROVIDER_SERVER_ERROR | ✅ Yes | Transient 5xx from provider |
| PROVIDER_REQUEST_ERROR | ❌ No | 4xx is a caller error (bad input, insufficient funds) |
| RATE_EXPIRED | ❌ No | Logic error, not a transient failure |
| PROVIDER_NOT_CONFIGURED | ❌ No | Config error |
Default backoff schedule (with defaults retryDelayMs: 500, retryBackoffMultiplier: 2):
Attempt 1 fails → wait 500ms → Attempt 2
Attempt 2 fails → wait 1000ms → Attempt 3
Attempt 3 fails → wait 2000ms → Attempt 4
Attempt 4 fails → throw RampErrorTo disable retries entirely, set maxRetries: 0:
const ramp = new NairaRamp({
quidaxApiKey: '...',
maxRetries: 0, // fail fast, no retries
});Core API
ramp.getRate(options)
const rate = await ramp.getRate({
currency: 'USDT', // 'USDT' | 'USDC'
type: 'offramp', // 'offramp' | 'onramp'
provider: 'auto', // optional, overrides defaultProvider
});
// → RateResult { provider, rate, merchantRate, fee, feePercent, expiresAt, raw }rate is the raw provider exchange rate. merchantRate is the effective rate after any merchantPercent spread configured on the provider — use this when displaying rates to end users. When merchantPercent is unset or 0, merchantRate equals rate.
ramp.offramp(params) — Sell crypto, receive NGN
const tx = await ramp.offramp({
amount: 100, // crypto amount
currency: 'USDT',
bankAccount: {
accountNumber: '0123456789',
bankCode: '058',
accountName: 'John Doe', // optional
},
provider: 'auto', // optional
onSuccess: (tx) => { ... },
onError: (err) => { ... },
onStatusChange: (status) => { ... },
})
// → Transactionramp.onramp(params) — Buy crypto with NGN
const tx = await ramp.onramp({
amount: 50_000, // NGN amount
currency: 'USDT',
walletAddress: '0xabc...',
provider: 'auto',
onSuccess: (tx) => { ... },
onError: (err) => { ... },
onStatusChange: (status) => { ... },
})
// → Transactionramp.getTransaction(id, provider)
const tx = await ramp.getTransaction('tx-id-123', 'quidax');
// → Transactionramp.verifyBankAccount(account, provider?)
const verified = await ramp.verifyBankAccount(
{ accountNumber: '0123456789', bankCode: '058' },
'yellowcard' // optional, uses defaultProvider if omitted
);
// → BankAccount { accountNumber, bankCode, accountName }Note: Quidax has no bank lookup endpoint. When Quidax is the active provider,
verifyBankAccountreturns the input unchanged withaccountNameasundefined.
Get supported banks
const banks = await ramp.getBanks()
// or specify provider:
const banks = await ramp.getBanks('yellowcard')
console.log(banks)
// [{ name: 'GT Bank', code: '058', country: 'NG' }, ...]Provider notes:
- YellowCard: fetches live bank list from the API
- Quidax: returns a built-in list of major Nigerian banks (no API endpoint available)
Supported networks
// All networks across all configured providers
const all = ramp.getSupportedNetworks()
// Filter by currency
const usdtNetworks = ramp.getSupportedNetworks('USDT')
// Filter by currency and provider
const ycNetworks = ramp.getSupportedNetworks('USDT', 'yellowcard')
// [{ currency: 'USDT', network: 'ethereum', provider: 'yellowcard' }, ...]Provider network support:
| Provider | USDT | USDC | |------------|------------------------|-----------------------------| | YellowCard | ethereum, tron, bsc | ethereum, base, optimism | | Quidax | ethereum, tron | ethereum |
Pass network to offramp(), onramp(), or getRate() to target a specific chain:
const tx = await ramp.offramp({
amount: 50000,
currency: 'USDT',
network: 'tron', // optional — routes to a provider that supports USDT on Tron
bankAccount: { accountNumber: '0123456789', bankCode: '058' },
})
const tx = await ramp.onramp({
amount: 50000,
currency: 'USDC',
network: 'base',
walletAddress: '0xYourWalletAddress',
})If the requested network is not supported by any configured provider, a RampError with code NETWORK_NOT_SUPPORTED is thrown.
React Hooks
useRates
import { useRates } from 'naira-ramp';
function RateDisplay({ ramp }) {
const { rate, loading, error, refetch } = useRates(ramp, {
currency: 'USDT',
type: 'offramp',
refreshIntervalMs: 30_000, // default
});
if (loading) return <span>Loading...</span>;
if (error) return <button onClick={refetch}>Retry</button>;
return <span>1 USDT = ₦{rate.rate}</span>;
}useOfframp
import { useOfframp } from 'naira-ramp';
function OfframpForm({ ramp }) {
const { initiate, status, transaction, loading, error, reset } =
useOfframp(ramp, { pollIntervalMs: 3000 }); // default 5000ms
async function handleSubmit() {
await initiate({
amount: 100,
currency: 'USDT',
bankAccount: { accountNumber: '0123456789', bankCode: '058' },
});
}
return (
<div>
<button onClick={handleSubmit} disabled={loading}>
{loading ? 'Processing...' : 'Send'}
</button>
{status && <p>Status: {status}</p>}
{transaction?.status === 'completed' && (
<p>Done! Ref: {transaction.reference}</p>
)}
<button onClick={reset}>Reset</button>
</div>
);
}useOnramp
Mirror of useOfframp — accepts OnrampParams and calls ramp.onramp() internally. Also accepts an optional { pollIntervalMs } second argument.
useTransactionHistory
import { useTransactionHistory } from 'naira-ramp';
function HistoryPanel({ ramp }) {
const { transactions, refresh, clear } = useTransactionHistory(ramp);
return (
<div>
{transactions.map((tx) => (
<div key={tx.id}>
{tx.reference} — {tx.status} — ₦{tx.amountNGN.toLocaleString()}
</div>
))}
<button onClick={refresh}>Refresh</button>
<button onClick={clear}>Clear</button>
</div>
);
}Drop-in Widgets (React web only)
OfframpWidget
import { OfframpWidget } from 'naira-ramp';
// Inline (default)
<OfframpWidget
ramp={ramp}
theme='light' // 'light' | 'dark' | 'auto' (default: 'light')
variant='inline' // 'inline' | 'modal' (default: 'inline')
defaultCurrency='USDT'
defaultAmount={100}
onSuccess={(tx) => console.log('Done', tx.reference, tx.txHash)}
onError={(err) => console.error(err)}
onClose={() => setVisible(false)}
style={{ maxWidth: 480 }}
/>;
// Modal — renders a trigger button; clicking opens a full-screen overlay
<OfframpWidget
ramp={ramp}
variant='modal'
triggerLabel='Cash Out' // label on the trigger button (default: 'Cash Out')
theme='auto' // follows OS dark/light mode
onSuccess={(tx) => console.log(tx)}
/>;Props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| ramp | NairaRamp | — | Required. SDK instance. |
| theme | 'light' \| 'dark' \| 'auto' | 'light' | Color theme. 'auto' follows OS preference. |
| variant | 'inline' \| 'modal' | 'inline' | Inline renders directly; modal renders a trigger button. |
| triggerLabel | string | 'Cash Out' | Label for the modal trigger button. |
| defaultCurrency | SupportedCurrency | 'USDT' | Pre-selected currency. |
| defaultAmount | number | — | Pre-filled crypto amount. |
| onSuccess | (tx: Transaction) => void | — | Called when the transaction completes. |
| onError | (error: RampError) => void | — | Called on error. |
| onClose | () => void | — | Called when the widget is dismissed (close button or Escape). |
| style | React.CSSProperties | — | Extra styles on the card container. |
Flow: Enter amount + bank details → Verify account → Confirm rate → Submit → Live status polling → Done.
OnrampWidget
import { OnrampWidget } from 'naira-ramp';
// Inline (default)
<OnrampWidget
ramp={ramp}
theme='dark'
defaultCurrency='USDT'
onSuccess={(tx) => console.log('Done', tx.reference, tx.txHash)}
/>;
// Modal
<OnrampWidget
ramp={ramp}
variant='modal'
triggerLabel='Buy Crypto'
theme='auto'
onSuccess={(tx) => console.log(tx)}
/>;Props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| ramp | NairaRamp | — | Required. SDK instance. |
| theme | 'light' \| 'dark' \| 'auto' | 'light' | Color theme. 'auto' follows OS preference. |
| variant | 'inline' \| 'modal' | 'inline' | Inline renders directly; modal renders a trigger button. |
| triggerLabel | string | 'Buy Crypto' | Label for the modal trigger button. |
| defaultCurrency | SupportedCurrency | 'USDT' | Pre-selected currency. |
| defaultAmount | number | — | Pre-filled NGN amount. |
| onSuccess | (tx: Transaction) => void | — | Called when the transaction completes. |
| onError | (error: RampError) => void | — | Called on error. |
| onClose | () => void | — | Called when the widget is dismissed (close button or Escape). |
| style | React.CSSProperties | — | Extra styles on the card container. |
Flow: Enter NGN amount + wallet address → Submit → Live status polling → Done.
Transaction History
Every offramp() and onramp() call is automatically saved to the store. getTransaction() polls update the stored status.
Default — in-memory (zero config)
const ramp = new NairaRamp({ quidaxApiKey: '...' })
const tx = await ramp.offramp({ ... })
ramp.getHistory() // → Transaction[]
ramp.clearHistory() // → voidPersist across sessions with LocalStorageStore
import { NairaRamp, LocalStorageStore } from 'naira-ramp';
const ramp = new NairaRamp({
quidaxApiKey: '...',
store: new LocalStorageStore(), // stored in localStorage
// store: new LocalStorageStore('my-key') // custom storage key
});Custom store (e.g. React Native AsyncStorage)
Implement the TransactionStore interface:
import type { TransactionStore, Transaction } from 'naira-ramp'
class MyStore implements TransactionStore {
save(tx: Transaction): void { ... }
update(id: string, tx: Transaction): void { ... }
getById(id: string): Transaction | null { ... }
getAll(): Transaction[] { ... }
clear(): void { ... }
}
const ramp = new NairaRamp({ quidaxApiKey: '...', store: new MyStore() })Error Handling
All errors are instances of RampError:
import { RampError } from 'naira-ramp'
try {
await ramp.offramp({ ... })
} catch (err) {
if (err instanceof RampError) {
console.log(err.code) // error code (see below)
console.log(err.provider) // 'yellowcard' | 'quidax' | undefined
console.log(err.raw) // raw API response
}
}| Code | Cause |
| --------------------------- | ---------------------------------------------------- |
| PROVIDER_REQUEST_ERROR | 4xx response from provider |
| PROVIDER_SERVER_ERROR | 5xx response from provider |
| NETWORK_ERROR | Network timeout or connection failure |
| RATE_EXPIRED | Rate expired before transaction was submitted |
| PROVIDER_NOT_CONFIGURED | Requested provider has no API key configured |
| NO_PROVIDERS_AVAILABLE | All providers failed in auto mode |
| WEBHOOK_SIGNATURE_INVALID | Webhook signature verification failed |
| WEBHOOK_PAYLOAD_INVALID | Webhook payload missing required fields or malformed |
| NETWORK_NOT_SUPPORTED | Requested network not supported by any configured provider |
Webhook Support
Webhooks are server-side only — you need an HTTP endpoint to receive them. They work alongside polling; both can run simultaneously.
ramp.handleWebhook(options)
const tx = await ramp.handleWebhook({
provider: 'yellowcard', // or 'quidax'
rawBody: '...', // raw unparsed request body string
signature: '...', // value from provider's signature header
});
// → Transaction- Verifies the signature using HMAC-SHA256 (throws
WEBHOOK_SIGNATURE_INVALIDon failure) - Parses payload and maps to
Transaction(throwsWEBHOOK_PAYLOAD_INVALIDif malformed) - Saves/updates the transaction in the store automatically
- Fires
onSuccess,onError,onStatusChangecallbacks registered duringofframp()/onramp()
Next.js App Router example
// app/api/webhooks/yellowcard/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { ramp } from '@/lib/ramp'; // your NairaRamp singleton
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-yc-signature') ?? '';
try {
const tx = await ramp.handleWebhook({
provider: 'yellowcard',
rawBody,
signature,
});
console.log('Webhook received:', tx.id, tx.status);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: 'Invalid webhook' }, { status: 400 });
}
}// app/api/webhooks/quidax/route.ts
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-quidax-signature') ?? '';
try {
const tx = await ramp.handleWebhook({
provider: 'quidax',
rawBody,
signature,
});
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: 'Invalid webhook' }, { status: 400 });
}
}Signature headers
| Provider | Header |
| ---------- | -------------------- |
| YellowCard | X-YC-Signature |
| Quidax | X-Quidax-Signature |
Sandbox Mode
Run without real API keys using Quidax's mock mode:
const ramp = new NairaRamp({
sandbox: true,
quidaxApiKey: 'mock', // any non-empty string
});Or run the built-in sandbox script:
npm run sandboxReact Native
Hooks work on React Native out of the box:
import { useRates, useOfframp, useOnramp } from 'naira-ramp';Widgets (OfframpWidget, OnrampWidget) are web-only. Import naira-ramp/native in a React Native context and you'll get a clear error pointing you to the hooks.
Environment Variables
Copy .env.example to .env and fill in your keys:
cp .env.example .envYELLOWCARD_API_KEY=
YELLOWCARD_SECRET_KEY=
QUIDAX_API_KEY=Build
npm run build # compile to dist/
npm run typecheck # type-check without emitting
npm run sandbox # run the sandbox demoLicense
MIT
