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.

  • Integrations
  • $stripe-billing
  • 206 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- stripe-billing

Installs to ~/.agents/skills/stripe-billing/ for every project on this machine.

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

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.

~/.agents/skills/stripe-billing/SKILL.md

--- name: stripe-billing description: Builds Stripe Billing subscriptions end to end - Checkout in subscription mode, trials, plan upgrades and downgrades with proration, invoice… license: MIT ---

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

  1. Read AGENTS.md and find the existing stripe client, webhook route, user/account model and any plan or entitlement columns.
  2. Note the pinned API version. Since 2025-03-31.basil, current_period_start and current_period_end live on subscription items (sub.items.data[0].current_period_end), not on the subscription. Older code reading sub.current_period_end breaks after an upgrade.
  3. Decide the billing unit with the user: per account, per seat, or metered. It drives the data model and cannot be changed cheaply later.
  4. 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 statusAccessNotes
trialing, activeYes
past_dueYes, with a bannerSmart Retries are running; decide a grace period
incompleteNoFirst payment needs action (3DS) or failed
incomplete_expired, canceledNoTerminal
unpaidNoRetries exhausted, invoices left open
pausedNoTrial 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 / deleted
  • invoice.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

ChangeDefaultSetting
Upgrade (more expensive or more seats)Charge the difference nowproration_behavior: 'always_invoice'
DowngradeApply at renewal, no creditSubscription Schedule, or store intent and switch on invoice.upcoming
Quantity change, prorated on next invoiceAccrue proration itemsproration_behavior: 'create_prorations' (the API default)
Interval change (monthly to yearly)Bill immediately, reset anchorbilling_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 with stripe.subscriptions.cancel(id) should be a deliberate, confirmed choice.
  • Offer reactivation by setting cancel_at_period_end: false before 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_end on a basil-or-later API version: it is undefined.
  • Granting access on checkout.session.completed alone: the subscription can still be incomplete. Check the subscription status.
  • Trial without collecting a card: set trial_settings.end_behavior.missing_payment_method deliberately (cancel, pause or create_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.yamlgenerated from this page

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.md63 lines, 4.1 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem