Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame402 lines · 2 contributors
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";
8298a0fccantynz-alt21import { eq, and, or, isNull, sql } from "drizzle-orm";
619109aClaude22import { 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 ?? "");
8298a0fccantynz-alt171 const subId = String(obj.id ?? "");
619109aClaude172 if (!customerId) return;
8298a0fccantynz-alt173
174 // Scope the downgrade to the subscription we actually have on record.
175 // Matching on customer id alone downgraded PAYING customers: one
176 // Stripe customer can hold several subscriptions, and a plan change
177 // creates the new one and deletes the old. Webhook delivery order is
178 // not guaranteed, so `created`(sub_NEW) frequently lands before
179 // `deleted`(sub_OLD) — and the old subscription's deletion then wiped
180 // the row that had just been upgraded, silently dropping the customer
181 // to free while they were being billed.
182 //
183 // A row whose subscription id is NULL is still matched: that is a row
184 // checkout populated but reconcile never enriched, and the deletion is
185 // the only subscription it could refer to.
186 const affected = await db
619109aClaude187 .update(userQuotas)
188 .set({
189 planSlug: "free",
190 stripeSubscriptionId: null,
191 stripeSubscriptionStatus: "canceled",
192 currentPeriodEnd: null,
193 updatedAt: new Date(),
194 })
8298a0fccantynz-alt195 .where(
196 and(
197 eq(userQuotas.stripeCustomerId, customerId),
198 recordedSubscriptionScope(subId)
199 )
200 )
201 .returning({ userId: userQuotas.userId });
202
203 if (affected.length === 0) {
204 // Expected whenever a superseded subscription is cleaned up. Logged
205 // rather than silent so a genuine mismatch stays diagnosable.
206 console.log(
207 `[stripe-webhook] subscription.deleted sub=${subId} customer=${customerId} did not match the subscription on record — no downgrade`
208 );
209 } else {
210 console.log(
211 `[stripe-webhook] downgraded customer=${customerId} to free`
212 );
213 }
619109aClaude214 return;
215 }
216 case "invoice.payment_failed": {
217 const customerId = String(obj.customer ?? "");
218 if (!customerId) return;
219 // Mark status — don't downgrade immediately (Stripe retries billing
220 // over a grace window; a future subscription.updated with status=
221 // past_due/unpaid/canceled will do the actual plan move).
8298a0fccantynz-alt222 // Scoped like the two above: an invoice names its subscription, and a
223 // failed invoice for a superseded one must not flag the customer's
224 // current plan as past_due. A one-off invoice carries no subscription,
225 // in which case the customer match is all there is to go on.
226 const invoiceSubId = obj.subscription ? String(obj.subscription) : "";
619109aClaude227 await db
228 .update(userQuotas)
229 .set({
230 stripeSubscriptionStatus: "past_due",
231 updatedAt: new Date(),
232 })
8298a0fccantynz-alt233 .where(
234 and(
235 eq(userQuotas.stripeCustomerId, customerId),
236 recordedSubscriptionScope(invoiceSubId)
237 )
238 );
619109aClaude239 return;
240 }
241 default:
242 // All other events (invoice.payment_succeeded etc.) — accept silently.
243 return;
244 }
245}
246
8298a0fccantynz-alt247/**
248 * Restrict an update to the subscription this user actually has on record.
249 *
250 * One Stripe customer can hold several subscriptions, so `customer` alone
251 * does not identify which one an event is about. A plan change creates the
252 * new subscription and deletes the old, and webhook delivery order is not
253 * guaranteed — so `created`(sub_NEW) routinely arrives before
254 * `deleted`(sub_OLD). Keying only on the customer meant the old
255 * subscription's event landed on the row that had just been upgraded.
256 *
257 * A NULL subscription id on the row still matches: that is a row checkout
258 * populated but reconcile never enriched, so the event is the only
259 * subscription it could be about.
260 *
261 * Returns undefined when the event names no subscription (a one-off
262 * invoice), leaving the caller's customer match as the only constraint.
263 */
264export function recordedSubscriptionScope(eventSubscriptionId: string) {
265 if (!eventSubscriptionId) return undefined;
266 return or(
267 eq(userQuotas.stripeSubscriptionId, eventSubscriptionId),
268 isNull(userQuotas.stripeSubscriptionId)
269 );
270}
271
619109aClaude272async function reconcileSubscription(
273 subscriptionId: string,
274 fallbackUserId: string | null
275): Promise<void> {
276 const res = await getSubscription(subscriptionId);
277 if (!res.ok) {
278 console.warn(
279 `[stripe-webhook] getSubscription(${subscriptionId}) failed: ${res.error}`
280 );
281 return;
282 }
283 const sub = res.subscription;
284 const slug = planSlugFromSubscription(sub);
285 const customerId = sub.customer;
286 const status = sub.status;
287 const periodEnd = sub.current_period_end
288 ? new Date(sub.current_period_end * 1000)
289 : null;
290
291 // Prefer locating by customer id (set during checkout.session.completed).
292 // Fall back to metadata gluecron_user_id if present.
293 const userId =
294 (sub.metadata?.gluecron_user_id as string | undefined) ?? fallbackUserId ?? null;
295
296 if (status !== "active" && status !== "trialing") {
297 // Non-active subscriptions don't grant a paid plan. We still record
298 // their state so the user can see "past_due" in /settings/billing.
299 if (customerId) {
8298a0fccantynz-alt300 // Same scoping rule as subscription.deleted. Without it, a `canceled`
301 // or `past_due` event for a SUPERSEDED subscription overwrote the row
302 // belonging to the customer's current, active one — stamping the dead
303 // subscription's id and status over a live paid plan.
619109aClaude304 await db
305 .update(userQuotas)
306 .set({
307 stripeSubscriptionId: sub.id,
308 stripeSubscriptionStatus: status,
309 currentPeriodEnd: periodEnd,
310 updatedAt: new Date(),
311 })
8298a0fccantynz-alt312 .where(
313 and(
314 eq(userQuotas.stripeCustomerId, customerId),
315 recordedSubscriptionScope(sub.id)
316 )
317 );
619109aClaude318 }
319 return;
320 }
321
322 if (!slug) {
323 console.warn(
324 `[stripe-webhook] subscription ${sub.id} active but no plan slug resolvable — leaving plan unchanged`
325 );
326 return;
327 }
328
329 if (userId) {
330 await upsertQuotaRow(userId, {
331 planSlug: slug,
332 stripeCustomerId: customerId,
333 stripeSubscriptionId: sub.id,
334 stripeSubscriptionStatus: status,
335 currentPeriodEnd: periodEnd,
336 });
337 console.log(
338 `[stripe-webhook] user=${userId} → plan=${slug} (status=${status})`
339 );
340 return;
341 }
342
8298a0fccantynz-alt343 // Last resort: locate by customer id and update in place.
344 //
345 // Deliberately NOT scoped by recordedSubscriptionScope, unlike the three
346 // sites that write a downgrade or a failure status. This branch only runs
347 // for an active or trialing subscription with a resolved paid plan, and a
348 // newly-created subscription is by definition not yet the one on record —
349 // scoping here would reject the upgrade and leave a paying customer on
350 // their old plan, which is the very failure this change exists to stop.
351 // Stale events cannot reach this branch: a superseded subscription is not
352 // active. Positive events take the row; only negative ones must prove
353 // they concern the subscription it already holds.
619109aClaude354 if (customerId) {
355 await db
356 .update(userQuotas)
357 .set({
358 planSlug: slug,
359 stripeSubscriptionId: sub.id,
360 stripeSubscriptionStatus: status,
361 currentPeriodEnd: periodEnd,
362 updatedAt: new Date(),
363 })
364 .where(eq(userQuotas.stripeCustomerId, customerId));
365 }
366}
367
368async function upsertQuotaRow(
369 userId: string,
370 patch: {
371 planSlug?: string;
372 stripeCustomerId?: string | null;
373 stripeSubscriptionId?: string | null;
374 stripeSubscriptionStatus?: string | null;
375 currentPeriodEnd?: Date | null;
376 }
377): Promise<void> {
378 // Try update first; if no row exists, insert.
379 const res = await db
380 .update(userQuotas)
381 .set({ ...patch, updatedAt: new Date() })
382 .where(eq(userQuotas.userId, userId))
383 .returning({ userId: userQuotas.userId });
384 if (res.length === 0) {
385 await db
386 .insert(userQuotas)
387 .values({
388 userId,
389 planSlug: patch.planSlug ?? "free",
390 stripeCustomerId: patch.stripeCustomerId ?? null,
391 stripeSubscriptionId: patch.stripeSubscriptionId ?? null,
392 stripeSubscriptionStatus: patch.stripeSubscriptionStatus ?? null,
393 currentPeriodEnd: patch.currentPeriodEnd ?? null,
394 })
395 .onConflictDoUpdate({
396 target: userQuotas.userId,
397 set: { ...patch, updatedAt: new Date() },
398 });
399 }
400}
401
5f7c71eClaude402export default stripeWebhook;