Skip to content
shipanysaas

Stripe subscriptions or one-time payments for your SaaS?

When Stripe subscriptions fit a SaaS, when a one-time payment does, and what each changes in Checkout, webhooks and your app's access checks.

Stripe subscriptions bill a customer on a schedule, usually by charging a saved payment method, until someone cancels. A one-time payment charges once and is done. It looks like a pricing decision, but it also decides how much your app has to track. A one-time sale is one payment that succeeds or fails. A subscription has a status that can change at every renewal, often while nobody has your app open.

Here is the short answer, then what each model asks of your Checkout page, your webhooks and your access checks, and how shipanysaas handles both.

The short answer

Use Stripe subscriptions when your product keeps costing you money after the sale (hosting, storage, third-party API calls, email, support), or keeps giving customers a reason to come back. The charge recurs because the cost and the value do.

Use a one-time payment when the customer gets something finished: a template, a download, a report, a lifetime licence, a pack of credits. They pay, they own it, and there is no billing relationship to manage.

Many products mix the two, which is covered below.

shipanysaas itself is sold the second way: you pay once and get the repository, with no renewal. That fits because what you buy is code you keep. The plans are on the pricing page.

What changes in Stripe

Both models can use Stripe's hosted Checkout page. The difference starts with one parameter on the Checkout Session, mode: payment for one-time prices, subscription when the session includes at least one recurring price.

One-time paymentStripe subscription
Price in StripeOne-timeRecurring, with a monthly or yearly interval
Checkout modepaymentsubscription
What Stripe createsOne payment, and a Customer only if you ask for one (customer_creation)A Customer, the Subscription, then an invoice and a payment every billing period
What you storeAn order that is paid or notA subscription whose status keeps changing
What your app checksDid this order succeed?Is the subscription active or trialing right now?

A subscription can be trialing, active, incomplete, incomplete_expired, past_due, unpaid, canceled or paused. Stripe's subscription webhooks guide says to contact the customer when a subscription goes past_due, and to revoke access when it becomes canceled or unpaid. A one-time order has nothing like that. Once it has succeeded, it stays succeeded.

The fees differ too. On Stripe's standard pricing, Checkout adds no charge on top of card processing. Subscriptions also run on Stripe Billing, which charges 0.7% of billing volume on its pay-as-you-go plan, according to stripe.com/pricing in October 2026. Check the page for your country before you set prices.

When Stripe subscriptions fit

  • Your costs recur. Servers, database storage, third-party API calls per user. If serving a customer costs you money every month, a single payment eventually runs out.
  • People use it every week. Dashboards, team tools and anything that holds a customer's data are easy to justify monthly.
  • The price grows with the account. Per-seat pricing follows the size of the team, and usage-based pricing follows what the customer consumes. Both need a subscription.
  • A trial helps you sell. A subscription can start with a free trial, with or without a card on file.

The trade-off is more work: failed renewals, plan changes, cancellations, and events that arrive while you are asleep.

When a one-time payment fits

  • The product is finished when it is delivered. Templates, downloads, courses, reports, a code kit.
  • Your buyers resist subscriptions. Some audiences will pay once for a tool they would never subscribe to.
  • You sell credits. A credit pack is a one-time purchase that your app draws down.

The catch is that one price has to cover everything that customer will ever cost you. "Lifetime" means for as long as the product runs, so be careful selling lifetime access to anything with a hosting bill per customer.

Mixing both

A one-time core with a recurring add-on, such as hosting or priority support, is a common combination. So is a subscription with one-time credit top-ups. Both kinds of price live in the same Stripe account. A subscription-mode Checkout Session can also carry one-time prices, which appear on the first invoice only. That suits a setup fee.

Building the Stripe Checkout page for each

The steps are the same for both models:

  1. Create a product and a price in the Stripe Dashboard: one-time, or recurring with an interval.
  2. On your server, create a Checkout Session with that price and the matching mode. Put your own account id in client_reference_id so the webhook knows who paid. For a subscription, copy it into subscription_data.metadata as well, because later renewal and update events point to the subscription, not to the Checkout Session.
  3. Send the customer to Stripe's hosted page (ui_mode: 'hosted_page', the default) or embed the form in your app (embedded_page).
  4. Grant access from a webhook, not from the success page.

Step 4 is the easy one to skip. Stripe's fulfillment guide says you can't rely on the landing page alone, because a customer can pay and lose their connection before it loads. Webhooks are required for subscriptions and for payment methods that confirm later.

For a trial, add subscription_data.trial_period_days. To start a trial without a card, set payment_method_collection to if_required, then choose whether the subscription is cancelled or paused if the trial ends with no payment method.

The webhooks that matter

EventWhat it tells you
checkout.session.completedCheckout finished. Record the order or the new subscription.
checkout.session.async_payment_succeeded and _failedA one-time payment by a delayed method, such as a bank debit, settled or failed
invoice.paidAn invoice was paid. Stripe says to provision when this arrives and the subscription is active.
customer.subscription.updatedThe subscription changed: a renewal, a plan change, a new status
customer.subscription.deletedThe subscription ended. Revoke access.
invoice.payment_failedAn invoice payment failed, usually a renewal. Tell the customer.

