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
npm install @paylera/serverLow-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 onev1=, |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
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
| Outcome | Status | Paylera behaviour |
|---|---|---|
| Signature OK + handler OK | 200 | Acknowledged, no retry. |
| Signature OK + no handler | 200 | Acknowledged. Register "*" to log unmatched events. |
| Bad / missing signature | 401 | Not retried (4xx). |
| Body fails to parse | 400 | Not retried. |
| Handler throws | 500 | Retried 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:
| Family | Types | Data shape |
|---|---|---|
invoice.* | finalized, issued, paid, partially_paid, voided, overdue, uncollectible | InvoiceData |
subscription.* | created, updated, cancel_scheduled, canceled, expired, paused, resumed, trial_ended, plan_changed | SubscriptionData |
payment.* | requires_action, succeeded, failed, refunded, charged_back | PaymentData |
customer.* | created, updated, archived, external_ref_discovered | CustomerData |
revenue.* | captured, refunded, subscription_started, subscription_renewed, subscription_canceled, subscription_grace_period_entered, subscription_recovered, subscription_expired, one_time_purchase, unknown_currency_seen | RevenueCapturedEvent (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.