Skip to main content
友田 陽大
Payments & billing
Stripe
決済
サブスクリプション
Next.js
TypeScript
B2B SaaS
冪等性

Stripe Billing implementation guide (2026 edition, official-compliant): subscriptions, usage-based (Billing Meters / Metronome), customer portal, and proration in real code

A Stripe-Billing-official-compliant implementation guide. It explains, in Next.js 16 + TypeScript real code, starting a subscription (Checkout / Subscriptions API), usage-based (Billing Meters), the Customer Portal, proration, and idempotent webhook processing.

Published
Reading time
18 min read
Author
友田 陽大
Share

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 by event.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."

ObjectRoleImportant point
Customerthe container of billing infoholds the payment method, address, and the billing destination for next time on. Tie it 1-to-1 to a user in your DB
Productwhat you sell (plan name, etc.)a concept like "Pro plan," "Business plan." Tied to Entitlements (feature access)
Pricethe recurring amount, currency, cycle"¥1,980/month," "¥19,800/year." One Product can have multiple Prices (monthly/yearly, different currencies)
Subscriptionthe continuous relationship connecting Customer and Pricethe subject of the lifecycle (trialing→active→past_due…). The access-right judgment is here
Invoicethe invoice for each billing cycleStripe 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.

statusMeaningAccess right
trialingin trial (not yet billed)granted (trial)
activenormal. The latest invoice is paidgranted
incompletethe first payment is unconfirmed (up to 23 hours grace)held
incomplete_expiredthe first payment didn't confirm within 23 hours and lapsednot granted
past_duethe latest invoice's payment failed. Retryingneeds a policy judgment (grace or restrict)
unpaidretries exhausted. The latest invoice stays unpaidrevoke
canceledcanceled (final, immutable)revoke
pauseda payment method was absent at trial end and it's pausedrevoke

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.

ViewpointCheckout (mode: 'subscription')Subscriptions API directly
Implementation amountminimallarge (collect/confirm the payment method yourself)
Payment UIStripe-hosted / embeddedassemble the Payment Element yourself
Tax, discount, saving the payment methodbuilt-inmanual configuration
Suited sceneMVP, standard flow, fast and safethe payment UX is the business's core, custom state management needed

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.

SituationRecommendation
You're newly implementing usage-based from nowMetronome (Stripe's new usage-based foundation)
Adding usage to an existing flat subscription, but needing full compatibility with Connect / Checkout / Adaptive Pricing / WorkflowsBilling Meters (Metronome's support is partial)
Already billing with Billing Meterscontinue 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" with identifier.


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.

ValueBehavior
create_prorations (default)create a proration line item of the difference (may be bundled into the next billing)
always_invoiceimmediately issue an invoice and charge at the change point
noneno 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_date differs between the preview and the execution, the amount changes. Thoroughly pass the same proration_date in 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.

EventMeaningRecommended action
customer.subscription.createdsubscription created (may be incomplete)record sub_id in the DB. Save the status
customer.subscription.updatedplan change, status transition, etc.sync status. Judge access grant on becoming active
customer.subscription.deletedcancellation complete (final)revoke access
customer.subscription.trial_will_end3 days before trial endconfirm the presence of a payment method and notify
invoice.paidinvoice payment succeededgrant access (status is active)
invoice.payment_failedpayment failednotify the customer, prompt a card update (dunning)
invoice.upcomingthe next billing is nearadd 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_failednotify only. The status becomes past_due, but don't revoke immediately (set a grace policy).
  • When Smart Retries are exhausted and the status becomes unpaid (or canceled), 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_due is 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 to unpaid / deleted means a policy change completes in one handler.


7. Design tips (7 principles that matter in production)

  1. Save IDs in your own DB. Save cus_xxx (Customer) and sub_xxx (Subscription) tied to the user. Without this, you can't reconcile the webhook and your own system.
  2. 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.
  3. Consolidate the access judgment in one place. Make the function that derives "usable/unusable" from status one, and have UI, API, and batch call the same function.
  4. 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.
  5. Don't trust the client. Whitelist-verify priceId, resolve customerId from the authentication info. Don't receive amounts or quantities from the client.
  6. Idempotency in two stages. The send side is a deterministic idempotencyKey / identifier, the receive side is the unique constraint of event.id. Absorb retries and duplicates as the normal path.
  7. 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: CustomerSubscriptionPrice/ProductInvoice. Consolidate the access judgment in Subscription.status.
  • Start: Checkout (mode: 'subscription') first. The Subscriptions API with default_incomplete only 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 on unpaid/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.)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading