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) |
| `payment.authorization_requested` | Sent only to an endpoint enrolled for transfer authorization (not subscribable) |

`checkout_session.completed` fires once the payer's payment is accepted and credited. In that payload the session `status` is usually `clearing` (or already `complete` if settlement landed first): fulfill on the event itself, not on the status. `checkout_session.settled` follows once the provider settles the funds.

## Payload format

Every webhook delivers a JSON payload with this structure:

`data.transfer.payment_id` identifies the originating payment and matches the `id` returned by `POST /api/payments`. It is null when the transfer has no linked payment. `data.transfer.id` remains the transfer ID.

```json
{
  "event": "transfer.executed",
  "created_at": "2026-03-16T12:00:00Z",
  "data": {
    "transfer": {
      "id": "01958e51-7c5a-7000-8000-000000000002",
      "payment_id": "01958e51-7c5a-7000-8000-000000000001",
      "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` | Manza-Webhook/1.0 |
| `X-Manza-Signature` | HMAC-SHA256 signature for verification |
| `X-Manza-Timestamp` | Unix timestamp of the request |
| `X-Manza-Event` | Event type (e.g. transfer.executed) |
| `X-Manza-Event-Id` | Unique event ID (UUID v7) |
| `X-Manza-Delivery-Id` | Unique delivery ID (UUID v7) |

> **Legacy X-Zazu-* headers:** Every delivery also carries the same five values under the original X-Zazu-* names, so an integration that verifies against those keeps working unchanged. New integrations should read the X-Manza-* headers; the legacy names are deprecated and will be removed in a future API version. The User-Agent is NOT dual-sent — it is now `Manza-Webhook/1.0` on every delivery. If you allow-list by User-Agent rather than by signature, update that rule.

## 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-Manza-Signature` header using a constant-time comparison.

Ruby

JavaScript

Python

```ruby
signature = request.headers["X-Manza-Signature"]
timestamp = request.headers["X-Manza-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-manza-signature"];
const timestamp = req.headers["x-manza-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-Manza-Signature"]
timestamp = headers["X-Manza-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-Manza-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.

## Automatic disabling

Brief outages do not immediately disable an endpoint. Once failures span at least **12 hours**, a subsequent failed delivery starts a **five-day** countdown. Another failure at or after that countdown expires disables the endpoint. Any successful delivery clears the failure window and countdown.

Authorized team members are notified when an endpoint is disabled. Inspect the delivery log, fix the underlying problem, then re-enable the endpoint from the dashboard or [Webhook endpoints API](https://ma.manza.dev/docs/api/webhook-endpoints#enable-webhook-endpoint). Re-enabling resets the failure window.

## 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

## Best practices

1. Verify `X-Manza-Signature` against the raw request body before accepting an event.
2. Persist the event or enqueue it durably, return a `2xx` within 10 seconds, and process business work asynchronously. Do not acknowledge work you would lose on a crash.
3. Deduplicate with `X-Manza-Event-Id` so retries cannot fulfill an order twice.
4. Send a `test.ping` from the dashboard or test endpoint before going live.
5. Monitor the dashboard delivery log: inspect response codes and bodies for failed attempts, and reconcile payment state after an outage.