npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

webhooks-sdk

v0.1.1

Published

One way to verify, parse, and route webhooks from every provider.

Downloads

269

Readme

webhooks-sdk

One way to verify, parse, and route webhooks from every provider.

Every provider invented its own signature scheme, its own replay window, and its own setup handshake. The differences are real but almost never interesting, and getting them subtly wrong fails quietly — a verification bug looks exactly like "the webhook didn't fire".

npm install webhooks-sdk

Zero dependencies. Web Crypto and fetch only, so the same code runs on Node 22+, Cloudflare Workers, Deno, and Bun.

Quickstart

import { createWebhookHandler } from 'webhooks-sdk'
import { stripe } from 'webhooks-sdk/stripe'

const handler = createWebhookHandler({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  on: {
    'payment_intent.succeeded': async (event) => {
      await fulfill(event.payload.data.object)
    },
    'customer.subscription.deleted': async (event) => {
      await revoke(event.payload.data.object)
    },
  },
})

// Next.js App Router, Hono, Deno, Bun, Workers — anything with a Request.
export const POST = handler.fetch

That call verifies the signature, enforces the replay window, parses the body, and dispatches — returning 401 on a bad signature, 400 on a malformed one, 500 if your handler throws (so the provider retries), and 200 otherwise.

Why the raw body matters

Every signature scheme signs the exact bytes on the wire. Parse the body and re-serialize it and the signature no longer matches, because key order, whitespace, and unicode escaping all changed. This is the single most common cause of "verification randomly fails".

The SDK therefore takes the request, not your parsed object, and reads the body once as bytes. In Express that means mounting the webhook route before any JSON parser:

import express from 'express'
import { toExpressHandler } from 'webhooks-sdk/express'

// Raw on the webhook path only; JSON everywhere else.
app.post('/webhooks/stripe', express.raw({ type: '*/*' }), toExpressHandler(handler))
app.use(express.json())

Core concepts

The event envelope

Everything above payload is identical for every provider:

{
  id: 'evt_1abc',                    // used for idempotency
  provider: 'stripe',
  type: 'payment_intent.succeeded',  // provider-native name
  timestamp: Date,
  payload: { … },                    // the provider's own body, untouched
  raw: { headers, body, text(), json() },
}

payload stays provider-native on purpose. A Stripe PaymentIntent and a GitHub push have nothing in common, and flattening them into a shared shape would lose information without buying much. A cross-provider semantic view for the domains where it genuinely fits — payments, git, messaging — is planned as an opt-in layer, not a replacement.

Standalone verification

The router is optional. When you want the check and nothing else:

import { verifyStripeWebhook, parseStripeWebhook } from 'webhooks-sdk/stripe'
import { toRawWebhook } from 'webhooks-sdk'

const raw = await toRawWebhook(request)
await verifyStripeWebhook(raw, { secret })  // throws WebhookError on failure
const event = parseStripeWebhook(raw)

Handshakes

Most providers will not deliver anything until you answer a one-time challenge, and the right order differs. Unsigned challenges — Meta's hub.challenge, Asana's X-Hook-Secret — must be answered before verification, because the request is often what establishes the secret. Signed ones must be verified first: Discord probes a new endpoint with a deliberately invalid signature and refuses to save the URL unless it receives a 401.

Providers declare which they are, and the handler orders the two calls to match. A handshake short-circuits before dispatch and reports outcome: 'handshake', so your event handlers never see it.

Idempotency

Providers retry on non-2xx, and several deliver at-least-once even when you succeed. Duplicate deliveries are routine, not an edge case:

import { memoryIdempotencyStore } from 'webhooks-sdk'

createWebhookHandler({
  provider: stripe({ secret }),
  idempotency: memoryIdempotencyStore(),
  on: { … },
})

The in-memory store suits a single long-lived process. On serverless or across several instances, implement the two-method IdempotencyStore interface over Redis, KV, or a Durable Object — each instance otherwise keeps its own map and catches nothing.

A suppressed duplicate returns 200, not an error. Rejecting it would make the provider redeliver it forever.

Secret rotation

