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.
{
"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"
}
}
}{
"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"
}
}
}{
"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"
}
}
}{
"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"
}
}
}
}{
"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) |
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.
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
endconst 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");
}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#
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. 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#
- Verify
X-Manza-Signatureagainst the raw request body before accepting an event. - Persist the event or enqueue it durably, return a
2xxwithin 10 seconds, and process business work asynchronously. Do not acknowledge work you would lose on a crash. - Deduplicate with
X-Manza-Event-Idso retries cannot fulfill an order twice. - Send a
test.pingfrom the dashboard or test endpoint before going live. - Monitor the dashboard delivery log: inspect response codes and bodies for failed attempts, and reconcile payment state after an outage.