Subscription lifecycle
After checkout, every state change is webhook-driven.
Events the marketplace cares about
| Stripe event | Marketplace effect |
|---|---|
checkout.session.completed | Upsert subscription row, status from session |
customer.subscription.created | Same; idempotent with the above |
customer.subscription.updated | Sync status, currentPeriodEnd, cancelAtPeriodEnd |
customer.subscription.deleted | status: 'canceled', canceledAt: now() |
invoice.payment_succeeded | Audit-log only |
invoice.payment_failed | status: 'past_due' |
Cancellation flow
Failed payment retry
The marketplace does not block access on past_due. The agent decides. Recommended:
- Treat
past_dueas access-granted with a banner: "Payment failed. Update your card." - After ~7 days of
past_duewith no recovery, soft-block by treating it likecanceled.
Status truth table for the agent
| Marketplace status | Agent should treat as | UI hint |
|---|---|---|
trialing | Active | "Free trial — ends {date}" |
active | Active | (none) |
past_due | Active (configurable) | "Update payment method" |
incomplete | NOT active | "Payment pending" |
incomplete_expired | NOT active | "Payment failed" |
unpaid | NOT active | "Subscription unpaid" |
canceled | NOT active | "Subscription ended" |
Plan changes
When a user upgrades or downgrades:
Stripe handles proration. The marketplace just syncs the new tierId (derived from stripePriceId → matching agent_pricing_tiers row) on webhook.
Reconciliation
If a webhook is missed (Stripe retried but the backend was down for > 24h), use the operations script:
pnpm tsx scripts/reconcile-stripe.ts --agent-id=<id>
(Build this script if absent — it lists all Stripe subscriptions for the customer and ensures local rows are in sync.)