Gareth Wilson Gareth Wilson

Guide to Tabby Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only: POST /api/v1/webhooks and POST /api/v1/dispute-webhooks
API hostapi.tabby.ai, or api.tabby.sa for merchants in Saudi Arabia
LimitUp to 4 payment webhooks per merchant code and key; up to 4 dispute webhooks per merchant code
EnvironmentsThe key decides: sk_test_... for test payments, sk_... for live. Dispute webhooks need a live key
Method and formatPOST, JSON
AuthenticationOptional header (title and value) set at registration
Signature and timestampNone
Event fieldNone in payment payloads; the change tells you the event
Timeout1 minute
RetriesUp to 4 more attempts, 1 to 4 minutes apart
Ordering and duplicatesNot guaranteed; duplicates happen
Source IPs8 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:

EventWhat you see
Authorizestatus is authorized
Capturestatus is still authorized, with a new entry in captures
Closestatus is closed, with closed_at set
Rejectstatus is rejected
Expirestatus is expired and is_expired is true. This event is optional; ask Tabby to enable it
Refundstatus is closed, with a new entry in refunds
Updateorder.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.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.

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 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 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_id plus status.

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

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.


Gareth Wilson

Gareth Wilson

Product Marketing

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