Webhooks
intermediateReceive webhooks securely with signature verification. Includes Stripe and GitHub verification utilities.
webhookssignaturestripegithubsecurity
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add infrastructure/webhooksInteractive demo coming soon
1The Problem
Receiving webhooks requires:
- Signature verification to prevent spoofing
- Idempotency to handle retries
- Provider-specific implementations
- Error handling and logging
2The Solution
Generic signature verification utilities with provider-specific implementations for Stripe, GitHub, and more. Uses Redis for idempotency tracking.
3Files
lib/webhooks/verify.ts
lib/webhooks/verify.tsTypeScript
import crypto from "crypto";
// Generic HMAC-SHA256 verification
export function verifyHmacSha256(
payload: string,
signature: string,
secret: string
): { valid: boolean; error?: string } {
try {
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// timingSafeEqual throws if the buffers differ in length, so guard first.
if (a.length !== b.length) {
return { valid: false, error: "Invalid signature length" };
}
return { valid: crypto.timingSafeEqual(a, b) };
} catch {
return { valid: false, error: "Verification failed" };
}
}
// Stripe signature verification
export function verifyStripeSignature(
payload: string,
signature: string,
secret: string,
tolerance = 300
): { valid: boolean; error?: string } {
try {
const parts = signature.split(",").reduce(
(acc, part) => {
const [key, value] = part.split("=");
acc[key] = value;
return acc;
},
{} as Record<string, string>
);
const timestamp = parseInt(parts.t, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance) {
return { valid: false, error: "Timestamp out of tolerance" };
}
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
// Stripe uses the exact key "v1"; compare in constant time.
const signatures = Object.entries(parts)
.filter(([key]) => key === "v1")
.map(([, value]) => value);
const expectedBuffer = Buffer.from(expected);
const isValid = signatures.some((sig) => {
const sigBuffer = Buffer.from(sig);
return (
sigBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(sigBuffer, expectedBuffer)
);
});
return { valid: isValid };
} catch {
return { valid: false, error: "Stripe verification failed" };
}
}
// GitHub signature verification
export function verifyGitHubSignature(
payload: string,
signature: string,
secret: string
): { valid: boolean; error?: string } {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(payload).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// timingSafeEqual throws if the buffers differ in length, so guard first.
if (a.length !== b.length) {
return { valid: false, error: "Invalid signature length" };
}
return { valid: crypto.timingSafeEqual(a, b) };
}lib/webhooks/idempotency.ts
lib/webhooks/idempotency.tsTypeScript
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const TTL = 60 * 60 * 24; // 24 hours
export async function checkIdempotency(
provider: string,
eventId: string
): Promise<{ isNew: boolean; processedAt?: number }> {
const key = `webhook:${provider}:${eventId}`;
const processed = await redis.get<number>(key);
return processed ? { isNew: false, processedAt: processed } : { isNew: true };
}
export async function markProcessed(provider: string, eventId: string) {
const key = `webhook:${provider}:${eventId}`;
await redis.set(key, Date.now(), { ex: TTL });
}
// Claims the key atomically with SET NX so two concurrent deliveries of the
// same event can't both run the processor (providers retry in parallel).
export async function processIdempotent<T>(
provider: string,
eventId: string,
processor: () => Promise<T>
): Promise<{ result: T; wasProcessed: boolean }> {
const key = `webhook:${provider}:${eventId}`;
const claimed = await redis.set(key, Date.now(), { nx: true, ex: TTL });
if (claimed !== "OK") {
return { result: undefined as T, wasProcessed: true };
}
try {
const result = await processor();
return { result, wasProcessed: false };
} catch (error) {
// Release the claim so a retry can re-run the handler.
await redis.del(key);
throw error;
}
}app/api/webhooks/stripe/route.ts
app/api/webhooks/stripe/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { verifyStripeSignature } from "@/lib/webhooks/verify";
import { processIdempotent } from "@/lib/webhooks/idempotency";
export async function POST(req: NextRequest) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const payload = await req.text();
const secret = process.env.STRIPE_WEBHOOK_SECRET!;
const verification = verifyStripeSignature(payload, signature, secret);
if (!verification.valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(payload);
const { wasProcessed } = await processIdempotent("stripe", event.id, async () => {
switch (event.type) {
case "checkout.session.completed":
// await handleCheckout(event.data.object);
break;
case "customer.subscription.updated":
// await handleSubscriptionUpdate(event.data.object);
break;
}
});
return NextResponse.json({ received: true, processed: !wasProcessed });
}4Dependencies
$ bun add @upstash/redis5Configuration
Environment Variables
| Variable | Description | Required |
| -------------------------- | --------------------- | --------------------- |
| STRIPE_WEBHOOK_SECRET | Stripe webhook secret | Yes (for Stripe) |
| GITHUB_WEBHOOK_SECRET | GitHub webhook secret | Yes (for GitHub) |
| UPSTASH_REDIS_REST_URL | Upstash Redis URL | Yes (for idempotency) |
| UPSTASH_REDIS_REST_TOKEN | Upstash Redis token | Yes (for idempotency) |
6Usage
Register Custom Handler
import { registerHandler } from "@/lib/webhooks/handlers";
registerHandler("stripe", "invoice.paid", async (ctx) => {
const invoice = ctx.payload as { id: string };
await db.invoice.update({
where: { stripeId: invoice.id },
data: { status: "paid" },
});
});TypeScript
Testing Locally
# Stripe CLI for local testing
stripe listen --forward-to localhost:3000/api/webhooks/stripeBash