Stripe Billing skill for Codex
Subscriptions that never drift from Stripe. Codex mirrors every Stripe subscription into your database from webhooks and grants access from that mirror, not from a redirect. Upgrades preview their proration with createPreview before charging, downgrades wait for renewal, and a test clock walks a customer through trial, renewal, failed payment and cancellation so drift shows up before real customers hit it.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- stripe-billing Installs to ~/.agents/skills/stripe-billing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install stripe-billing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project stripe-billing Installs to .agents/skills/stripe-billing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/stripe-billing.zipunzip -o stripe-billing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) stripe-billing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $stripe-billing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Stripe Billing subscriptions end to end - Checkout in subscription mode, trials, plan upgrades and downgrades with proration, invoice previews, failed-payment dunning, the customer portal, usage-based meters and test clocks - with app access driven by webhook-synced subscription state. Use when adding paid plans or a pricing page backend, handling invoice.payment_failed, changing a customer's plan mid-cycle, enabling the Stripe customer portal, or debugging subscription status drift.
Use it when
- Adding recurring plans, seats or trials to a SaaS product
- Plan changes need correct proration and a price preview
- Subscription status in your database disagrees with Stripe
- Upgrading to a basil API version broke current_period_end reads
Use something else when
- You only need one-time payments: use stripe-payments
- You want Paddle or Lemon Squeezy to handle sales tax as merchant of record: use merchant-of-record-payments
How to check the result
Advance a Stripe test clock through trial, renewal, a failed payment and cancellation, and confirm the database row matches stripe subscriptions retrieve after each step.
The mistake it prevents
Updating a subscription with a new price but without the existing item id, which adds a second item and bills the customer for both plans.
Done well means: your database mirrors each Stripe subscription from webhooks, access is granted from that mirror (never from a redirect), plan changes preview their proration before the customer confirms, and a full billing cycle (trial, renewal, failed payment, recovery, cancellation) has been simulated with a test clock.
Before writing code
- Read
AGENTS.mdand find the existingstripeclient, webhook route, user/account model and anyplanorentitlementcolumns. - Note the pinned API version. Since
2025-03-31.basil,current_period_startandcurrent_period_endlive on subscription items (sub.items.data[0].current_period_end), not on the subscription. Older code readingsub.current_period_endbreaks after an upgrade. - Decide the billing unit with the user: per account, per seat, or metered. It drives the data model and cannot be changed cheaply later.
- Live-mode actions (cancelling, refunding, migrating real customers between prices, changing portal configuration) need explicit user confirmation.
Data model
Store per customer account: stripe_customer_id (create one Customer per account, once),
stripe_subscription_id, status, price_id, current_period_end, cancel_at_period_end,
and a derived entitlement (plan tier, seat count). Keep Prices in config keyed by plan and
interval: PRICES.pro.monthly = 'price_...'.
Grant access from status:
| Subscription status | Access | Notes |
|---|---|---|
trialing, active | Yes | |
past_due | Yes, with a banner | Smart Retries are running; decide a grace period |
incomplete | No | First payment needs action (3DS) or failed |
incomplete_expired, canceled | No | Terminal |
unpaid | No | Retries exhausted, invoices left open |
paused | No | Trial ended without a payment method, or paused collection |
Start a subscription
const session = await stripe.checkout.sessions.create(
{
mode: "subscription",
customer: account.stripeCustomerId, // create the Customer first and reuse it
line_items: [{ price: PRICES.pro.monthly, quantity: seats }],
subscription_data: {
trial_period_days: 14,
metadata: { accountId: account.id },
},
client_reference_id: account.id,
success_url: `${APP_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
},
{ idempotencyKey: `sub-checkout-${account.id}-${PRICES.pro.monthly}` },
);
Block a second Checkout while the account already has a non-terminal subscription, or customers end up paying twice.
Sync from webhooks
Handle these and upsert the mirror by subscription ID; re-fetch the subscription from the API inside the handler instead of trusting event order:
customer.subscription.created/updated/deletedinvoice.paid(extend access, record the period)invoice.payment_failed(notify, show banner)invoice.payment_action_required(send the hosted invoice link for 3DS)customer.subscription.trial_will_end(three days before trial end)
async function syncSubscription(subId: string) {
const sub = await stripe.subscriptions.retrieve(subId);
const item = sub.items.data[0];
await db.accounts.update({
where: { stripeCustomerId: sub.customer as string },
data: {
stripeSubscriptionId: sub.id,
status: sub.status,
priceId: item.price.id,
seats: item.quantity ?? 1,
currentPeriodEnd: new Date(item.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
});
}
For the full event-to-action table, including invoice-level edge cases and ordering, read references/webhook-state-machine.md when wiring the handler or debugging drift.
Webhook signature verification is identical to one-time payments; see $stripe-payments.
Plan changes and proration
| Change | Default | Setting |
|---|---|---|
| Upgrade (more expensive or more seats) | Charge the difference now | proration_behavior: 'always_invoice' |
| Downgrade | Apply at renewal, no credit | Subscription Schedule, or store intent and switch on invoice.upcoming |
| Quantity change, prorated on next invoice | Accrue proration items | proration_behavior: 'create_prorations' (the API default) |
| Interval change (monthly to yearly) | Bill immediately, reset anchor | billing_cycle_anchor: 'now' plus always_invoice |
// Preview before confirming: show the customer the exact amount
const preview = await stripe.invoices.createPreview({
customer: account.stripeCustomerId,
subscription: sub.id,
subscription_details: {
items: [{ id: sub.items.data[0].id, price: PRICES.business.monthly }],
proration_behavior: "always_invoice",
},
});
// After the customer confirms in the UI:
await stripe.subscriptions.update(sub.id, {
items: [{ id: sub.items.data[0].id, price: PRICES.business.monthly }],
proration_behavior: "always_invoice",
payment_behavior: "pending_if_incomplete", // keep the old plan if the charge fails
});
Always pass the existing item id when swapping a price. Passing only price adds a second
item and bills both.
Cancellation
- Default to
cancel_at_period_end: true(customer keeps access until period end). Immediate cancellation withstripe.subscriptions.cancel(id)should be a deliberate, confirmed choice. - Offer reactivation by setting
cancel_at_period_end: falsebefore the period ends.
Customer portal
const portal = await stripe.billingPortal.sessions.create({
customer: account.stripeCustomerId,
return_url: `${APP_URL}/billing`,
});
Configure allowed actions (cancel, switch plans, update payment method, invoice history) in the
Dashboard or via billingPortal.configurations. Portal changes fire the same subscription
webhooks, so the mirror stays correct without extra code.
Usage-based billing
Create a Meter, attach a metered Price to it, then report usage:
await stripe.billing.meterEvents.create({
event_name: "api_requests",
payload: { stripe_customer_id: account.stripeCustomerId, value: "120" },
identifier: `usage-${account.id}-${batchId}`, // dedupes retries
});
Aggregate locally and send batches; do not send one meter event per API call. Legacy usage
records were removed in the basil API versions; migrate old usage_records code to meters.
Dunning
- Enable Smart Retries and failed-payment emails in Billing settings, and decide the final action after retries (cancel, mark unpaid, or leave past due).
- On
invoice.payment_failed, show an in-app banner with a portal link; do not cut access on the first failure.
Gotchas
- Creating a new Customer per checkout: duplicate customers, split history, broken portal.
- Reading
subscription.current_period_endon a basil-or-later API version: it is undefined. - Granting access on
checkout.session.completedalone: the subscription can still beincomplete. Check the subscription status. - Trial without collecting a card: set
trial_settings.end_behavior.missing_payment_methoddeliberately (cancel,pauseorcreate_invoice). - Timezones: periods are Unix seconds UTC. Convert once, at the boundary.
- Changing a Price’s amount is impossible; create a new Price and migrate subscriptions on purpose, with user approval for live customers.
Verification with test clocks
const clock = await stripe.testHelpers.testClocks.create({ frozen_time: Math.floor(Date.now() / 1000) });
// create a Customer with test_clock: clock.id, subscribe it, then advance:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: nowPlus(15, "days") });
Run the cycle: trial ends and converts, renewal succeeds, switch the payment method to
pm_card_chargeCustomerFail and advance to see past_due, recover, then cancel at period
end. After each step, compare the database row with stripe subscriptions retrieve <id>.
Report template
**Plans:** pro/business x monthly/yearly (Price IDs in config/billing.ts)
**Mirror:** accounts.status, price_id, seats, current_period_end (from subscription items)
**Webhooks handled:** customer.subscription.*, invoice.paid, invoice.payment_failed, invoice.payment_action_required
**Proration:** upgrades always_invoice with preview; downgrades at renewal
**Test clock run:** trial -> active -> past_due -> active -> canceled, mirror matched at each step
**Needs user:** portal configuration, Smart Retries policy, live migration of existing customers What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Stripe Billing"
short_description: "Subscriptions that never drift from Stripe"
default_prompt: "$stripe-billing Add monthly and yearly Pro plans with a 14-day trial using Stripe subscriptions" references/webhook-state-machine.md
# Stripe Billing webhook state machine
Read this when wiring the subscription webhook handler or when the database disagrees with
Stripe. The rule underneath every row: treat the event as a signal, re-fetch the object, and
write the current state. Never apply event payloads as deltas.
## Event to action
| Event | When it fires | Action in your app |
|---|---|---|
| `checkout.session.completed` (mode subscription) | Customer finished Checkout | Link `customer` and `subscription` IDs to the account, then sync the subscription. Do not grant access from this event alone |
| `customer.subscription.created` | Subscription object exists (may be `incomplete` or `trialing`) | Sync |
| `customer.subscription.updated` | Status, items, quantity, cancel flags, trial or period changed | Sync. Inspect `data.previous_attributes` only for analytics or emails |
| `customer.subscription.deleted` | Subscription ended (immediately or at period end) | Sync; status becomes `canceled`; revoke access |
| `customer.subscription.trial_will_end` | Three days before trial end | Remind the customer; nudge to add a payment method |
| `customer.subscription.paused` / `resumed` | Pause collection or trial-end pause | Sync; access follows status |
| `invoice.created` | Draft invoice for a renewal (about one hour before finalising) | Optional: add one-off invoice items |
| `invoice.finalized` | Invoice locked and ready to pay | Optional: store hosted invoice URL |
| `invoice.paid` | Payment succeeded (also for zero-amount invoices) | Record payment, extend access to the item period end |
| `invoice.payment_failed` | A charge attempt failed | Notify, show banner with portal link; keep access during retries |
| `invoice.payment_action_required` | Card needs 3DS or other action | Send the `hosted_invoice_url` to the customer |
| `invoice.upcoming` | Days before renewal (configurable) | Apply scheduled downgrades, send renewal notices |
| `invoice.marked_uncollectible` | Final dunning action marked it uncollectible | Revoke or limit access per policy |
| `customer.updated` | Default payment method or details changed | Usually nothing; useful to clear a failed-payment banner |
| `charge.dispute.created` | Chargeback opened | Alert a human; consider suspending the account |
## Status transitions to expect
```text
(checkout) -> incomplete --pay--> active
\--23h, no payment--> incomplete_expired
(checkout with trial) -> trialing --trial ends, paid--> active
\--no payment method--> paused | canceled (per trial_settings)
active --renewal fails--> past_due --retry succeeds--> active
\--retries exhausted--> canceled | unpaid (per Billing settings)
active --cancel_at_period_end, period ends--> canceled
```
## Ordering and duplicates
- `invoice.paid` can arrive before `customer.subscription.updated` for the same renewal.
Because the handler re-fetches the subscription, order does not matter.
- Store processed `event.id` values with a unique constraint and skip repeats.
- If the handler crashes after writing, the retry re-syncs to the same state: idempotent.
- Lock per customer (row lock or advisory lock) if two events for one customer can run
concurrently in your worker pool.
## Invoice fields that moved in basil API versions
On `2025-03-31.basil` and later, check field locations against your pinned version before
reading them: subscription billing periods are on subscription items, and the invoice's link to
its subscription is under the invoice's parent details rather than a top-level `subscription`
field. When unsure, retrieve the object with the CLI (`stripe invoices retrieve in_...`) and read
the actual shape instead of guessing.
## Drift repair job
Run nightly or on demand:
1. List subscriptions changed recently: `stripe.subscriptions.list({ status: 'all', created: { gte } })`
with auto-pagination, or iterate your own accounts with a non-null subscription ID.
2. For each, run the same `syncSubscription` used by the webhook.
3. Log every row whose stored status differed; a non-zero count means a webhook path is broken.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.