Docs / Build your own

Payments with Stripe

Adding payment to a site does not mean adding Stripe to your code. Your site never talks to Stripe and never holds a key. It writes an order; a function writes a checkout command into the Stripe mount; the engine issues it through Maravilla’s connector under the tenant’s own Stripe account; the paid session comes back as a node; a trigger settles the order. You stay the merchant of record and the money never touches Maravilla.

1. Connect the account

In the admin console: Integrations → Stripe (Managed by Maravilla) → Connect account. The operator signs in to Stripe and approves; nothing else is asked, because Maravilla owns the OAuth registration. Test and live are separate Stripe modes: connect the account you mean.

If the connector is missing or disabled, the tenant has not been provisioned for it. That is a request to your Maravilla contact, not something to fix in the package.

2. Mount it

Mounts → Add bundle → Stripe creates the mounts under the finance workspace — a workspace of its own, organiser-only, because the command nodes in it move money the moment a property reads queued:

PathWhatModeNeeded for checkout
/stripe/sessionscheckout sessionssubmityes
/stripe/refundsrefundssubmityes
/stripe/payment-intentspayment intents, with the processing feeread-onlyyes
/stripe/portalbilling-portal sessionssubmitopt-in
/stripe/products, /stripe/prices, /stripe/customersthe catalogue, editable in Studiostate_onlyopt-in

The bundle asks for two URLs, the success and cancel pages of your site, and nothing else. Use hybrid sync (the default); webhook alone neither polls nor receives. After the first walk, check the sessions mount reports written greater than zero.

3. What Studio already does

Studio’s ticketing ships the whole payment loop; a site with products, memberships or donations reuses the same functions with its own record type.

Starting. The site invokes /flows/checkout-begin: it allocates the tickets (an oversell-safe hold with a clock) and creates the studio:Order as pending. create-order then writes one node:

# finance:/stripe/sessions/<order>   node_type: stripe:CheckoutSession
status: queued            # the authorization: queued means "issue this once"
order_ref: /orders/ORD-A4NF45
mode: payment
amount: 4800              # minor units, read from the pool, never from the request
currency: CHF
product_name: Order ORD-A4NF45
customer_email: buyer@example.com
success_url: https://acme.example/checkout/return/ORD-A4NF45
cancel_url: https://acme.example/basket

The flow returns no pay link, on purpose: producing one would mean calling Stripe from a process that must not hold a key. The link appears on the session node a second or two later, when the mount has issued the command.

Paying. The site’s checkout route reads the session’s url server-side and redirects with a 303. The URL exists only in that response header, never in a page or a JSON body.

Settling. A trigger on stripe:CheckoutSession (created or updated in finance) runs /flows/checkout-settle: copy the pay link to the order, or cancel an expired session, or — when payment_status says paid — capture, confirm the holds, verify the order is still fulfillable, and fulfil (tickets with QR codes, the email). Every step is a gate: an update that only carried the URL stops at the first one. A five-minute sweep is the safety net, not the driver. Measured on a live tenant with a real card, the order was paid three to four seconds after the webhook.

Refunding. refund-order writes a queued stripe:Refund and parks the order at refund pending; nothing is released until the money has gone back. A refund that Stripe declines leaves the customer with a usable ticket.

Returning buyers are remembered: the Stripe customer id is written onto the buyer’s user once, and later orders reuse it.

4. The site side

@maravilla/studio-commerce is the framework-agnostic half, with a Svelte adapter for reactivity: money in integer minor units, capability tokens for guest access, an invoke helper that knows a ticketing function is asynchronous, and createPayments() which resolves the pay link, waits for it, reads settlement from the session’s payment_status and never from the return URL.

const payments = createPayments({ sql, workspace: 'ticketing', recordType: 'studio:Order', codeField: 'code', statusField: 'status', payableStatuses: ['pending'], urlField: 'payment_url', refField: 'payment_ref', invoker, sweepFunction: '/lib/studio/sweep-checkout-sessions' });
// checkout/go/[code]: server-side, then a 303 — the URL never reaches the browser as data
const url = await payments.awaitPayLink(code);
// checkout/return/[code]: the return is a claim, not a fact
const status = await payments.paymentStatus(code);   // 'paid' comes from the session node

Three rules the reference site follows: nothing price-bearing comes from the browser (pool and quantity only; price, capacity, VAT and currency are read from the node); a guest buyer is a real user created server-side and later promoted, never the shared anonymous user; and ?paid=1 on the return is a marker for the page, not proof of payment.

5. Automations around money

Because a paid session, an order and a refund are nodes, they are triggers like any other:

  • Order paid → send the confirmation, notify the organiser, add the buyer to an audience.
  • Refund settled → release inventory, close the support conversation that asked for it.
  • Session expired → a follow-up mail with a fresh basket link.
  • Daily → find orders pending for more than an hour and post a summary to the Inbox.

Approvals fit the same way: put a people-in-the-loop gate in front of refund-order and refunds over a threshold wait for a person.

What is and is not there

Verified end to end on a live tenant: one-time payment, subscription, full refund, pay-what-you-want, catalogue prices, two buyers racing for the last unit, and a payment completing after its hold expired (detected and refunded). Not built: partial refunds and payouts — the connector deliberately refuses the payout and transfer resources until that is designed.

Pilot documentation. Studio is in a partner pilot. Screens and YAML shown here are current as of the version noted on each page; tell your Maravilla contact when something does not match what you see.