Payments

Pro

Enable payments with Stripe, Shopify or WhatsApp.

Syntax

$ vela enable payments [--provider <stripe|shopify|whatsapp>]

Without --provider you’re prompted to pick one. You’re then asked for the provider’s keys, and any already in your .env are kept without asking. Outside a terminal (CI, scripts) the flag is required, and so are the keys: set them in .env or the environment first.

A project uses one payments provider. Patterns built on payments, such as ecommerce, follow it.

Stripe

$ vela enable payments --provider stripe
VariableDescription
STRIPE_SECRET_KEYThe secret key (sk_...) the server calls the Stripe API with.
PUBLIC_STRIPE_PUBLISHABLE_KEYThe publishable key (pk_...) Stripe.js loads in the browser.
STRIPE_WEBHOOK_SECRETThe signing secret (whsec_...) /webhooks/stripe verifies events with. stripe listen --forward-to localhost:5173/webhooks/stripe prints one for local development.

You’re also asked for a price_id for the demo page at /payment; leave it blank and a test product is created for you.

The payments module creates the necessary database tables and code for payment processing, webhook events, receipts, sweeps and more.

Database tables

The payments module creates the following database tables:

stripe_products
stripe_prices

Webhooks

Stripe webhooks are scaffolded in src/routes/webhooks/stripe, with the payment intent succeeded event included by default.

Customers

If authentication is enabled with the vela enable auth command, a Stripe customer is created for each user on sign up and a user’s payment methods can be saved and managed in the user’s billing settings page. The customer is linked by the link-stripe-customer workflow, started by the signup action with the user’s id as its idempotency key, so signing up never waits on Stripe and a retry never makes a second customer. The billing page shows a settling-up state for the moment it takes.

Database tables

These additional tables are created if authentication is enabled:

stripe_customers
stripe_payment_methods
transactions

Webhooks

In addition to the payment intent succeeded event, the payment method attached event is included by default for the authenticated user.

Workflows

The Stripe work that does not belong on the request path runs as workflows in src/lib/workflows:

  • stripe-event - The webhook route verifies each event’s signature and queues one run per event id. The run calls the handler in src/routes/webhooks/stripe/handlers/dispatch.ts; a handler that throws is retried with backoff, and a redelivery of the same event lands on the run that already exists.
  • sync-stripe-products and sync-stripe-prices - Recurring every ten minutes in sync-stripe-catalog.ts, adding products and prices new in Stripe and updating the rest.
  • sync-stripe-customers - Recurring every ten minutes when authentication is enabled, linking any user that did not come through signup.

Every run, with its steps and any error, is listed under Workflows in the PocketBase dashboard.

Shopify

$ vela enable payments --provider shopify
VariableDescription
PUBLIC_SHOPIFY_STORE_DOMAINThe store’s *.myshopify.com domain.
PUBLIC_SHOPIFY_STOREFRONT_TOKENA public Storefront API access token. In Shopify admin, add the Headless sales channel, create a storefront, and copy its public access token.

Both are public: the Storefront API is called from the browser with a token made for it. Shopify keeps the products, customers and orders, so this needs no backend and works on a static site.

  • src/lib/shopify.ts - The Storefront API client, from Shopify’s @shopify/storefront-api-client, pinned to API version 2026-10. Shopify supports each version for a year; move SHOPIFY_API_VERSION forward before it ends.
  • src/lib/components/payments/buy-button.svelte - A Buy button that creates a one-line cart and sends the buyer to Shopify’s checkout.

