Testing
Sign fixtures with the real algorithm instead of stubbing the verifier — a test that mocks verification tests nothing.
Every provider ships a signing helper, so your tests exercise the real verification path. A test that stubs the verifier tests nothing: it passes whether or not your route can verify an actual delivery.
import { createWebhookHandler } from 'webhooks-sdk'
import { createWebhookRequest, eventRecorder } from 'webhooks-sdk/testing'
import { stripe, signStripeWebhook } from 'webhooks-sdk/stripe'
const SECRET = 'whsec_test'
const NOW = new Date('2026-08-20T12:00:00Z')
const body = JSON.stringify({
id: 'evt_1',
object: 'event',
type: 'payment_intent.succeeded',
created: Math.floor(NOW.getTime() / 1000),
data: { object: {} },
})
const request = createWebhookRequest({
body,
headers: {
'stripe-signature': await signStripeWebhook(body, SECRET, Math.floor(NOW.getTime() / 1000)),
},
})
const recorder = eventRecorder()
const handler = createWebhookHandler({
provider: stripe({ secret: SECRET }),
now: () => NOW,
onEvent: recorder.record,
})
const result = await handler.process(request)
expect(result.outcome).toBe('handled')
expect(recorder.types).toEqual(['payment_intent.succeeded'])
The utilities
createWebhookRequest
Builds a Request whose body bytes are exactly what you passed:
createWebhookRequest({
body, // string or object
headers: { /* … */ },
url: 'https://example.test/api/webhooks', // the default
method: 'POST', // the default
})
An object body is serialized once, and that same string is what you sign
and what gets sent — the property signature tests depend on. GET and
HEAD requests are built bodiless, matching the challenge probes several
providers send.
eventRecorder
A tiny sink for dispatched events:
const recorder = eventRecorder()
// pass recorder.record as onEvent, then assert:
recorder.events // WebhookEvent[]
recorder.types // string[] of event.type, in order
recorder.clear()
memoryIdempotencyStore is re-exported from webhooks-sdk/testing too, so
test files need only the one import path.
Controlling the clock
Replay windows compare the signed timestamp against now. Inject now to
test them without waiting:
const handler = createWebhookHandler({
provider: stripe({ secret: SECRET }),
now: () => new Date('2026-08-20T12:10:00Z'), // 10 minutes after signing
})
const result = await handler.process(request)
expect(result.error?.code).toBe('timestamp_out_of_tolerance')
Setting tolerance: 0 disables the window entirely — useful when replaying
captured production deliveries whose timestamps are long past.
The signing helpers
| Provider | Helper | Returns |
|---|---|---|
| Stripe | signStripeWebhook(body, secret, timestamp?) |
the Stripe-Signature header value |
| GitHub | signGitHubWebhook(body, secret) |
the X-Hub-Signature-256 header value |
| Standard Webhooks | signStandardWebhook(body, secret, { id?, timestamp?, headerPrefix? }) |
all three headers as a record |
| Discord | signDiscordWebhook(body, privateKey, timestamp?) |
both signature headers as a record |
| Google Pub/Sub | signGooglePubSubWebhook(privateKey, options) |
the Authorization header as a record |
| Twilio | signTwilioWebhook(url, params, authToken) |
the X-Twilio-Signature header value |
The asymmetric ones (Discord, Pub/Sub) take a private CryptoKey. Generate
a throwaway pair in the test and hand the public half to the provider:
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{ name: 'Ed25519' }, true, ['sign', 'verify'],
) as CryptoKeyPair
Testing failure paths
Verification code earns its keep on the failures. Worth covering in any integration:
- the wrong secret — expect
invalid_signature - a body tampered with after signing — expect
invalid_signature - a missing header — expect
missing_signature - a stale timestamp, where the scheme signs one — expect
timestamp_out_of_tolerance - the same event twice with an idempotency store — expect
outcome: 'duplicate'on the second pass