@samveys/webhooks

Version
1.0.2Nov 5, 2026
Install
pnpm add @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.

ts
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

DriverHeader(s)Signed valueReplay id
standardwebhook-id, webhook-timestamp, webhook-signature (v1,<base64>, several allowed)HMAC-SHA256 of id.timestamp.body, secret whsec_<base64>webhook-id
githubX-Hub-Signature-256HMAC-SHA256 of the bodyX-GitHub-Delivery
stripeStripe-Signature (t=…,v1=…)HMAC-SHA256 of t.body; timestamp tolerance appliesevent id
midtranssignature_key in the JSON bodySHA-512 of order_id + status_code + gross_amount + serverKeyorder_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.

ts
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.

ts
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.