Guide to Payt Webhooks: Features and Best Practices
Payt automates accounts receivable for businesses, mostly in the Netherlands: it imports invoices from the accounting system, sends reminders, runs payment plans and hands overdue invoices to debt collection. Its webhooks push those changes to other systems, for example to put a customer on delivery stop in your ERP as soon as one of their invoices goes to collection.
This guide covers enabling Payt webhooks per administration, the event families and payload shape, verifying X-PAYT-SIGNATURE, and Payt's advice for coping with events that arrive in the wrong order.
What are Payt webhooks?
Payt webhooks are configured per administration, which is Payt's term for one company's set of books. You switch them on all at once or event by event, give an HTTPS URL, and Payt posts a JSON body for each event. Payt imports from accounting software such as Exact Online and Twinfield, so the same invoice number can exist in more than one administration. Match records on Payt's own id values instead.
For OAuth applications, payloads include only what the granted scopes allow. An object you can't read arrives as {}, while null means there's no related record.
Payt webhook features
| Feature | Details |
|---|---|
| Signature | hex HMAC-SHA256 |
| Signature header | x-payt-signature |
| Body | JSON |
| Event routing | Body field event.event_name |
From Payt's webhook overview:
- HTTPS only,
POSTwith a JSON body, timestamps in UTC ISO 8601. - Redirects are followed.
- Requests come from a fixed IP address, and mutual TLS is available with Payt's published client certificate.
- Each request carries
X-PAYT-DELIVERY, a UUID for that attempt, andX-PAYT-ADMINISTRATION-IDENTIFIER. - The timeout is 10 seconds, with up to 10 attempts at increasing intervals. Webhooks that fail often are deactivated, and Payt sends an email.
Events and payloads
Every body has one event object with event_name, context and event_time. context.resource_type tells you what the event is about, such as invoice, credit_case or bundled_reminder, and most contexts carry debtor and administration.
Invoices
| Event | Triggered when |
|---|---|
invoice_created | An invoice is imported for the first time |
invoice_paid | The open amount reaches zero |
invoice_closed | The invoice is both sent and paid |
invoice_reopened | The invoice is no longer paid |
invoice_payment_created | The amount the debtor must pay changes (payment, administration, interest or collection costs) |
invoice_paused / invoice_resumed | The invoice flow is paused by the user / resumed |
invoice_blocked | The invoice flow is blocked for some reason |
invoice_step_performed | An invoice step is performed |
invoice_ | A call reminder is performed |
invoice_ | The invoice is marked ready for debt collection |
invoice_new_comment | A new reaction on an invoice |
invoice_email_bounced / invoice_letter_returned / invoice_sms_failed / invoice_ubl_failed | Sending the invoice by email, post, SMS or UBL failed |
incoming_ubl_received | An incoming invoice UBL is received |
Debt collection cases
| Event | Triggered when |
|---|---|
case_created | A debt collection case is created |
case_paid | The case is fully paid |
case_invoice_amount_paid | All invoices in the case are paid, but not the collection costs or interest |
case_reopened | The case is no longer closed |
case_step_performed | A case step is performed (name: summons, summons_reminder, second_summons, second_summons_reminder, notice_of_default, prejudiciary) |
case_to_bailiff | The case is forwarded to a bailiff |
case_new_comment | A new reaction on a case |
case_email_bounced / case_letter_returned | Sending the case by email or post failed |
Reminders, debtors and communication
| Event | Triggered when |
|---|---|
bundled_reminder_sent | A bundled reminder is sent |
bundled_ | An invoice is mentioned in a bundled reminder |
bundled_ / bundled_ | Sending a bundled reminder by email or post failed |
credit_limit_exceeded | A debtor's open amounts exceed the credit limit |
debtor_new_comment | A new reaction on a debtor |
debtor_ / debtor_ | An email address was added to / removed from a debtor |
email_bounced | An email sent to a debtor bounced (any type) |
label_added / label_removed | A label was added to or removed from a debtor, invoice or case |
message_created | A message was created (by the debtor, a user or automatically) |
note_created | An internal note was created |
todo_list_item_created / todo_list_item_updated / todo_list_item_deleted | A task was created, updated or deleted |
Payment plans and PSP
| Event | Triggered when |
|---|---|
payment_plan_created / payment_plan_updated / payment_plan_deactivated / payment_plan_finished | A payment plan changes (same payload, only event_name differs) |
payment_ | A payment plan term expired |
psp_mandate_created / psp_mandate_revoked | A PSP mandate is signed or revoked |
psp_ | A new PSP transaction is registered |
psp_ | A PSP transaction received a chargeback |
psp_transaction_refunded | A PSP transaction is (partially) refunded |
Creditworthiness
| Event | Triggered when |
|---|---|
creditworthiness_changed | Creditworthiness information changed (only when monitoring is enabled for the administration) |
An abridged invoice_created from Payt's event reference:
{
"event": {
"event_name": "invoice_created",
"context": {
"id": 1,
"resource_type": "invoice",
"invoice_number": "2011-1",
"amount_total": "1337",
"amount_open": "1000",
"currency_code": "EUR",
"due_date": "2018-04-29",
"debtor": { "id": 1, "name": "Bosch-Brink", "debtor_code": "1" },
"administration": { "id": 1, "name": "Administratie 1", "origin_id": "Voorbeeldig" },
"labels": [{ "name": "Risico" }]
},
"event_time": "2018-06-05T09:48:55.314159Z"
}
}
Amounts are strings. origin_id holds the administration code from the external accounting system when Payt knows it, but it can be empty or out of date. Several sections of Payt's reference point to other events' payloads rather than showing their own, so capture real deliveries for the events you handle.
Setting up Payt webhooks
- In Payt, open the administration's Dashboard > Administration settings > Modules.
- Open the Webhooks tab and activate all webhooks or choose them one by one.
- Enter your HTTPS endpoint, for example
https://example.com/webhooks/payt. - On the webhook settings page, generate the secret and copy it straight into
PAYT_WEBHOOK_SECRET. It's shown only once.
To route through Hookdeck, create a Payt source with the secret and register the source URL. If you use Payt's fixed IP or mTLS to restrict your own endpoint, apply those rules to traffic from Hookdeck instead. For local testing:
npx hookdeck-cli listen 3000 payt --path /webhooks/payt
Securing Payt webhooks
X-PAYT-SIGNATURE is a bare hex HMAC-SHA256 of the raw body, keyed with the secret. Compute it over the bytes received, before any parsing, and compare in constant time. See Payt's verification page.
Node.js verification
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-payt-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
When you regenerate the secret, Payt warns that the new one takes a short while to apply everywhere, so requests can arrive signed with either. Accept both during the changeover, or let the few that fail come back through Payt's retries.
The handler follows Payt's advice for out-of-order events: an event about an invoice you haven't seen yet gets a 404, so Payt sends it again later.
const express = require('express');
const app = express();
app.post('/webhooks/payt', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.PAYT_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let parsed;
try {
parsed = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
const { event } = parsed;
if (!event || typeof event !== 'object') return res.sendStatus(400);
const context = event.context && typeof event.context === 'object' ? event.context : {};
const isInvoiceEvent = context.resource_type === 'invoice';
try {
if (isInvoiceEvent && event.event_name !== 'invoice_created' && !(await knownInvoice(context.id))) {
return res.sendStatus(404);
}
await storePaytEvent(event.event_name, context.resource_type, context.id, event.event_time, event);
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
storePaytEvent should tolerate repeats: there's no event ID in the body, X-PAYT-DELIVERY changes with each attempt, and Payt says simultaneous events often share a timestamp. Make each handler safe to apply twice, for example by setting an invoice's state rather than toggling it.
Hookdeck's Payt source verifies the same HMAC, answers 200 and records mismatches as failed verification. Because Hookdeck always accepts, the 404 pattern only works between Hookdeck and your service, where Hookdeck's own retry policy applies.
Make Payt webhooks production-ready. Hookdeck Event Gateway answers Payt within 10 seconds, so webhooks aren't deactivated for failures, and retries your ERP until it has caught up with each invoice event.
Payt webhook limitations and pain points
Webhooks are deactivated after frequent failures
The Problem: A run of failures switches the webhook off and further events stop.
Why It Happens: Payt makes up to 10 attempts per event and deactivates the webhook if deliveries fail often within a certain period.
Workarounds:
- Make sure the administration's notification email or the application's developer email is monitored.
- Reactivate the webhook once fixed and retry failed requests from the settings page.
How Hookdeck Can Help: Hookdeck accepts every delivery, so Payt never sees repeated failures, and Hookdeck keeps retrying your service.
Events arrive out of order
The Problem: You get invoice_paid for an invoice you haven't created yet.
Why It Happens: Payt delivers asynchronously and doesn't guarantee order, even for simultaneous events on the same invoice.
Workarounds:
- Return
404for unknown objects so Payt retries later, as Payt suggests. - Or fetch the invoice from the Payt API when you first see it.
How Hookdeck Can Help: Hookdeck can retry your service when it returns 404, giving the creation event time to arrive first.
No stable event ID
The Problem: Duplicates are hard to spot.
Why It Happens: The body has no event ID, X-PAYT-DELIVERY differs per attempt, and event timestamps aren't unique.
Workarounds:
- Design handlers to set state rather than apply increments.
- Compare the event's context with what you've stored before acting.
How Hookdeck Can Help: Hookdeck deduplication can compare the full body within a time window and drop exact repeats.
Secret rotation isn't instant
The Problem: After regenerating the secret, some requests fail verification.
Why It Happens: The new secret takes a short while to reach every sender, so both can be in use.
Workarounds:
- Accept either secret for a short period.
- Retry failed requests from the settings page afterwards.
How Hookdeck Can Help: Hookdeck records failed verifications in its request log, so you can see whether any requests arrived with the old secret during the switch.
Best practices
Match on Payt IDs
Invoice numbers repeat across administrations. Store Payt's id for each debtor and invoice and map it to your own records.
Respect OAuth scopes
An empty {} means your application can't read that object, not that it's empty. Request the scopes you need rather than treating {} as missing data.
Return quickly
Store the event and respond within 10 seconds, then update your ERP. Asynchronous webhook processing explains the approach.
Make every handler idempotent
Without an event ID, idempotency has to come from how you apply changes. Our idempotency guide covers the options.
Conclusion
Payt webhooks report invoice, collection, payment plan and PSP activity per administration, signed with a hex HMAC-SHA256 in X-PAYT-SIGNATURE. Verify the raw body, match records on Payt IDs, use the 404 pattern for objects you don't know yet, and keep failures down, because Payt deactivates webhooks that fail too often.
Hookdeck Event Gateway keeps the Payt side healthy and handles retries to your systems.
Get started with Hookdeck for free and handle Payt webhooks reliably in minutes.