Gareth Wilson Gareth Wilson

Guide to Ziina Webhooks: Features and Best Practices

Published


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_intent.status.updated 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

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-hmac-signature
BodyJSON
Event routingBody field event

Events and payloads

EventTriggered WhenCommon Use Cases
payment_intent.status.updatedA payment intent changes status (the docs example shows completed)Mark an order paid, release goods, record card details
refund.status.updatedA refund changes statusUpdate 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_id and operation_id
  • amount, tip_amount and vat_amount as integers in minor units, with currency_code (for example AED, so 1050 is 10.50 dirhams)
  • status
  • created_at, a string holding a millisecond Unix timestamp
  • settled, the amounts actually settled
  • card_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_intent.status.updated 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_webhooks scope
  • A public HTTPS endpoint (use npx hookdeck-cli listen 3000 ziina --path /webhooks/ziina locally)

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_intent.status.updated 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.