---
title: Hosted Checkout and the customer portal
---

# Hosted Checkout and the customer portal

Create a $24 payment with Stripe's real SDK, open the hosted Checkout page through your World,
retry a declined card and read the saved result. Then start a subscription trial and manage its
payment method and cancellation through the customer portal.

This is a local payment workflow. It transfers no money and needs no Stripe account or real key.
It does not implement an application's order fulfillment or a webhook receiver. Stripe's
[Checkout guide](https://docs.stripe.com/payments/checkout) is the reference for the hosted-session
pattern; the selected twin's README and operation table own its narrower supported surface.

## Prepare the client and World

You need Node 22.6+, npm, `@volter/twin-stripe` 3.0.3 or later with hosted Checkout and portal
support, and a World console that offers **New browser…** in Records. An older Stripe release
is not interchangeable for this recipe. If the catalog does not yet offer the required release,
start with [customer onboarding](../customer-onboarding/README.md).
Use [Stripe's catalog Setup](https://world.volter.ai/twins/stripe) in an empty project to install
the selected exact release and World dependency cohort. Keep the resulting manifest and lockfile.
Install the SDK:

```console
$ npm install --save-exact stripe@17.7.0
```

Run the commands below from that project. They use its installed, pinned CLI by path;
[installation](../../docs/reference/cli.md#installing-and-updating) covers other shells and package managers.

Create `checkout.mjs` in that project from the complete client at the end of this page. Create
`.env.example` with the single empty name `STRIPE_SECRET_KEY=`; init supplies a throwaway value.
This client uses the normal SDK defaults and never overrides its host.

```console
$ ./node_modules/.bin/volter world init --name checkout --twins stripe
```

Review the selected Stripe source in `.volter/world.json`. This workflow needs Stripe only; keep
other vendors out. Start the console instead of separate compute:

```console
$ ./node_modules/.bin/volter world view --no-open
```

That command prints the dashboard URL and keeps running. Open it in your browser and leave the
viewer running. In a second terminal, set the clock:

```console
$ ./node_modules/.bin/volter world clock set 2026-01-15T12:00:00Z
```

Use the dashboard's URL, without a sign-in fragment, as `<console-url>` below. The return address
is navigation only; returning to it does not mark a payment paid.

## Decline, then pay the same session

```console
$ ./node_modules/.bin/volter world run -- node checkout.mjs payment '<console-url>'
```

Keep the printed customer, product and session IDs. Open **Records → Stripe**, select the
`checkout.session` record, and choose **New browser…** under **Vendor browser**. Name it `buyer`.
This is empty synthetic vendor state, separate from your ordinary browser profile. Use **Open
in World as buyer** beside the stored URL; do not paste the vendor URL into ordinary Chrome.

On Checkout, enter the documented decline sample `4000 0000 0000 0002`, expiry `12/30`, CVC
`123`, name `Payment developer` and US ZIP `10001`. Submit. The refusal should stay on the
World-hosted page. Read the same session using the ID the client printed:

```console
$ ./node_modules/.bin/volter world run -- node checkout.mjs read <session-id>
```

The session is still `open` and `unpaid`. Replace the number with `4242 4242 4242 4242` and submit
again. Read it again: the session is `complete` and `paid`, and its payment intent and charge have
success events. Those are vendor outcomes; they do not prove your application shipped an order.

The card samples come from [Stripe's testing reference](https://docs.stripe.com/testing). Real
cards and credentials are unnecessary. A declined attempt must not become a successful stored
mutation through a scenario handler.

## Start a trial and manage billing

Reuse the customer and product IDs from the first payment:

```console
$ ./node_modules/.bin/volter world run -- node checkout.mjs trial <customer-id> <product-id> '<console-url>'
```

Open this new Checkout Session through Records as `buyer`, enter the successful sample and
choose **Start trial**. Read its session ID with `checkout.mjs read`. It names the new
subscription; with this frozen clock, the seven-day trial ends January 22, 2026.

```console
$ ./node_modules/.bin/volter world run -- node checkout.mjs portal <customer-id> '<console-url>'
```

Open the printed portal session from its Records URL as `buyer`. Choose **Cancel plan**, then
read the actual saved subscription:

```console
$ ./node_modules/.bin/volter world run -- node checkout.mjs billing <customer-id> <subscription-id>
```

`cancel_at_period_end` becomes `true`. Choose **Renew plan** and read again: it becomes `false`.
Update the payment method with the documented Mastercard sample `5555 5555 5555 4444`, expiry
`11/31` and the same billing details. The portal and SDK should agree on Mastercard ending
`4444`, with that method selected as the customer's and subscription's default.

## Keep the result and understand its limits

Use `world log` to inspect writes. Stop any application consumers you added, then stop the
viewer with Ctrl-C and run `./node_modules/.bin/volter world down`. State remains for
[resume](../../docs/guides/resume-a-world.md); resetting this branch discards its work.

This recipe creates subscriptions through Checkout. Direct `POST /v1/subscriptions` is a
declared gap in this Stripe twin; do not substitute it silently. A card's displayed brand does
not establish all issuer, authentication or payment-method behavior. Advancing time, delivery
retries, taxes, embedded Checkout and Connect onboarding need their own declared workflows.

For application fulfillment, register the application's actual callback URL and verify the raw
body with `stripe.webhooks.constructEvent`; [signed webhooks](../../docs/guides/test-signed-webhooks.md)
shows the receiver pattern. Reading Stripe's event list alone does not establish callback
delivery or deduplication. After a refusal, use
[recovery](../../docs/guides/recover-a-failed-call.md) to distinguish routing, twin coverage and
application behavior.

## Complete SDK client

Save this as `checkout.mjs`. It prints the vendor's stored result; it contains no test assertions.

```js
import Stripe from 'stripe';

if (!process.env.VOLTER_WORLD) throw new Error('Run this client with volter world run.');
const [act, ...args] = process.argv.slice(2);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const print = value => console.log(JSON.stringify(value, null, 2));
const required = (value, name) => {
  if (!value) throw new Error(`Supply ${name}; see the cookbook command.`);
  return value;
};
const returnURL = value => {
  const url = new URL(required(value, 'the World console return URL'));
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
    throw new Error('Use the console HTTP(S) URL without credentials or its sign-in fragment.');
  }
  return url.href;
};

switch (act) {
  case 'payment': {
    const back = returnURL(args[0]);
    const customer = await stripe.customers.create({ name: 'Payment developer', email: 'payment@world.test' });
    const product = await stripe.products.create({ name: 'Starter purchase' });
    const price = await stripe.prices.create({ product: product.id, unit_amount: 2400, currency: 'usd' });
    const session = await stripe.checkout.sessions.create({
      mode: 'payment', customer: customer.id, line_items: [{ price: price.id, quantity: 1 }],
      success_url: back, cancel_url: back,
    });
    print({ customer: customer.id, product: product.id, price: price.id, session: session.id, url: session.url });
    break;
  }
  case 'trial': {
    const customer = required(args[0], 'the customer ID from payment');
    const product = required(args[1], 'the product ID from payment');
    const back = returnURL(args[2]);
    const price = await stripe.prices.create({ product, unit_amount: 2400, currency: 'usd', recurring: { interval: 'month' } });
    const session = await stripe.checkout.sessions.create({
      mode: 'subscription', customer, line_items: [{ price: price.id, quantity: 1 }],
      subscription_data: { trial_period_days: 7 }, success_url: back, cancel_url: back,
    });
    print({ customer, product, price: price.id, session: session.id, url: session.url });
    break;
  }
  case 'portal': {
    const customer = required(args[0], 'the customer ID');
    const session = await stripe.billingPortal.sessions.create({ customer, return_url: returnURL(args[1]) });
    print({ customer, portal: session.id, url: session.url });
    break;
  }
  case 'read': {
    const session = await stripe.checkout.sessions.retrieve(required(args[0], 'the Checkout Session ID'));
    const customer = await stripe.customers.retrieve(session.customer);
    const events = await stripe.events.list({ limit: 20 });
    print({ session, customer, events: events.data.map(event => ({ id: event.id, type: event.type, object: event.data.object.id })) });
    break;
  }
  case 'billing': {
    const customerID = required(args[0], 'the customer ID');
    const subscriptionID = required(args[1], 'the subscription ID from read');
    const customer = await stripe.customers.retrieve(customerID);
    const subscription = await stripe.subscriptions.retrieve(subscriptionID);
    const methods = await stripe.paymentMethods.list({ customer: customerID, type: 'card' });
    print({ customer, subscription, paymentMethods: methods.data });
    break;
  }
  default:
    throw new Error('Choose payment, trial, portal, read or billing; see README.md for arguments.');
}
```
