A buyer opens Polar checkout, gets distracted, and comes back later. The session has expired. Your logs may show a cancelled status, a missing webhook, or a return URL that never fired. None of that means a card was declined.
An expired checkout session is a shopping cart that timed out. A failed payment is a charge attempt that Polar tried and rejected. Mixing the two will send support on the wrong trail and will make your funnel metrics look worse than they are.
What actually happened
Polar checkout sessions have a lifetime. When that lifetime ends without a completed payment, the session is no longer usable. The buyer can start a new checkout. Your app should treat the old session as abandoned, not as a hard decline.
Typical signals that look scary but are often just expiry:
- The return URL never loads because the buyer closed the tab.
- Your webhook endpoint receives no paid event for that checkout id.
- A later status lookup shows the session is no longer open.
- Your pending purchase row stays pending until you mark it abandoned.
A real payment failure usually shows up as an explicit failure on the charge attempt, often with a reason you can show in support tools. Expiry has no charge.
Do not map expiry to "payment failed"
If your status machine collapses every non paid outcome into one failed bucket, you lose the story that matters for support and for product decisions.
Keep at least these outcomes separate in your own database:
- pending. You created a checkout and are waiting.
- paid. A verified webhook (or a verified server side Polar lookup) confirmed payment.
- expired or abandoned. The session timed out or the buyer never finished.
- failed. Polar reported a declined or failed charge attempt.
- refunded or disputed. Money moved the other way after a paid state.
Expiry is closer to "they left" than to "the bank said no."
A safe status poll on the server
When your success page polls for access, ask your own database first. Only call Polar from the server when you need to reconcile a stuck pending row. Never let the browser decide that a missing entitlement means the card failed.
export async function GET(request: Request) {
const url = new URL(request.url)
const checkoutId = url.searchParams.get("checkoutId")
if (!checkoutId) {
return Response.json({ error: "Missing checkout id" }, { status: 400 })
}
const purchase = await db.purchases.findByCheckoutId(checkoutId)
if (!purchase) {
return Response.json({ status: "unknown" })
}
if (purchase.status === "paid") {
return Response.json({ status: "paid", access: true })
}
if (purchase.status === "expired" || purchase.status === "abandoned") {
return Response.json({
status: "expired",
access: false,
message: "This checkout timed out. Start a new checkout to try again."
})
}
if (purchase.status === "failed") {
return Response.json({
status: "failed",
access: false,
message: "The payment did not go through. You can try another method."
})
}
return Response.json({ status: "pending", access: false })
}
The copy the buyer sees should match the status. "Try again with a new checkout" is the right message for expiry. "Your card was declined" is only right when Polar told you that.
Reconcile stuck pending rows
Pending rows that sit forever confuse support. Add a quiet job or a manual admin action that:
- Loads pending purchases older than your chosen window.
- Asks Polar from the server whether that checkout completed.
- Marks paid rows when a verified paid state exists.
- Marks expired or abandoned when Polar shows the session is gone and no payment exists.
- Leaves true failures in the failed bucket when Polar reports one.
Do this on the server with your Polar access token. Do not put that token in NEXT_PUBLIC_ variables. Do not trust a query string on the return URL as proof of payment.
What to tell support
When someone writes "I paid but nothing unlocked," check the checkout id against your purchase row and against Polar.
- If status is paid and your entitlement is missing, fix the webhook path.
- If status is pending and Polar shows no payment, ask them to complete a fresh checkout.
- If status is expired, explain that the old link timed out and send a new checkout link.
- If status is failed, share the decline path Polar gave you when it is safe to do so.
That script keeps expired sessions out of your "payment failed" pile.
Soft sell
If you want a longer go live checklist for Next.js on Vercel with Polar checkout, webhook, and entitlement notes, the Next.js / Vercel Production Launch Kit is $19.
If you want a small lab for sandbox and live webhook shapes side by side, Dual Mode Webhook Lab is $14.
Support: gumbosveins@gmail.com
More in this series
- The Next.js / Vercel production env mistakes that break launches
- Polar sandbox vs live webhooks: why checkout works and entitlements do not
- Build a Vercel env matrix offline before you merge
- Stop pasting Polar webhook secrets into online signature debuggers
- The Checkout Is Not the Entitlement
- Polar Access Tokens Do Not Belong in NEXT_PUBLIC_
Top comments (0)