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.0Run 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 stripeReview 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-openThat 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:00ZUse 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.');
}