# Guide to Tabby Webhooks: Features and Best Practices

Tabby is a buy now, pay later provider for shoppers in the Middle East. Its webhooks tell your store when a payment is authorized, captured, closed, rejected, expired, refunded or updated, even if the customer never comes back to your site after checkout. A separate set of webhooks reports disputes on your payments.

This guide covers how both kinds of Tabby webhook work, how to tell payment events apart when the payload doesn't name them, how to authenticate requests with your registration header, and how to handle Tabby's short retry window.

## What are Tabby webhooks?

Tabby has two webhook families, each registered through its own API endpoint:

* **Payment webhooks**, registered with `POST /api/v1/webhooks`, send the payment object whenever a payment changes.
* **Dispute webhooks**, registered with `POST /api/v1/dispute-webhooks`, send a short dispute record when a customer opens a dispute or its status changes.

There's no dashboard form for either. You register the endpoint with your secret key and merchant code, and you can include a `header` object with a name (`title`) and a `value`. Tabby adds that header to every notification.

Tabby doesn't sign webhooks. Its registration docs say the header is used to sign requests, but it's a fixed header, not a signature: the same value arrives with every notification and covers nothing in the body. A webhook registered without it sends nothing you can check.

## Tabby webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /api/v1/webhooks` and `POST /api/v1/dispute-webhooks` |
| API host | `api.tabby.ai`, or `api.tabby.sa` for merchants in Saudi Arabia |
| Limit | Up to 4 payment webhooks per merchant code and key; up to 4 dispute webhooks per merchant code |
| Environments | The key decides: `sk_test_...` for test payments, `sk_...` for live. Dispute webhooks need a live key |
| Method and format | `POST`, JSON |
| Authentication | Optional header (`title` and `value`) set at registration |
| Signature and timestamp | None |
| Event field | None in payment payloads; the change tells you the event |
| Timeout | 1 minute |
| Retries | Up to 4 more attempts, 1 to 4 minutes apart |
| Ordering and duplicates | Not guaranteed; duplicates happen |
| Source IPs | 8 published IP addresses |

### Payment events

A payment payload is the payment object with a lowercase `status`. There's no `event` field, so you work out what happened from what changed:

| Event | What you see |
| --- | --- |
| Authorize | `status` is `authorized` |
| Capture | `status` is still `authorized`, with a new entry in `captures` |
| Close | `status` is `closed`, with `closed_at` set |
| Reject | `status` is `rejected` |
| Expire | `status` is `expired` and `is_expired` is `true`. This event is optional; ask Tabby to enable it |
| Refund | `status` is `closed`, with a new entry in `refunds` |
| Update | `order.reference_id` has changed |

Tabby notes that a typical successful payment produces three notifications: authorize, capture and close.

### Payment payload

An abridged payment notification looks like this:

```json
{
  "id": "PAYMENT_ID",
  "created_at": "2026-10-26T09:12:04Z",
  "expires_at": "2027-10-26T09:12:04Z",
  "closed_at": null,
  "status": "authorized",
  "is_test": true,
  "is_expired": false,
  "amount": "100.00",
  "currency": "SAR",
  "order": { "reference_id": "ORDER-1001" },
  "captures": [],
  "refunds": [],
  "meta": { "order_id": null, "customer": null },
  "token": "..."
}

```

`amount` is a string, and `is_test` tells you whether the payment came from a test key.

### Dispute payload

Dispute notifications are short:

```json
{
  "status": "pending",
  "dispute_id": "DISPUTE_ID",
  "payment_id": "PAYMENT_ID",
  "currency": "SAR",
  "created_at": "2026-10-26T09:12:04Z"
}

```

The `status` is one of `pending`, `arbitration`, `evidence_merchant`, `approved`, `declined` or `cancelled`. Every dispute event is sent; there's no filter. A `dispute_id` field tells you a request is a dispute, not a payment, if both families share an endpoint.

> **Inspect Tabby webhooks as they arrive.** Register a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as a test webhook to see the exact headers and payment object Tabby sends, with no account or setup required.

## Setting up Tabby webhooks

Pick a header name, such as `X-Auth-Key`, and generate a long random value, for example with `openssl rand -hex 32`. Then register your endpoint with the header:

```bash
curl -X POST https://api.tabby.ai/api/v1/webhooks \
  -H "Authorization: Bearer sk_test_..." \
  -H "X-Merchant-Code: your_merchant_code" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/tabby",
    "header": { "title": "X-Auth-Key", "value": "your_random_value" }
  }'

```

Merchants in Saudi Arabia use `https://api.tabby.sa` instead. Register once with your test key for test payments and again with your live key for live ones. The response to a payment webhook registration returns `header.value` as you sent it.

Dispute webhooks take the same `url` and `header` body at `POST /api/v1/dispute-webhooks`, with a live key and the `X-Merchant-Code` header. A test key gets a `403`. Two details catch people out:

* Responses mask `header.value` as `****` plus its last 4 characters, so keep your own copy.
* `PUT` replaces the whole webhook. An update that leaves out `header` removes it, and the endpoint stops sending anything to check.

Dispute webhook URLs must be public, and each must be unique.

