hooksentinel

Introduction

What hooksentinel is, why it exists, and how it compares to Svix and Tern.

What is hooksentinel?

@hooksentinel/core is a TypeScript-first library for receiving inbound webhooks in Node.js. It sits at the top of your webhook route and handles the part every provider makes you re-implement by hand:

  • Verifying the request actually came from the provider (HMAC / signature checks)
  • Rejecting replayed or expired requests (timestamp tolerance)
  • Deduplicating events so retried deliveries don't run your handler twice
  • Acknowledging fast, so the provider doesn't time out and retry unnecessarily
  • Giving you a typed event object instead of unknown JSON

It is not a webhook sending platform. It doesn't manage outbound delivery, retries to your customers, or a dashboard of your own webhooks. It's the receiving half — the code that runs inside your server when Stripe, GitHub, or Shopify calls you.

Why it exists

Every project that accepts webhooks ends up writing roughly the same 150–250 lines of plumbing:

  1. Get the raw request body before any JSON parsing middleware touches it.
  2. Read the provider's signature header and compute an HMAC to compare against it.
  3. Check the request timestamp so a captured payload can't be replayed a week later.
  4. Parse the body into JSON, ideally with a runtime check that it matches your event shape.
  5. Look up whether you've already processed this event ID, because providers deliver at least once, not exactly once.
  6. Respond 200 immediately, and only then do the actual work — otherwise the provider retries because your database write took too long.

Get any one of these wrong and you get a real incident: a spoofed webhook mutating billing state, a duplicate charge.succeeded double-fulfilling an order, or a slow handler causing Stripe to retry the same event six times in a row.

hooksentinel is that plumbing, written once, typed, tested against each provider's real signature scheme, and shipped as a single dependency-free package.

Design goals

  • TypeScript-first. Event payloads are typed per provider. No any, no manual casting.
  • Framework-agnostic core. The verification and idempotency pipeline has no framework dependency. Thin adapters map it onto Express, Fastify, NestJS, Next.js, Hono, and Lambda.
  • Zero runtime dependencies. The core package ships with nothing else in node_modules. See Bundle size.
  • Fail closed. If a signature can't be verified, the request is rejected. There is no "verify in dev, skip in prod" footgun — you opt into that explicitly if you ever want it.
  • Pluggable idempotency. In-memory for local dev, Redis or Prisma for production, or bring your own store by implementing a three-method interface.

hooksentinel vs. Svix vs. Tern

Svix and Tern primarily solve the sending side of webhooks — they're platforms (hosted or self-hosted) that manage outbound delivery, retries, and a customer-facing dashboard for your webhooks product. hooksentinel solves the opposite problem: verifying and handling webhooks sent to you by third parties like Stripe or GitHub.

hooksentinelSvixTern
DirectionInbound (receiving)Outbound (sending)Outbound (sending)
What it isA library you importA hosted/self-hosted platformA hosted platform
InfrastructureNone — runs in your processMessage queue + dashboard + APIManaged service
Use caseVerify & handle Stripe/GitHub/etc. webhooksSend webhooks to your customersSend webhooks to your customers
DependenciesZeroServer + databaseNone (hosted)
PricingFree, open sourceFree tier + paid plansUsage-based

If you're building a product that emits webhooks to customers, you want Svix or Tern. If you're consuming webhooks from Stripe, GitHub, Shopify, or similar, hooksentinel is a purpose-built fit — and the two are often used together in the same codebase, on opposite ends of the pipe.

Why not just use the provider SDK?

Stripe, GitHub, and most other providers ship a constructEvent-style helper in their own SDK that verifies a signature. That's often enough — until one of these four things happens.

Multi-provider

stripe.webhooks.constructEvent() only works for Stripe. The moment you add GitHub, Shopify, or Slack webhooks to the same app, you're writing three different verification flows with three different header names, three different algorithms, and three different raw-body pitfalls. hooksentinel gives you one interface for all of them — swap stripe({ secret }) for github({ secret }) and everything else stays the same.

// Stripe
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),

// GitHub — same createWebhookHandler call, same onEvent shape
provider: github({ secret: process.env.GITHUB_WEBHOOK_SECRET! }),

Idempotency

Provider SDKs verify the signature and hand you a parsed event. None of them deduplicate. Stripe will retry checkout.session.completed if your server is slow or returns a non-2xx — without atomic deduplication, your handler runs twice. hooksentinel's redisStore() and prismaStore() implement an atomic claim (Redis SET NX EX, Postgres unique constraint) that guarantees exactly one handler invocation per event ID, even under concurrent retries from multiple server instances.

idempotency: redisStore({ url: process.env.REDIS_URL! }),

Raw body in frameworks

Every framework that parses JSON bodies first — express.json(), NestJS's body parser, Next.js Pages Router — silently breaks signature verification, because the signature covers the raw bytes, not a re-serialized object. hooksentinel detects this and throws a HookSentinelError with code: 'missing_raw_body' and an actionable message naming the exact fix, instead of returning a silent 400 Invalid Signature at 3am. See the missing_raw_body error page for the Express and NestJS fixes.

onError: async (error) => {
  if (error.code === 'missing_raw_body') {
    // error.message names the exact fix for your framework
  }
},

Fast acknowledgement

Providers retry on timeout. The correct production pattern is: verify → deduplicate → hand off to a queue → return 200 in under a second → process on a worker. hooksentinel supports this directly — onEvent enqueues a job (e.g. with BullMQ) and returns, keeping the request path fast regardless of how long the actual work takes. If enqueueing throws, hooksentinel automatically releases the idempotency claim and responds with handler_error (500, retryable), so the provider redelivers instead of the event silently being marked processed. See the BullMQ fast-ack pattern for the full setup.

onEvent: async (event, ctx) => {
  await fulfillmentQueue.add('fulfill-order', { eventId: ctx.eventId }, { jobId: ctx.eventId });
},

If you're only receiving Stripe webhooks in a single-process app, stripe.webhooks.constructEvent() is perfectly fine. hooksentinel adds the most value when you need multi-provider support, distributed deduplication across multiple instances, or durable queue handoff.

Next steps

Last updated on

On this page