Guide to Ziina Webhooks: Features and Best Practices
Ziina is a payments app and payment API based in the UAE, used by businesses and individuals to send and accept payments. Its webhook tells your backend when a payment intent or a refund changes status, which is how an online store learns that a checkout has completed without polling the API.
This guide covers registering Ziina's single account-level webhook through the API, the two status events and the payment intent payload, verifying X-Hmac-Signature, and working with Ziina's short retry policy and fixed sender IPs.
What are Ziina webhooks?
A Ziina account has exactly one webhook URL. You set it, with an optional secret, by calling POST /webhook using an API token with the write_webhooks scope. There's no dashboard form and no choice of events. Calling the endpoint again replaces both the URL and the secret, so production, staging and any other consumer can't each have their own webhook on the same account.
Ziina sends two events, both of which fire on any status change: payment_ and refund.status.updated. Deliveries come from four published IP addresses, and Ziina's docs tell you to reject requests from anywhere else.
Ziina webhook features
| Feature | Details |
|---|---|
| Signature | hex HMAC-SHA256 |
| Signature header | x-hmac-signature |
| Body | JSON |
| Event routing | Body field event |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
payment_ | A payment intent changes status (the docs example shows completed) | Mark an order paid, release goods, record card details |
refund.status.updated | A refund changes status | Update refund state in your system, notify the customer |
The event name is the same whatever the new status is, so the decision is always made on data.status. A completed payment intent is the point to fulfil an order.
Event Payload Structure
The body has two fields: event and data. For payment intents, data includes:
id,account_idandoperation_idamount,tip_amountandvat_amountas integers in minor units, withcurrency_code(for exampleAED, so1050is 10.50 dirhams)statuscreated_at, a string holding a millisecond Unix timestampsettled, the amounts actually settledcard_details, with last four digits, brand, funding type, category, BIN, issuer, country and sub-brand
Ziina publishes no example for refund.status.updated and no delivery or event ID. data.id identifies the payment intent or refund, so pair it with data.status when deduplicating.
Published payload example
This is the payment_ sample from the webhook samples library, taken from Ziina's API reference:
{
"event": "payment_intent.status.updated",
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 1050,
"tip_amount": 0,
"vat_amount": 0,
"currency_code": "AED",
"created_at": "1712345678901",
"status": "completed",
"operation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"settled": {
"amount": 1050,
"tip_amount": 0,
"vat_amount": 0,
"currency_code": "AED"
},
"card_details": {
"card_last_four": "6789",
"card_brand": "mastercard",
"card_funding_type": "credit",
"card_category": "commercial",
"card_bin": "123456",
"card_issuer": "ABU DHABI COMMERCIAL BANK",
"card_country_code": "AE",
"card_sub_brand": "mastercard_premium_credit"
}
}
}
Setting up Ziina webhooks
Prerequisites
- A Ziina account with API access and a token that has the
write_webhooksscope - A public HTTPS endpoint (use
npx hookdeck-cli listen 3000 ziina --path /webhooks/ziinalocally)
Set a Secret
Set a secret when you register the webhook through the Ziina API. Without one, deliveries are unsigned and fail verification.
You choose the secret; Ziina doesn't specify a format, so use a long random string. Store it as WEBHOOK_SECRET and use it exactly as typed.
Register Your Endpoint
Call POST /webhook (see the webhook API reference for the base URL and authentication) with:
{ "url": "https://your-domain.com/webhooks/ziina", "secret": "your_chosen_secret" }
Because this call overwrites any existing webhook, keep a record of the current URL and secret, and change both sides together.
Test a delivery
Start a local receiver and forward requests with the Hookdeck CLI:
npx hookdeck-cli listen 3000 ziina --path /webhooks/ziina
Register the CLI URL on a test account, create a small payment intent and pay it. You'll get one or more payment_ deliveries as the status changes. Refund it to capture a refund.status.updated body, which Ziina doesn't document.
Securing Ziina webhooks
When a secret is set, X-Hmac-Signature carries the hex HMAC-SHA256 of the body with no prefix. Compute it over the raw bytes using the secret as the key and compare in constant time. Ziina publishes no sample code and signs no timestamp. As a second layer, check that the request came from one of Ziina's IPs: 3.29.184.186, 3.29.190.95, 20.233.47.127 or 13.202.161.181. See the webhook reference.
Node.js verification
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-hmac-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
The handler deduplicates on the resource ID and status together, since a payment intent produces one event per status change and there's no event ID. It only fulfils orders on completed.
const express = require('express');
function createZiinaApp({ seenTransition, saveTransition, fulfilOrder, updateRefund }) {
const app = express();
app.post('/webhooks/ziina', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let parsed;
try {
parsed = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
const { event, data } = parsed;
if (typeof event !== 'string' || !data || typeof data !== 'object' || !data.id) {
return res.sendStatus(400);
}
const transitionKey = `${event}:${data.id}:${data.status}`;
try {
if (await seenTransition(transitionKey)) return res.sendStatus(200);
if (event === 'payment_intent.status.updated' && data.status === 'completed') {
await fulfilOrder(data.id, data.amount, data.currency_code);
} else if (event === 'refund.status.updated') {
await updateRefund(data.id, data.status, data);
}
await saveTransition(transitionKey);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
Ziina retries a failed delivery only three times, so treat a 503 here as a last resort and alert on it.
Behind a Hookdeck Ziina source, Hookdeck checks X-Hmac-Signature against an HMAC of the body with your secret and returns 200 to Ziina whatever the result. A failed check is recorded as such and the request isn't forwarded. Your service then receives requests from Hookdeck rather than Ziina's four addresses, so the IP check belongs on whatever receives Ziina's traffic directly.
Never lose a completed payment. Hookdeck Event Gateway verifies Ziina's signature, accepts each status update on the first attempt and retries your order system for as long as it takes, well past Ziina's three retries.
Ziina webhook limitations and pain points
Only three retries
The Problem: A brief outage can mean a paid order is never marked as paid.
Why It Happens: Ziina retries a non-ok response three times and documents no timeout or spacing.
Workarounds:
- Poll payment intents that have been pending longer than expected through the Ziina API.
- Respond quickly and process from a queue.
How Hookdeck Can Help: Hookdeck takes delivery on Ziina's first attempt and retries your service on its own schedule, with replay for anything that still fails.
One webhook per account
The Problem: Staging and production, or two services, can't each have their own webhook.
Why It Happens: Ziina stores a single URL and secret per account, and setting a new one overwrites the old.
Workarounds:
- Point the webhook at one receiver that forwards to each consumer.
- Keep the registration call in one script, so no one overwrites the URL by accident.
How Hookdeck Can Help: Register a Hookdeck source as the single URL and add a connection per consumer, each with its own filters.
Signing is optional
The Problem: A webhook registered without a secret sends unsigned requests.
Why It Happens: secret is optional in POST /webhook.
Workarounds:
- Always include a secret, and re-register if you're unsure what was set.
- Enforce Ziina's IP list as well.
How Hookdeck Can Help: If verification is on and a delivery arrives unsigned, Hookdeck records it as failed instead of passing it on.
Thin documentation
The Problem: Refund handling and deduplication are guesswork.
Why It Happens: There's no refund example, no delivery ID and no sample verification code.
Workarounds:
- Capture refund deliveries in testing.
- Deduplicate on event, resource ID and status.
How Hookdeck Can Help: Hookdeck stores each delivery in full, so you have real refund bodies to build against.
Best practices
Fulfil only on completed
Act on data.status === 'completed' for payment intents and record other statuses without side effects. Check the amount and currency against the order before fulfilling.
Rotate deliberately
Rotating the secret means calling POST /webhook again. Update the receiver at the same moment, or briefly accept both secrets, to avoid rejecting deliveries in between.
Keep card details out of logs
card_details includes BIN, issuer and last four digits. Store what you need for receipts and disputes and leave the rest out of logs.
Allow only Ziina's IPs
On whatever receives Ziina's traffic directly, accept only the four published addresses in addition to checking the signature.
Conclusion
Ziina's webhook is small: one URL per account, two events and a hex HMAC over the body. That keeps setup simple, but it also means only three retries, no event ID, optional signing and no way to give separate consumers their own webhook.
Hookdeck Event Gateway adds longer retries, replay and fan-out on top of Ziina's single webhook.
Get started with Hookdeck for free and handle Ziina webhooks reliably in minutes.