Delete a user, cancel the Stripe subscription first: the Better Auth cascade, the beforeDelete hook, and the webhook that 500s
Deleting a user cascades away the only record of their Stripe subscription while Stripe keeps billing. A tested Better Auth hook and the webhook fix.
A user asks you to delete their account, and one DELETE FROM "user" does it. In the kit, that same statement also removes every row that knew they pay you, while their Stripe subscription stays active and bills the card again next month. To delete a user and cancel the Stripe subscription in the right order, the cancel has to happen before the delete and has to be able to stop it. I measured the cascade on a throwaway Postgres, then wired the cancel into Better Auth's delete hook and ran it against a mock Stripe, including the webhook Stripe sends back afterwards.
What one DELETE takes with it
Both billing tables point at user with a cascade. From the kit's migrations:
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_user_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade;
ALTER TABLE "billing_customers" ADD CONSTRAINT "billing_customers_user_id_user_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade;
On Postgres 16 with both migrations applied, I inserted a paying user: the user, an account, a session, a billing_customers row for cus_demo_123, and an active Pro row in subscriptions for sub_demo_456. After DELETE FROM "user", all five tables went from 1 row to 0, and no row anywhere mentioned cus_demo_123. Nothing on Stripe's side changed, because nothing told it to.
The kit does not expose account deletion today. Better Auth 1.6.18's /delete-user endpoint returns NOT_FOUND unless user.deleteUser.enabled is set, so in a stock install the deletes that happen are manual ones: a SQL console, a cleanup script, a support request handled by hand. Those take the cascade path above.
Cancel the Stripe subscription first, in beforeDelete
Better Auth's delete endpoint runs its hooks in a fixed order: it awaits beforeDelete(user, request), then deletes the user's sessions, accounts and user row, then calls afterDelete. A throw in beforeDelete stops everything after it, which is exactly the property needed: no cancel, no delete.
The cancel goes next to the other billing helpers, reusing the kit's lookup of a user's Stripe customer and its set of live subscription statuses:
export async function cancelStripeBilling(userId: string): Promise<number> {
const customerId = await getStripeCustomerId(userId);
if (!customerId) return 0;
const { data } = await stripe().subscriptions.list({ customer: customerId, status: "all", limit: 20 });
const live = data.filter((sub) => LIVE_STATUSES.has(sub.status));
for (const sub of live) await stripe().subscriptions.cancel(sub.id);
return live.length;
}
And the hook, in the betterAuth config:
user: {
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
if (!hasStripe()) return;
try {
await cancelStripeBilling(user.id);
} catch (err) {
console.error("Could not cancel Stripe billing before deleting user", user.id, err);
throw new APIError("SERVICE_UNAVAILABLE", {
message: "We could not cancel your subscription, so your account was not deleted. Please try again.",
});
}
},
},
},
stripe-node 22.2.0 documents subscriptions.cancel as cancelling immediately: "The customer won't be charged again for the subscription."
I tested it on the kit's production build with the Stripe client pointed at a local mock server through stripe-node's host, port and protocol options, so no real Stripe call was made. The user deleted themselves through /api/auth/delete-user with their password.
- Stripe failing (the mock answered 500 to the cancel): without the
try/catch, the endpoint returned a bare 500 with an empty body after 1,161 ms. With it, a 503 and the message above after 1,174 ms. Either way the user, subscription and billing rows were all still there. stripe-node made one list call and three cancel attempts about 500 ms apart, its two default retries. - Stripe working: 200 in 140 to 156 ms, one list call, one
DELETE /v1/subscriptions/sub_demo_456, and all three rows gone.
The webhook that comes back after the cancel
Cancelling a subscription makes Stripe send customer.subscription.deleted, and its metadata still carries the userId of the user you just deleted. The kit's handler upserts a subscriptions row for that user, and the insert fails on the foreign key. I sent the event twice, signed with stripe.webhooks.generateTestHeaderString:
- Both deliveries: HTTP 500,
violates foreign key constraint "subscriptions_user_id_user_id_fk". The handler returns 500 on purpose, with a comment that says "so Stripe retries", which means every retry fails the same way.
The fix is to skip events for users who no longer exist, in the handler's persist step, before the upsert:
const [owner] = await db().select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1);
if (!owner) {
console.warn(`Subscription ${sub.id} belongs to deleted user ${userId}; skipping.`);
return;
}
With that in place, both deliveries answered 200 and the log showed the skip. The kit ships with neither change today; these are the edits I would make, and the numbers above come from exactly this code. The handler itself is covered in Stripe webhook signature verification in Next.js.
Things that bite
- Manual deletes skip the hook. A SQL console or a script never runs
beforeDelete. Route every deletion through one function that cancels first, or putON DELETE RESTRICTonbilling_customersso Postgres refuses a raw delete instead of quietly orphaning a paying customer. The price is that the hook then has to delete those rows itself, or Better Auth's own delete fails too. - Cancel or delete the customer.
customers.delis the stronger option: stripe-node describes it as permanent, and it "also immediately cancels any active subscriptions on the customer". Cancelling keeps the customer, invoices and payment history for your accounting. Pick based on what you owe the user and what you owe your bookkeeping. - Cancel is not a refund. The current period stays paid, and per stripe-node's own doc comment, pending invoice items you created are still charged at the end of the period unless you delete them.
- Without a password, the session must be fresh. Better Auth refuses
/delete-userwithSESSION_EXPIREDwhen no password is sent and the session is older thanfreshAge, a day by default. Its email-confirmation flow,sendDeleteAccountVerification, is the other way in. - Metering rows stay.
usage_eventshas no foreign key touser, so a deleted user's usage history survives, keyed by an id that no longer resolves. Decide whether your retention policy wants it deleted or kept for the books.
Shipwright, the Next.js and Claude starter kit this blog documents, keeps a user's Stripe customer and subscriptions in two tables that every billing route reads, so the cancel-before-delete step is one helper and one hook. You can try the product side in the live demo.