If a one-time payment is the problem of "receiving money once," a subscription is the problem of "receiving the correct amount, every time, without fail, along the time axis." The essence of the difficulty is that the state keeps changing over time — the trial expires, the card expires, the plan is upgraded midway, a retry runs, a cancellation is scheduled. If you don't hold this down with structure, "billing omissions," "overbilling," and "access-right inconsistencies" quietly accumulate.
This article is a Billing (subscription/billing)-dedicated implementation guide, written so that "in which scene, why, and how to use it" is clear, while being faithful to the latest spec of the Stripe official documentation. The latest API version at writing is 2026-05-27.dahlia. The samples are shown in Next.js 16 (App Router / RSC / Server Actions) + TypeScript strict.
The basics of accepting payments (Checkout Sessions, signature verification, the two-layer idempotency model of webhooks) aren't re-explained in this article. Those are separated into the sister article "A complete guide to implementing Stripe payments at production quality (2026 edition)." This article concentrates on the subscription-specific points — components, usage-based, proration, the customer portal, lifecycle webhooks.
The author's practical background: I built a production subscription foundation with Next.js 16.1 + Stripe 17. Webhooks are idempotent with the unique constraint of
event.id+ ordering guarantee byevent.created+ recursive PII redaction, multi-channel rate calculation is implemented as pure functions, the bank-transfer subscription state is modeled with a state machine, and it's protected by 433 tests. In addition, I led the reliability layer on a serverless payment platform in the environmental field and achieved 0 double charges in production. All the Stripe specs in the body are backed by the official documentation, and I don't handle unconfirmed numbers like MRR, churn, or ROI.
1. The components of a subscription
Stripe Billing is a combination of five objects. If you enter implementation while keeping this vague, you later can't tell "where the truth of the access right is."
| Object | Role | Important point |
|---|---|---|
Customer | the container of billing info | holds the payment method, address, and the billing destination for next time on. Tie it 1-to-1 to a user in your DB |
Product | what you sell (plan name, etc.) | a concept like "Pro plan," "Business plan." Tied to Entitlements (feature access) |
Price | the recurring amount, currency, cycle | "¥1,980/month," "¥19,800/year." One Product can have multiple Prices (monthly/yearly, different currencies) |
Subscription | the continuous relationship connecting Customer and Price | the subject of the lifecycle (trialing→active→past_due…). The access-right judgment is here |
Invoice | the invoice for each billing cycle | Stripe auto-generates it each cycle. It contains a PaymentIntent, and the payment result is reflected in status |
The relationship, diagrammed, is as follows.
Customer
└─ Subscription ── Price (recurring) ── Product
└─ Invoice(per cycle) ── PaymentIntent(payment processing)
The transition of Subscription.status is the lifeline of the implementation. Let me summarize the official definition.
| status | Meaning | Access right |
|---|---|---|
trialing | in trial (not yet billed) | granted (trial) |
active | normal. The latest invoice is paid | granted |
incomplete | the first payment is unconfirmed (up to 23 hours grace) | held |
incomplete_expired | the first payment didn't confirm within 23 hours and lapsed | not granted |
past_due | the latest invoice's payment failed. Retrying | needs a policy judgment (grace or restrict) |
unpaid | retries exhausted. The latest invoice stays unpaid | revoke |
canceled | canceled (final, immutable) | revoke |
paused | a payment method was absent at trial end and it's paused | revoke |
The design crux: don't doubly hold an "enabled/disabled" flag in your own DB. The truth is
Subscription.status, and your DB is its cache. Sync with webhooks, and consolidate the judgment logic in one place (§7, §8).
2. Starting a subscription: Checkout or the Subscriptions API
There are two paths to "starting" a subscription. The official stance is clear: Checkout is the minimal implementation and recommended, and directly hitting the Subscriptions API is only when full control is needed.
| Viewpoint | Checkout (mode: 'subscription') | Subscriptions API directly |
|---|---|---|
| Implementation amount | minimal | large (collect/confirm the payment method yourself) |
| Payment UI | Stripe-hosted / embedded | assemble the Payment Element yourself |
| Tax, discount, saving the payment method | built-in | manual configuration |
| Suited scene | MVP, standard flow, fast and safe | the payment UX is the business's core, custom state management needed |
2-A. Recommended: Checkout Session (mode: 'subscription')
The basics of accepting payments (redirect/embedded, success_url, signature verification) are left to the basics article. What changes for subscription is only the point that you set mode: 'subscription' and pass a recurring Price to line_items.
// app/actions/start-subscription.ts
"use server";
import Stripe from "stripe";
import { redirect } from "next/navigation";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startSubscription(formData: FormData): Promise<void> {
const priceId = String(formData.get("priceId"));
// 顧客は自サイトの認証済みユーザーから解決する(クライアントの値は信用しない)
const customerId = await resolveStripeCustomerId(); // 自前: users → cus_xxx
const session = await stripe.checkout.sessions.create(
{
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
// トライアルや支払い方法保存はここで指定(§5)
subscription_data: { trial_period_days: 14 },
success_url: `${process.env.APP_URL}/billing/return?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
},
// 同一ユーザーの二重作成を防ぐ決定的キー(送信側冪等性)
{ idempotencyKey: `sub-start:${customerId}:${priceId}:v1` },
);
if (!session.url) throw new Error("Checkout session URL missing");
redirect(session.url); // Next.jsのredirectはthrowするので戻り値はvoidで良い
}
There are three points. ① Resolve the customer ID from the server's authentication info (if even priceId is client-derived, you should whitelist-verify it against the permitted set of Prices). ② Make the idempotencyKey deterministic to prevent a double-click or retry from doubly creating the subscription. ③ Confirmation is done not by this redirect but by the webhook (§7).
2-B. Full control: the Subscriptions API directly
If you want to fully control the payment UI with your own Payment Element, create the subscription with default_incomplete and pass the first Invoice's confirmation_secret to the client to confirm it.
const subscription = await stripe.subscriptions.create(
{
customer: customerId,
items: [{ price: priceId }],
// 初回支払いが確定するまで incomplete に留める(推奨パターン)
payment_behavior: "default_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
// 初回InvoiceのPaymentIntent確定情報を展開して取得
expand: ["latest_invoice.confirmation_secret"],
},
{ idempotencyKey: `sub-create:${customerId}:${priceId}:v1` },
);
// この confirmation_secret をクライアントへ渡し、Payment Element で確定する
const invoice = subscription.latest_invoice;
// invoice.confirmation_secret をフロントの stripe.confirmPayment() に渡す
payment_behavior: "default_incomplete" is important. With it, you can safely express the state "the subscription is created but the first charge is unconfirmed," and naturally handle 3D Secure (requires_action) too. The signal of confirmation is again the webhook (invoice.paid).
3. Usage-based: the correct answer for 2026
This is a point where the recommendation changed in 2026, so taking old articles at face value errs. The official documentation's current stance is clear.
"Metronome is Stripe's flagship usage-based billing platform and is recommended for all new implementations." "Basic usage-based billing (Billing Meters) should be continued only if you're already billing customers with Billing Meters."
That is, the judgment is as follows.
| Situation | Recommendation |
|---|---|
| You're newly implementing usage-based from now | Metronome (Stripe's new usage-based foundation) |
| Adding usage to an existing flat subscription, but needing full compatibility with Connect / Checkout / Adaptive Pricing / Workflows | Billing Meters (Metronome's support is partial) |
| Already billing with Billing Meters | continue Billing Meters as-is |
This article's policy: I respect the official recommendation to consider Metronome if new. On the other hand, since many existing systems are on Billing Meters, here I explain the idea of Billing Meters' meter events, narrowed to idempotency. Always confirm the final form of the API signature in the official documentation (for the reason described later, I explain the behavior conservatively here).
The idea of Billing Meters
In Billing Meters, the aggregation is handled on the Stripe side. Your responsibility is only to send a meter event that represents "when, who, how much was used." A Price is defined as "a Price tied to a meter," and at the billing cycle's end, Stripe sums the events and bills.
What's most important in sending meter events is idempotency. Recording usage, unless designed on the premise that "API calls duplicate over the network," leads to double-counting = overbilling. A meter event can be given an identifier for idempotency (identifier), and events with the same identifier are deduplicated.
// lib/usage/report.ts —— 使用量の冪等記録
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
type UsageEvent = {
readonly eventName: string; // メーター作成時に決めた event_name
readonly stripeCustomerId: string; // cus_xxx
readonly value: number; // 今回の使用量(整数で扱う)
readonly occurredAt: Date; // 使用が発生した時刻
readonly dedupeKey: string; // 自システム側の決定的キー(例: 集計バッチID + 行ID)
};
export async function reportUsage(e: UsageEvent): Promise<void> {
await stripe.billing.meterEvents.create({
event_name: e.eventName,
// payload のキーは作成したメーターの設定に合わせる(value / stripe_customer_id 等)
payload: {
value: String(e.value),
stripe_customer_id: e.stripeCustomerId,
},
// 使用が発生した実時刻(請求周期の帰属を正しくするため)
timestamp: Math.floor(e.occurredAt.getTime() / 1000),
// ★冪等キー:同一値の重複イベントは排除される。リトライ安全。
identifier: e.dedupeKey,
});
}
There are three iron rules in design. ① Make the identifier deterministic (not random generation of a timestamp or UUID, but a key that becomes the same value even on re-send, like "aggregation batch ID + row ID"). ② Treat value as an integer, and finalize the rounding on the app side before sending. ③ The timestamp is the usage-occurrence time — using the occurrence time, not the send time, prevents the accident where usage right at month-end shifts into the next month.
Why so careful: what I keenly felt in the payment platforms I've operated is the single point that "events involving money guarantee exactly-once with structure." In my subscription foundation, I made multi-channel rate calculation a pure function and made webhooks idempotent with
event.id. Usage-based is the same idea: absorb "resends and duplicates as the normal path" withidentifier.
4. Trial & proration (pro-rating)
Trial
The simplest is trial_period_days. For Checkout, subscription_data.trial_period_days; for the Subscriptions API, specify it directly (see §2's code). During a trial it's status: trialing, and since customer.subscription.trial_will_end fires 3 days before the end, the standard is to confirm the presence of a payment method here.
Proration: upgrade/downgrade midway
When you change the plan mid-month, Stripe automatically calculates the credit for the unused portion and the billing for the remaining period of the new plan. The behavior is controlled by proration_behavior.
| Value | Behavior |
|---|---|
create_prorations (default) | create a proration line item of the difference (may be bundled into the next billing) |
always_invoice | immediately issue an invoice and charge at the change point |
none | no pro-rating. The new price from the next cycle |
Example: upgrading from ¥1,000/month → ¥2,000 at exactly the halfway point — old-plan unused portion −¥500, new-plan remaining period +¥1,000, net +¥500 is billed.
// プランをアップグレードし、即時に差額請求する
const updated = await stripe.subscriptions.update(
subscriptionId,
{
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: "always_invoice",
// 後述のプレビューと「同じ proration_date」を渡すと金額が一致する
proration_date: prorationDate,
},
{ idempotencyKey: `sub-upgrade:${subscriptionId}:${newPriceId}:v1` },
);
To present the amount to the user before the change, use invoices.createPreview. It returns "the next invoice will look like this" without changing the subscription.
// 確定せずに差額を見積もる(UIで「+¥500を本日請求します」と表示できる)
const prorationDate = Math.floor(Date.now() / 1000);
const preview = await stripe.invoices.createPreview({
customer: customerId,
subscription: subscriptionId,
subscription_details: {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_date: prorationDate,
},
});
// preview.lines のうち proration: true の明細が日割り(クレジット/デビット)
An implementation pitfall: since proration is calculated by the second, if
proration_datediffers between the preview and the execution, the amount changes. Thoroughly pass the sameproration_datein the preview and the update.
5. The Customer Portal: don't build the cancel UI
What most tends to be "reinventing the wheel" in subscriptions is the UI for cancellation, plan change, card update, and invoice download. Building these yourself means carrying the PCI boundary, 3D Secure, multilingual, tax display, and receipts all yourself.
Conclusion: don't build it. Redirect to Stripe's Customer Portal. Issue a short-lived session URL with stripe.billingPortal.sessions.create and just fly there. The permitted operations (whether cancellation is allowed, the changeable Prices) are controlled in the Dashboard's portal settings.
// app/actions/open-billing-portal.ts
"use server";
import Stripe from "stripe";
import { redirect } from "next/navigation";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function openBillingPortal(): Promise<void> {
const customerId = await resolveStripeCustomerId(); // 認証済みユーザーから解決
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
// 操作完了後に自サイトへ戻すURL
return_url: `${process.env.APP_URL}/settings/billing`,
// 任意: 特定フロー(解約確認など)に直接入る場合は flow_data を指定
});
redirect(session.url); // 短命セッション。都度発行する
}
A portal session is, in principle, short-lived and issued each time. Don't cache or reuse the URL.
The UI side just calls the Server Action. From the accessibility viewpoint, telling in advance that a redirect occurs is kind (screen-reader users are bewildered by a sudden page transition).
// app/settings/billing/portal-button.tsx
import { openBillingPortal } from "@/app/actions/open-billing-portal";
export function BillingPortalButton() {
return (
<form action={openBillingPortal}>
<button
type="submit"
// 外部のStripe管理画面へ遷移する旨をAT利用者に明示
aria-label="お支払い情報の管理画面(Stripe)へ移動します"
>
お支払い・プランの管理
</button>
</form>
);
}
Why avoid building it yourself: the cancellation flow is delicate for both the business and the user, and Stripe continuously improves it here and follows each country's laws (cooling-off, etc.). Building it yourself becomes not only an initial cost but a permanent maintenance debt. Unless it's a differentiator, delegation is the correct answer.
6. Process the Billing lifecycle with webhooks
A subscription's state changes on its own over time (renewal, failure, retry, the firing of a scheduled cancellation). The only way to know this is the webhook. Signature verification and the two-layer idempotency model (the unique constraint of event.id + the ordering comparison of event.created) are detailed in the basics article, so follow that. Here I narrow to which events to handle and how.
| Event | Meaning | Recommended action |
|---|---|---|
customer.subscription.created | subscription created (may be incomplete) | record sub_id in the DB. Save the status |
customer.subscription.updated | plan change, status transition, etc. | sync status. Judge access grant on becoming active |
customer.subscription.deleted | cancellation complete (final) | revoke access |
customer.subscription.trial_will_end | 3 days before trial end | confirm the presence of a payment method and notify |
invoice.paid | invoice payment succeeded | grant access (status is active) |
invoice.payment_failed | payment failed | notify the customer, prompt a card update (dunning) |
invoice.upcoming | the next billing is near | add line items if needed |
Make the source of truth of access control one. My recommendation is to consolidate, in one handler, the rule "grant on invoice.paid, revoke on customer.subscription.deleted / status === 'unpaid'."
// app/api/stripe/webhook/route.ts(抜粋。署名検証・冪等化は基礎記事の実装を前提)
import type Stripe from "stripe";
async function handleBillingEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
// 支払い成功 → アクセス付与。subscription IDで自DBを更新
await grantAccessForSubscription(invoice);
break;
}
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
// statusをDBへ同期(active/past_due/unpaid…)
await syncSubscriptionStatus(sub);
if (sub.status === "unpaid") await revokeAccess(sub.id);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await revokeAccess(sub.id); // 解約 → 剥奪
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
// dunning: 通知のみ。剥奪はせず Smart Retries に委ねる
await notifyPaymentFailed(invoice);
break;
}
// それ以外は記録のみ(未知のイベントで500を返さない)
}
}
Dunning and revenue recovery
Cutting access immediately on a payment failure is almost always wrong. A card's temporary failure or expiry happens daily, and Stripe's Smart Retries (automatic retry at the optimal data-based timing) recovers it. On the implementation side, the following stance is robust.
invoice.payment_failed→ notify only. The status becomespast_due, but don't revoke immediately (set a grace policy).- When Smart Retries are exhausted and the
statusbecomesunpaid(orcanceled), revoke for the first time. - The schedule of retries and dunning emails is set in the Dashboard (no code needed). Don't re-implement on the app side.
The handling of
past_dueis a product judgment. For B2B, there are options like "give grace and protect the relationship" or handle it with a downgrade to the free tier. Fixing the revocation trigger tounpaid/deletedmeans a policy change completes in one handler.
7. Design tips (7 principles that matter in production)
- Save IDs in your own DB. Save
cus_xxx(Customer) andsub_xxx(Subscription) tied to the user. Without this, you can't reconcile the webhook and your own system. - Make Stripe the source of truth. Your DB's status is a cache. If it diverges, overwrite-sync with the webhook. Don't place the "authority" of dual management in the DB.
- Consolidate the access judgment in one place. Make the function that derives "usable/unusable" from
statusone, and have UI, API, and batch call the same function. - Money is an integer. Handle amounts and usage as integers in the smallest currency unit. Hold the fee rate as an integer in basis points, and finalize rounding on the app side. Don't interpose floating-point in billing calculations.
- Don't trust the client. Whitelist-verify
priceId, resolvecustomerIdfrom the authentication info. Don't receive amounts or quantities from the client. - Idempotency in two stages. The send side is a deterministic
idempotencyKey/identifier, the receive side is the unique constraint ofevent.id. Absorb retries and duplicates as the normal path. - Make time-dependency testable. Verify renewal, lapse, and retry by virtually advancing time with Test Clocks. Carve out rate/proration calculation into pure functions and firm it up with unit tests (my subscription foundation's 433 tests are this policy).
8. Frequently asked questions (FAQ)
Q. How should I implement subscription "cancellation"?
A. Not building it yourself is the correct answer. Redirect to the Customer Portal (§5) and leave cancellation, immediate/end-of-period cancellation, and resumption to Stripe. Only when you must complete it in-app, use stripe.subscriptions.cancel(id) (immediate) or update(id, { cancel_at_period_end: true }) (end-of-period), and reflect the result to your own DB via the customer.subscription.deleted / updated webhook.
Q. How about pro-rating when changing plans midway?
A. Control it with proration_behavior (§4). For immediate difference billing, always_invoice; for from the next cycle, none. Present the amount with invoices.createPreview before the change, and passing the same proration_date in the preview and the update is the iron rule for accident prevention.
Q. Do I aggregate the usage-based myself?
A. No. The aggregation is on the Stripe side. You just send a meter event each time usage occurs (§3). But a new implementation is officially recommended to be Metronome, and Billing Meters changed to a positioning for existing users. With either, the design idea of "send the usage event idempotently with identifier" is common.
Q. May I manage the access right with a DB flag?
A. You may hold the DB as a cache, but the source of truth is Subscription.status. Consolidate the rule "grant on invoice.paid, revoke on unpaid / deleted" in one webhook handler, and the DB just reflects it. Holding "authority" doubly definitely diverges.
Q. Should I cut access immediately if a payment fails?
A. No. Temporary failures happen daily. On invoice.payment_failed, notify only and leave the recovery to Smart Retries, and only when the status becomes unpaid (retries exhausted) or canceled, revoke for the first time (§6).
Q. I want to test renewal a month later or trial end.
A. With Test Clocks, advancing the virtual clock reproduces renewal, lapse, retry, and trial_will_end in seconds. It's robust to carve the rate/proration logic into pure functions and comprehensively cover it with unit tests.
Summary: a subscription's correctness is made by "state" and "idempotency"
Implementing a subscription is, not how to call the API, but "keeping a state that changes over time correctly in one place, inside untrusted external (client, network, retry)." Let me fold up the key points.
- Components:
Customer→Subscription→Price/Product→Invoice. Consolidate the access judgment inSubscription.status. - Start: Checkout (
mode: 'subscription') first. The Subscriptions API withdefault_incompleteonly when full control is needed. - Usage-based: Metronome is officially recommended for new. For existing, send Billing Meters' meter events idempotently with
identifier. - Proration: present the amount before confirmation with
proration_behavior+invoices.createPreview. - Customer Portal: don't build the cancel / card-update UI. Delegate to
billingPortal.sessions.create. - Webhook: grant on
invoice.paid, revoke onunpaid/deleted. Don't revoke immediately on dunning. Idempotency is two-layered.
I've implemented and operated this level with a Next.js 16.1 + Stripe 17 subscription foundation (idempotent webhooks, pure-function rate calculation, a bank-transfer state machine, 433 tests) and a payment-reliability layer that achieved 0 double charges in production. If you're considering the new build of a subscription/billing platform, the introduction of usage-based (Metronome / Billing Meters), or the reliability rebuilding of an existing subscription (proration, dunning, webhook design, access-right consistency), I take it on at this article's level, from requirement definition to production operation and guaranteeing testability. Operating one person × generative AI (Claude Code) with a verification gate, I deliver this quality fast and safely.
The code-level deep dive of this article's webhook idempotency, PII redaction, and state machine is published in the related case "A thorough anatomy of the subscription learning platform's architecture." For the basics of accepting payments, see the sister article "A complete guide to implementing Stripe payments at production quality (2026 edition)."
(This article's Stripe specs are based on the official documentation / API version 2026-05-27.dahlia as of June 2026. The usage-based recommendation (Metronome / Billing Meters) and the exact signature of meter events are a fast-evolving area. Always confirm the latest behavior in the official documentation.)