# Guide to Ziina Webhooks: Features and Best Practices

[Ziina](https://ziina.com) 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

| 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_intent.status.updated` | 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_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](https://github.com/hookdeck/webhook-samples/blob/main/providers/ziina/latest/payment_intent.status.updated.json), taken from Ziina's API reference:

```json
{
  "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](https://docs.ziina.com/api-reference/webhook/index) for the base URL and authentication) with:

```json
{ "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](/docs/cli):

```bash
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](https://docs.ziina.com/api-reference/webhook).

### Node.js verification

```javascript
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`.

```javascript
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](/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](https://hookdeck.com) adds longer retries, replay and fan-out on top of Ziina's single webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ziina webhooks reliably in minutes.