Guide

# Webhooks

Receive real-time HTTP notifications when events occur in your account. When an event fires, we POST a signed JSON payload to your endpoint URL.

## Event types

Subscribe to the events you need. Each endpoint can listen to one or more:

| Event | Trigger |
| --- | --- |
| `transfer.executed` | Fires when an outgoing transfer is executed |
| `payment_link.paid` | Fires when a payment link receives a payment |
| `payment_link.payment_settled` | Fires when the provider settles a payment link payment |
| `checkout_session.completed` | Fires when a checkout session payment completes |
| `checkout_session.settled` | Fires when the provider settles a checkout session payment |
| `test.ping` | Manual test event sent from the dashboard (not subscribable) |

## Payload format

Every webhook delivers a JSON payload with this structure:

```json
{
  "event": "transfer.executed",
  "created_at": "2026-03-16T12:00:00Z",
  "data": {
    "transfer": {
      "id": "01958e51-7c5a-7000-8000-000000000002",
      "amount": "2500.0",
      "currency_code": "MAD",
      "operation": "debit",
      "status": "accepted",
      "payment_reference": "PAY-2026-001",
      "posted_at": "2026-03-16T11:58:00Z",
      "created_at": "2026-03-16T11:55:00Z"
    }
  }
}
```

```json
{
  "event": "payment_link.paid",
  "created_at": "2026-03-16T12:00:00Z",
  "data": {
    "payment_link": {
      "id": "01958e51-7c5a-7000-8000-000000000001",
      "slug": "pay-invoice-42",
      "amount": "1500.0",
      "currency_code": "MAD",
      "status": "clearing",
      "title": "Invoice #42",
      "description": "Invoice for January 2026",
      "payment_reference": "REF-2026-001",
      "link_type": "single",
      "paid_at": "2026-03-16T11:59:00Z",
      "settled_at": null,
      "payments_count": 1,
      "created_at": "2026-03-15T10:00:00Z",
      "client_reference_id": "user-42",
      "customer_email": "customer@example.com",
      "customer_id": "usr-42",
      "customer_name": "Alice Customer"
    }
  }
}
```

```json
{
  "event": "payment_link.payment_settled",
  "created_at": "2026-03-16T12:15:00Z",
  "data": {
    "payment_link": {
      "id": "01958e51-7c5a-7000-8000-000000000001",
      "slug": "pay-invoice-42",
      "amount": "1500.0",
      "currency_code": "MAD",
      "status": "paid",
      "title": "Invoice #42",
      "description": "Invoice for January 2026",
      "payment_reference": "REF-2026-001",
      "link_type": "single",
      "paid_at": "2026-03-16T11:59:00Z",
      "settled_at": "2026-03-16T12:15:00Z",
      "payments_count": 1,
      "created_at": "2026-03-15T10:00:00Z",
      "client_reference_id": "user-42",
      "customer_email": "customer@example.com",
      "customer_id": "usr-42",
      "customer_name": "Alice Customer"
    }
  }
}
```

```json
{
  "event": "checkout_session.completed",
  "created_at": "2026-03-24T10:05:00Z",
  "data": {
    "checkout_session": {
      "currency_code": "MAD",
      "status": "clearing",
      "metadata": {
        "order_id": "ORD-123",
        "user_id": "usr_456"
      },
      "customer_email": "customer@example.com",
      "description": "Order #123",
      "id": "cs_abc123def456",
      "amount": "1500.0",
      "completed_at": "2026-03-24T10:05:00Z",
      "settled_at": null,
      "created_at": "2026-03-24T10:00:00Z",
      "transaction": {
        "id": "01964a3b-7c8d-7000-8000-deadbeef1234",
        "status": "accepted",
        "currency_code": "MAD",
        "payment_reference": "REF-2026-001",
        "card_brand": "Visa",
        "card_last_four": "4242",
        "amount": "1500.0",
        "fee_amount": "15.0",
        "net_amount": "1485.0",
        "provider_reference": "PAY-2026-001",
        "account_transaction_id": "01964a3b-7c8d-7000-8000-feedface1234",
        "processed_at": "2026-03-24T10:04:58Z",
        "created_at": "2026-03-24T10:04:00Z"
      }
    }
  }
}
```

