# Payt Webhooks

Payt (paytsoftware.com, Dutch accounts-receivable and credit-management software) sends a webhook
when invoices, debtors, debt-collection cases, payment plans or PSP transactions change. Every
delivery is a `POST` with a JSON body and an `X-PAYT-SIGNATURE` header: the hex HMAC-SHA256 of the raw body.

## When to Use This Skill

* How do I receive Payt webhooks?
* How do I verify the `X-PAYT-SIGNATURE` header?
* Why does my Payt webhook signature verification fail?
* How do I handle `invoice_created`, `invoice_paid`, `invoice_payment_created`, `case_created` or `bundled_reminder_sent`?
* Why do Payt events arrive out of order or more than once?

## Verification (core)

The signature is `hex(HMAC-SHA256(secret, raw request body))`: the whole header value, no prefix. Only the
raw body bytes are signed (no timestamp or id), and the key is the secret's UTF-8 bytes, used as-is.
Check the header's format (exactly 64 hex characters) before comparing; hex is case-insensitive, so
lowercase it, then compare in constant time.

```javascript
const crypto = require('crypto');
const FORMAT = /^[0-9a-fA-F]{64}$/;

function verifyPaytSignature(rawBody, signature, secret) {   // rawBody: Buffer
  if (!secret || typeof signature !== 'string' || !FORMAT.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(signature.toLowerCase()), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

```python
import hashlib, hmac, re
from typing import Optional
FORMAT = re.compile(r"[0-9a-fA-F]{64}")

def verify_payt_signature(raw_body: bytes, signature: Optional[str], secret: Optional[str]) -> bool:
    if not secret or not signature or not FORMAT.fullmatch(signature):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("ascii"), expected.encode("ascii"))

```

Read the body raw (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`, `await request.body()`)
and parse JSON only after the check. Return `401` for a missing, malformed or wrong signature. Payt signs no
timestamp, so there is no replay window to check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/payt-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/payt-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/payt-webhooks/examples/fastapi/).

## Event Types

The body is `{ "event": { "event_name", "context", "event_time" } }`: dispatch on `event.event_name`; `context`
holds the invoice, case, debtor or other object. Payt documents 53 event types; the common ones:

| Event | Triggered when |
| --- | --- |
| `invoice_created` | An invoice is imported for the first time |
| `invoice_paid` | The invoice's open amount reaches zero |
| `invoice_closed` | The invoice is both sent and paid |
| `invoice_payment_created` | The amount the debtor must pay changes (payment, costs, interest) |
| `case_created` | A debt collection case is created |
| `case_paid` | A debt collection case is fully paid |
| `bundled_reminder_sent` | A bundled reminder is sent |
| `payment_plan_created` | A payment plan is created |
| `credit_limit_exceeded` | A debtor's open amounts exceed the credit limit |
| `psp_transaction_successful` | A PSP transaction is registered |

The other 43 are listed in [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/payt-webhooks/references/overview.md); log the ones you don't handle.

## Delivery

Per Payt's docs, respond `200` within 10 seconds. Failures are retried up to 10 times at increasing
intervals, and frequent failures deactivate the webhook (Payt emails you). Order is not guaranteed
(`invoice_closed` can arrive before `invoice_created`) and events can repeat, so make processing idempotent.
For an object you don't know yet, Payt suggests answering `404` so the event is retried later.

## Environment Variables

```bash
PAYT_WEBHOOK_SECRET=your_payt_webhook_secret   # shown only once, on the webhook settings page

```

## Local Development

```bash
npx hookdeck-cli listen 3000 payt --path /webhooks/payt

```

No account required. Use the printed URL as the webhook URL in Payt.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/payt-webhooks/references/overview.md) - Payload shape, all event types, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/payt-webhooks/references/setup.md) - Activating webhooks and generating the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/payt-webhooks/references/verification.md) - Signature check in Node, Python and Next.js, gotchas
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/payt-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests