---
title: "Open checkout and the portal"
description: "Start a checkout for a plan or a pack, send a customer to the billing portal, and change, cancel, pause or resume a subscription."
canonical_url: "https://nuxt-backend.dev/docs/billing/subscriptions/checkout"
---
# Open checkout and the portal

> Start a checkout for a plan or a pack, send a customer to the billing portal, and change, cancel, pause or resume a subscription.

`useBilling()` opens the provider's hosted checkout and customer portal, and runs every plan change from the browser. The entity it acts for resolves on the server, the active workspace by default.

```vue
<script setup lang="ts">
const { products, checkout, portal, changePlan, cancel } = useBilling()

// Subscribe (opens checkout in a new tab; `redirect: true` for same-tab)
await checkout(products.value!.pro!.id)

// Manage payment methods, invoices, the subscription
await portal({ returnUrl: window.location.href })

// Upgrade / downgrade the active subscription
await changePlan(products.value!.ultra!.id, { proration: 'invoice' })

// Cancel at the end of the paid period (the default) — `atPeriodEnd: false`
// revokes on the spot, with no refund implied either way.
await cancel({ atPeriodEnd: true, reason: 'too_expensive' })
</script>
```

The rest of the lifecycle sits on the same composable: `uncancel()` undoes a scheduled cancellation, `pause({ resumesAt })` pauses at the end of the period, and `resume()` restarts a paused subscription immediately. Each takes an optional `subscriptionId` — needed only with `setupBilling({ multipleSubscriptions: true })`, when one entity can hold several at once (the ids are on `billing.subscriptions`).

`checkout(productIds, options)` accepts `subscriptionId`, `metadata`, `locale`, `successUrl` (defaults to the current URL), `redirect`, the checkout-form options `prefill`, `customFields`, `requireBillingAddress` and `allowDiscountCodes`, and a campaign's `discountCode` and `utm`. After checkout returns, the `subscription.*` / `order.paid` webhooks refresh the cache — or call `syncEntitlements` (`useCredits().refresh()`) to pull immediately.

## What the server decides

The browser picks which of your products to buy, and the server checks the rest before the provider hears of it. A refusal is `invalid_input` with the field it refuses, except where a rule below says otherwise (see [Errors](https://nuxt-backend.dev/docs/reference/backend/errors)):

- **Products.** `productIds` must be products of your catalog: the plans and packs `billing sync` wrote into `billing.generated.ts`, or `setupBilling({ products })`. A plan change (`changePlan`) is held to the same list, and must switch to a recurring plan, never a credit pack. An app with no catalog yet sells nothing: that is `not_ready` with reason `no_catalog`, and `npx nuxt-backend billing sync` fixes it.
- **Where the buyer lands.** `successUrl`, the embedding page's `origin` and the portal's `returnUrl` must be on `SITE_URL`'s origin. List any other origin the app is served from in `setupBilling({ trustedOrigins })`, each with its scheme (`https://app.example.com`; an entry without one trusts nothing, and the deployment log says so). With `AUTH_TRUST_LOCAL_ORIGINS=1` on a development deployment, a loopback origin on any port passes too: `http` or `https`, on `localhost`, `127.0.0.1` or `[::1]`. The list is billing's own, not auth's `trustedOrigins`: those can be a function of the request, and a checkout has no request to ask.
- **Metadata.** Your own keys pass, up to 20 of them, with string values. The keys the package writes and reads back are refused in any letter case: `userId` (the billing entity a webhook resolves), `giftId` and `phase` (the gift a paid order buys or claims), the keys earlier releases wrote (`gift`, `recipientEmail`, `purchaserUserId`, `giftRecipient`), and the `utm_` prefix.
- **The trial.** A plan's trial is its own, set by the catalog's `trial`. A client that sends `trialInterval` or `trialIntervalCount` is refused. One trial runs per owner: when the workspace's owner already had one on any workspace they own, the checkout starts without a trial (see [One customer per billing entity](https://nuxt-backend.dev/docs/billing/subscriptions#one-customer-per-billing-entity)).
- **The customer.** The checkout is the billing entity's own, by its id: the package creates its customer on the first checkout and never matches one by email.
- **A subscription to upgrade.** `subscriptionId` must be one of the billing entity's own. Another one is `forbidden` with reason `not_owner`, as for every subscription action.

### A different trial or discount, from the server

Server code can grant a trial of 1 to 30 days, counted in days or weeks (`{ interval: 'day', count: 30 }`, `{ interval: 'week', count: 4 }`), remove the plan's own with `trial: false`, or apply a discount by id, through `billing.checkout`. Months are refused: the provider counts a month as a calendar month, up to 31 days. Decide who qualifies in your own action, then call it:

```ts [backend/billing.ts]
export const partnerCheckout = action({
  args: { productId: v.string(), origin: v.string(), successUrl: v.string() },
  handler: async (ctx, { productId, ...urls }) => {
    if (!(await cameThroughPartner(ctx))) throw backendError('forbidden', { reason: 'permission' })
    return billing.checkout(ctx, { productIds: [productId], ...urls, trial: { interval: 'day', count: 30 } })
  },
})
```

`billing.checkout` runs every check above too, and resolves with `{ url }` for the page to open.

## Campaign links

A campaign link carries a discount code and its UTM parameters. Pass them on, and the server applies the code and stores the attribution on the order or subscription as `utm_source`, `utm_medium`, `utm_campaign`, `utm_term` and `utm_content` metadata, the way the provider's own checkout links store it:

```ts
const route = useRoute()
const text = (value: unknown) => (typeof value === 'string' ? value : undefined)

await checkout(products.value!.pro!.id, {
  discountCode: text(route.query.code),
  utm: { source: text(route.query.utm_source), medium: text(route.query.utm_medium), campaign: text(route.query.utm_campaign) },
})
```

Each attribution value is trimmed and cut to 100 characters, and empty ones are dropped, so an edited link shortens instead of failing. The provider checks the code like one the customer typed: one it does not accept (unknown, expired, used up, not for this product) refuses the checkout as `invalid_input` with `field: 'discountCode'`, and a code turns `allowDiscountCodes` on for that checkout.

Each refused code counts against the billing entity: after ten in an hour, a checkout with a code is `rate_limited` (`limit: 'discountCode'`, with `retryAfter`) until the hour is up, and one without a code still goes through. The count is the package's own, kept whether or not you pass a `rateLimiter`, so a checkout can't be used to try codes one after another. A code counts from before the provider checks it, so checkouts sent in parallel can't try more between them; an accepted code, or one a failed checkout never got to try, is not counted.

## Without code

`<BillingCheckoutLink>` and `<BillingPortalLink>` render the same two actions as links, bound to `generateCheckoutLink` and `generateCustomerPortalUrl`:

```vue
<BillingCheckoutLink :product-ids="[productId]" @error="error = getBackendError($event)">Upgrade</BillingCheckoutLink>
<BillingPortalLink return-url="/app/billing">Manage billing</BillingPortalLink>
```

Each creates its provider session when clicked, never when it renders, so the server checks every input then and a page view opens nothing. `<BillingCheckoutLink>` takes `product-ids`, `subscription-id`, `metadata`, `locale`, `discount-code`, `utm` and `success-url` (the current page by default); `<BillingPortalLink>` takes `return-url`. Both open in the same tab; `:redirect="false"` opens a new tab instead, which a browser may block, since the tab opens only once the session exists. Both mark themselves `aria-busy` while the session is created, pass `{ pending }` to their default slot, and emit a refusal as `error`.

<page-keys>

- Try it live in the playground: [/playground/platform/billing](https://nuxt-backend.dev/playground/platform/billing)
- API reference: [/docs/reference/api/use-billing](https://nuxt-backend.dev/docs/reference/api/use-billing)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-billing.ts](https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-billing.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
