Skip to content

Start typing to search the documentation.

Verify webhooks

On this page

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 call req.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.

Was this page helpful?