You’re asked for a product variant (gid://shopify/ProductVariant/...) for a demo page at /payment; leave it blank to skip the page.

WhatsApp

$ vela enable payments --provider whatsapp

Nothing is paid on the site. The customer picks what they want, and an order opens a WhatsApp chat with the business, prefilled with the order. The business confirms it, takes payment and arranges delivery in the chat. The same order is recorded to a hosted form on velastack.dev in the background, so it is kept even if the customer never sends the message.

VariableDescription
PUBLIC_WHATSAPP_NUMBERThe business’s WhatsApp number, with country code, that orders open a chat with.
PUBLIC_WHATSAPP_ORDER_FORMThe hosted form each order is recorded to. Leave it empty to record nothing.

WhatsApp needs the business’s currency (see Currencies): every WhatsApp price is in it, the button, the cart, the message and the recorded order.

When the project is linked to velastack.dev and you’re logged in, the command creates the project’s order form and fills in PUBLIC_WHATSAPP_ORDER_FORM itself, so you’re only asked for the number. Recorded orders are under the project’s forms on velastack.dev, each with its reference, the customer, the lines and the message as it would have been sent. Like Shopify, this needs no backend and works on a static site.

  • src/lib/order.ts - orderMessage(order), the message the business receives; change it as you like. placeOrder records the order and opens the chat.
  • src/lib/components/payments/order-button.svelte - An Order on WhatsApp button: <OrderButton name="Concha" price={12} />. It asks for a name, a phone number and an optional note, then opens the chat. The price is optional; leave it out to quote in the chat.

Each order has a short reference, such as A7K-3Q2, that appears in both the message and the recorded order, so a chat can be matched to its record. The links, references and recording come from @velastack/whatsapp.

Currencies

Every provider asks three things about the business and keeps them in src/lib/site.ts, next to the site’s name (or pass --currency, --country and --currencies; a value the file already has is kept):

  • currency - The currency the business prices in and is paid in, ISO 4217, such as MXN. WhatsApp cannot take orders without it.
  • country - The country the business is in, ISO 3166-1, such as MX. Prices format for the page’s language in that country: a bare es would print 360,00 MXN, es-MX prints $360.00, and an English page on the same site prints MX$360.00.
  • currencies - Other currencies the site shows prices in, comma-separated. Blank means the home currency only, and nothing below appears.

With a second currency listed, a currency select sits in the navbar beside the language select, and the choice is kept in a cookie for a year. On a site with a server, a visitor who has not chosen starts in their region’s currency when the site offers it, read from the CDN’s country header (Cloudflare, Vercel, CloudFront) or the browser’s language; a static site starts in the business’s currency. A price shown in another currency is an estimate at the day’s rate, marked ≈; what the customer pays is the price in the business’s currency.

Where the provider itself has a price in the visitor’s currency, the page shows that instead, exact and unmarked. A Stripe price’s fixed amount in another currency (currency_options, set in the dashboard and synced with the catalog) is shown and, when every price in the cart has one, charged; otherwise Stripe charges in the price’s currency unless Adaptive Pricing is on in your dashboard. A Shopify store that sells in that currency through Shopify Markets prices the catalog, the cart and the checkout in it. On WhatsApp the message adds the customer’s estimate as one line, and the business quotes in its own currency.

The rates are central bank reference rates, blended by Frankfurter and published once a day at velastack.dev/v1/rates, which every site reads: the response names the institutions behind each rate. A site fetches them in the browser once a day; without them, prices stay in the business’s currency.

  • src/lib/currency.svelte.ts - The currency prices show in, the rates, convert and format. formatPrice(amount) is what the generated pages use.
  • src/lib/components/currency-select.svelte - The select. It renders nothing until currencies lists a second currency.
  • src/lib/currency.server.ts - With a server: handleCurrency decides each visitor’s currency (their choice, else their region’s, else the business’s) into locals.currency, and the root layout hands it to the store with the day’s rates, so the first render already shows it. A static site has none and applies the visitor’s choice after load.

A project that enabled payments before this has no country or currencies: add them to src/lib/site.ts by hand, since re-running vela enable payments would redo the provider’s setup.

Switching providers

Run vela disable payments, then enable payments again with the other --provider. Enabling a second provider on top of the first is refused, since the patterns built on payments could no longer tell which one the project uses.