@outworx/hooks
v1.5.1
Published
Lightweight webhook monitoring SDK — know when your webhooks break
Maintainers
Readme
@outworx/hooks
Lightweight webhook monitoring SDK -- know when your webhooks break.
Zero dependencies. Under 5KB. Works with Next.js, Express, and Fastify.
Installation
npm install @outworx/hooksQuick Start
1. Initialize
Add your API key once at app startup, or set the OUTWORX_HOOKS_API_KEY environment variable.
import { init } from "@outworx/hooks";
init({ apiKey: "your-api-key" });Or via environment variable:
OUTWORX_HOOKS_API_KEY=your-api-key2. Wrap Your Webhook Handler
Next.js (App Router)
// app/api/webhooks/stripe/route.ts
import { withWebhookMonitoring } from "@outworx/hooks/nextjs";
async function handler(req: Request) {
const body = await req.json();
// ... process webhook
return new Response("OK", { status: 200 });
}
export const POST = withWebhookMonitoring(
{ provider: "stripe", eventTypeField: "type" },
handler
);Express
import express from "express";
import { withWebhookMonitoring } from "@outworx/hooks/express";
const app = express();
app.post(
"/webhooks/stripe",
withWebhookMonitoring({ provider: "stripe", eventTypeField: "type" }),
(req, res) => {
// ... process webhook
res.json({ received: true });
}
);Fastify
import Fastify from "fastify";
import { withWebhookMonitoring } from "@outworx/hooks/fastify";
const fastify = Fastify();
fastify.register(
withWebhookMonitoring({ provider: "stripe", eventTypeField: "type" })
);
fastify.post("/webhooks/stripe", async (request, reply) => {
// ... process webhook
return { received: true };
});Silent drop detection (v1.5+)
The most common webhook bug nobody tells you about: your handler returns
200 OK, the provider thinks delivery succeeded, but your business logic
short-circuited (early return inside an if, swallowed exception in a
try/catch, missing await). Provider doesn't retry. Dashboard says
success. State is wrong. Hours later, a customer notices.
Outworx 1.5 catches this. Opt in by setting requireProcessingMark and
calling track.processed() once your handler actually finishes its work:
import { withWebhookMonitoring } from "@outworx/hooks/nextjs";
export const POST = withWebhookMonitoring(
{
provider: "stripe",
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
requireProcessingMark: true, // ← opt in to silent-drop detection
},
async (req, { track }) => {
const event = await stripe.webhooks.constructEventAsync(...);
if (event.type !== "charge.succeeded") {
// Handler returns 200 but we never call track.processed() —
// dashboard flags this as a silent_drop in the rare case it was
// unintended. (For genuinely-ignored events, add an explicit
// `track.processed({ reason: "ignored" })` so the signal is clean.)
return Response.json({ received: true });
}
await chargeCustomer(event.data.object);
track.processed(); // ← explicit ack
return Response.json({ received: true });
}
);You can also explicitly mark application-level failures (handler returns 200 to avoid retries, but flags the event as not actually processed):
try {
await chargeCustomer(event.data.object);
track.processed();
} catch (err) {
track.failed(err.message); // tagged as application failure
return Response.json({ received: true }); // 200 — don't retry
}Express
import { withWebhookMonitoring } from "@outworx/hooks/express";
app.post(
"/webhooks/stripe",
withWebhookMonitoring({
provider: "stripe",
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
requireProcessingMark: true,
}),
async (req, res) => {
await chargeCustomer(req.body);
req.outworx?.track.processed();
res.json({ received: true });
}
);Fastify
fastify.post("/webhooks/stripe", {
config: { webhookProvider: "stripe", requireProcessingMark: true },
handler: async (request, reply) => {
await chargeCustomer(request.body);
request.outworx?.track.processed();
return { received: true };
},
});Signature failure diagnostics (v1.5+)
When signature verification fails, Outworx now tells you why. Each
failure includes a stable reason code and a developer-facing hint,
surfaced on the event detail page in the dashboard:
| Reason | Meaning | Fix |
|---|---|---|
| missing_header | Provider's signature header was absent | Confirm endpoint URL in provider dashboard |
| malformed_header | Header was present but didn't match expected format | Check for proxies/middleware mutating headers |
| timestamp_drift | Signed timestamp outside tolerance | Sync server clock (NTP) |
| hmac_mismatch | Signature didn't verify against your secret | Wrong/rotated secret, or modified body |
| parsed_body | Body looks like JSON.stringify output rather than raw bytes | Verify against the raw request body |
| unsupported_provider | No built-in verifier for this provider | Use a custom signatureVerifier function |
| verifier_threw | Verifier threw an exception | Check error_message on the event |
To use the structured form programmatically:
import { verifyStripeSignatureDetailed } from "@outworx/hooks/security";
const result = verifyStripeSignatureDetailed({
rawBody, header: req.headers["stripe-signature"], secret,
});
if (!result.valid) {
console.warn(`signature failed: ${result.reason} — ${result.hint}`);
}The boolean form (verifyStripeSignature(...)) still works unchanged.
Local tunnel (outworx forward)
Develop against real provider webhooks without deploying. The CLI ships inside this package — no extra install:
npx outworx forward 3000 outworx forward → http://localhost:3000
Public URL: https://hooks.outworx.io/t/8b3a9f1c2d4e5067
Expires: 11/7/2026, 4:18:42 PM
Press Ctrl+C to stop.
16:18:51 POST /webhooks/stripe 200 42ms
16:18:52 POST /webhooks/stripe 200 31msPoint your provider (Stripe, GitHub, Shopify, …) at the printed Public URL. Inbound requests are forwarded to your local server, and the response your handler returns is relayed back to the provider.
# Auth — set OUTWORX_API_KEY or pass --api-key
export OUTWORX_API_KEY=sk_live_...
npx outworx forward http://localhost:3000
# Shorthands
npx outworx forward 3000 # → http://localhost:3000
npx outworx forward localhost:8080 # → http://localhost:8080
# Override the Outworx server (self-hosted / staging)
npx outworx forward 3000 --endpoint=https://hooks.staging.example.comSessions auto-expire after 24 hours. Hop-by-hop headers are stripped on
both legs; the relayed response includes X-Outworx-Tunnel: <slug> so
your handler can detect tunneled traffic if needed.
Configuration
init({
apiKey: "your-api-key",
endpoint: "https://hooks.outworx.io/api/ingest", // default
debug: true, // log events to console
timeout: 3000, // HTTP timeout in ms
onError: (err) => console.error(err), // error callback
});Track Options
Each adapter accepts TrackOptions:
| Option | Type | Default | Description |
| ----------------- | ------------------------ | ------- | -------------------------------------------------- |
| provider | string | -- | Webhook provider name (e.g., "stripe", "shopify") |
| eventTypeHeader | string | -- | Header name to extract event type from |
| eventTypeField | string | -- | Body field to extract event type from (e.g., "type")|
| captureBody | boolean | false | Capture request + response bodies (disabled by default for privacy) |
| captureHeaders | boolean | true | Capture request headers (sensitive ones redacted) |
| metadata | Record<string, unknown>| -- | Custom metadata attached to every event |
| signatureSecret | string | -- | Auto-verify signature (see below) |
| signatureVerifier| function | -- | Custom verifier function |
| rejectInvalidSignatures | boolean | true | Respond with 401 when verification fails |
| signatureTolerance | number | 300 | Replay-attack window (seconds) for timestamp providers |
| idempotencyKey | function | -- | Extract a dedup key from the request (see below) |
| idempotencyTtl | number | 86400 | TTL (seconds) for idempotency cache. Max 604800 (7d) |
Signature Verification
Built-in support for verifying webhook signatures for Stripe, GitHub, Shopify,
Svix / Clerk, and Slack. When you provide a signatureSecret, the SDK:
- Computes the expected HMAC-SHA256 signature
- Compares it with the one in the request header (timing-safe)
- Rejects replay attacks (for timestamp-based providers)
- Returns
401 Invalid webhook signaturewithout calling your handler if invalid - Reports
signature_validto your Outworx dashboard for every request
Quick start
// Next.js (App Router) — works out of the box
import { init } from '@outworx/hooks';
import { withWebhookMonitoring } from '@outworx/hooks/nextjs';
init({ apiKey: process.env.OUTWORX_HOOKS_API_KEY! });
export const POST = withWebhookMonitoring(
{
provider: 'stripe',
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
},
async (req) => {
const body = await req.json();
// Signature has already been verified — handle the event
return Response.json({ received: true });
}
);Express — preserve the raw body
Express parses the body before your handler runs, so we need to stash the raw bytes first:
import express from 'express';
import { withWebhookMonitoring } from '@outworx/hooks/express';
const app = express();
app.use(express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString('utf8');
},
}));
app.post(
'/webhooks/stripe',
withWebhookMonitoring({
provider: 'stripe',
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
(req, res) => res.json({ received: true })
);Fastify — use fastify-raw-body
import Fastify from 'fastify';
import rawBody from 'fastify-raw-body';
import { webhookMonitoringPlugin } from '@outworx/hooks/fastify';
const app = Fastify();
app.register(rawBody);
app.register(webhookMonitoringPlugin);
app.post('/webhooks/stripe', {
config: {
webhookProvider: 'stripe',
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
handler: async (_req, reply) => reply.send({ received: true }),
});Custom verifier (any provider)
For providers not in the built-in list, or to plug in your own logic:
withWebhookMonitoring(
{
provider: 'my-service',
signatureVerifier: async (rawBody, headers) => {
// return true if valid, false otherwise
return myCheck(rawBody, headers['x-my-signature']);
},
},
handler
);Standalone verifiers
Import and call the per-provider verifiers directly:
import {
verifyStripeSignature,
verifyGithubSignature,
verifyShopifySignature,
verifySvixSignature,
verifySlackSignature,
} from '@outworx/hooks/security';
const valid = verifyStripeSignature({
rawBody,
header: req.headers['stripe-signature'] as string,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});Idempotency
Webhook providers retry deliveries when your handler times out or returns a
non-2xx response — which can cause you to double-process the same event
(double-charge the customer, send duplicate emails, etc.). Pass an
idempotencyKey function and the SDK will short-circuit retries with the
cached response from the first successful delivery.
export const POST = withWebhookMonitoring(
{
provider: 'stripe',
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
idempotencyKey: (_req, body) => (body as any).id, // Stripe event ID
},
async (req) => {
// Handler runs at most once per event ID, regardless of retries.
const body = await req.json();
await chargeCustomer(body);
return Response.json({ received: true });
}
);On a duplicate delivery (same key, within idempotencyTtl), the SDK
returns the cached response (status code + body) without calling your
handler. The event still appears in your dashboard tagged as a duplicate.
Recommended keys per provider
// Stripe — event ID in the body
idempotencyKey: (_req, body) => (body as any).id
// GitHub — delivery ID header
idempotencyKey: (_req, _body, headers) => headers['x-github-delivery']
// Shopify — webhook ID header
idempotencyKey: (_req, _body, headers) => headers['x-shopify-webhook-id']
// Svix / Clerk — message ID header
idempotencyKey: (_req, _body, headers) => headers['svix-id']Returning null or undefined from the function skips idempotency for
that request.
Failure behavior
If our backend is unreachable when the SDK tries to check for duplicates, the check fails open — your handler runs as normal. We never let an idempotency failure block webhook delivery.
If your handler throws or returns 5xx, the idempotency key is not committed, so the next retry is free to re-run the handler. Stale reservations older than 30 seconds are automatically cleared.
Dashboard
View your webhook activity at hooks.outworx.io.
License
Business Source License 1.1 — see LICENSE for full text.
Free to use in production with the Outworx Hooks service. You may not use this SDK or any derivative work to offer a hosted webhook monitoring, analytics, or alerting service that competes with Outworx. The license converts to Apache 2.0 on 2030-04-17.
For commercial licensing inquiries, contact [email protected].
