Verify webhooks
Self signs every webhook delivery with HMAC-SHA256. Verify the signature before trusting the payload, and don’t roll your own check: the SDK’s SelfWebhooks.verify(...) does it correctly in one call and returns a typed event.
verify(...) checks two things:
- The signature matches the raw request body and your
whsec_...signing secret. - The timestamp is recent (a 5-minute tolerance, which defends against replay).
Setup
You need:
- The raw request body (string or Buffer). Not the JSON-parsed object, signature verification operates on the byte string.
- The request headers from the delivery. Pass them through as-is; the SDK reads the signature and timestamp off them.
- The signing secret (
whsec_...) for the webhook endpoint. You get it once, when you add the endpoint; rotation works by deleting and recreating the endpoint.
Next.js (App Router)
// app/api/webhooks/self/route.ts
import { SelfWebhooks, WebhookVerificationError } from '@selfxyz/enterprise-sdk';
export async function POST(req: Request) {
const raw = await req.text(); // raw body, before any JSON parsing
const headers = Object.fromEntries(req.headers); // pass all headers through
try {
const event = SelfWebhooks.verify(raw, headers, process.env.SELF_WEBHOOK_SECRET!);
if (event.type === 'verification.completed') {
// event.verification_id, event.external_uuid, event.proof_attributes, event.status
}
return new Response('ok', { status: 200 });
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response('bad signature', { status: 400 });
}
// Unknown payload shape (SelfValidationError) or server bug: log and 5xx so Self retries.
return new Response('error', { status: 500 });
}
}
Any other framework works the same way: read the raw request body before JSON parsing, pass (rawBody, headers, secret) to SelfWebhooks.verify(...), and branch on event.type.
Type narrowing
event is typed as WebhookEvent; narrow it on event.type and TypeScript does the rest:
if (event.type === 'verification.completed') {
event.status; // 'valid' | 'invalid' | 'error' | 'expired'
event.proof_attributes; // the enforced predicate config, echoed back
event.nullifier; // uniqueness id (string | null), for Sybil resistance
// event.proof and event.storage_uri are also available
}
Common failure modes
- Parsing the body before verification. If a middleware or framework hook JSON-parses the request before your handler runs, the raw bytes are gone and verification will fail. In a Next.js route handler, read the body with
await req.text()and don’t callreq.json()first. This is the most common failure by far. - Trimming or transforming. A trailing newline added by your proxy will break the signature. Configure the proxy to pass the body unchanged.
- Using the wrong secret. Each endpoint has its own
whsec_.... If you have multiple endpoints registered (e.g. staging + prod), don’t share secrets across them. - Clock skew. A drifted server clock can fail the timestamp tolerance check. Make sure NTP is healthy.
Redeliveries
The same event can arrive more than once (a retry after a failed delivery, for example). The body and signature are valid each time, so verification succeeds normally. Make your handler idempotent, dedupe on the event’s verification_id.
See Best practices for idempotency patterns.
Thanks — what went wrong?
Thanks for your feedback!