Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

stripe-webhook.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

stripe-webhook.tsBlame112 lines · 1 contributor
5f7c71eClaude1/**
2 * Minimal Stripe webhook endpoint — verifies signature + returns 200.
3 *
4 * v1 scope: authentic, logged, ignored. Full handling (auto-assign plan
5 * on subscription.created, downgrade on subscription.deleted, grace
6 * period on invoice.payment_failed) ships in the Stripe integration
7 * sprint. This stub exists so the Stripe dashboard's "Send test webhook"
8 * succeeds (returns 200) and so live webhook deliveries during the gap
9 * don't fail-retry-fail forever.
10 *
11 * Signature verification follows Stripe's documented scheme:
12 * Header: Stripe-Signature: t=<ts>,v1=<hmac>
13 * Signed payload: <ts> . <raw body>
14 * HMAC: SHA-256 with STRIPE_WEBHOOK_SECRET as key
15 *
16 * We use the Web Crypto API (available in Bun + workers) — no Node
17 * crypto, no @stripe/sdk dependency.
18 */
19
20import { Hono } from "hono";
21import { reportError } from "../lib/observability";
22
23const stripeWebhook = new Hono();
24
25const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own default
26
27async function hmacSha256Hex(key: string, message: string): Promise<string> {
28 const enc = new TextEncoder();
29 const cryptoKey = await crypto.subtle.importKey(
30 "raw",
31 enc.encode(key),
32 { name: "HMAC", hash: "SHA-256" },
33 false,
34 ["sign"]
35 );
36 const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
37 return Array.from(new Uint8Array(sig))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
40}
41
42function parseSigHeader(header: string): { t?: string; v1?: string } {
43 const out: { t?: string; v1?: string } = {};
44 for (const part of header.split(",")) {
45 const [k, v] = part.split("=");
46 if (k === "t") out.t = v;
47 else if (k === "v1" && !out.v1) out.v1 = v;
48 }
49 return out;
50}
51
52function constantTimeEqual(a: string, b: string): boolean {
53 if (a.length !== b.length) return false;
54 let diff = 0;
55 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
56 return diff === 0;
57}
58
59stripeWebhook.post("/api/webhooks/stripe", async (c) => {
60 const secret = process.env.STRIPE_WEBHOOK_SECRET;
61 if (!secret) {
62 console.warn("[stripe-webhook] STRIPE_WEBHOOK_SECRET not set — returning 503");
63 return c.json({ error: "webhook not configured" }, 503);
64 }
65
66 const sigHeader = c.req.header("stripe-signature");
67 if (!sigHeader) return c.json({ error: "missing stripe-signature header" }, 400);
68
69 const raw = await c.req.text();
70
71 const { t, v1 } = parseSigHeader(sigHeader);
72 if (!t || !v1) return c.json({ error: "malformed stripe-signature" }, 400);
73
74 // Replay window check
75 const ts = parseInt(t, 10);
76 if (!Number.isFinite(ts)) return c.json({ error: "bad timestamp" }, 400);
77 const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
78 if (age > TOLERANCE_SECONDS) {
79 return c.json({ error: "timestamp outside tolerance window" }, 400);
80 }
81
82 // HMAC check
83 const expected = await hmacSha256Hex(secret, `${t}.${raw}`);
84 if (!constantTimeEqual(expected, v1)) {
85 return c.json({ error: "signature mismatch" }, 400);
86 }
87
88 let event: { id?: string; type?: string } = {};
89 try {
90 event = JSON.parse(raw);
91 } catch {
92 return c.json({ error: "invalid json" }, 400);
93 }
94
95 console.log(
96 `[stripe-webhook] authentic event id=${event.id} type=${event.type}`
97 );
98
99 // v1: accept-and-log only. Full handling ships with the billing integration
100 // sprint. Stripe retries non-200s — returning 200 here prevents that.
101 return c.json({ received: true });
102});
103
104// Defensive error handler local to this route — never leak exception details
105// back to Stripe; always prefer 200 for malformed-but-authenticated payloads
106// to avoid retry storms.
107stripeWebhook.onError((err, c) => {
108 reportError(err, { path: c.req.path, scope: "stripe-webhook" });
109 return c.json({ error: "internal error" }, 500);
110});
111
112export default stripeWebhook;