Three rules apply to all of them. Verify the Stripe-Signature header against the raw request body before you trust anything. Expect the same event more than once, and events out of order, so write with upserts and re-read the object from Stripe when order matters. Return a 2xx quickly; if you don't, Stripe retries for up to three days in live mode.

Managing Stripe subscriptions after launch

Let customers serve themselves. Stripe's hosted customer portal lets customers update their payment method and billing details, switch plans if you allow it, cancel now or at the end of the period, and pay or download invoices. You configure it in the Dashboard. Your server creates a short-lived portal session for a customer and redirects them to it. One limit to know: a subscription with usage-based prices or several products can be cancelled in the portal but not changed there.

Know what a plan change costs. When a price or quantity changes mid-period, Stripe creates prorations by default (proration_behavior: 'create_prorations'): a credit for the unused time and a charge for the rest of the period. They usually land on the next invoice. Pass always_invoice to bill the difference now, or none to skip it.

Let Stripe chase failed payments. With Smart Retries, Stripe chooses when to retry. The recommended default is 8 tries within 2 weeks. Your Dashboard settings decide what happens after the last one: cancel the subscription, mark it unpaid, leave it past_due, or pause it if it is eligible.

Using the Stripe API for subscriptions beyond Checkout

Checkout creates the subscription. Later changes are calls to the Stripe API from your server: subscriptions.update to change a price or quantity, subscriptions.cancel to end one, and a preview invoice to show the customer what a change will cost before they confirm.

For usage-based pricing, Stripe now recommends Metronome, its usage-billing platform, for new integrations. The kit's metered line items use the Meters API instead, which Stripe calls basic usage-based billing. Stripe says it remains fully supported for existing integrations and works more fully with Checkout.

If your agent writes this code, the kit includes Stripe's own stripe-best-practices skill in .agents/skills/.

How the kit handles both

The billing code lives in packages/billing: core holds the config schema, gateway does provider-neutral checkout, portal and webhook handling, and there is one package each for Stripe and Lemon Squeezy. NEXT_PUBLIC_BILLING_PROVIDER picks the provider.

Plans live in apps/web/config/billing.config.ts, and a plan's paymentType decides the model. Two plans from one product:

plans: [
  {
    id: 'pro-monthly',
    name: 'Pro Monthly',
    paymentType: 'recurring',
    interval: 'month',
    lineItems: [{ id: 'price_123', name: 'Pro', cost: 19, type: 'flat' }],
  },
  {
    id: 'pro-lifetime',
    name: 'Pro Lifetime',
    paymentType: 'one-time',
    lineItems: [{ id: 'price_456', name: 'Pro', cost: 199, type: 'flat' }],
  },
],

The Stripe package turns recurring into mode: 'subscription' and one-time into mode: 'payment', with customer_creation: 'always' so one-time buyers get a Stripe customer too. The schema rejects a one-time plan with an interval or with anything but flat items. Recurring line items can be flat, per_seat (the quantity follows the team's member count) or metered (usage sent to Stripe as meter events). Plans can have trialDays, and STRIPE_ENABLE_TRIAL_WITHOUT_CC=true starts trials without a card. Checkout is embedded by default; STRIPE_UI_MODE=hosted_page sends customers to Stripe's page instead.

The webhook at /api/billing/webhook verifies Stripe's signature with constructEventAsync, then acts on six events: checkout.session.completed, the two async payment events, customer.subscription.updated, customer.subscription.deleted and invoice.paid. Subscriptions go to subscriptions and subscription_items, one-time payments to orders and order_items, all through upserts, so a repeated event rewrites the same row. On customer.subscription.updated, the handler fetches the subscription from Stripe instead of trusting the event body, so a late event can't write an old status. If anything fails, the route answers 500 and Stripe retries.

Signed-in users can only read those tables. Only the webhook writes them, so nobody can give themselves a paid plan. For a Stripe subscription, the row's active flag is true only for active and trialing, and has_active_subscription(account_id) lets a row-level security policy require a paid plan. For a one-time plan, check for a succeeded row in orders.

The full reference: Billing, Pricing plans and Webhooks.

Common questions

Can I move a customer from a one-time payment to a subscription later?

Not with a switch. One-time and recurring prices are separate objects, so the customer goes through a new Checkout Session in subscription mode. Pass their existing Stripe customer id so you don't create a duplicate. The kit does this from its billing_customers table.

What happens when a renewal payment fails?

The subscription moves to past_due, Stripe retries on your schedule, and invoice.payment_failed arrives after each failed attempt. After the last retry the subscription is cancelled, marked unpaid, left past_due or paused, depending on your settings. In the kit, past_due already sets active to false, so decide whether your product keeps working during the retry window.

Can customers start a trial without a card?

Yes. Stripe supports it with payment_method_collection: 'if_required'. In the kit, give the plan trialDays and set STRIPE_ENABLE_TRIAL_WITHOUT_CC=true. A trial that ends without a card is cancelled, and an account that is already a Stripe customer starts without a trial.