**With Hookdeck.** Tabby is a supported source in Hookdeck. Its setup note reads: "Set the header when you register the webhook through the Tabby API (POST /api/v1/webhooks). Webhooks without one send nothing to verify." Create a source with the **Tabby** type and register its URL as the webhook `url`. Enter the header name you used as `header.title` in **API Key Header**, and the exact `header.value` in **API Key**. Header names match case-insensitively; the value must match exactly. Use the same header for dispute webhooks if they go to the same source.

The source accepts `POST` only, which is what Tabby sends. A request with a missing or wrong header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

For local development, `hookdeck listen 3000 tabby --path /webhooks/tabby` gives you a public URL that forwards to your machine.

## Securing Tabby webhooks

Read the header you registered and compare its whole value with the one you stored, in constant time, before you parse the body.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = (process.env.TABBY_WEBHOOK_HEADER || 'x-auth-key').toLowerCase();
const SECRET = process.env.TABBY_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/tabby', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // Disputes carry dispute_id; payments carry the payment object.
  // Queue the payload and acknowledge with exactly 200.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("TABBY_WEBHOOK_HEADER", "X-Auth-Key")
SECRET = os.environ.get("TABBY_WEBHOOK_SECRET", "")

def verify_tabby_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header proves the sender knows the value and nothing more. It doesn't cover the body, so a modified payload still passes, and with no timestamp a captured request can be replayed. Tabby's dispute docs tell you to use HTTPS because the secret in the header travels with every notification. Use a long random value and rotate it if it leaks. If you receive Tabby webhooks directly, you can also allowlist Tabby's published IPs as an extra check: `34.166.36.90`, `34.166.35.211`, `34.166.34.222`, `34.166.37.207`, `34.93.76.191`, `34.166.128.182`, `34.166.170.3` and `34.166.249.7`.

> **Make Tabby webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your registration header, stores every payment and dispute notification durably, and gives you retries and replay that last longer than Tabby's few minutes.

## Tabby webhook limitations and pain points

### Payment payloads don't name the event

**The Problem:** An authorize and a capture both arrive with `status: "authorized"`, and a close and a refund both arrive with `status: "closed"`.

**Why It Happens:** Tabby sends the current payment object, not an event record. The event is whatever changed since the last notification.

**Workarounds:**

* Compare the payload with the state you stored: a longer `captures` array is a capture, a longer `refunds` array is a refund.
* Treat the capture notification as confirmation. Tabby warns that it isn't a request to capture, so check `captures` before you trigger a capture from a webhook.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on `status`, on `dispute_id` or on fields inside the payload, so you can send disputes and payments to different destinations.

### A short retry window

**The Problem:** If your service is down for more than a few minutes, Tabby stops trying and you miss the notification.

**Why It Happens:** Tabby times out after 1 minute and resends up to 4 more times, 1 to 4 minutes apart.

**Workarounds:**

* Reconcile against the Tabby API for payments that didn't reach a final status.
* Keep the handler fast and move the work to a queue.

**How Hookdeck Can Help:** Hookdeck acknowledges Tabby immediately and stores the notification. [Retries](/docs/retries) to your service follow a schedule you set, you can replay anything that failed, and [issues](/docs/issues) alert you when deliveries start failing.

### Out-of-order and duplicate notifications

**The Problem:** A late `authorized` arrives after `closed`, or the same notification arrives twice.

**Why It Happens:** Tabby doesn't guarantee order and documents that duplicates happen.

**Workarounds:**

* Payment statuses only move forward, so ignore a notification that would move a payment backwards.
* Deduplicate disputes on `dispute_id` plus `status`.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated notifications within a window, based on the whole payload or fields you choose.

### The "order not saved yet" retry doesn't reach Tabby

**The Problem:** Tabby's docs suggest returning a non-`200` (such as `404`) when an authorize notification arrives before your order is saved, so that Tabby retries. Behind Hookdeck, Tabby always sees Hookdeck's `200`.

**Why It Happens:** Hookdeck answers Tabby at the edge and delivers to your service afterwards.

**Workarounds:**

* Return the same non-`200` from your service. Hookdeck treats it as a failed delivery and retries according to your connection's retry rules, which can run longer than Tabby's schedule.

**How Hookdeck Can Help:** You can replay the notification by hand once the order exists, from the dashboard or the API.

## Best practices

### Always register a header

Include `header` in every registration and every `PUT`. A webhook without it can't be told apart from a forged request.

### Separate test and live

Register test webhooks with your test key and live ones with your live key, and check `is_test` in the payload before you change real orders.

### Make handlers idempotent

Key payment work on the payment `id` and the change you detected, and disputes on `dispute_id` and `status`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Tabby treats anything other than exactly `200` as a failure. Return it once the header checks out and the payload is stored, then update orders in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Tabby webhooks are the dependable way to learn that a payment was authorized, even when the customer never returns to your site. You have to work out each event from the payment object, and Tabby gives up after a few minutes. Register every webhook with a long random header, compare it in full and in constant time, and store notifications before you process them.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, stores every notification, and lets you retry and replay delivery to your store.

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