← All skills
stripe-integration-expert
Use when building or debugging Stripe integrations: subscriptions with trials/proration, one-time payments, usage-based billing, checkout, webhooks, portal, or invoicing.
About this skill
Use when building or debugging Stripe integrations: subscriptions with trials/proration, one-time payments, usage-based billing, checkout, webhooks, portal, or invoicing.
SKILL.md
stripe-integration-expert.SKILL.mdPlace it in your Syntic skills folder. For Syntic Code, install in your local skills directory, review the contents and run it in a controlled environment first.
---
name: stripe-integration-expert
description: Use when building or debugging Stripe integrations: subscriptions with trials/proration, one-time payments, usage-based billing, checkout, webhooks, portal, or invoicing.
category: Engineering
version: 1.0.0
tools: []
---
# Stripe Integration Expert
Design and reason about production-grade Stripe integrations: subscriptions with trials and proration, one-time payments, usage-based billing, checkout sessions, idempotent webhook handlers, customer portal, and invoicing. Patterns apply across Next.js, Express, and Django.
## Core Capabilities
Subscription lifecycle management (create, upgrade, downgrade, cancel, pause); trial handling and conversion tracking; proration calculation and credit application; usage-based billing with metered pricing; idempotent webhook handlers with signature verification; customer portal integration; invoice generation and PDF access; local testing via the Stripe CLI.
## Subscription Lifecycle State Machine
A subscription moves: `FREE_TRIAL` -> (paid) -> `ACTIVE` -> (cancel) -> `CANCEL_PENDING` -> (period end) -> `CANCELED`, with a `reactivate` path back. From `ACTIVE`, a `downgrade` moves to `DOWNGRADING`, which resolves to `ACTIVE` on a lower plan at period end. From `FREE_TRIAL`, trial end without payment moves to `PAST_DUE`; three failed payment attempts move to `CANCELED`; a successful payment moves back to `ACTIVE`.
DB subscription status values: `trialing | active | past_due | canceled | cancel_pending | paused | unpaid`.
## Client Setup
Initialize the Stripe SDK with the secret key, a pinned `apiVersion`, and `appInfo` metadata. Store price IDs per plan (monthly/yearly) in environment variables, keyed by plan tier, alongside a feature list per tier for display purposes.
## Checkout Session
To start a subscription checkout: authenticate the user; get or create a Stripe customer (create one with `email` and `metadata.userId` if none exists yet, persist `stripeCustomerId`); create a checkout session in `subscription` mode with the chosen `priceId`, `allow_promotion_codes: true`, a `trial_period_days` of 14 only if the user hasn't had a trial before, and `success_url`/`cancel_url` pointing back into the app with `userId` in metadata for webhook correlation.
## Subscription Upgrade/Downgrade
**Upgrade (immediate):** update the subscription item to the new price with `proration_behavior: "always_invoice"` and `billing_cycle_anchor: "unchanged"` — this applies immediately and invoices the prorated difference.
**Downgrade (at period end):** update the subscription item to the new price with `proration_behavior: "none"` — this takes effect at the next billing cycle with no proration.
**Preview proration before confirming:** retrieve the subscription, compute a proration date (now), and call the upcoming-invoice preview with the new price and proration date to get `amount_due` and the line items — show this to the user before they confirm an upgrade.
## Idempotent Webhook Handling
The core pattern: verify the Stripe signature against the raw request body and webhook secret before doing anything else — reject with 400 on failure. Check a processed-events table for the event ID; if already processed, return success immediately without reprocessing (Stripe retries on 500, so duplicate delivery is expected, not exceptional). Only after a successful handler run, mark the event ID as processed; if the handler throws, return 500 so Stripe retries and do NOT mark it processed.
Events to handle, and what each should do:
- `checkout.session.completed` — for subscription-mode sessions, pull `userId` from metadata, re-fetch the subscription from the Stripe API (never trust webhook payload data alone), and persist `stripeCustomerId`, `stripeSubscriptionId`, `stripePriceId`, `stripeCurrentPeriodEnd`, `subscriptionStatus`, and `hasHadTrial: true`.
- `customer.subscription.created` / `customer.subscription.updated` — look the user up by `stripeSubscriptionId` (fall back to `stripeCustomerId` if not found), then sync `stripePriceId`, `stripeCurrentPeriodEnd`, `subscriptionStatus`, and `cancelAtPeriodEnd`.
- `customer.subscription.deleted` — clear `stripeSubscriptionId`/`stripePriceId`/`stripeCurrentPeriodEnd` and set status to `canceled`.
- `invoice.payment_failed` — set status to `past_due`; check `attempt_count` and send a dunning email — a milder "retry" message before 3 attempts, a "final" warning at 3+.
- `invoice.payment_succeeded` — set status to `active` and refresh `stripeCurrentPeriodEnd` from `invoice.period_end`.
## Usage-Based Billing
Report usage against a metered subscription item with `createUsageRecord({ quantity, timestamp, action: "increment" })`. To meter API calls, look up the user's subscription, find the item whose price has `recurring.usage_type === "metered"`, and report one unit per call in middleware.
## Customer Portal
Create a billing-portal session for an authenticated user's `stripeCustomerId` with a `return_url` back into account settings, and redirect them to the returned `url`. Reject with 400 if the user has no billing account yet.
## Testing with Stripe CLI
`stripe login`, then `stripe listen --forward-to localhost:3000/api/webhooks/stripe` to forward webhooks to local dev. Trigger specific events for testing: `stripe trigger checkout.session.completed`, `stripe trigger customer.subscription.updated`, `stripe trigger invoice.payment_failed` — optionally override fields, e.g. `--override subscription:customer=cus_xxx`. `stripe events list --limit 10` shows recent events.
Test cards: success `4242 4242 4242 4242`; requires authentication `4000 0025 0000 3155`; decline `4000 0000 0000 9995`; insufficient funds `4000 0000 0000 9995`.
## Feature Gating
Treat a subscription as active if status is `active` or `trialing`. For `past_due`, extend a grace period — still treat it as active as long as `stripeCurrentPeriodEnd` hasn't passed yet. Everything else is inactive. Gate protected routes/actions on this check and redirect to billing with a `reason=subscription_required` query param on failure.
## Common Pitfalls
- **Webhook delivery order isn't guaranteed** — always re-fetch state from the Stripe API rather than trusting the order or content of webhook events alone for DB updates.
- **Double-processing webhooks** — Stripe retries on any non-2xx response, including 500s, so an idempotency table keyed on event ID is mandatory, not optional.
- **Trial conversion tracking** — persist `hasHadTrial: true` in the DB the first time a user completes checkout, to prevent repeat trial abuse on the same account.
- **Proration surprises** — always preview proration before an upgrade takes effect, since `always_invoice` charges immediately and users are frequently surprised by an unexpected mid-cycle charge.
- **Signature verification on the raw body** — frameworks that parse JSON automatically will break signature verification; the webhook route must read the raw, unparsed request body.
- **Storing amounts as floats** — Stripe amounts are integers in the smallest currency unit (cents); converting to float anywhere in the pipeline introduces rounding bugs.
Install
Syntic App
- 1Create a dedicated folder for this skill in your local skills library.
- 2Place SKILL.md into that folder.
- 3Restart Syntic and invoke this skill on matching tasks.
Syntic Code (CLI)
- 1Save SKILL.md in your local Syntic Code skills directory.
- 2Keep related files in the same skill folder.
- 3Run in a safe environment and validate outputs.