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:
transfer.executed.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"
}
}
}payment_link.paid.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"
}
}
}payment_link.payment_settled.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"
}
}
}checkout_session.completed.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"
}
}
}
}checkout_session.settled.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.
verify.rb
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
endverify.js
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");
}verify.py
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