# 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)

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tabby-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tabby-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tabby-webhooks/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

```bash
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):

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/tabby-webhooks/references/overview.md) - Payment and dispute webhooks, statuses and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tabby-webhooks/references/setup.md) - Register a webhook with the Tabby API
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tabby-webhooks/references/verification.md) - Header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tabby-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests