Skip to content

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.


When a transaction’s status changes, Zirzir sends an HTTP POST to your callbackUrl with a JSON payload describing the event. Your endpoint should:

  1. Verify the webhook signature
  2. Return 200 OK immediately
  3. Process the event asynchronously

EventDescription
transaction.successPayment completed successfully
transaction.failedPayment failed or was declined
transaction.cancelledCustomer cancelled checkout
transaction.refundedRefund completed
transaction.pendingTransaction initiated (sent for USSD providers)

{
"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"
}
}

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.

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
}
}
from zirzir import Zirzir, WebhookVerificationError
import 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 {}, 200
func 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)
}
}()
}

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.


If your endpoint returns a non-2xx status code or times out, Zirzir retries with exponential backoff:

AttemptDelay
1Immediate
25 minutes
330 minutes
42 hours
58 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.


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)
}

Use a tunneling tool to expose your local server for webhook testing:

Terminal window
# Using ngrok
ngrok http 3000
# Set your ngrok URL as callback
# https://abc123.ngrok.io/webhooks/zirzir

In the Zirzir Dashboard, you can also use the Webhook Replay feature to replay any transaction event to your endpoint.


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.