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.tsBlame317 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";
619109aClaude21import { eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import { userQuotas } from "../db/schema";
5f7c71eClaude24import { reportError } from "../lib/observability";
619109aClaude25import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
5f7c71eClaude26
27const stripeWebhook = new Hono();
28
29const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own default
30
31async function hmacSha256Hex(key: string, message: string): Promise<string> {
32 const enc = new TextEncoder();
33 const cryptoKey = await crypto.subtle.importKey(
34 "raw",
35 enc.encode(key),
36 { name: "HMAC", hash: "SHA-256" },
37 false,
38 ["sign"]
39 );
40 const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
41 return Array.from(new Uint8Array(sig))
42 .map((b) => b.toString(16).padStart(2, "0"))
43 .join("");
44}
45
46function parseSigHeader(header: string): { t?: string; v1?: string } {
47 const out: { t?: string; v1?: string } = {};
48 for (const part of header.split(",")) {
49 const [k, v] = part.split("=");
50 if (k === "t") out.t = v;
51 else if (k === "v1" && !out.v1) out.v1 = v;
52 }
53 return out;
54}
55
56function constantTimeEqual(a: string, b: string): boolean {
57 if (a.length !== b.length) return false;
58 let diff = 0;
59 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
60 return diff === 0;
61}
62
63stripeWebhook.post("/api/webhooks/stripe", async (c) => {
64 const secret = process.env.STRIPE_WEBHOOK_SECRET;
65 if (!secret) {
66 console.warn("[stripe-webhook] STRIPE_WEBHOOK_SECRET not set — returning 503");
67 return c.json({ error: "webhook not configured" }, 503);
68 }
69
70 const sigHeader = c.req.header("stripe-signature");
71 if (!sigHeader) return c.json({ error: "missing stripe-signature header" }, 400);
72
73 const raw = await c.req.text();
74
75 const { t, v1 } = parseSigHeader(sigHeader);
76 if (!t || !v1) return c.json({ error: "malformed stripe-signature" }, 400);
77
78 // Replay window check
79 const ts = parseInt(t, 10);
80 if (!Number.isFinite(ts)) return c.json({ error: "bad timestamp" }, 400);
81 const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
82 if (age > TOLERANCE_SECONDS) {
83 return c.json({ error: "timestamp outside tolerance window" }, 400);
84 }
85
86 // HMAC check
87 const expected = await hmacSha256Hex(secret, `${t}.${raw}`);
88 if (!constantTimeEqual(expected, v1)) {
89 return c.json({ error: "signature mismatch" }, 400);
90 }
91
92 let event: { id?: string; type?: string } = {};
93 try {
94 event = JSON.parse(raw);
95 } catch {
96 return c.json({ error: "invalid json" }, 400);
97 }
98
99 console.log(
100 `[stripe-webhook] authentic event id=${event.id} type=${event.type}`
101 );
102
619109aClaude103 // Handle subscription lifecycle. Every handler swallows errors and returns
104 // 200 so Stripe doesn't retry-storm on transient DB issues — we log and
105 // rely on the next event (Stripe fires subscription.updated periodically).
106 try {
107 await handleStripeEvent(event as StripeEvent);
108 } catch (err) {
109 reportError(err as Error, {
110 path: "/api/webhooks/stripe",
111 stripeEventType: (event as StripeEvent).type,
112 stripeEventId: (event as StripeEvent).id,
113 });
114 }
115
5f7c71eClaude116 return c.json({ received: true });
117});
118
119// Defensive error handler local to this route — never leak exception details
120// back to Stripe; always prefer 200 for malformed-but-authenticated payloads
121// to avoid retry storms.
122stripeWebhook.onError((err, c) => {
123 reportError(err, { path: c.req.path, scope: "stripe-webhook" });
124 return c.json({ error: "internal error" }, 500);
125});
126
619109aClaude127// ---------------------------------------------------------------------------
128// Event handlers — each is defensive (never throws). Called from the main
129// route handler after signature verification.
130// ---------------------------------------------------------------------------
131
132type StripeEvent = {
133 id: string;
134 type: string;
135 data?: { object?: Record<string, unknown> };
136};
137
138async function handleStripeEvent(event: StripeEvent): Promise<void> {
139 const obj = event.data?.object ?? {};
140 switch (event.type) {
141 case "checkout.session.completed": {
142 const userId = String(obj.client_reference_id ?? "");
143 const customerId = String(obj.customer ?? "");
144 const subscriptionId = obj.subscription ? String(obj.subscription) : null;
145 if (!userId || !customerId) {
146 console.warn(
147 `[stripe-webhook] checkout.session.completed missing userId/customerId — skipping`
148 );
149 return;
150 }
151 await upsertQuotaRow(userId, {
152 stripeCustomerId: customerId,
153 stripeSubscriptionId: subscriptionId,
154 });
155 // Enrich from the subscription object to set the actual plan slug
156 if (subscriptionId) await reconcileSubscription(subscriptionId, userId);
157 return;
158 }
159 case "customer.subscription.created":
160 case "customer.subscription.updated": {
161 const subId = String(obj.id ?? "");
162 if (!subId) return;
163 const userId = String(
164 (obj.metadata as Record<string, string> | undefined)?.gluecron_user_id ?? ""
165 );
166 await reconcileSubscription(subId, userId || null);
167 return;
168 }
169 case "customer.subscription.deleted": {
170 const customerId = String(obj.customer ?? "");
171 if (!customerId) return;
172 await db
173 .update(userQuotas)
174 .set({
175 planSlug: "free",
176 stripeSubscriptionId: null,
177 stripeSubscriptionStatus: "canceled",
178 currentPeriodEnd: null,
179 updatedAt: new Date(),
180 })
181 .where(eq(userQuotas.stripeCustomerId, customerId));
182 console.log(`[stripe-webhook] downgraded customer=${customerId} to free`);
183 return;
184 }
185 case "invoice.payment_failed": {
186 const customerId = String(obj.customer ?? "");
187 if (!customerId) return;
188 // Mark status — don't downgrade immediately (Stripe retries billing
189 // over a grace window; a future subscription.updated with status=
190 // past_due/unpaid/canceled will do the actual plan move).
191 await db
192 .update(userQuotas)
193 .set({
194 stripeSubscriptionStatus: "past_due",
195 updatedAt: new Date(),
196 })
197 .where(eq(userQuotas.stripeCustomerId, customerId));
198 return;
199 }
200 default:
201 // All other events (invoice.payment_succeeded etc.) — accept silently.
202 return;
203 }
204}
205
206async function reconcileSubscription(
207 subscriptionId: string,
208 fallbackUserId: string | null
209): Promise<void> {
210 const res = await getSubscription(subscriptionId);
211 if (!res.ok) {
212 console.warn(
213 `[stripe-webhook] getSubscription(${subscriptionId}) failed: ${res.error}`
214 );
215 return;
216 }
217 const sub = res.subscription;
218 const slug = planSlugFromSubscription(sub);
219 const customerId = sub.customer;
220 const status = sub.status;
221 const periodEnd = sub.current_period_end
222 ? new Date(sub.current_period_end * 1000)
223 : null;
224
225 // Prefer locating by customer id (set during checkout.session.completed).
226 // Fall back to metadata gluecron_user_id if present.
227 const userId =
228 (sub.metadata?.gluecron_user_id as string | undefined) ?? fallbackUserId ?? null;
229
230 if (status !== "active" && status !== "trialing") {
231 // Non-active subscriptions don't grant a paid plan. We still record
232 // their state so the user can see "past_due" in /settings/billing.
233 if (customerId) {
234 await db
235 .update(userQuotas)
236 .set({
237 stripeSubscriptionId: sub.id,
238 stripeSubscriptionStatus: status,
239 currentPeriodEnd: periodEnd,
240 updatedAt: new Date(),
241 })
242 .where(eq(userQuotas.stripeCustomerId, customerId));
243 }
244 return;
245 }
246
247 if (!slug) {
248 console.warn(
249 `[stripe-webhook] subscription ${sub.id} active but no plan slug resolvable — leaving plan unchanged`
250 );
251 return;
252 }
253
254 if (userId) {
255 await upsertQuotaRow(userId, {
256 planSlug: slug,
257 stripeCustomerId: customerId,
258 stripeSubscriptionId: sub.id,
259 stripeSubscriptionStatus: status,
260 currentPeriodEnd: periodEnd,
261 });
262 console.log(
263 `[stripe-webhook] user=${userId} → plan=${slug} (status=${status})`
264 );
265 return;
266 }
267
268 // Last resort: locate by customer id and update in place
269 if (customerId) {
270 await db
271 .update(userQuotas)
272 .set({
273 planSlug: slug,
274 stripeSubscriptionId: sub.id,
275 stripeSubscriptionStatus: status,
276 currentPeriodEnd: periodEnd,
277 updatedAt: new Date(),
278 })
279 .where(eq(userQuotas.stripeCustomerId, customerId));
280 }
281}
282
283async function upsertQuotaRow(
284 userId: string,
285 patch: {
286 planSlug?: string;
287 stripeCustomerId?: string | null;
288 stripeSubscriptionId?: string | null;
289 stripeSubscriptionStatus?: string | null;
290 currentPeriodEnd?: Date | null;
291 }
292): Promise<void> {
293 // Try update first; if no row exists, insert.
294 const res = await db
295 .update(userQuotas)
296 .set({ ...patch, updatedAt: new Date() })
297 .where(eq(userQuotas.userId, userId))
298 .returning({ userId: userQuotas.userId });
299 if (res.length === 0) {
300 await db
301 .insert(userQuotas)
302 .values({
303 userId,
304 planSlug: patch.planSlug ?? "free",
305 stripeCustomerId: patch.stripeCustomerId ?? null,
306 stripeSubscriptionId: patch.stripeSubscriptionId ?? null,
307 stripeSubscriptionStatus: patch.stripeSubscriptionStatus ?? null,
308 currentPeriodEnd: patch.currentPeriodEnd ?? null,
309 })
310 .onConflictDoUpdate({
311 target: userQuotas.userId,
312 set: { ...patch, updatedAt: new Date() },
313 });
314 }
315}
316
5f7c71eClaude317export default stripeWebhook;