Volter World

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 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. Use Stripe's catalog Setup in an empty project to install the selected exact release and World dependency cohort. Keep the resulting manifest and lockfile. Install the SDK:

$ npm install --save-exact stripe@17.7.0

Run the commands below from that project. They use its installed, pinned CLI by path; installation 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.

$ ./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:

$ ./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:

$ ./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

$ ./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:

$ ./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. 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:

$ ./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.

$ ./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:

$ ./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; 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 shows the receiver pattern. Reading Stripe's event list alone does not establish callback delivery or deduplication. After a refusal, use recovery 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.

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.');
}
View Markdown source

On this page