Stripe Checkout Session links expire in 24h — email blasts lose 78% of payers #488
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api#488
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type
Bug
Lineage
Standalone — discovered 2026-04-17 investigating stranded Utah Invitational orders (#486 for recovery). Root cause of the 78% blast failure rate. Affects every Stripe checkout link in basketball-api.
Refinement applied after
/review-ticketpass 1 (review notereview-1023-2026-04-17):routes/checkout.pyshared helper androutes/admin.pyregenerate handler).origin/main @ a4047d2.CHECKOUT_SESSION_TTL_SECONDSconstant made a required element, not optional.Repo
forgejo_admin/basketball-apiWhat Broke
Every
stripe.checkout.Session.createcall in the codebase omits theexpires_atparameter. Stripe defaultsexpires_at = created + 86400s(24 hours). All email-delivered checkout links die 24h after mint, regardless of whether the parent has opened the email.Evidence gathered via Stripe API retrieve on 2026-04-17:
pendingorders → allstatus=expired(created 2026-04-12 or 2026-04-15, TTL=86400s exactly)paidorders → allstatus=complete(clicked within the 24h window)Stranded revenue right now: $985 tournament + $610 monthly = $1,595. Trust damage unquantified but substantial (parent Daniel Niyitanga reported the link as "broken," the first complaint to surface this).
Affected call sites — all 8 (verified against
origin/main @ a4047d2):src/basketball_api/services/tournament_checkout.py:78— blessed tournament helpersrc/basketball_api/routes/jersey.py:291— legacy jersey purchasesrc/basketball_api/routes/checkout.py:247— generic/create-sessionendpointsrc/basketball_api/routes/checkout.py:410— first monthly payment (prorated)src/basketball_api/routes/checkout.py:553— shared-metadata helper pathsrc/basketball_api/routes/register.py:1378— tryout registration card pathsrc/basketball_api/routes/register.py:1423— tryout registration fallback pathsrc/basketball_api/routes/admin.py:2005— admin "regenerate session" handlerRepro Steps
stripe.checkout.Session.create(...)site without passingexpires_atOr reproduce via Stripe API:
Expected Behavior
Checkout sessions minted for email blasts should remain valid long enough for real parent open/click latency — at minimum 30 days (Stripe's maximum
expires_atwindow for payment mode). Emitting session URLs into emails should not silently fail after 24h.Fix approach — required pattern:
Introduce a module-level constant in a shared location (recommend
src/basketball_api/config.pyorsrc/basketball_api/constants.py— pick whichever already hosts similar constants):Import and use at each of the 8 call sites:
The constant is required (not optional). It makes #489's future cutover to Payment Links a single-line change and prevents the "new call site forgets to set expires_at" regression.
Runbook updates (
docs/tournament-billing-runbook.md):order_idmetadata. Every verified session (paid and pending) in the Apr 2026 incident carried properorder_id. Move the metadata concern to a "Historical hypothesis (corrected)" subsection.Environment
basketball-apimainas of 2026-04-17 (HEAD includesfd081e0 fix: require amount match when reusing fresh pending Stripe session (#480)— review pinned againsta4047d2)Acceptance Criteria
CHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600constant declared in a shared config/constants moduleexpires_at=int(time.time()) + CHECKOUT_SESSION_TTL_SECONDStests/test_checkout_session_ttl.pyparametrizes across all 8 routes/helpers and asserts each passesexpires_atwithin 29–30 days of nowpytest tests/green in CIruff checkcleandocs/tournament-billing-runbook.md"What broke" narrative updated: TTL is the documented primary root cause; metadata hypothesis demoted to historical subsectiondocs/tournament-billing-runbook.mdsanctioned-call-sites table updated: all 8 sites listed, correct line numbers, replaces the stale 5-entry tableexpires_at - created ≈ 2592000sRelated
project-pal-e-platformforgejo_admin/basketball-api #486— recovery ticket for the 17 stranded orders. Blocked by this patch.forgejo_admin/basketball-api #487— expired-session metric (observability follow-up)forgejo_admin/pal-e-platform #295— alert rule consuming that metricforgejo_admin/basketball-api #489— spike on Payment Links vs lazy-mint (independent; this patch is the stopgap regardless of spike outcome)Scope Review: NEEDS_REFINEMENT
Review note:
review-1023-2026-04-17Audited against
origin/main @ a4047d2. Scope is solid in intent and the runbook critique is correct, but the call-site inventory is wrong.Blocking [BODY] refinements:
src/finds 8stripe.checkout.Session.createcall sites, not 6. Missing from the ticket:src/basketball_api/routes/checkout.py:553andsrc/basketball_api/routes/admin.py:2005(admin "regenerate session" handler). Title, "Affected call sites (all 6)" header, AC-1, and AC-2 all need "6" → "8".routes/checkout.py:246→ actual call at 247routes/checkout.py:398→ actual call at 410 (line 398 isorder = Order(...))routes/register.py:1373→ actual call at 1378routes/register.py:1418→ actual call at 1423docs/tournament-billing-runbook.md's sanctioned-call-sites table (lines 23-29) also needs a rewrite — it shares the same stale line numbers and omitscheckout.py:553+admin.py:2005. Not just the "What broke" narrative.CHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600constant in one module, imported at all 8 sites. Makes the #489 spike's cutover a one-line change.Non-blocking [SCOPE] items (can ship as follow-up board items, do not hold this Bug):
arch-stripe-checkoutnote exists in pal-e-docs. Worth creating — siblings #486 and #487 both need it too.story:payment-reliabilitylabel does not match project-westside-basketball'sWS-S{N}taxonomy (WS-S11 is the nearest existing fit). Same drift affects siblings #486 (story:payment-recovery) and #487 (story:observability). Project-wide reconciliation needed.Correctly ordered vs. sibling #486: Ticket must merge + deploy before #486's regen script runs, or the recovery sessions inherit 24h TTL instead of 30d. Called out in dependencies section of the review note.
Stripe TTL value confirmed: 30 × 24 × 3600 = 2,592,000s, which is Stripe's published maximum for payment mode. Correct.
Decomposition: Not needed. 8 mechanical edits + 1 runbook + 1 new test file, single invariant, ~5 min Dev agent pass.
Full audit in
review-1023-2026-04-17(pal-e-docs).