Webhook Integration Guide
Webhooks allow your application to receive real-time notifications when transaction events occur, instead of polling for status changes.
Overview
Zet API Your Server
│ │
│ Transaction completes │
│ │
├── POST https://yourapp.com ─────►│
│ Headers: │
│ x-zet-signature: hmac... │
│ Body: │
│ { event, timestamp, data } │
│ │
│◄── 200 OK ──────────────────────┤
│ │
│ (If no 200 within 30s) │
│ │
├── Retry #1 (1 min) ────────────►│
├── Retry #2 (5 min) ────────────►│
├── Retry #3 (30 min) ───────────►│
├── Retry #4 (2 hours) ──────────►│
├── Retry #5 (24 hours) ─────────►│
│ │
Step 1: Register a webhook
const webhook = await fetch('https://api.zet.money/v1/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ZET_API_KEY,
},
body: JSON.stringify({
url: 'https://yourapp.com/webhooks/zet',
events: ['onramp.completed', 'offramp.completed', 'swap.completed', 'transfer.completed'],
description: 'Production webhook',
}),
}).then(r => r.json());
// IMPORTANT: Store the secret securely — it's only shown once!
const webhookSecret = webhook.data.secret; // "whsec_abc123..."
Subscribe to all events
Use * to receive every event type:
{
"url": "https://yourapp.com/webhooks/zet",
"events": ["*"]
}
Available events
| Event | Triggered When |
|---|
onramp.completed | On-ramp transaction completed, crypto delivered |
onramp.failed | On-ramp transaction failed |
offramp.completed | Off-ramp completed, NGN sent to bank |
offramp.failed | Off-ramp failed |
swap.completed | Token swap completed |
swap.failed | Token swap failed |
transfer.completed | Cross-chain transfer completed |
transfer.failed | Cross-chain transfer failed |
Step 2: Verify webhook signatures
Every webhook request includes an x-zet-signature header containing an HMAC-SHA256 signature of the request body, computed using your webhook secret.
Always verify signatures to ensure the webhook came from Zet and wasn’t tampered with.
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(typeof payload === 'string' ? payload : JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express.js middleware
app.post('/webhooks/zet', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-zet-signature'];
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.ZET_WEBHOOK_SECRET)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
handleWebhookEvent(event);
res.status(200).send('OK');
});
Use the raw request body for signature verification, not a parsed/re-serialized JSON. JSON serialization can change key order or whitespace, invalidating the signature.
Step 3: Handle events
Webhook payload structure
{
"event": "onramp.completed",
"timestamp": "2026-03-02T10:12:00Z",
"data": {
"transactionId": "txn_01H8X3def",
"reference": "order_12345",
"type": "onramp",
"status": "completed",
"amount": "32.45",
"tokenSymbol": "USDC",
"chain": "base",
"transactionHash": "0xabc123...",
"fiatAmount": "50000",
"fiatCurrency": "NGN",
"walletId": "wal_01H8X3abc",
"metadata": {}
}
}
Example handler
async function handleWebhookEvent(event) {
const { event: eventType, data } = event;
// Idempotency: check if you've already processed this event
const processed = await db.webhookEvents.findOne({
transactionId: data.transactionId,
event: eventType,
});
if (processed) return;
switch (eventType) {
case 'onramp.completed':
await handleOnrampComplete(data);
break;
case 'offramp.completed':
await handleOfframpComplete(data);
break;
case 'swap.completed':
await handleSwapComplete(data);
break;
case 'transfer.completed':
await handleTransferComplete(data);
break;
case 'onramp.failed':
case 'offramp.failed':
case 'swap.failed':
case 'transfer.failed':
await handleFailure(data);
break;
}
// Mark as processed
await db.webhookEvents.insertOne({
transactionId: data.transactionId,
event: eventType,
processedAt: new Date(),
});
}
Retry policy
If your endpoint doesn’t respond with a 2xx status within 30 seconds, Zet retries with exponential backoff:
| Retry | Delay |
|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |
After 5 failed retries, the webhook is marked as failed. You can check missed events via the Transactions API.
Managing webhooks
List all webhooks
curl https://api.zet.money/v1/webhooks \
-H "x-api-key: zet_live_your_api_key"
Delete a webhook
curl -X DELETE https://api.zet.money/v1/webhooks/wh_01H8X7abc \
-H "x-api-key: zet_live_your_api_key"
Per-transaction callbacks
In addition to global webhooks, you can specify a callbackUrl when initiating a transaction. This URL receives events for that specific transaction only:
{
"quoteId": "qt_01H8X3...",
"walletId": "wal_01H8X3...",
"callbackUrl": "https://yourapp.com/orders/12345/callback"
}
Per-transaction callbacks use the same signature verification as global webhooks.
Best practices
- Always verify signatures — never process unsigned or incorrectly signed webhooks
- Implement idempotency — webhooks may be delivered more than once; deduplicate by
transactionId + event
- Respond quickly — return
200 immediately, then process asynchronously
- Use a queue — push webhook payloads to a queue (SQS, Redis, etc.) for reliable processing
- Log raw payloads — store the raw request body for debugging and auditing
- Use raw body for verification — don’t parse and re-serialize JSON before verifying the signature
- Monitor webhook health — alert if your endpoint starts returning errors
- Use HTTPS only — webhook URLs must use HTTPS with a valid TLS certificate
- Set up a fallback — periodically poll the Transactions API to catch any missed events
- Limit to 10 webhooks — you can register up to 10 webhook URLs per API key