Webhooks
Webhooks are the reliable way to receive payment notifications. Unlike return URLs — which depend on the customer’s browser staying open — webhooks are server-to-server and will be retried on failure.
How It Works
Section titled “How It Works”When a transaction’s status changes, Zirzir sends an HTTP POST to your callbackUrl with a JSON payload describing the event. Your endpoint should:
- Verify the webhook signature
- Return
200 OKimmediately - Process the event asynchronously
Event Types
Section titled “Event Types”| Event | Description |
|---|---|
transaction.success | Payment completed successfully |
transaction.failed | Payment failed or was declined |
transaction.cancelled | Customer cancelled checkout |
transaction.refunded | Refund completed |
transaction.pending | Transaction initiated (sent for USSD providers) |
Payload Structure
Section titled “Payload Structure”{ "id": "evt_01HX...", "type": "transaction.success", "createdAt": "2024-01-15T10:35:22Z", "data": { "id": "zz_tx_01HX...", "externalId": "chapa_tx_abc123", "status": "success", "amount": 500, "currency": "ETB", "provider": "chapa", "txRef": "order_123", "metadata": { "orderId": "9182" }, "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:35:22Z" }}Signature Verification
Section titled “Signature Verification”Every webhook includes an x-zirzir-signature header. Always verify this before processing.
The signature is an HMAC-SHA256 of the raw request body, using your webhook secret as the key.
TypeScript
Section titled “TypeScript”import { Zirzir } from '@zirzir/sdk'import express from 'express'
const zirzir = new Zirzir({ ... })
app.post( '/webhooks/zirzir', express.raw({ type: 'application/json' }), // Important: raw body needed for verification (req, res) => { const signature = req.headers['x-zirzir-signature'] as string
const isValid = zirzir.webhooks.verify( req.body, // Buffer — must be raw, not parsed signature, process.env.ZIRZIR_WEBHOOK_SECRET! )
if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }) }
const event = JSON.parse(req.body.toString())
// Always respond 200 before doing any async work res.sendStatus(200)
// Process event handleEvent(event).catch(console.error) })
async function handleEvent(event: ZirzirEvent) { switch (event.type) { case 'transaction.success': await fulfillOrder(event.data.txRef) break case 'transaction.failed': await notifyCustomer(event.data.txRef, 'payment failed') break case 'transaction.refunded': await processRefundConfirmation(event.data.txRef) break }}Python
Section titled “Python”from zirzir import Zirzir, WebhookVerificationErrorimport json
client = Zirzir(...)
@app.route("/webhooks/zirzir", methods=["POST"])def webhook(): signature = request.headers.get("x-zirzir-signature") raw_body = request.get_data()
try: event = client.webhooks.verify_and_parse( raw_body, signature, os.environ["ZIRZIR_WEBHOOK_SECRET"] ) except WebhookVerificationError: return {"error": "Invalid signature"}, 401
# Respond 200 immediately # (In production, push event to a queue and process async) if event["type"] == "transaction.success": fulfill_order(event["data"]["tx_ref"])
return {}, 200func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) signature := r.Header.Get("x-zirzir-signature")
event, err := client.Webhooks.VerifyAndParse( body, signature, os.Getenv("ZIRZIR_WEBHOOK_SECRET"), ) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return }
w.WriteHeader(http.StatusOK)
go func() { switch event.Type { case "transaction.success": fulfillOrder(event.Data.TxRef) case "transaction.failed": notifyCustomer(event.Data.TxRef) } }()}Manual Signature Verification
Section titled “Manual Signature Verification”If you want to verify signatures without the SDK:
import crypto from 'crypto'
function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean { const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex')
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) )}Always use
timingSafeEqual(or equivalent) to prevent timing attacks.
Retry Behavior
Section titled “Retry Behavior”If your endpoint returns a non-2xx status code or times out, Zirzir retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
After 5 failed attempts, the webhook is marked as failed and no further retries occur. You can manually replay failed webhooks from the Dashboard.
Idempotency
Section titled “Idempotency”Your webhook handler will receive the same event multiple times (retries, at-least-once delivery). Make your handler idempotent.
async function fulfillOrder(txRef: string) { const order = await db.orders.findOne({ txRef })
// Already fulfilled — don't do it again if (order.status === 'fulfilled') return
await db.orders.update({ txRef }, { status: 'fulfilled' }) await sendConfirmationEmail(order.customerEmail) await shipItems(order.items)}Local Development
Section titled “Local Development”Use a tunneling tool to expose your local server for webhook testing:
# Using ngrokngrok http 3000
# Set your ngrok URL as callback# https://abc123.ngrok.io/webhooks/zirzirIn the Zirzir Dashboard, you can also use the Webhook Replay feature to replay any transaction event to your endpoint.
Webhook Logs
Section titled “Webhook Logs”The Dashboard shows all webhook deliveries including request/response bodies, status codes, and retry history. Navigate to Projects > [Your Project] > Webhooks to see the log.