Agent skill

Tabby Webhooks Skill

Receive and authenticate Tabby webhooks. Use when setting up a Tabby (buy now, pay later) payment or dispute webhook endpoint, checking the secret header you set at registration, debugging 401s, or handling payment statuses like authorized, closed, rejected and expired and dispute statuses like pending and approved.

Install this skill

npx skills add hookdeck/webhook-skills --skill tabby-webhooks


Tabby (buy now, pay later, MENA) sends a JSON webhook to your server when a payment changes state (authorized, captured, closed, rejected, expired, refunded) and, through a separate family of endpoints, when a dispute is opened or its status changes. Webhooks are not signed. There is no HMAC. The only authentication is an optional header you choose when you register the webhook through the Tabby API.

Set the header when you register the webhook through the Tabby API (POST /api/v1/webhooks). Webhooks without one send nothing to verify.

When to Use This Skill

  • How do I receive Tabby webhooks?
  • How do I authenticate Tabby webhook requests? (Does Tabby sign payloads? No: secret header)
  • How do I register a Tabby webhook with an X-Auth-Key header?
  • How do I tell a Tabby authorization, capture, close and refund apart? (No event field)
  • How do I handle Tabby dispute webhooks (pending, approved, declined)?
  • Why does my Tabby webhook endpoint return 401?

Verification (core)

const crypto = require('crypto');

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest();
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyTabbyRequest(headers, name = process.env.TABBY_WEBHOOK_HEADER || 'X-Auth-Key',
                            secret = process.env.TABBY_WEBHOOK_SECRET) {
  if (!secret) return false;                    // never accept when unconfigured
  const received = headers[name.toLowerCase()]; // Node lower-cases header names
  return Boolean(received) && safeEqual(received, secret); // whole value, no prefix parsing
}
  • The header name and value are yours: you send them as header.title and header.value when you register the webhook. Put the name in TABBY_WEBHOOK_HEADER (default X-Auth-Key) and the exact, whole value in TABBY_WEBHOOK_SECRET. If you registered Bearer abc, the secret is Bearer abc: compare the whole value and do not strip or parse a Bearer prefix.
  • Look the header up case-insensitively. Return 401 when it is missing or does not match.
  • Read the body raw (express.raw({ type: '*/*' })) and authenticate before parsing it.
  • A static shared credential authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Use HTTPS, and rotate the value (update the webhook with PUT) if it leaks. Tabby also publishes a list of server IPs you may allowlist.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Payload and Statuses

Payment webhooks send the payment object (id, status, created_at, amount, currency, order, captures, refunds, ...) with no event field. The event is what changed, so dispatch on status and on captures / refunds:

What happenedPayload
Authorizestatus: "authorized", captures empty
Capturestatus: "authorized", new entry in captures
Closestatus: "closed", closed_at set
Refundstatus: "closed", new entry in refunds
Rejectstatus: "rejected"
Expire (optional, ask Tabby to enable)status: "expired", is_expired: true

Dispute webhooks (/api/v1/dispute-webhooks) send status, dispute_id, payment_id, currency, created_at, with status pending, arbitration, evidence_merchant, approved, declined or cancelled. Use dispute_id to tell them from payments.

Delivery

Respond with exactly 200 within 1 minute. Any other response (or none) is a failure and Tabby resends up to 4 more times (1 to 4 minutes apart). Duplicates and reordering happen: statuses only move forward (authorized then closed), so ignore a late authorized once you have closed, and check captures is empty before triggering a capture from a webhook. If an authorized webhook arrives before your order is saved, return a non-200 (such as 404) so Tabby retries.

Environment Variables

TABBY_WEBHOOK_HEADER=X-Auth-Key            # header.title you registered
TABBY_WEBHOOK_SECRET=your_random_signing_secret  # header.value, exactly as registered

Local Development

Tabby can't reach localhost. Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 tabby --path /webhooks/tabby

Register the printed URL as the webhook url, with the same header.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →