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:

EventTrigger
transfer.executedFires when an outgoing transfer is executed
payment_link.paidFires when a payment link receives a payment
payment_link.payment_settledFires when the provider settles a payment link payment
checkout_session.completedFires when a checkout session payment completes
checkout_session.settledFires when the provider settles a checkout session payment
test.pingManual test event sent from the dashboard (not subscribable)
payment.authorization_requestedSent 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.

transfer.executed.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"
    }
  }
}
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#

HeaderDescription
Content-Typeapplication/json
User-AgentManza-Webhook/1.0
X-Manza-SignatureHMAC-SHA256 signature for verification
X-Manza-TimestampUnix timestamp of the request
X-Manza-EventEvent type (e.g. transfer.executed)
X-Manza-Event-IdUnique event ID (UUID v7)
X-Manza-Delivery-IdUnique 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.

verify.rb
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
verify.js
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");
}
verify.py
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:

AttemptDelay before this attempt
1st retry5 seconds
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry5 hours
6th retry10 hours
7th retry10 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. 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.