Webhooks & Signature Verification
Two kinds of webhooks are involved in a Zirzir server deployment:
- Provider → Zirzir. The payment provider calls the Zirzir server at
/api/v1/webhooks/{provider}. Zirzir authenticates the callback and updates the payment. - Zirzir → your backend. Once a payment settles, Zirzir sends a signed event to your notification URL.
Server setup
Section titled “Server setup”| Setting | Purpose |
|---|---|
server.public_url / ZIRZIR_PUBLIC_URL | The 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_SECRET | HMAC secret used to sign events sent to your backend. If unset, no merchant events are sent. |
ZIRZIR_ALLOW_PRIVATE_WEBHOOKS | Set 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.
Which URL receives events
Section titled “Which URL receives events”When a payment reaches completed, failed or cancelled, Zirzir POSTs an event to:
- the payment’s
callback_url(callbackUrlin the TypeScript SDK), if one was given andpublic_urlis set, or - the merchant’s
webhook_urlotherwise.
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.
Event format
Section titled “Event format”POST /webhooks/zirzir HTTP/1.1Content-Type: application/jsonX-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", ... }}| Field | Description |
|---|---|
id | Unique event ID (evt_...). Use it to deduplicate retries. |
type | payment.completed, payment.failed or payment.cancelled |
created | Unix timestamp (seconds) |
data | The transaction as returned by GET /api/v1/payments/{id} (snake_case keys) |
Signature header
Section titled “Signature header”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.
Verifying with the TypeScript SDK
Section titled “Verifying with the TypeScript SDK”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}`); }});Verifying with the Python SDK
Section titled “Verifying with the Python SDK”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.
How payments settle (provider matrix)
Section titled “How payments settle (provider matrix)”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.
| Provider | How an inbound callback is trusted | Automatic settlement |
|---|---|---|
| Chapa | Re-verified with Chapa’s status API | Yes |
| Arifpay | Re-verified with Arifpay’s status API | Yes |
| LakiPay | Signature checked with webhook_public_key, or re-verified with the status API | Yes |
| SantimPay | Re-verified with SantimPay’s status API | Yes |
| M-Pesa (plugin) | Re-verified with the M-Pesa status API | Yes |
| Airtel Money (plugin) | Re-verified with the Airtel status API | Yes |
| Telegram (plugin) | Signature checked with webhook_secret | Yes |
| Telebirr | No status API available | No. Reconcile manually in the dashboard. |
| CBE Birr | No status API available | No. Reconcile manually in the dashboard. |
Callbacks that fail these checks are ignored, so a forged request cannot mark a payment paid.