Gareth Wilson Gareth Wilson

Guide to Payt Webhooks: Features and Best Practices

Published


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

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-payt-signature
BodyJSON
Event routingBody field event.event_name

From Payt's webhook overview:

  • HTTPS only, POST with 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, and X-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

EventTriggered when
invoice_createdAn invoice is imported for the first time
invoice_paidThe open amount reaches zero
invoice_closedThe invoice is both sent and paid
invoice_reopenedThe invoice is no longer paid
invoice_payment_createdThe amount the debtor must pay changes (payment, administration, interest or collection costs)
invoice_paused / invoice_resumedThe invoice flow is paused by the user / resumed
invoice_blockedThe invoice flow is blocked for some reason
invoice_step_performedAn invoice step is performed
invoice_call_reminder_performedA call reminder is performed
invoice_to_debt_collectionThe invoice is marked ready for debt collection
invoice_new_commentA new reaction on an invoice
invoice_email_bounced / invoice_letter_returned / invoice_sms_failed / invoice_ubl_failedSending the invoice by email, post, SMS or UBL failed
incoming_ubl_receivedAn incoming invoice UBL is received

Debt collection cases

EventTriggered when
case_createdA debt collection case is created
case_paidThe case is fully paid
case_invoice_amount_paidAll invoices in the case are paid, but not the collection costs or interest
case_reopenedThe case is no longer closed
case_step_performedA case step is performed (name: summons, summons_reminder, second_summons, second_summons_reminder, notice_of_default, prejudiciary)
case_to_bailiffThe case is forwarded to a bailiff
case_new_commentA new reaction on a case
case_email_bounced / case_letter_returnedSending the case by email or post failed

Reminders, debtors and communication

EventTriggered when
bundled_reminder_sentA bundled reminder is sent
bundled_reminder_invoice_performedAn invoice is mentioned in a bundled reminder
bundled_reminder_email_bounced / bundled_reminder_letter_returnedSending a bundled reminder by email or post failed
credit_limit_exceededA debtor's open amounts exceed the credit limit
debtor_new_commentA new reaction on a debtor
debtor_email_address_provided / debtor_email_address_removedAn email address was added to / removed from a debtor
email_bouncedAn email sent to a debtor bounced (any type)
label_added / label_removedA label was added to or removed from a debtor, invoice or case
message_createdA message was created (by the debtor, a user or automatically)
note_createdAn internal note was created
todo_list_item_created / todo_list_item_updated / todo_list_item_deletedA task was created, updated or deleted

Payment plans and PSP

EventTriggered when
payment_plan_created / payment_plan_updated / payment_plan_deactivated / payment_plan_finishedA payment plan changes (same payload, only event_name differs)
payment_plan_term_expiredA payment plan term expired
psp_mandate_created / psp_mandate_revokedA PSP mandate is signed or revoked
psp_transaction_successfulA new PSP transaction is registered
psp_transaction_charged_backA PSP transaction received a chargeback
psp_transaction_refundedA PSP transaction is (partially) refunded

Creditworthiness

EventTriggered when
creditworthiness_changedCreditworthiness 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

  1. In Payt, open the administration's Dashboard > Administration settings > Modules.
  2. Open the Webhooks tab and activate all webhooks or choose them one by one.
  3. Enter your HTTPS endpoint, for example https://example.com/webhooks/payt.
  4. 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 404 for 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.