Skip to content

TypeScript — webhook receivers

@paylera/server/webhook ships a low-level signature verifier plus four framework handler factories (Express, Next App Router, Hono, Fastify) that wrap verification, replay-window enforcement, and per-event-type dispatch.

The wire scheme is documented at /webhooks/signatures:

Paylera-Signature: t=<unix>,v1=<hex>[,v1=<hex>]

HMAC-SHA-256 over ${t}.${raw_body}. Multi-v1 supports secret rotation; default ±300 s replay window. Verification is byte-exact — parsing + re-serialising JSON changes the bytes and breaks the HMAC, so each adapter takes care to pull the original request body.

Install

Terminal window
npm install @paylera/server

Low-level verifier

import { verifyWebhookSignature } from "@paylera/server/webhook";
const ok = verifyWebhookSignature(
rawBodyBuffer, // Buffer | Uint8Array | string (bytes only)
request.headers["paylera-signature"],
process.env.PAYLERA_WEBHOOK_SECRET!,
{
toleranceSeconds: 300, // optional; default 300
acceptedSecrets: [previousSecret], // optional; rotation overlap
},
);

verifyWebhookSignature returns true only when:

  • the header parses into a t= plus at least one v1=,
  • |now - t| <= toleranceSeconds,
  • at least one v1= HMAC matches at least one secret in [primarySecret, ...acceptedSecrets].

The comparison is constant-time. The function never throws on malformed input — bad headers fail closed and return false.

Express

Mount express.raw({ type: "application/json" }) before the handler so req.body arrives as a Buffer of the original bytes.

import express from "express";
import { payleraWebhookExpress } from "@paylera/server/webhook";
app.post(
"/webhooks/paylera",
express.raw({ type: "application/json" }),
payleraWebhookExpress({
signingSecret: process.env.PAYLERA_WEBHOOK_SECRET!,
handlers: {
"invoice.paid": async (event) => { /* event.data.invoice_id, ... */ },
"subscription.canceled": async (event) => { /* ... */ },
"*": async (event) => { /* fallback */ },
},
toleranceSeconds: 300,
}),
);

Next.js App Router

app/api/webhooks/paylera/route.ts
import { payleraWebhookNextRouteHandler } from "@paylera/server/webhook";
export const POST = payleraWebhookNextRouteHandler({
signingSecret: process.env.PAYLERA_WEBHOOK_SECRET!,
handlers: { /* ... */ },
});

Hono

import { Hono } from "hono";
import { payleraWebhookHono } from "@paylera/server/webhook";
const app = new Hono();
app.post("/webhooks/paylera", payleraWebhookHono({
signingSecret: process.env.PAYLERA_WEBHOOK_SECRET!,
handlers: { /* ... */ },
}));

Fastify

Fastify’s default JSON parser turns the body into an object before the handler runs — that mutates the bytes. Register the bundled content-type parser so the webhook route receives a raw Buffer:

import Fastify from "fastify";
import {
payleraWebhookFastify,
payleraWebhookFastifyContentTypeParser,
} from "@paylera/server/webhook";
const app = Fastify();
app.removeContentTypeParser("application/json");
app.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
payleraWebhookFastifyContentTypeParser,
);
app.post("/webhooks/paylera", payleraWebhookFastify({
signingSecret: process.env.PAYLERA_WEBHOOK_SECRET!,
handlers: { /* ... */ },
}));

If you have other JSON routes in the same app, isolate the raw-parser inside a Fastify scope (app.register(...)) so it only applies to the webhook route.

Status-code semantics

OutcomeStatusPaylera behaviour
Signature OK + handler OK200Acknowledged, no retry.
Signature OK + no handler200Acknowledged. Register "*" to log unmatched events.
Bad / missing signature401Not retried (4xx).
Body fails to parse400Not retried.
Handler throws500Retried on the standard schedule.

Secret rotation

Paylera’s rotate-secret endpoint issues a new secret while continuing to sign deliveries under the previous one for 24 hours. During the overlap, pass both:

payleraWebhookExpress({
signingSecret: [process.env.WEBHOOK_SECRET_NEW!, process.env.WEBHOOK_SECRET_OLD!],
handlers: { /* ... */ },
});

The verifier accepts any v1= that matches any supplied secret.

Idempotency — bring your own

Paylera retries on 5xx. Handlers MUST be safe to invoke twice for the same event.id; the SDK does not ship a state-store dependency. A common pattern with Redis:

handlers: {
"invoice.paid": async (event) => {
const claimed = await redis.set(
`paylera:event:${event.id}`, "1", "EX", 86_400, "NX",
);
if (claimed !== "OK") return; // replay — no-op
await applyInvoicePaid(event.data);
},
},

Swap Redis for whatever your stack offers (Postgres unique constraint, DynamoDB conditional write, etc.). De-duping on event.id is the only key that matters — event.type + event.data alone are not unique.

Typed events

PayleraEvents is a discriminated union by event.type. The high-traffic families (invoice.*, subscription.*, payment.*, customer.*) carry strict data shapes; any unmodelled type surfaces as { type: string; data: unknown } so the "*" fallback is still typed.

import type { PayleraEvents } from "@paylera/server/webhook";
type InvoicePaid = Extract<PayleraEvents, { type: "invoice.paid" }>;

Typed-family quick reference:

FamilyTypesData shape
invoice.*finalized, issued, paid, partially_paid, voided, overdue, uncollectibleInvoiceData
subscription.*created, updated, cancel_scheduled, canceled, expired, paused, resumed, trial_ended, plan_changedSubscriptionData
payment.*requires_action, succeeded, failed, refunded, charged_backPaymentData
customer.*created, updated, archived, external_ref_discoveredCustomerData
revenue.*captured, refunded, subscription_started, subscription_renewed, subscription_canceled, subscription_grace_period_entered, subscription_recovered, subscription_expired, one_time_purchase, unknown_currency_seenRevenueCapturedEvent (and sibling types per event.type)

revenue.* is a multi-source observability family — see Multi-source revenue capture for the upstream provider list and the money-bearing vs. lifecycle split. customer.external_ref_discovered is emitted by the RevenueAttribution worker when a previously-orphaned revenue.* event is back-linked to an existing customer via email-fingerprint match.

The canonical event-type catalog lives in contracts/vocabulary.yaml and is spec-drift-checked by tools/check-spec-drift.

Example

A standalone Express webhook receiver example with curl-based local testing lives at examples/typescript/webhook-receiver/ in the monorepo.