# 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.

Source: https://www.shipanysaas.com/blog/stripe-subscriptions-vs-one-time-payments
Published: 2026-10-18

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](https://www.shipanysaas.com/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 payment | Stripe subscription |
| --- | --- | --- |
| Price in Stripe | One-time | Recurring, with a monthly or yearly interval |
| Checkout `mode` | `payment` | `subscription` |
| What Stripe creates | One 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 store | An order that is paid or not | A subscription whose status keeps changing |
| What your app checks | Did 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](https://docs.stripe.com/billing/subscriptions/webhooks) 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](https://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](https://docs.stripe.com/checkout/fulfillment) 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

| Event | What it tells you |
| --- | --- |
| `checkout.session.completed` | Checkout finished. Record the order or the new subscription. |
| `checkout.session.async_payment_succeeded` and `_failed` | A one-time payment by a delayed method, such as a bank debit, settled or failed |
| `invoice.paid` | An invoice was paid. Stripe says to provision when this arrives and the subscription is `active`. |
| `customer.subscription.updated` | The subscription changed: a renewal, a plan change, a new status |
| `customer.subscription.deleted` | The subscription ended. Revoke access. |
| `invoice.payment_failed` | An 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](https://docs.stripe.com/customer-management) 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](https://docs.stripe.com/billing/subscriptions/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:

```typescript
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](https://www.shipanysaas.com/docs/billing/billing), [Pricing plans](https://www.shipanysaas.com/docs/billing/pricing-plans) and [Webhooks](https://www.shipanysaas.com/docs/billing/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.
