Every account in PubTrivia has a stripe_id column, written once at signup, long before anybody has decided to pay for anything. It is a string that points at a customer object in somebody else's database, and the thing about a pointer into somebody else's database is that it can stop resolving.
Three ways it happens, in rough order of how often:
- The account was created while the app was running on test keys. Live Stripe answers
No such customerfor acus_id that only exists in test mode, and will do so forever. - The customer was deleted in the Stripe dashboard, which is a thing you do while cleaning up after yourself and do not think of as touching production data.
- The id is from a different Stripe account entirely, which happens exactly once per project and is memorable.
So the question is not whether a broken id can exist. It is what each reader of that column does when it turns out to be broken. We have four readers and they do four different things.
Reader one repairs it
The checkout route is the only place that fixes the row:
export async function ensureStripeCustomer(
user: { id: string; email: string; name: string; stripe_id: string | null },
): Promise<string> {
if (user.stripe_id) {
try {
const existing = await stripe.customers.retrieve(user.stripe_id)
if (!existing.deleted) return user.stripe_id
} catch (error) {
if ((error as { code?: unknown })?.code !== 'resource_missing') throw error
}
}
const customerId = await createStripeCustomer(user.id, user.email, user.name)
await db.update(usersTable).set({ stripe_id: customerId }).where(eq(usersTable.id, user.id))
return customerId
}
Three states collapse into one path. No id, an id Stripe does not know, and an id that resolves to a deleted customer all end in a fresh customer and a write back to our own row. An id that resolves to a live customer returns immediately, which is the overwhelming majority of calls and costs one API round trip.
The condition I care most about in that function is the !==. Only resource_missing is swallowed; a rate limit, an auth failure, a network error or a Stripe outage is rethrown, because "I could not reach Stripe" and "Stripe says that customer does not exist" are opposite facts and only one of them means it is safe to create a second customer. Getting that comparison backwards would mean a transient 500 from Stripe silently duplicating the customer of whoever was checking out at the time, which is not a bug you find in the logs. You find it in the dashboard, months later, as two customers with the same email and one subscription between them.
retrieve returning a deleted customer rather than throwing is the other sharp edge. Stripe's deleted objects come back as a normal response with deleted: true, so if (!existing.deleted) is the whole check, and omitting it means happily reusing a dead customer and failing at session creation instead, where the error is about a session.
Reader two turns it into null and prices you by country
The plans page inside the dashboard, which is the signed-in twin of our public pricing page, needs the currency a live subscription is billed in, because Stripe holds a customer to one currency while a subscription exists, and the prices on the page have to match what a plan change will actually charge.
export async function liveSubscriptionCurrency(userId: string): Promise<string | null> {
const [user] = await db.select({ stripe_id: usersTable.stripe_id }).from(usersTable)
.where(eq(usersTable.id, userId)).limit(1)
if (!user?.stripe_id) return null
try {
const [live] = await liveSubscriptions(user.stripe_id)
return live?.currency ?? null
} catch (error) {
if ((error as { code?: unknown })?.code === 'resource_missing') return null
throw error
}
}
Same error code, opposite handling. Here resource_missing is not a problem to be fixed, it is an answer: a customer Stripe has never heard of has no live subscription, so there is no currency to pin, so fall through to the visitor's own country. The caller then catches anything that got rethrown:
liveSubscriptionCurrency(user.id).catch((error) => {
console.error('[plans] could not read the live subscription currency', error)
return null
}),
Which means a reader with a broken stripe_id sees correct prices in their own currency and nothing else happens. The row stays broken. Nothing on this path writes.
Reader three throws, and the page catches it by hiding a button
export async function generateStripeBillingPortalLink(email: string) {
const user = await db.select().from(usersTable).where(eq(usersTable.email, email))
if (!user[0]?.stripe_id) throw new Error('User has no Stripe ID');
const portalSession = await stripe.billingPortal.sessions.create({
customer: user[0].stripe_id,
return_url: `${PUBLIC_URL}/dashboard/plans`,
})
return portalSession.url
}
No repair, no resource_missing branch, and a thrown Error with a message written for a developer. Both pages that call it do the same thing with the throw:
generateStripeBillingPortalLink(user.email!).catch(() => null)
null means the button is not rendered. So the symptom of a stripe_id that Stripe cannot resolve, for somebody who never clicks through to checkout, is that the "Manage billing" button is simply absent from their billing page. No error, no toast, nothing in the UI that suggests anything is wrong. If they write in to say they cannot find the billing portal, the honest answer is that we are not showing it to them.
This is also the only one of the four readers that looks the user up by email rather than by id, which is a second lookup of a row the calling page already has.
Reader four never sees it
The Stripe webhook takes the customer id out of the event it just verified, so it is reading Stripe's own idea of the id rather than ours:
const live = await liveSubscriptions(customerId)
It cannot encounter a broken pointer, because the pointer came from the other side. Which is worth noticing: our column is only ever wrong relative to Stripe, and every path that starts at Stripe is immune.
The shared list filter
Both of the readers that ask about subscriptions go through one helper:
export async function liveSubscriptions(customerId: string): Promise<Stripe.Subscription[]> {
const { data } = await stripe.subscriptions.list({ customer: customerId, limit: 100 })
return data.filter(sub => sub.status !== 'canceled' && sub.status !== 'incomplete_expired')
}
Without a status filter, subscriptions.list returns everything that is not canceled, which sounds like what you want until you remember that incomplete_expired exists: a subscription whose first payment never completed and whose window closed. It never started, so counting it as live means a customer who abandoned a checkout three weeks ago is told they already have a subscription.
Those two exclusions are the entire definition of "live" in this codebase, and the two call sites that matter, deciding whether a plan change is a replacement and deciding which currency to price in, inherit it from here rather than each writing a status check.
The failure I found writing this
The billing page asks Stripe for the subscription so it can print the real amount and the real renewal date, and it wraps the whole thing in a bare catch:
async function getSubscriptionDetails(stripeSubscriptionId: string | null) {
if (!stripeSubscriptionId) return null
try {
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { ... })
...
} catch {
return null
}
}
Then renders on isActive && sub, where isActive comes from our own database row. So if Stripe is unreachable for one request, an active paying customer gets a page with a Pro badge at the top and, directly underneath it, the sentence:
Your subscription has ended. Resubscribe to continue hosting.
Two statements about the same account, contradicting each other, on the same screen, because one of them is sourced from our row and the other from an API call that returned null. The fix is not more error handling, it is to stop letting one null mean both "no subscription" and "could not ask". A third state, with copy that says the billing details are temporarily unavailable, is about six lines.
What I would change in the client itself
One line, at the top of the file:
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
No apiVersion. That means the account's default version applies, and the version an SDK upgrade brings with it applies, and the thing that decides what shape a subscription object has is a dependency bump. Pinning it is a one-line change that converts a class of surprise into a compile error at upgrade time, and the only reason it has not happened yet is that nothing has broken.
Beyond that, two of the four readers should be one. ensureStripeCustomer already knows how to repair a row, and the billing portal link already knows how to fail. The portal link calling the repair would mean a stale id gets fixed the first time somebody looks at their billing page, instead of only when they reach checkout, and the missing button would stop being a thing we have to explain.
Four readers, one column, one error code, four answers. You can watch the parts of this that are public: pricing is where the currency decision surfaces, and the free tier creates the Stripe customer whose id this whole post is about, with no card and no checkout.
Top comments (1)
the deleted-in-dashboard case is the sneakiest of the three, because from the app's point of view it looks identical to every other customer id sitting in the column. one guard worth adding before the repair path: check why the customer is gone before recreating it. if someone on the team deleted it in the dashboard during a cleanup, auto-creating a fresh customer object silently puts personal data back on stripe's side and undoes the cleanup. logging the repair event with the user id at least makes it auditable later, and in some products the right answer on resource_missing for a deleted customer is to leave the row null and let the user re-enter billing, not to resurrect anything.