Skip to main content

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

EventTriggered When
onramp.completedOn-ramp transaction completed, crypto delivered
onramp.failedOn-ramp transaction failed
offramp.completedOff-ramp completed, NGN sent to bank
offramp.failedOff-ramp failed
swap.completedToken swap completed
swap.failedToken swap failed
transfer.completedCross-chain transfer completed
transfer.failedCross-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:
RetryDelay
11 minute
25 minutes
330 minutes
42 hours
524 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

  1. Always verify signatures — never process unsigned or incorrectly signed webhooks
  2. Implement idempotency — webhooks may be delivered more than once; deduplicate by transactionId + event
  3. Respond quickly — return 200 immediately, then process asynchronously
  4. Use a queue — push webhook payloads to a queue (SQS, Redis, etc.) for reliable processing
  5. Log raw payloads — store the raw request body for debugging and auditing
  6. Use raw body for verification — don’t parse and re-serialize JSON before verifying the signature
  7. Monitor webhook health — alert if your endpoint starts returning errors
  8. Use HTTPS only — webhook URLs must use HTTPS with a valid TLS certificate
  9. Set up a fallback — periodically poll the Transactions API to catch any missed events
  10. Limit to 10 webhooks — you can register up to 10 webhook URLs per API key