> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zet.money/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Integration

> Receive real-time event notifications for transactions

# 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

```javascript theme={null}
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:

```json theme={null}
{
  "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.

<CodeGroup>
  ```javascript Node.js theme={null}
  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');
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import json

  def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode('utf-8'),
          payload,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  # Flask example
  @app.route('/webhooks/zet', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('x-zet-signature')
      raw_body = request.get_data()

      if not verify_webhook_signature(raw_body, signature, ZET_WEBHOOK_SECRET):
          return 'Invalid signature', 401

      event = json.loads(raw_body)
      handle_event(event)

      return 'OK', 200
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "io"
      "net/http"
  )

  func verifyWebhookSignature(payload []byte, signature, secret string) bool {
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(payload)
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(signature), []byte(expected))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      signature := r.Header.Get("x-zet-signature")

      if !verifyWebhookSignature(body, signature, os.Getenv("ZET_WEBHOOK_SECRET")) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      // Process event...
      w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

## Step 3: Handle events

### Webhook payload structure

```json theme={null}
{
  "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

```javascript theme={null}
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](/api-reference/transactions/list).

## Managing webhooks

### List all webhooks

```bash theme={null}
curl https://api.zet.money/v1/webhooks \
  -H "x-api-key: zet_live_your_api_key"
```

### Delete a webhook

```bash theme={null}
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:

```json theme={null}
{
  "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
