From 38ff3d8505a3e347a3744782dc13e4202e15227b Mon Sep 17 00:00:00 2001 From: davidasix Date: Fri, 28 Nov 2025 09:54:13 -0500 Subject: [PATCH 1/6] Reduce if --- src/app/api/purchases/webhook/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index bcad52a..a537eb0 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -192,9 +192,10 @@ export async function POST(request: NextRequest) { if (!handler) { console.warn("No handler registered for event type:", event.type); return NextResponse.json({ received: true }); - } else { - console.log(`Handling event: ${event.type}`); } + + console.log(`Handling event: ${event.type}`); + try { await handler(event); } catch (error) { From 9cfa456dbb72bfa4ad82b6ce3395a6abbe4a7923 Mon Sep 17 00:00:00 2001 From: davidasix Date: Fri, 28 Nov 2025 11:22:39 -0500 Subject: [PATCH 2/6] More robust error handling, improved likelihood to not send 500's to stripe --- src/app/api/purchases/webhook/route.ts | 75 ++++++++++++++++++++------ 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index a537eb0..c187534 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -10,6 +10,37 @@ import { users, subscription_payments } from "@/schema/schema"; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; +/** + * Custom error class for expected errors that should still return 200 to Stripe. + * + * Stripe webhooks require a 200 status code to acknowledge successful receipt of the event. + * If we return 500 too much, Stripe will eventually disable the webhook.. + * This error class represents situations where: + * - We successfully received and processed the webhook + * - We encountered an expected/handled error condition (e.g., missing customer ID in DB) + * - We don't want Stripe to retry the webhook + * + * Only throw regular errors (which return 500) for truly unexpected failures that + * might be resolved on retry (e.g., database connection issues, temporary failures). + * + * @example + * // Customer not found - expected condition, no need to retry + * if (!userId) { + * throw new HandledError(`No user found with Stripe customer ID: ${customerId}`); + * } + * + * // Database connection failed - unexpected, should retry + * if (dbConnectionFailed) { + * throw new Error("Database connection failed"); + * } + */ +class HandledError extends Error { + constructor(message: string) { + super(message); + this.name = "HandledError"; + } +} + /** * Handles checkout.session.completed events * @@ -31,7 +62,7 @@ const handleCheckoutSessionCompleted: Handler = async ({ type, data }) => { if (!appUserId) { // Orphaned record - we have a successful checkout but can't identify the user // TODO: Add notification emails to alert about orphaned checkout sessions - throw new Error( + throw new HandledError( "Checkout session completed without app_user_id metadata, cannot link to user", ); } @@ -43,7 +74,7 @@ const handleCheckoutSessionCompleted: Handler = async ({ type, data }) => { // Validate that customer is a string (it could be a Customer object or null) if (!customerId) { - throw new Error( + throw new HandledError( "Checkout session completed without a valid Stripe customer ID, cannot link to user", ); } @@ -55,7 +86,10 @@ const handleCheckoutSessionCompleted: Handler = async ({ type, data }) => { .set({ stripe_customer_id: customerId, has_active_subscription: true }) .where(eq(users.id, appUserId)); } catch (error) { - throw error; + console.error("Error updating user with Stripe customer ID:", error); + throw new HandledError( + "Failed to update user with Stripe customer ID after checkout completion", + ); } }; @@ -67,16 +101,18 @@ const handleCheckoutSessionCompleted: Handler = async ({ type, data }) => { */ const handleInvoicePaymentSucceeded: Handler = async ({ type, data }) => { if (type !== "invoice.payment_succeeded") { - throw new Error(`Expected invoice.payment_succeeded, got ${type}`); + throw new HandledError(`Expected invoice.payment_succeeded, got ${type}`); } const invoice = data.object; if (!invoice?.customer) { // Invoice does not have a customer ID, we cannot look up the associated user - throw new Error("Invoice is missing customer ID, cannot process payment"); + throw new HandledError( + "Invoice is missing customer ID, cannot process payment", + ); } else if (!invoice.id) { - throw new Error( + throw new HandledError( "Invoice is missing ID and is thus a future invoice, payment not processed", ); } @@ -106,13 +142,15 @@ const handleInvoicePaymentSucceeded: Handler = async ({ type, data }) => { } } if (!userId) { - throw new Error(`No user found with Stripe customer ID: ${customerId}`); + throw new HandledError( + `No user found with Stripe customer ID: ${customerId}`, + ); } // Extract subscription period from the first line item const [lineItem] = invoice.lines.data; if (!lineItem?.period) { - throw new Error( + throw new HandledError( "Invoice line item is missing subscription period information", ); } @@ -138,7 +176,8 @@ const handleInvoicePaymentSucceeded: Handler = async ({ type, data }) => { .set({ has_active_subscription: true }) .where(eq(users.id, userId)); } catch (error) { - throw error; + console.error("Error processing invoice payment succeeded:", error); + throw new HandledError("Failed to process invoice payment succeeded event"); } }; @@ -199,12 +238,16 @@ export async function POST(request: NextRequest) { try { await handler(event); } catch (error) { - console.error(`Error handling event ${event.type}:`, error); - // console.log({event}) - return NextResponse.json( - { error: `Failed to handle event ${event.type}` }, - { status: 500 }, - ); + if (error instanceof HandledError) { + // Expected error - log it but return 200 to prevent Stripe Disabling the webhook + console.warn(`Handled error for event ${event.type}:`, error.message); + } else { + console.error(`Error handling event ${event.type}:`, error); + return NextResponse.json( + { error: `Failed to handle event ${event.type}` }, + { status: 500 }, + ); + } } return NextResponse.json({ received: true }); @@ -212,7 +255,7 @@ export async function POST(request: NextRequest) { console.error("Webhook error:", error); return NextResponse.json( { error: "Webhook handler failed" }, - { status: 500 }, + { status: 400 }, ); } } From 9f8653606ecb51c47bffd2e883d3a977ac45ece6 Mon Sep 17 00:00:00 2001 From: davidasix Date: Fri, 28 Nov 2025 11:44:02 -0500 Subject: [PATCH 3/6] Return 202 on errors --- src/app/api/purchases/webhook/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index c187534..084db42 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -245,7 +245,7 @@ export async function POST(request: NextRequest) { console.error(`Error handling event ${event.type}:`, error); return NextResponse.json( { error: `Failed to handle event ${event.type}` }, - { status: 500 }, + { status: 202 }, ); } } @@ -255,7 +255,7 @@ export async function POST(request: NextRequest) { console.error("Webhook error:", error); return NextResponse.json( { error: "Webhook handler failed" }, - { status: 400 }, + { status: 202 }, ); } } From c5c67f0be0a4311df86a9f58cbfea21856698d72 Mon Sep 17 00:00:00 2001 From: David Anderson 6 Date: Fri, 28 Nov 2025 12:08:42 -0500 Subject: [PATCH 4/6] Update src/app/api/purchases/webhook/route.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/api/purchases/webhook/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index 084db42..30132a4 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -239,7 +239,7 @@ export async function POST(request: NextRequest) { await handler(event); } catch (error) { if (error instanceof HandledError) { - // Expected error - log it but return 200 to prevent Stripe Disabling the webhook + // Expected error - log it but return 200 to prevent Stripe disabling the webhook console.warn(`Handled error for event ${event.type}:`, error.message); } else { console.error(`Error handling event ${event.type}:`, error); From d9820d3f0885dda8cfe0b3012b39bb06fe9c1014 Mon Sep 17 00:00:00 2001 From: David Anderson 6 Date: Fri, 28 Nov 2025 12:08:49 -0500 Subject: [PATCH 5/6] Update src/app/api/purchases/webhook/route.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/api/purchases/webhook/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index 30132a4..683b76b 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -14,7 +14,7 @@ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; * Custom error class for expected errors that should still return 200 to Stripe. * * Stripe webhooks require a 200 status code to acknowledge successful receipt of the event. - * If we return 500 too much, Stripe will eventually disable the webhook.. + * If we return 500 too much, Stripe will eventually disable the webhook. * This error class represents situations where: * - We successfully received and processed the webhook * - We encountered an expected/handled error condition (e.g., missing customer ID in DB) From a6eff0b211f038e736e6a2f08b3d96bdf2cc0357 Mon Sep 17 00:00:00 2001 From: davidasix Date: Fri, 28 Nov 2025 12:11:50 -0500 Subject: [PATCH 6/6] Add note and convert to 500s --- src/app/api/purchases/webhook/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/api/purchases/webhook/route.ts b/src/app/api/purchases/webhook/route.ts index 683b76b..3f780a7 100644 --- a/src/app/api/purchases/webhook/route.ts +++ b/src/app/api/purchases/webhook/route.ts @@ -240,12 +240,14 @@ export async function POST(request: NextRequest) { } catch (error) { if (error instanceof HandledError) { // Expected error - log it but return 200 to prevent Stripe disabling the webhook - console.warn(`Handled error for event ${event.type}:`, error.message); + // TODO: These actually SHOULD return a 500 so that Stripe retries, BUT I don't currently have a second stripe account set up + // which means this endpoint is receiving events from multiple products, which should fail silently. + console.error(`Handled error for event ${event.type}:`, error.message); } else { console.error(`Error handling event ${event.type}:`, error); return NextResponse.json( { error: `Failed to handle event ${event.type}` }, - { status: 202 }, + { status: 500 }, ); } } @@ -255,7 +257,7 @@ export async function POST(request: NextRequest) { console.error("Webhook error:", error); return NextResponse.json( { error: "Webhook handler failed" }, - { status: 202 }, + { status: 500 }, ); } }