stay-hooked
v0.1.0
Published
Unified webhook ingestion library — type-safe verification and parsing for 19+ SaaS providers
Downloads
11
Maintainers
Readme
stay-hooked
Unified webhook ingestion for TypeScript. Type-safe verification and parsing for 19+ SaaS providers with a consistent API.
- Zero dependencies — uses only Node.js built-in
crypto - Tree-shakable — import only the providers you need
- Type-safe — fully typed event payloads per provider
- Framework-agnostic — works with Express, Fastify, Next.js, Hono, NestJS, or plain Node.js
Installation
npm install stay-hookedQuick Start
import { createWebhookHandler } from "stay-hooked";
import { stripe } from "stay-hooked/providers/stripe";
const handler = createWebhookHandler(stripe, {
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
// In your HTTP handler:
const event = handler.verifyAndParse(headers, rawBody);
if (event.type === "checkout.session.completed") {
console.log(event.data.customer_email); // fully typed
}The Problem
Every SaaS sends webhooks differently:
// Before stay-hooked — repetitive, error-prone, untyped
// Stripe: HMAC-SHA256 with timestamp
const sig = req.headers["stripe-signature"];
const parts = sig.split(",");
const timestamp = parts.find(p => p.startsWith("t="))...
const hmac = crypto.createHmac("sha256", secret).update(`${ts}.${body}`)...
// GitHub: HMAC-SHA256 with sha256= prefix
const sig = req.headers["x-hub-signature-256"];
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(body)...
// Shopify: HMAC-SHA256 base64-encoded
const sig = req.headers["x-shopify-hmac-sha256"];
const expected = crypto.createHmac("sha256", secret).update(body).digest("base64")...
// Clerk: Svix-based with whsec_ prefix, base64 secret, multiple signature versions...The Solution
// After stay-hooked — one API for everything
import { createWebhookHandler } from "stay-hooked";
import { stripe } from "stay-hooked/providers/stripe";
import { github } from "stay-hooked/providers/github";
import { shopify } from "stay-hooked/providers/shopify";
import { clerk } from "stay-hooked/providers/clerk";
// Same API for every provider
const stripeHandler = createWebhookHandler(stripe, { secret: STRIPE_SECRET });
const githubHandler = createWebhookHandler(github, { secret: GITHUB_SECRET });
const shopifyHandler = createWebhookHandler(shopify, { secret: SHOPIFY_SECRET });
const clerkHandler = createWebhookHandler(clerk, { secret: CLERK_SECRET });
// Each returns a typed WebhookEvent
const event = stripeHandler.verifyAndParse(headers, rawBody);Framework Adapters
Express
import express from "express";
import { expressAdapter } from "stay-hooked/adapters/express";
import { stripe } from "stay-hooked/providers/stripe";
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
expressAdapter(stripe, {
secret: process.env.STRIPE_WEBHOOK_SECRET!,
onEvent: async (event) => {
switch (event.type) {
case "checkout.session.completed":
await handleCheckout(event.data);
break;
case "invoice.payment_failed":
await handleFailedPayment(event.data);
break;
}
},
})
);Next.js App Router
// app/api/webhooks/stripe/route.ts
import { nextjsAdapter } from "stay-hooked/adapters/nextjs";
import { stripe } from "stay-hooked/providers/stripe";
export const POST = nextjsAdapter(stripe, {
secret: process.env.STRIPE_WEBHOOK_SECRET!,
onEvent: async (event) => {
console.log(event.type, event.data);
},
});Fastify
import { fastifyAdapter } from "stay-hooked/adapters/fastify";
import { github } from "stay-hooked/providers/github";
fastify.post(
"/webhooks/github",
fastifyAdapter(github, {
secret: process.env.GITHUB_WEBHOOK_SECRET!,
onEvent: async (event) => {
if (event.type === "push") {
console.log(event.data.commits);
}
},
})
);Hono
import { Hono } from "hono";
import { honoAdapter } from "stay-hooked/adapters/hono";
import { shopify } from "stay-hooked/providers/shopify";
const app = new Hono();
app.post(
"/webhooks/shopify",
honoAdapter(shopify, {
secret: process.env.SHOPIFY_WEBHOOK_SECRET!,
onEvent: async (event) => {
if (event.type === "orders/create") {
console.log(event.data.total_price);
}
},
})
);NestJS
import { Controller, Post, UseGuards, Req } from "@nestjs/common";
import { createWebhookGuard, getWebhookEvent } from "stay-hooked/adapters/nestjs";
import { stripe } from "stay-hooked/providers/stripe";
const StripeGuard = createWebhookGuard({
provider: stripe,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
@Controller("webhooks")
export class WebhooksController {
@Post("stripe")
@UseGuards(StripeGuard)
handleStripe(@Req() req: Request) {
const event = getWebhookEvent(req);
console.log(event.type, event.data);
}
}Supported Providers
| Category | Provider | Verification Method |
|---|---|---|
| Payments | Stripe | HMAC-SHA256 + timestamp |
| | Shopify | HMAC-SHA256 (base64) |
| | PayPal | Header validation* |
| | Square | HMAC-SHA256 (base64) |
| | Paddle | HMAC-SHA256 + timestamp |
| | LemonSqueezy | HMAC-SHA256 (hex) |
| DevOps | GitHub | HMAC-SHA256 (sha256= prefix) |
| | GitLab | Token comparison |
| | Bitbucket | HMAC-SHA256 (sha256= prefix) |
| | Linear | HMAC-SHA256 (hex) |
| | Jira | Header validation |
| Communication | Slack | HMAC-SHA256 (versioned) |
| | Discord | Ed25519 |
| | Twilio | HMAC-SHA1 (base64) |
| | SendGrid | Header validation |
| | Postmark | Header validation |
| | Resend | Svix (HMAC-SHA256) |
| Auth | Clerk | Svix (HMAC-SHA256) |
| Infrastructure | Svix | HMAC-SHA256 (base64) |
*PayPal requires API-based verification for full security. The library validates required headers are present.
Verification notes
Some providers do not use cryptographic (HMAC) signatures. For these, stay-hooked validates that required headers are present, but cannot verify the payload was sent by the genuine provider without an additional API call:
| Provider | Reason |
|---|---|
| PayPal | Uses OAuth-based webhooks; cryptographic verification requires a separate API call |
| Twilio | Uses HMAC-SHA1 but requires the full request URL to compute — provide it via options if available |
| SendGrid | Uses a signed public-key scheme; header-presence check only in this release |
| Jira | Sends a shared x-hub-signature but many plans omit it; header-presence check only |
| Postmark | No signature scheme; validates x-postmark-token header is present |
For production use with these providers, consider adding your own secondary validation (e.g., re-fetching the resource from the provider's API).
Error Handling
stay-hooked throws specific error types you can catch:
import {
WebhookVerificationError,
WebhookParseError,
} from "stay-hooked";
try {
const event = handler.verifyAndParse(headers, rawBody);
} catch (error) {
if (error instanceof WebhookVerificationError) {
// Signature verification failed
console.error(error.provider); // "stripe"
console.error(error.code); // "SIGNATURE_MISMATCH"
}
if (error instanceof WebhookParseError) {
// Payload parsing failed
console.error(error.provider);
console.error(error.code); // "PARSE_FAILED"
}
}API Reference
createWebhookHandler(provider, options)
Creates a configured webhook handler.
const handler = createWebhookHandler(provider, { secret: "..." });
const event = handler.verifyAndParse(headers, rawBody);WebhookEvent
The return type of verifyAndParse():
interface WebhookEvent<TEventMap> {
type: string; // Event type (e.g., "checkout.session.completed")
data: unknown; // Typed event payload
raw: unknown; // Raw parsed JSON
timestamp?: Date; // Event timestamp (if provided)
id?: string; // Event ID (if provided)
}WebhookProvider
Interface implemented by every provider:
interface WebhookProvider<TEventMap> {
name: string;
verify(secret: string, headers: Record<string, string>, rawBody: string): void;
parse(headers: Record<string, string>, rawBody: string): WebhookEvent<TEventMap>;
}Requirements
- Node.js >= 18
- TypeScript >= 5.0 (for type safety)
License
MIT
