Skip to content

Webhooks & Signature Verification

Two kinds of webhooks are involved in a Zirzir server deployment:

  1. Provider → Zirzir. The payment provider calls the Zirzir server at /api/v1/webhooks/{provider}. Zirzir authenticates the callback and updates the payment.
  2. Zirzir → your backend. Once a payment settles, Zirzir sends a signed event to your notification URL.
HMAC Signature Verification Lifecycle

SettingPurpose
server.public_url / ZIRZIR_PUBLIC_URLThe server’s externally reachable base URL, e.g. https://pay.example.com. When set, providers are always told to call back {public_url}/api/v1/webhooks/{provider}, and the per-payment callback_url becomes your notification URL.
ZIRZIR_WEBHOOK_SECRETHMAC secret used to sign events sent to your backend. If unset, no merchant events are sent.
ZIRZIR_ALLOW_PRIVATE_WEBHOOKSSet to true to allow event delivery to private or loopback addresses (local development only). These targets are refused by default.

If public_url is not set, the old behavior applies: callback_url goes straight to the provider, and the server logs a warning at startup. Set public_url in production.


When a payment reaches completed, failed or cancelled, Zirzir POSTs an event to:

  1. the payment’s callback_url (callbackUrl in the TypeScript SDK), if one was given and public_url is set, or
  2. the merchant’s webhook_url otherwise.

Without public_url, callback_url belongs to the provider, so events only go to the merchant webhook_url.

A delivery that fails is retried after 5 minutes, 15 minutes, 1 hour and 24 hours.


POST /webhooks/zirzir HTTP/1.1
Content-Type: application/json
X-Zirzir-Signature: t=1788319200,v1=52f08a478b273b5c3e5...
{
"id": "evt_...",
"type": "payment.completed",
"created": 1788319200,
"data": { "id": "txn_...", "reference": "order-1", "status": "completed", "amount": 100.0, "amount_minor": 10000, "currency": "ETB", "provider": "chapa", ... }
}
FieldDescription
idUnique event ID (evt_...). Use it to deduplicate retries.
typepayment.completed, payment.failed or payment.cancelled
createdUnix timestamp (seconds)
dataThe transaction as returned by GET /api/v1/payments/{id} (snake_case keys)

X-Zirzir-Signature: t=<unix>,v1=<hex>, where v1 is the hex HMAC-SHA256 of "{t}.{raw_body}" keyed with ZIRZIR_WEBHOOK_SECRET. The SDKs reject headers that lack t= or v1=, signatures that do not match, and timestamps more than 300 seconds old (configurable).

Always verify against the raw request body, before any JSON parsing.


import { Zirzir } from '@zirzir/sdk';
import express from 'express';
const app = express();
const zirzir = new Zirzir({ baseUrl: 'https://pay.example.com', apiKey: 'zz_live_...' });
app.post('/webhooks/zirzir', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-zirzir-signature'] as string;
try {
const event = zirzir.webhooks.constructEvent(req.body, signature, process.env.ZIRZIR_WEBHOOK_SECRET!);
if (event.type === 'payment.completed') {
await fulfillOrder(event.data.reference);
}
res.status(200).json({ received: true });
} catch (err) {
res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
});
from zirzir import Webhooks, WebhookVerificationError
webhooks = Webhooks()
@app.post("/webhooks/zirzir")
async def zirzir_webhook(request: Request):
body = await request.body()
try:
event = webhooks.verify_and_parse(
body, request.headers["X-Zirzir-Signature"], os.environ["ZIRZIR_WEBHOOK_SECRET"]
)
except WebhookVerificationError:
return Response(status_code=400)
if event.type == "payment.completed":
fulfill_order(event.data["reference"])
return {"received": True}

Respond with a 2xx quickly. Handlers should be idempotent, because the same event may be delivered more than once.


An inbound provider callback only changes a payment if Zirzir can trust it. It must either carry a valid signature or be confirmed against the provider’s status API.

ProviderHow an inbound callback is trustedAutomatic settlement
ChapaRe-verified with Chapa’s status APIYes
ArifpayRe-verified with Arifpay’s status APIYes
LakiPaySignature checked with webhook_public_key, or re-verified with the status APIYes
SantimPayRe-verified with SantimPay’s status APIYes
M-Pesa (plugin)Re-verified with the M-Pesa status APIYes
Airtel Money (plugin)Re-verified with the Airtel status APIYes
Telegram (plugin)Signature checked with webhook_secretYes
TelebirrNo status API availableNo. Reconcile manually in the dashboard.
CBE BirrNo status API availableNo. Reconcile manually in the dashboard.

Callbacks that fail these checks are ignored, so a forged request cannot mark a payment paid.