verifyWebhookSignature
verifyWebhookSignature(headers, rawBody, secret) parses the inbound X-Txnod-Signature: t=<unix>,v1=<hex> header, recomputes HMAC-SHA256 over ${timestamp}.${rawBody} using the project secret, compares in constant time, and enforces a ±300-second timestamp window. On success it returns a typed WebhookEvent discriminated on event_type. On failure it throws one of three typed errors — never a generic Error.
Signature
import type { WebhookEvent } from '@txnod/sdk';
import { verifyWebhookSignature } from '@txnod/sdk';
declare const headers: Headers;
declare const rawBody: string;
declare const secret: string;
const event: WebhookEvent = verifyWebhookSignature(headers, rawBody, secret);headers— anything that carries anx-txnod-signatureheader.Headers,Record<string, string>, andRecord<string, string[]>all work. Case-insensitive.rawBody— the exact bytes the server signed. Read it viaawait request.text()before any JSON parsing;request.json()consumes the stream and makes HMAC recomputation impossible.secret—TXNOD_WEBHOOK_SECRET(same value asTXNOD_API_SECRET).
When event.mode === 'sandbox', see Sandbox safety for the recommended assertion.
Worked example — Next.js App Router
The recipe below uses the Next.js 16 App Router for concreteness; the SDK itself is pure Node ≥ 20 and the same verifyWebhookSignature call works unchanged in Express, Fastify, Hono, Koa, NestJS, Nuxt, SvelteKit, Remix, Astro endpoints, or plain node:http — only the body-reading idiom changes per framework.
import {
verifyWebhookSignature,
TxnodSignatureFormatError,
TxnodHmacError,
TxnodTimestampError,
} from '@txnod/sdk';
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
try {
const event = verifyWebhookSignature(
request.headers,
rawBody,
process.env.TXNOD_WEBHOOK_SECRET!,
);
switch (event.event_type) {
case 'invoice.paid': {
const invoiceId = event.data['invoice_id'] as string;
console.log('paid', invoiceId, 'at', event.created_at_iso);
break;
}
case 'invoice.reverted': {
const invoiceId = event.data['invoice_id'] as string;
console.log('reverted', invoiceId);
break;
}
default:
console.log('event type', event.event_type);
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodSignatureFormatError) {
return new Response('bad signature format', { status: 401 });
}
if (err instanceof TxnodHmacError) {
return new Response('bad signature', { status: 401 });
}
if (err instanceof TxnodTimestampError) {
console.warn('clock skew', err.skew_seconds);
return new Response('stale signature', { status: 401 });
}
throw err;
}
}Error handling matrix
| Error class | error_code | When thrown | Recommended response |
|---|---|---|---|
TxnodSignatureFormatError | auth_invalid | X-Txnod-Signature missing or does not match the t=<unix>,v1=<hex> shape. | 401 Unauthorized. Do not process the payload. |
TxnodHmacError | signature_invalid | Computed HMAC does not match the signature bytes. | 401 Unauthorized. Do not retry or alert — a hostile sender will produce this every time. |
TxnodTimestampError | timestamp_out_of_window | Signed timestamp is more than ±300 seconds from server time. Carries skew_seconds. | 401 Unauthorized. Alert if skew_seconds is chronically non-zero — the sender’s clock is drifting. |
All three extend TxnodError, so a single catch (err) block that checks err instanceof TxnodError is sufficient for coarse-grained handling. Branch on the specific subclass when the response or alert differs per failure mode — see the recipe above.
Why no JSON schema validation
The discriminated union on event_type is derived at SDK build time from the same Zod schemas the TxNod server uses, then expanded to plain TypeScript inside the published tarball. TypeScript narrows event.data correctly per event_type in editors and at compile time. The SDK does not run a second Zod parse at the client — the HMAC is the authentication boundary. @txnod/shared is not a published npm package; partners install only @txnod/sdk and never need zod at runtime. If you want belt-and-suspenders runtime validation, parse event against your own zod schema after verifyWebhookSignature returns.