Skip to content

One-time and subscription products on Polar, from lifetime deal to renewal

Polar sells one price per product, so a plan sold monthly, yearly and for life is three products. How each kind checks out, which webhook proves it was paid, where it is stored, and how access resolves when a customer holds both.

Polar3 min readships at docs/solutions/polar/one-time-and-subscription-products-on-polar.md

Tags: polar · one-time-payments · lifetime-deal · subscriptions · checkout · entitlements · billing

A lifetime deal and a monthly plan feel like two prices of one thing. On Polar they are two products, they raise different webhooks, and they live in different tables. Get the mapping right once and the rest of the app never has to care.

One product, one price

Polar attaches a single price to each product, and the product carries the interval: recurring monthly, recurring yearly, or one-time. So this catalogue:

{ slug: "pro", prices: [
  { id: "pro-monthly",  interval: "month",    amount: 1900 },
  { id: "pro-yearly",   interval: "year",     amount: 19000 },
  { id: "pro-lifetime", interval: "one_time", amount: 29900 },
] }

is three Polar products, and three env vars hold their ids: BILLING_PRICE_PRO_MONTHLY, BILLING_PRICE_PRO_YEARLY, BILLING_PRICE_PRO_LIFETIME. bun run billing:sync-plans creates them with a price_id metadata key, so a product can always be traced back to its catalogue price even before its env var is set.

One checkout for both

There is no separate "buy once" code path. createCheckout sends the product id, our user id as externalCustomerId, and { userId, planSlug, priceId } as metadata. Polar copies that metadata onto the order and, for a recurring product, onto the subscription. The product decides what Polar sells.

Two details differ by kind:

  • A trial only makes sense on a subscription. trialDays in the catalogue is sent as a trial in days; a recurring price without one sends allowTrial: false, so a trial left on the product never applies silently.
  • The success page reads checkout_id from its URL, checks the checkout belongs to the signed-in user, and writes what it finds. The webhook still does the real work; this only saves the buyer a wait.

Which webhook means "paid"

Every payment on Polar is an order, and order.paid fires when the money is in. billing_reason tells you what kind:

billing_reasonsubscription_idWhat this repo does
purchasenullwrites a billing_purchases row (status paid)
subscription_createsetre-reads the subscription
subscription_cycleseta renewal: re-reads the subscription
subscription_updateseta plan change: re-reads the subscription

Each one also returns a receipt when the amount is above zero, sent once inside the webhook's idempotency claim.

order.created is not "paid" and is not subscribed to. Neither is checkout.updated: a succeeded checkout can precede the order by a moment.

Where each kind lives

  • Subscriptions are rows in billing_subscriptions, keyed on the Polar subscription id. Their state goes back and forth (active, past due, cancelled, revoked), and deliveries arrive out of order, so every subscription.* delivery makes the adapter fetch the subscription fresh and write what Polar says now.
  • One-time purchases are rows in billing_purchases, keyed on (provider, provider_order_id). An order only moves forward, and the upsert enforces it: pending < failed < paid < partially_refunded < disputed < refunded. So the signed order in the payload is written as delivered, and a late order.paid can never undo a refund.

A subscription's plan comes from its current product only, never from the checkout metadata. When a customer switches from Pro to Team in the portal, the product changes and the metadata still says Pro.

When someone holds both

A customer on Pro monthly who buys Pro lifetime holds an entitled subscription and an entitled purchase. getEntitlement picks the higher plan by catalogue rank, and a tie goes to the purchase, because it never lapses. The billing page then warns that the subscription is paying for nothing, with a button to the portal. Nothing is cancelled automatically: that is the customer's call.

The reverse is blocked on purpose. While a subscription is entitled, a new subscription checkout answers "you already have a subscription": plan changes belong in the portal, where Polar prorates. A lifetime owner who tries to buy the same plan again hears "you already own this".

Gate on the plan, never the product

import { hasPlan, requirePlan } from "@/lib/billing";

await requirePlan("pro");              // subscription or lifetime, Team included
if (await hasPlan(user.id, "team")) {}

Nothing outside src/lib/billing/provider.ts knows a product id, a billing reason or whether the access came from a renewal or a single payment.