Verify webhooks
# Verify webhooks
The SDK ships a `SelfWebhooks.verify(...)` helper that checks the signature and returns a typed event payload.
## 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; they carry the signature the SDK checks.
* The signing secret (`whsec_...`) for the webhook endpoint. You get it once, when you [add the endpoint](/docs/self-enterprise/dashboard/webhooks/).
## Express
```ts
import express from 'express';
import { SelfWebhooks, WebhookVerificationError } from '@selfxyz/enterprise-sdk';
const app = express();
app.post(
'/webhooks/self',
express.raw({ type: 'application/json' }), // keep the raw bytes
(req, res) => {
try {
const event = SelfWebhooks.verify(
req.body, // Buffer
req.headers as Record<string, string>,
process.env.SELF_WEBHOOK_SECRET!,
);
if (event.type === 'verification.completed') {
// event.verification_id, event.external_uuid, event.proof_attributes, event.status
}
res.status(200).end();
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.status(400).end();
} else {
// Unknown payload shape (SelfValidationError) or server bug, log and 5xx so we retry.
res.status(500).end();
}
}
},
);
```
## Hono
```ts
import { Hono } from 'hono';
import { SelfWebhooks } from '@selfxyz/enterprise-sdk';
const app = new Hono();
app.post('/webhooks/self', async (c) => {
const raw = await c.req.text(); // raw body as string
const headers = Object.fromEntries(c.req.raw.headers); // pass all headers through
try {
const event = SelfWebhooks.verify(raw, headers, process.env.SELF_WEBHOOK_SECRET!);
// ...handle event
return c.text('ok');
} catch {
return c.text('bad signature', 400);
}
});
```
## Next.js (App Router)
```ts
// app/api/webhooks/self/route.ts
import { SelfWebhooks } from '@selfxyz/enterprise-sdk';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const raw = await req.text();
const headers = Object.fromEntries(req.headers); // pass all headers through
try {
const event = SelfWebhooks.verify(raw, headers, process.env.SELF_WEBHOOK_SECRET!);
// ...
return new Response('ok', { status: 200 });
} catch {
return new Response('bad signature', { status: 400 });
}
}
```
## Type narrowing
`event` is a discriminated union on `event.type`. TypeScript narrows automatically:
```ts
if (event.type === 'verification.completed') {
event.status; // 'valid' | 'invalid' | 'error' | 'expired'
event.proof_attributes; // disclosed predicates
// event.storage_uri is also available
}
```
## Common mistakes
* **Parsing the body before verification.** If you `app.use(express.json())`, the raw body is gone by the time the route runs and verification will fail. Use `express.raw(...)` for the webhook path.
* **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.
* **Trusting the payload without verifying.** Always call `SelfWebhooks.verify(...)` first. Don't parse `event.type` until verification succeeds.
## 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](/docs/self-enterprise/webhooks/best-practices/) for idempotency patterns.
The SDK ships a SelfWebhooks.verify(...) helper that checks the signature and returns a typed event payload.
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; they carry the signature the SDK checks.
- The signing secret (
whsec_...) for the webhook endpoint. You get it once, when you add the endpoint.
Express
import express from 'express';
import { SelfWebhooks, WebhookVerificationError } from '@selfxyz/enterprise-sdk';
const app = express();
app.post(
'/webhooks/self',
express.raw({ type: 'application/json' }), // keep the raw bytes
(req, res) => {
try {
const event = SelfWebhooks.verify(
req.body, // Buffer
req.headers as Record<string, string>,
process.env.SELF_WEBHOOK_SECRET!,
);
if (event.type === 'verification.completed') {
// event.verification_id, event.external_uuid, event.proof_attributes, event.status
}
res.status(200).end();
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.status(400).end();
} else {
// Unknown payload shape (SelfValidationError) or server bug, log and 5xx so we retry.
res.status(500).end();
}
}
},
);
Hono
import { Hono } from 'hono';
import { SelfWebhooks } from '@selfxyz/enterprise-sdk';
const app = new Hono();
app.post('/webhooks/self', async (c) => {
const raw = await c.req.text(); // raw body as string
const headers = Object.fromEntries(c.req.raw.headers); // pass all headers through
try {
const event = SelfWebhooks.verify(raw, headers, process.env.SELF_WEBHOOK_SECRET!);
// ...handle event
return c.text('ok');
} catch {
return c.text('bad signature', 400);
}
});
Next.js (App Router)
// app/api/webhooks/self/route.ts
import { SelfWebhooks } from '@selfxyz/enterprise-sdk';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const raw = await req.text();
const headers = Object.fromEntries(req.headers); // pass all headers through
try {
const event = SelfWebhooks.verify(raw, headers, process.env.SELF_WEBHOOK_SECRET!);
// ...
return new Response('ok', { status: 200 });
} catch {
return new Response('bad signature', { status: 400 });
}
}
Type narrowing
event is a discriminated union on event.type. TypeScript narrows automatically:
if (event.type === 'verification.completed') {
event.status; // 'valid' | 'invalid' | 'error' | 'expired'
event.proof_attributes; // disclosed predicates
// event.storage_uri is also available
}
Common mistakes
- Parsing the body before verification. If you
app.use(express.json()), the raw body is gone by the time the route runs and verification will fail. Useexpress.raw(...)for the webhook path. - 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. - Trusting the payload without verifying. Always call
SelfWebhooks.verify(...)first. Don’t parseevent.typeuntil verification succeeds.
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.