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:
{
"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:
{
"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 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:
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.valueas****plus its last 4 characters, so keep your own copy. PUTreplaces the whole webhook. An update that leaves outheaderremoves 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.
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:
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 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
capturesarray is a capture, a longerrefundsarray is a refund. - Treat the capture notification as confirmation. Tabby warns that it isn't a request to capture, so check
capturesbefore you trigger a capture from a webhook.
How Hookdeck Can Help: Hookdeck 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 to your service follow a schedule you set, you can replay anything that failed, and 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_idplusstatus.
How Hookdeck Can Help: Hookdeck 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-
200from 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.
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.
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 checks the header, stores every notification, and lets you retry and replay delivery to your store.
Get started with Hookdeck for free and handle Tabby webhooks reliably in minutes.