```json
{
  "event": "checkout_session.settled",
  "created_at": "2026-03-24T10:20:00Z",
  "data": {
    "checkout_session": {
      "currency_code": "MAD",
      "status": "complete",
      "metadata": {
        "order_id": "ORD-123",
        "user_id": "usr_456"
      },
      "customer_email": "customer@example.com",
      "description": "Order #123",
      "id": "cs_abc123def456",
      "amount": "1500.0",
      "completed_at": "2026-03-24T10:05:00Z",
      "settled_at": "2026-03-24T10:20:00Z",
      "created_at": "2026-03-24T10:00:00Z",
      "transaction": {
        "id": "01964a3b-7c8d-7000-8000-deadbeef1234",
        "status": "accepted",
        "currency_code": "MAD",
        "payment_reference": "REF-2026-001",
        "card_brand": "Visa",
        "card_last_four": "4242",
        "amount": "1500.0",
        "fee_amount": "15.0",
        "net_amount": "1485.0",
        "provider_reference": "PAY-2026-001",
        "account_transaction_id": "01964a3b-7c8d-7000-8000-feedface1234",
        "processed_at": "2026-03-24T10:04:58Z",
        "created_at": "2026-03-24T10:04:00Z"
      }
    }
  }
}
```

## Request headers

| Header | Description |
| --- | --- |
| `Content-Type` | application/json |
| `User-Agent` | Zazu-Webhook/1.0 |
| `X-Zazu-Signature` | HMAC-SHA256 signature for verification |
| `X-Zazu-Timestamp` | Unix timestamp of the request |
| `X-Zazu-Event` | Event type (e.g. transfer.executed) |
| `X-Zazu-Event-Id` | Unique event ID (UUID v7) |
| `X-Zazu-Delivery-Id` | Unique delivery ID (UUID v7) |

## Verifying signatures

Every request is signed with your endpoint's signing secret using HMAC-SHA256. Concatenate the timestamp and raw request body separated by a dot, compute the HMAC, and compare with the `X-Zazu-Signature` header using a constant-time comparison.

Ruby

JavaScript

Python

```ruby
signature = request.headers["X-Zazu-Signature"]
timestamp = request.headers["X-Zazu-Timestamp"]
payload   = request.raw_post

expected = OpenSSL::HMAC.hexdigest(
  "SHA256", signing_secret, "#{timestamp}.#{payload}"
)

unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
  head :unauthorized and return
end
```

```javascript
const crypto = require("crypto");

const signature = req.headers["x-zazu-signature"];
const timestamp = req.headers["x-zazu-timestamp"];
const payload   = req.rawBody;

const expected = crypto
  .createHmac("sha256", signingSecret)
  .update(`${timestamp}.${payload}`)
  .digest("hex");

if (!crypto.timingSafeEqual(
  Buffer.from(signature), Buffer.from(expected)
)) {
  return res.status(401).send("Invalid signature");
}
```

```python
import hmac, hashlib

signature = headers["X-Zazu-Signature"]
timestamp = headers["X-Zazu-Timestamp"]

expected = hmac.new(
    signing_secret.encode(),
    f"{timestamp}.{body}".encode(),
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(signature, expected):
    raise ValueError("Invalid signature")
```

## Retry policy

> **Always build for idempotency:** The same event may be delivered more than once. Store every X-Zazu-Event-Id you process and skip duplicates. This is the single most common cause of production webhook bugs.

Failed deliveries (non-2xx or network error) are retried with exponential backoff:

| Attempt | Delay before this attempt |
| --- | --- |
| 1st retry | 5 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 5 hours |
| 6th retry | 10 hours |
| 7th retry | 10 hours |

After 7 retries (8 total attempts over ~28 hours) the delivery is marked permanently failed.

## Endpoint requirements

- HTTPS — only HTTPS URLs are accepted
- No private IPs — URLs resolving to private or loopback addresses are blocked
- 10-second timeout — your endpoint must respond within 10 seconds
- 2xx — any 200–299 status counts as a successful delivery