webhooks
| Package | @theoven/webhooks |
| Adds to context | ctx.webhook — on verified routes only |
| Endpoints | none — you write the route |
| Creates files | none |
| Creates tables | none |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/webhooksimport { env } from '@theoven/core'import { stripe, webhook } from '@theoven/webhooks'
export default webhook(stripe({ secret: env.string('STRIPE_WEBHOOK_SECRET') }), async (ctx) => { const event = ctx.webhook.payload as { type: string; id: string } return { received: event.id }})What it does
Section titled “What it does”Verifies an inbound webhook’s signature and refuses the request when it does not check out — so the handler runs only for deliveries that genuinely came from the provider.
It is not a brick in the .use() sense: there is nothing to register, and ctx.webhook exists on
the routes that ask for it rather than on every request.
Endpoints
Section titled “Endpoints”This package adds none. You write the route; it wraps the handler.
Providers
Section titled “Providers”| Header | What is signed | Encoding | Replay-protected | |
|---|---|---|---|---|
stripe |
Stripe-Signature |
${timestamp}.${body} |
hex | ✓ |
slack |
X-Slack-Signature |
v0:${timestamp}:${body} |
hex | ✓ |
svix |
svix-signature |
${id}.${timestamp}.${body} |
base64 | ✓ |
github |
X-Hub-Signature-256 |
the body | hex | ✗ |
razorpay |
X-Razorpay-Signature |
the body | hex | ✗ |
shopify |
X-Shopify-Hmac-Sha256 |
the body | base64 | ✗ |
svix covers Clerk, Resend, Brex and everything else built on Svix — same scheme, same
verifier.
The “what is signed” column is the reason this package exists. Every one of these is HMAC-SHA256 and every one signs a different string; signing the bare body for Slack, or hex instead of base64 for Shopify, produces a check that is wrong in a way that looks right.
Configuration
Section titled “Configuration”stripe({ secret: env.string('STRIPE_WEBHOOK_SECRET'), tolerance: 300 })| Option | Default | |
|---|---|---|
secret |
— | required; the endpoint’s signing secret, never an API key |
tolerance |
300 |
seconds a delivery may be old — the providers that support it |
For Svix the secret is the whsec_… string as given. The prefix is stripped and the rest is
base64-decoded to make the key — using it as a plain string is the single most common reason a
hand-rolled Svix check fails, and it fails for every delivery while looking correct.
What the handler gets
Section titled “What the handler gets”ctx.webhook.payload // parsed from the bytes the signature coveredctx.webhook.id // the provider's delivery id, where it sends onectx.webhook.timestamp // seconds, on the providers that sign onectx.webhook.provider // 'stripe', 'github', …ctx.webhook.raw // the exact bytesHanding off to a queue
Section titled “Handing off to a queue”A provider retries when your endpoint is slow, so the work belongs somewhere else:
export default webhook(stripe({ secret }), async (ctx) => { await ctx.queue.dispatch(handleStripe, ctx.webhook.payload, { key: ctx.webhook.id }) return { received: true }})The dedup key is doing real work: providers deliver at least once by design, and a duplicate
charge.succeeded must not fulfil an order twice. Keying the job on the delivery id makes the
second one a no-op.
Noticing when it breaks
Section titled “Noticing when it breaks”webhook(stripe({ secret }), handler, { onRejected: (failure, ctx) => ctx.log.warn('webhook rejected', { ...failure }),})A webhook that starts failing is silent: the provider retries, gives up, and nobody finds out until something downstream is missing. A rotated secret and a drifted clock both look like this.
What it creates
Section titled “What it creates”Nothing. No files, no tables, no endpoints.
Capabilities
Section titled “Capabilities”| Signature verification against the raw bytes | ✓ all providers |
| Constant-time comparison | ✓ — Node’s native timingSafeEqual |
| Replay rejection | ✓ Stripe, Slack, Svix · ✗ GitHub, Razorpay, Shopify |
| Multiple valid signatures during a rotation | ✓ Stripe, Svix |
| Idempotency | ✗ — yours, via ctx.webhook.id |
| A delivery log | ✗ |
Limitations
Section titled “Limitations”- Three providers cannot detect a replay. GitHub, Razorpay and Shopify sign the body and
nothing else, so a captured delivery stays valid forever and no implementation can tell. Check
verifier.replayProtected, and deduplicate on the delivery id where it matters. - No delivery log or automatic idempotency. The queue’s dedup keys are the intended answer.
payloadis JSON only. A form-encoded webhook — some Slack endpoints — comes through asraw.- No outbound webhooks. Sending them, with retries and signing, is a different feature.
- Six providers. Anything else means writing a
WebhookVerifier, which is one function.
How it is verified
Section titled “How it is verified”The HMAC is checked against GitHub’s own published example — secret, payload and expected signature taken from their documentation rather than produced by this code. An expected value computed by the implementation under test proves only that it agrees with itself.
Beyond that, the tests are mostly attacks: a body altered after signing, a wrong secret, a missing header, a signature over the bare body where the provider signs a prefixed string, hex where base64 is expected, a swapped Svix message id, and a replayed delivery whose timestamp was moved forward to escape the window — which must fail, or the window is decoration.