Pass an array; any match wins. Stripe keeps the previous secret valid for 24 hours after you roll it, and a single-secret implementation has a 24-hour hole:

stripe({ secret: [process.env.STRIPE_SECRET_NEW!, process.env.STRIPE_SECRET_OLD!] })

One route, many providers

import { WebhookRouter } from 'webhooks-sdk'

const router = new WebhookRouter({
  providers: {
    stripe: stripe({ secret: process.env.STRIPE_SECRET! }),
    github: github({ secret: process.env.GITHUB_SECRET! }),
  },
})

router.on('stripe', 'charge.refunded', handleRefund)
router.on('github', 'push', handlePush)

// app/api/webhooks/[provider]/route.ts
export const POST = router.fetch

The provider is taken from the last path segment by default; override with resolve.

Errors

Failures are returned, not thrown — handler.process() never throws:

const result = await handler.process(request)

if (!result.ok) {
  console.error(result.error?.code)  // 'invalid_signature' | 'timestamp_out_of_tolerance' | …
}

result.outcome is one of handled, unhandled, duplicate, handshake, or failed. Every WebhookError carries a code, an HTTP status, and an isVerificationFailure flag.

Framework adapters

| Import | For | |--------|-----| | handler.fetch | Anything Web-standard: Next.js App Router, Workers, Deno, Bun, Remix | | webhooks-sdk/next | export const { POST } = toNextRoute(handler) | | webhooks-sdk/hono | app.post('/hook', toHonoHandler(handler)) | | webhooks-sdk/express | toExpressHandler(handler), plus captureRawBody | | webhooks-sdk/node | toNodeHandler(handler) for bare node:http and the Pages Router |

Testing

Sign fixtures with the real algorithm instead of stubbing the verifier — a test that mocks verification tests nothing:

import { createWebhookRequest, eventRecorder } from 'webhooks-sdk/testing'
import { signStripeWebhook } from 'webhooks-sdk/stripe'

const body = JSON.stringify(event)
const request = createWebhookRequest({
  body,
  headers: { 'stripe-signature': await signStripeWebhook(body, secret) },
})

const recorder = eventRecorder()
const handler = createWebhookHandler({ provider, onEvent: recorder.record })
await handler.process(request)

expect(recorder.types).toEqual(['payment_intent.succeeded'])

Inject the clock with now to test replay windows without waiting.

Providers

Shipping today: Stripe, GitHub, Discord, Twilio, Google Pub/Sub (which also carries Gmail push, Play RTDN, and Workspace Events), and Standard Webhooks — the last of which covers every Svix-backed vendor, with named wrappers for Resend, Clerk, Polar, and Replicate.

Any other Standard Webhooks vendor works right now without a wrapper:

import { standardWebhooks } from 'webhooks-sdk/standard-webhooks'

standardWebhooks({ id: 'openai', secret: process.env.OPENAI_WEBHOOK_SECRET! })

That covers OpenAI, Dodo, Stytch, Loops, and Svix itself. Both header generations (webhook-* and the older svix-*) are accepted, along with the spec's asymmetric Ed25519 v1a signatures.

See INTEGRATIONS.md for the full catalog — roughly 90 providers grouped by the nine signature families that cover almost all of them, plus the handshake each one expects before it will deliver anything.

Adding a provider is one file and a test fixture; the contract is documented at the end of that page.

Roadmap

  • 0.1 — core, Stripe, GitHub, Standard Webhooks (+ Resend, Clerk, Polar, Replicate), framework adapters, testing utilities
  • 0.2 — ✅ the providers that stress the contract: Twilio (signs the public endpoint URL, not the body) and Google Pub/Sub push (JWKS plus a nested envelope). Next: the rest of tier 1 — Slack, Shopify, GitLab, Linear, Paddle, Square
  • 0.3 — the rest of the JWKS and certificate-chain families (PayPal, AWS SNS, Plaid) on the pluggable key cache that shipped with Pub/Sub
  • 0.4 — the opt-in semantic layer for payments, git, and messaging
  • Later — a CLI for replaying captured deliveries against a local endpoint

License

MIT