@samveys/webhooks
From packages/webhooks/README.md in the framework repository.
Receive webhooks that are genuine and new, and send signed webhooks safely. No runtime dependencies; it uses @samveys/cache for replay protection and @samveys/http-client for delivery.
Receive
webhookReceiver() is route middleware. It reads the raw body from a clone of the request (before the kernel parses it), verifies the signature with a constant-time comparison, rejects timestamps more than 5 minutes off, and remembers the delivery id in the cache so a replay is refused.
import { createMemoryStore } from '@samveys/cache'
import type { Router } from '@samveys/http'
import { webhookEvent, webhookReceiver } from '@samveys/webhooks'
const seen = createMemoryStore() // use the shared cache in production so every process agrees
export function routes(router: Router) {
router.post(
'/webhooks/billing',
{ use: [webhookReceiver({ driver: 'standard', secret: process.env.BILLING_WEBHOOK_SECRET ?? '', cache: seen })] },
(ctx) => {
const event = webhookEvent(ctx) // verified and first-seen
return { received: event.id }
},
)
}Anything wrong gets 401 SVS_E108 with the same fixed body. The reason (bad signature, old timestamp, replay) is kept in the error's meta for your logs and is never sent back, so a forger learns nothing.
Drivers
| Driver | Header(s) | Signed value | Replay id |
|---|---|---|---|
standard | webhook-id, webhook-timestamp, webhook-signature (v1,<base64>, several allowed) | HMAC-SHA256 of id.timestamp.body, secret whsec_<base64> | webhook-id |
github | X-Hub-Signature-256 | HMAC-SHA256 of the body | X-GitHub-Delivery |
stripe | Stripe-Signature (t=…,v1=…) | HMAC-SHA256 of t.body; timestamp tolerance applies | event id |
midtrans | signature_key in the JSON body | SHA-512 of order_id + status_code + gross_amount + serverKey | order_id:transaction_id:transaction_status |
secret can be a list during a rotation. toleranceSeconds (default 300) applies to standard and stripe.
Process off the request
Pass process to answer 200 {"received":true} at once and do the work elsewhere. Call queue.dispatch(...) inside it; if process throws, the delivery id is forgotten so the sender's retry is accepted.
import { createMemoryStore } from '@samveys/cache'
import { webhookReceiver } from '@samveys/webhooks'
import type { WebhookEvent } from '@samveys/webhooks'
const pending: WebhookEvent[] = []
export const receiver = webhookReceiver({
driver: 'github',
secret: process.env.GITHUB_WEBHOOK_SECRET ?? '',
cache: createMemoryStore(),
process: (event) => {
pending.push(event) // in an app: await queue.dispatch(HandleGithubEvent, event.payload)
},
})Send
sendWebhook() signs per Standard Webhooks and posts with @samveys/http-client in untrusted mode, so an endpoint that points at a private or metadata address (or redirects there) is refused with SVS_E106 before anything is sent. Redirects are not followed.
import { sendWebhook } from '@samveys/webhooks'
export async function notify() {
const result = await sendWebhook(
{ url: 'https://customer.example.com/hooks', secret: 'whsec_c2VjcmV0LWtleQ==' },
{ type: 'order.created', data: { id: 7 } },
)
return result.ok // false for a non-2xx answer: retry from a queue job
}The body is {"type":…,"timestamp":"<ISO>","data":…}; the headers are webhook-id, webhook-timestamp and webhook-signature.