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-Keyheader? - How do I tell a Tabby authorization, capture, close and refund apart? (No
eventfield) - 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.titleandheader.valuewhen you register the webhook. Put the name inTABBY_WEBHOOK_HEADER(defaultX-Auth-Key) and the exact, whole value inTABBY_WEBHOOK_SECRET. If you registeredBearer abc, the secret isBearer abc: compare the whole value and do not strip or parse aBearerprefix. - Look the header up case-insensitively. Return
401when 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 happened | Payload |
|---|---|
| Authorize | status: "authorized", captures empty |
| Capture | status: "authorized", new entry in captures |
| Close | status: "closed", closed_at set |
| Refund | status: "closed", new entry in refunds |
| Reject | status: "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
- references/overview.md - Payment and dispute webhooks, statuses and payload shape
- references/setup.md - Register a webhook with the Tabby API
- references/verification.md - Header check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests