Receive webhooks
Verify and handle Voidhash webhook events in a Node.js server.
constructWebhookEvent verifies a webhook request and parses its body. It needs
only the endpoint's signing secret (whsec_…), here VOIDHASH_WEBHOOK_SECRET; no client or secret
key.
Create an endpoint
In Studio, open Settings → Webhooks, select New webhook, and enter your endpoint's public
HTTPS URL, its environment and the events it receives. A Production endpoint receives
production purchases; a Test endpoint receives sandbox, simulated and test mode purchases.
Studio shows the signing secret (whsec_…) once; store it in your secret manager, here as
VOIDHASH_WEBHOOK_SECRET. See Endpoints for every field, or
create one from code.
Verify a request
Pass the body as text, read before any JSON parser, with the request headers.
import express from "express";
import { VoidhashWebhookVerificationError, constructWebhookEvent } from "@voidhash/node";
import { handleEvent } from "./handle-event";
const app = express();
// express.raw() keeps the signed bytes. Register express.json() for other routes after this one.
app.post("/webhooks/voidhash", express.raw({ type: "application/json" }), (req, res) => {
// Node types a missing header as undefined; pass only the headers that are set.
const headers: Record<string, string | string[]> = {};
for (const [name, value] of Object.entries(req.headers)) {
if (value !== undefined) headers[name] = value;
}
let event;
try {
event = constructWebhookEvent({
headers,
payload: req.body.toString("utf8"),
secret: process.env.VOIDHASH_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof VoidhashWebhookVerificationError) return res.sendStatus(400);
throw error;
}
res.sendStatus(200);
void handleEvent(event);
});
app.listen(3000);import { VoidhashWebhookVerificationError, constructWebhookEvent } from "@voidhash/node";
import { after } from "next/server";
import { handleEvent } from "@/server/handle-event";
export async function POST(request: Request) {
let event;
try {
event = constructWebhookEvent({
headers: Object.fromEntries(request.headers),
payload: await request.text(),
secret: process.env.VOIDHASH_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof VoidhashWebhookVerificationError) {
return new Response(null, { status: 400 });
}
throw error;
}
after(() => handleEvent(event));
return new Response(null, { status: 200 });
}import { Hono } from "hono";
import { VoidhashWebhookVerificationError, constructWebhookEvent } from "@voidhash/node";
import { handleEvent } from "./handle-event";
const app = new Hono();
app.post("/webhooks/voidhash", async (c) => {
let event;
try {
event = constructWebhookEvent({
headers: c.req.header(),
payload: await c.req.text(),
secret: process.env.VOIDHASH_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof VoidhashWebhookVerificationError) return c.body(null, 400);
throw error;
}
void handleEvent(event);
return c.body(null, 200);
});
export default app;Each example answers 200 before it processes the event. In
production, store the event first, so a crash after answering doesn't lose it.
Handle events
constructWebhookEvent returns a VoidhashWebhookEvent:
| Field | Type | Description |
|---|---|---|
type | string | The X-Webhook-Event header, such as purchase.refunded. |
payload | unknown | The parsed JSON body. Validate it before you use it. |
timestamp | DateTime.Utc | When the attempt was signed, as an Effect DateTime. |
Switch on type and skip duplicates:
import type { VoidhashWebhookEvent } from "@voidhash/node";
import { db } from "./db";
type LifecyclePayload = {
personId: string;
distinctId?: string;
subscriptionId?: string;
purchaseId?: string | null;
occurredAt?: string;
erasedAt?: string;
};
export async function handleEvent(event: VoidhashWebhookEvent) {
const payload = event.payload as LifecyclePayload;
const subject = payload.subscriptionId ?? payload.purchaseId ?? payload.personId;
const key = `${event.type}:${subject}:${payload.occurredAt ?? payload.erasedAt}`;
// Deliveries are at least once: skip one you have already processed.
if (!(await db.processedWebhooks.insertIfAbsent(key))) return;
switch (event.type) {
case "subscription.renewed":
case "subscription.expired":
case "purchase.refunded":
await db.accessCache.clear(payload.distinctId!);
break;
case "person.deleted":
await db.users.deleteByVoidhashPersonId(payload.personId);
break;
default:
break; // Includes test.ping and event types added after this code.
}
}WEBHOOK_EVENT_TYPES lists the types you can subscribe to, and the WebhookEventName type adds
test.ping.
Verification errors
constructWebhookEvent throws VoidhashWebhookVerificationError. Its reason says why:
reason | Cause |
|---|---|
missing_header | A signing header is missing, empty or sent more than once. |
timestamp_out_of_tolerance | The timestamp is malformed or more than toleranceSeconds from your clock. |
invalid_signature | The signature doesn't match: wrong secret, or a modified or re-serialized body. |
invalid_payload | The signature matches but the body isn't valid JSON. |
Answer 400 for each; see Troubleshooting.
Options
| Option | Default | Description |
|---|---|---|
toleranceSeconds | 300 | Accepted clock difference, in seconds, in either direction. |
now | Current time | An Effect DateTime.Utc to verify against. Use it in tests. |
verifyWebhookSignature({ payload, signature, timestamp, secret }) takes the same options and
returns a boolean, for when you read the headers yourself.
Respond and retries
Answer any 2xx within 30 seconds. A redirect, a 4xx or 5xx, a timeout or a connection
error is a failed attempt. Voidhash retries it 5 minutes, 30 minutes, 2 hours and 24 hours after
the attempt before, signing each retry again with a new timestamp, then marks the delivery
exhausted. After 20 consecutive failed attempts it sets the endpoint to Failed and stops
sending to it until you enable it again. See Retries and the
delivery log.
Duplicates and ordering
Delivery is at least once: a timeout after you processed an event, or a manual retry, sends it
again. Payloads carry no event ID, so the handler above builds a key from the type, the subject's
ID and occurredAt (erasedAt for person.deleted). Events can also arrive out of order; compare
occurredAt before you overwrite newer state, or check access for
the current answer.
Rotate the secret
Rotate a secret that may have leaked with Rotate secret in the endpoint's actions menu,
voidhash webhook rotate, or voidhash.webhooks.rotate. The old secret stops working at once, including for
retries of earlier events, so deploy the new one right away.
Test locally
Voidhash can't reach localhost, so expose your local server through a tunnel such as
ngrok http 3000 or cloudflared tunnel --url http://localhost:3000, and create a
Test endpoint with the tunnel's HTTPS URL. Then:
- Select Send test in the endpoint's actions menu, or run
voidhash webhook test <webhook>, which waits for your server's answer and prints it. A test only goes to an endpoint that is Active. Itstest.pingpayload has notypefield. - Make a sandbox or simulated purchase to receive real lifecycle events. See Test purchases (Help center).
Troubleshooting
- Every signature fails. A JSON parser read the body first: in Express, register
express.raw()on the webhook route before any globalexpress.json(). Or you are using another endpoint's secret: each endpoint has its own, and rotating replaces it at once. - Signatures fail only sometimes. Check your server's clock. Drift beyond 300 seconds rejects valid requests; keep clocks synced with NTP.
- A proxy rewrites the body. Some proxies re-encode JSON or change line endings. Verify against the bytes your application receives, and turn off body rewriting on the webhook route.
Manage endpoints from code
The client's webhooks namespace manages endpoints with a secret key. Only create and rotate
return the signing secret:
import { createVoidhashSdk } from "@voidhash/node";
const voidhash = createVoidhashSdk({ secretKey: process.env.VOIDHASH_SECRET_KEY! });
const webhook = await voidhash.webhooks.create({
payload: {
name: "Orders backend",
url: "https://api.example.com/webhooks/voidhash",
events: ["subscription.created", "subscription.renewed", "purchase.completed"],
environment: "production",
},
});
console.log(webhook.id, webhook.secret); // wh_ep_… whsec_…Other methods: list, get, update (set status to active or disabled), delete,
rotate, test, listDeliveries, getDelivery and retryDelivery. Each takes webhookId or
deliveryId in params.
Next steps
- Webhook events: every event type and its payload.
- Check access
- Persons and events
Something wrong or missing on this page? Tell us by email or on Discord.