Standard Webhooks
One spec, dozens of vendors — Resend, Clerk, Polar, Replicate, OpenAI, Loops, Dodo Payments ship as wrappers; everything else works generically.
Resend, Clerk, Polar, Replicate, OpenAI, Loops, Dodo Payments — and every
other Svix-backed vendor — sign webhooks the same way: the
Standard Webhooks spec. One provider covers them all, whichever
header generation your vendor sends (webhook-* or the older svix-*) and
whichever signature flavor it uses (symmetric v1 or Ed25519 v1a).
Named wrappers
Seven vendors ship as typed one-line wrappers:
import { resend } from 'webhooks-sdk/resend'
resend({ secret: process.env.RESEND_WEBHOOK_SECRET! })
// event types: email.sent, email.delivered, email.bounced, …import { clerk } from 'webhooks-sdk/clerk'
clerk({ secret: process.env.CLERK_WEBHOOK_SIGNING_SECRET! })
// event types: user.created, session.created, organization.updated, …import { polar } from 'webhooks-sdk/polar'
polar({ secret: process.env.POLAR_WEBHOOK_SECRET! })
// event types: order.paid, subscription.active, checkout.created, …import { replicate } from 'webhooks-sdk/replicate'
replicate({ secret: process.env.REPLICATE_WEBHOOK_SIGNING_SECRET! })
// event types are prediction statuses: starting, processing, succeeded, failed, canceled, …import { openai } from 'webhooks-sdk/openai'
openai({ secret: process.env.OPENAI_WEBHOOK_SECRET! })
// event types: response.completed, batch.failed, fine_tuning.job.succeeded, …import { loops } from 'webhooks-sdk/loops'
loops({ secret: process.env.LOOPS_SIGNING_SECRET! })
// event types: contact.created, email.delivered, email.hardBounced, …import { dodoPayments } from 'webhooks-sdk/dodo-payments'
dodoPayments({ secret: process.env.DODO_PAYMENTS_WEBHOOK_KEY! })
// event types: payment.succeeded, subscription.active, refund.succeeded, …Each takes { secret, tolerance? } and adds typed event names, plus the
one per-vendor deviation where there is one — Replicate’s and Loops’
event-name fields, Polar’s raw secret. Nothing else differs from the generic
provider.
Any other vendor, right now
A vendor without a wrapper doesn’t need one:
import { standardWebhooks } from 'webhooks-sdk/standard-webhooks'
standardWebhooks({ id: 'svix', secret: process.env.SVIX_WEBHOOK_SECRET! })
That covers Stytch and Svix itself — and any
vendor whose docs mention whsec_ secrets or svix-*/webhook-* headers.
Options
| Option | Type | Default | |
|---|---|---|---|
secret |
string | string[] |
— | The whsec_… signing secret(s). |
publicKey |
string | string[] |
— | The whpk_… Ed25519 key(s), for vendors signing with v1a. Either credential alone suffices. |
tolerance |
number |
300 |
Replay window in seconds. |
id |
string |
'standard-webhooks' |
Provider slug used in the envelope and error messages. |
name |
string |
'Standard Webhooks' |
Display name. |
eventType |
string | ((payload) => string | undefined) |
'type' |
Which body field names the event. |
Two details the spec makes easy to get wrong
The secret is base64 after the whsec_ prefix. Signing with the
literal string produces a digest that never matches. The SDK decodes it for
you — and throws a loud ConfigurationError on an undecodable secret
instead of failing every delivery as a bad signature.
The signature header is a list. During key rotation, vendors send
several space-delimited candidates (v1,abc v1,def). The SDK checks all of
them; unknown versions are ignored rather than rejected, so a future v2
fails closed instead of breaking you.
The envelope
event.id— thewebhook-idheader, the spec’s canonical dedup key.event.type— the body’stypefield by default; override witheventType(Replicate’s wrapper uses thestatusfield, Loops’ useseventName).event.timestamp— the signedwebhook-timestamp.
Standalone & testing
import {
verifyStandardWebhook, // (raw, options) — throws on failure
parseStandardWebhook, // (raw, options?) — the envelope
signStandardWebhook, // (body, secret, { id?, timestamp?, headerPrefix? }) — all three headers
} from 'webhooks-sdk/standard-webhooks'
signStandardWebhook returns the three headers as a record — pass
headerPrefix: 'svix' to produce the legacy names. See
Testing.