Billing and payments
Updated Oct 16, 2026 Open the tool
All Zoov.dev payments go through Stripe: Stripe Checkout for new purchases, saved cards for one-click pack purchases, and the Stripe customer portal at /api/v2/billing/portal?return=/app for card changes, invoices and cancellation. Access and credits are granted by a Stripe webhook after payment, which is why "paid but nothing happened" is almost always a webhook question.
How a Stripe checkout works
Every purchase creates a Stripe Checkout session tied to the user's Stripe customer, which is looked up from the profile, then by email on Stripe, and created if missing. A user with no email on their account cannot check out ("Failed to create or retrieve stripe customer").
Credit packs and monthly credit plans start from POST /api/v1/payments/checkout. The store opens Stripe in a popup window and shows a waiting card with "Open checkout" and "Cancel" buttons; if the popup is blocked the message is "Popup blocked. Open the secure checkout link to continue." Sessions expire after 1 hour (2 hours before 2026-10-06), so the recovery DM below reaches the person while the cart is still in mind. Promotion codes can be entered at checkout unless a site sale is already attached (see the credit packs doc). The card used is saved for later one-click purchases when the buyer ticks Stripe's save box (since 2026-10-06; it was saved by force before, and that setup_future_usage: off_session hid every payment method that cannot be reused off-session).
Subscription plans start from /store/subscription/{price id} and redirect to Stripe rather than using a popup.
After a pack purchase the popup lands on /store/success and shows "Payment Received" with "Your payment is confirmed. We're finalizing your credits now." It polls /api/v1/payments/session-status for up to 30 seconds until the webhook has applied the credits, then the original window shows the credit celebration. If it times out the message is "Payment received. Credits are still being finalized and should appear shortly." A cancelled checkout shows "Payment was cancelled."
If a user abandons a checkout, a Discord DM goes out when the session expires (once per customer per 24 hours) under the heading "You left something behind". Since 2026-10-06 its words come from src/lib/server/payments/recovery.ts and know the cart: "You were a step from 400 credits ($12.99) and the page timed out. Nothing was charged.", then what the item is in the game the checkout's door belongs to (a Roblox door such as generate-livery or erlc-* counts credits in liveries; a FiveM door speaks of textures and conversions; a plan door names the plan, its monthly credits and three of its features). Someone who has never paid (no succeeded row in stripe_payments, no row in stripe_subscriptions) is offered the recovery code (RECOVERY_PROMO_CODE in config/plans.ts, BACK15: 15% off once, Stripe first_time_transaction; CART_RECOVERY_PROMO_CODE overrides it) on a "Continue with BACK15" button that opens the workspace's plans dialog with the code applied (/app?plans=packs or ?plans=<plan>, &promo=BACK15&from=recovery-dm), beside "Same cart, no code" (Stripe's recovery link). A returning buyer gets "Continue checkout" and, for a plan, "Compare plans". Before this the DM was one FiveM pitch with no code and brought back 1 of 547 in six months, and plan sessions carried no recovery link at all, so no DM ever went out for a plan.
The workspace's own routes (2026-09-14)
Inside /app nothing under /store is visited. Four routes under /api/v2/billing do the same work on the same Stripe customer: GET /api/v2/billing/packs lists the packs and boosts with the sale; POST /api/v2/billing/packs/checkout { packId, returnTo } and POST /api/v2/billing/subscribe { priceId, returnTo } each answer { url }, the Stripe Checkout page the browser then goes to (a full-page visit rather than the store's popup); GET /api/v2/billing/portal?return=/app/... redirects straight to Stripe's customer portal (the profile menu's Billing, the credits dialog's Manage, and the "Billing" links in the plans dialog point there, in a new tab). Signed out, the two POSTs answer 401 and the portal route goes to the sign-in first; returnTo and return must be paths under /app, anything else becomes /app. Both POSTs take an optional promo, a Stripe promotion code typed on the plans dialog, and attach it to the Checkout session where it reaches the item (409 promo_invalid when Stripe no longer takes it); GET /api/v2/billing/promo?code= prices a code for the dialog (signed in, thirty lookups an hour per account since 2026-10-13), and GET|PUT /api/v2/billing/sale (staff only) reads and sets the site sale. Details in the credit packs doc.
Stripe sends the person back to the tool they were in, and the workspace says what happened (the address is cleaned first): ?subscription=success&plan=pro&session_id=… opens the dialog "Pro plan active" with "1,500 credits a month · $25 paid" and confetti; ?credits=success&session_id=… opens "Payment received" with the credits bought, the amount paid and confetti (the top bar asks for the balance at once and again 4 and 12 seconds later, so it shows the credits once the webhook has written them); a session Stripe has not confirmed yet opens "Payment pending"; ?credits=cancelled or ?subscription=cancelled&plan=… reopens the plans dialog where the person was (the packs, or the plan they had chosen drawn out) with the line "Checkout cancelled · nothing charged"; ?billing=failed opens "Could not open billing" with Try again. The facts come from GET /api/v2/billing/session?id= (paid, kind, credits, amount, monthly), which answers only for the person the session belongs to. Access and credits still come from the webhook.
Buying with a saved card
Once a card has been used at Stripe Checkout it is saved to the customer. On later visits the store shows a lightning-bolt "Buy {n} credits" button that charges the saved card without leaving the page. Clicking it opens a "Confirm Purchase" modal: "Are you sure you want to purchase {pack} for USD {price}?" with the card brand and last four digits.
Confirming creates and charges a Stripe PaymentIntent on the saved card. If the bank asks for extra verification the user sees "This saved card needs additional verification. Continue with secure checkout to finish the payment." and can switch to the normal Stripe Checkout. A card that is still settling shows "Your payment is processing. Please wait a moment and check your credits shortly." On success the button shows "Purchased!" and the balance updates within a few seconds.
Saved-card purchases are not available for packs that are on sale, because the promotion code can only be applied inside Stripe Checkout; those packs always open Checkout. Saved cards can be removed or replaced in /api/v2/billing/portal?return=/app.
Paying inside the credits dialog (2026-10-10)
Owner's ask: payments that stay on the site, in a dialog, in the workspace's own components. A credit pack bought from the Credits dialog without a card on file no longer sends the browser to Stripe's page: the dialog turns into a payment step titled "Checkout" (shell/PayStep.svelte). Redrawn the same day after the owner's first look ("feels old": the promotion link half hidden behind the Pay bar, Stripe's method tiles, its country dropdown, near-black fields, the save box unticked). Top to bottom: a "Credits" back link; the order card (the pack, "150 credits · Never expire", and the price Stripe computed; under a hairline the row "Promotion code" with Add, which opens a field with Apply in place; a code that takes money off shows as a chip with its amount, the price struck and a Total row); Apple Pay and Google Pay as full buttons over "or", only on a device that has them; the payment methods as accordion rows with the card first and open, Stripe's own Country dropdown inside the card under the expiry and security code, then Stripe's own box "Save payment details to Zoov for future purchases", unticked (other methods, such as WeChat Pay in Australia, are rows under it); then the dialog's foot, outside the scrolling body: the Pay button ("Pay A$8.92", disabled until the form is complete) over "Processed by Stripe". Who sees it: everyone, since 2026-10-12 (PAY_STEP in src/lib/config/release.ts is everyone). From 2026-10-10 to 2026-10-12 it was staff (the developer Discord role, page.data.admin, and every dev server) while the paths were walked on a Stripe sandbox; setting it back to staff sends everyone else to Stripe's page again. Stripe's page is still where the step falls back to when Stripe.js cannot load ("Pay on Stripe's page"), and where a press goes for anyone whose browser never runs the step (the legacy store).
- Server: the same
packCheckoutwithembedded: true(POST /api/v2/billing/packs/checkout, clientbilling.packPay). Same customer, line item, metadata, discounts, card-saving setting (payment_method_save: 'enabled') and webhook path. Inui_mode: 'elements'Stripe refusessuccess_url,cancel_urlandafter_expiration(checked on the live account;node_modules/.probe/elements-params.mjs), so those are dropped andreturn_url(?credits=success&session_id=) is set for methods that must visit their bank; the answer is{ clientSecret, sessionId, country },countrybeing Vercel'sx-vercel-ip-countryfor the request (null in dev), which Stripe's Country field starts on. - Browser:
@stripe/stripe-js10, loaded on the step only.stripe.initCheckoutElementsSdkwithadaptivePricing.allowed(the total can show in the buyer's currency: A$8.92 for a $5.99 pack in Australia), the request's country asdefaultValues.billingAddress(left out when the server does not know it, so Stripe guesses);createExpressCheckoutElement(Apple Pay and Google Pay, 44px, white on dark and black on light; Link, PayPal, Amazon Pay and Klarna off there), confirmed withconfirm({ expressCheckoutConfirmEvent });createPaymentElement(accordion,radios: 'if_multiple', card first, Apple Pay and Google Pay off there since they are the buttons above, Link on),loadActions,applyPromotionCode/removePromotionCode(Stripe's own error words: "This promotion code is invalid."), andconfirm({ redirect: 'if_required' }): a card payment, the bank's 3D Secure check included, resolves in the page. Paid: the dialog closes, confetti, the balance expects the credits, toast "150 credits added · A$8.92", and the balance is asked for again at 4 s and 12 s while the webhook grants them. - Country: Stripe's own dropdown, inside the card form (the owner's call, after a first version that hid it behind the workspace's Select above the methods). It starts on the request's country: the server sends
x-vercel-ip-countryback with the session and the step hands it to Stripe as the billing address's default; with no header (dev) Stripe guesses from the visitor's address itself. Stripe asks for a postcode where the country needs one (the US: "ZIP code"; Australia: none) and follows a change in the dropdown by itself. - Link, and saving the card: as on Stripe's page. Link is on in the card form: in the 60 days to 2026-10-10 it paid for 48% of credit packs (121) and 43% of plan payments (981) on Stripe's page, ahead of typed cards (38%) and Apple Pay (11 to 14%). The session's customer carries the email, so Stripe finds a Link account and texts its code without asking, then fills the card; a buyer without Link sees the plain card form. The card is kept for the dialog's one-press Buy only when the buyer ticks Stripe's own box, which starts unticked. A first version of the redraw hid that box for a ticked one of ours (
enableSave: 'never'withsavePaymentMethodonconfirm) and turned Link off, because with the box hidden Stripe put Link's sign-up form (email, mobile number, full name) under every card; the Link figures above made the owner choose Stripe's page exactly. Link stays out of the wallet buttons at the top (a green button for everyone). - Look:
services/stripe-appearance.tsreads the--app-*tokens off the page and turns them into what the Appearance API takes (it refusesvar()andoklch(), andrgba()in its main colours), through a one-pixel canvas. Fields are onfield-lift(a token added for this: one step above the dialog in dark, white in light; the workspace's darkest well read as too dark here), 36px with a strong hairline, the brand border on focus and no ring (the workspace's rule); labels 13px medium in ink; the accordion rows on the dialog's fill with hairlines and the card radius, a muted radio that turns brand; checkboxes and the terms text in the workspace's tones. The code field takes the samefield-lift. Inter comes from Google Fonts (the iframe cannot load our self-hosted file). A light/dark switch while the step is open callschangeAppearance. - The foot:
ui/Dialog.sveltegainedfill: the dialog stops scrolling as a whole and its body is a column, so the step scrolls its own middle and the Pay button sits in a real foot under it (it was a sticky bar inside the scroll, and the "Add promotion code" line slid half under it). Escape in the code field closes the field, not the checkout (onEscapeKeydownon the dialog; the field closes one task later so the dialog can still find it focused); a code Stripe refuses puts the caret back in the field. - The bank's check: Stripe draws 3D Secure in its own iframe on
<body>, outside the dialog. While the step is open the dialog does not trap focus and does not close on an outside press (ui/Dialog.sveltegainedtrapFocusandonInteractOutside), andtheme.cssgives Stripe's iframes pointer events back under a dialog's scroll lock. Not walked yet with a real 3D Secure card: the dev machine has live keys only. - When the form cannot load (a blocker, the network): "Could not load the payment form" with Retry and "Pay on Stripe's page", which is the old redirect.
- Abandoned: an elements session has no Stripe recovery page, so the expiry DM's link opens the workspace's plans dialog on the packs (
https://zoov.dev/app?plans=packs&from=recovery-dm). Closing the step and buying on a second try leaves the first session to expire, so since 2026-10-10 the DM is skipped for anyone with a succeeded payment after the expired session began (any checkout, hosted too). - Events:
payment_pack_checkout_startedcarriescheckout_mode(elements|redirect);payment_elements_completed(package_id, credit_amount, total) on a payment in the step; the server'spayment_checkout_createdsayscheckout_mode: elements. - Probes:
node_modules/.probe/paystep-walk.mjsmakes one live elements session with no customer, hands it to the dialog by intercepting the checkout request, checks the form, the total, a refused code, the outside press and Back in dark and light, and expires the session; nothing is paid.paystep-look.mjs(the redesign) photographs the step at 1440x900, 1280x720 and 390x844 in both themes and, withDEEP=1, checks Stripe's save box is there and unticked and that a buyer without Link (the session's email ispaystep-probe@example.com) gets no Link sign-up form, Escape in the code field, and that Stripe's Country starts on the country the server sent (COUNTRY=USfrom an Australian machine gives United States and a ZIP code);paystep-experiment.mjsmounts the elements bare to compare Stripe's options. Not seen headless: the Apple Pay and Google Pay buttons (no wallet in the test browser); zoov.dev is registered for both on Stripe.plans-pay-walk.mjsdoes the same through the plans dialog: a one-time pack opens the step titled "Checkout" with a back link to the plans, an outside press keeps it, Back returns to the plans, and the monthly boost still asks for Stripe's page. - The plans dialog's packs (2026-10-10): a one-time pack bought at the foot of the plans dialog takes the same payment step in place of the plans ("Checkout", a "Plans" back link), behind the same
PAY_STEP(shell/pay.tsholds the check and the paid ending both dialogs share: the dialog closes, confetti, the toast, the balance asked for again). A promotion code typed at the top of the plans dialog is sent withembedded: trueand is on the session when the step opens (the order card shows it as its chip); a code Stripe has stopped taking ends the step back on the plans with "Code no longer valid · removed", and a lapsed sign-in goes to the sign-in, as a press on Stripe's page would. Since later the same day the monthly boosts and the plans take the step too (below).
Plans and boosts in the step (2026-10-10)
Every purchase in the plans dialog uses the step (PAY_STEP, everyone since 2026-10-12): the paid plans (Standard, Pro, Max), the Plugin Pass, the monthly boosts and the packs. planCheckout(..., embedded) (POST /api/v2/billing/subscribe with embedded: true, client billing.subscribePay) makes the same subscription session as Stripe's page in ui_mode: 'elements'; Stripe refuses custom_text in that mode (checked on the live account, node_modules/.probe/sub-elements-params.mjs), so the terms Stripe's page printed over its button come back as notes (replaces: a main plan is held; yearly) and the step prints them over its own: "Replaces your current plan the moment it is paid. The old plan ends with no refund or proration, and its unused monthly credits are removed. Credit packs are not affected." and "One payment covers 12 months and is not refundable. Credits arrive month by month. Cancel any time to stop the next year." The order card names the plan with a gem in the tier's colour ("Pro plan", "1,500 credits a month"; a boost: "500 credits", "Unused expire at renewal" with the repeat glyph), the price carries "/ month" or "/ year", a code that only covers the first period adds "Then A$357.42 a year" under the total, and the button reads "Subscribe · A$321.68". A plan paid there closes the dialog and opens the layout's own "Pro plan active" dialog (plans.celebrate, the same one a return from Stripe's page opens; a toast off the workspace, on /app/comp). A plan already held, a code Stripe stopped taking or a lapsed sign-in ends the step back on the plans as a press on Stripe's page would.
Link on a subscription. A subscription keeps its card whatever the buyer says, so Stripe shows no save box there, and Link's sign-up block takes its place, ticked, with the account's email filled in and a mobile number asked for: Pay stays disabled until the number is typed or the box is unticked. Stripe's own page offers the same sign-up; left as Stripe draws it (Link paid for 43% of plan payments in the 60 days before).
Plan changes did not cancel the old plan (2026-09-04 to 2026-10-10). Found by the sandbox walk: supersedeOlderSubscriptions listed the customer's subscriptions with expand: ['data.items.data.price.product'], five levels, and Stripe refuses more than four ("You cannot expand more than 4 levels of a property"), so every plan change since the replacement shipped kept the old subscription running. It now expands to the price and reads each product on its own; a test asserts the depth (src/tests/services/subscription-supersede.test.ts). See plans-and-subscriptions for the accounts this left holding two plans.
The customer portal
The Stripe customer portal is the place to update a card, download invoices, change the billing interval and cancel. In the user menu it is "Billing portal"; the path is /api/v2/billing/portal?return=/app. It requires sign-in and redirects straight to Stripe. A return_to query parameter (a site path) controls where "Return" goes; the default is /app.
Cancellation from the portal takes effect at the end of the paid term. The Terms of Service say: "All purchases are non-refundable. You can cancel your subscription via our customer portal, located within the profile or from your subscription email, all payments are securely made via Stripe" and "Your cancellation will take effect at the end of the current paid term."
If the portal fails to open, the user is redirected to /editor?error=subscription_failed; ask them to try again and, if it repeats, ask staff to check that their profile has a Stripe customer.
Failed payments and past due subscriptions
When a renewal charge fails, Stripe retries on its own schedule. As soon as the subscription is marked past_due (or unpaid, incomplete_expired, paused), the webhook treats it as suspended: the tier role is removed and that plan's remaining monthly credits are voided. Permanent credits are untouched.
The user gets a Discord DM headed "Payment issue with your subscription": "We had trouble processing your latest payment for {plan}. Your subscription is currently past due. To recover your subscription and any monthly credits or access, please update your payment method." with an "Update Payment Method" button to /api/v2/billing/portal?return=/app.
Stripe tries a failed renewal 9 times over about 28 days, then cancels the subscription. When staff have switched it on, a dunning pass also runs every day at 15:00 UTC. It charges the card on file for an unpaid invoice at most once a week and 3 times in all. It does not email anyone. In its early setting it charges between Stripe's own attempts, but only after a decline that can clear on its own (not enough funds, a bank hiccup) and at least 2 days after the last try. The email subject is "Your zoov.dev {plan} payment did not go through"; it says the plan is paused with "no monthly credits, no plugin access, and no competition entries until it clears" and has "Pay or update card" and "Manage billing" buttons.
What the user should do: open /api/v2/billing/portal?return=/app, pay the open invoice or add a working card. The moment the invoice is paid, the webhook reactivates the subscription, restores the role, re-grants the current period's credits and grants any competition entries. If they meant to cancel, they can ignore it and the plan ends on its own.
Since 2026-10-05 a failed plan payment also puts Payment failed in the workspace bell (the amount, "update the card in Billing"), once per invoice however many retries fail. It links to /app/profile, where Billing opens Stripe.
Non-refundable policy
Zoov.dev does not refund purchases. The Terms of Service at /policy/terms-of-service state "All purchases are non-refundable." and the custom payment page says "All payments are non-refundable unless otherwise agreed upon in writing." The pricing FAQ adds "Purchases are non-refundable, which is why yearly checkout asks you to confirm twice."
Related behaviour that follows from this: cancelling keeps access until the paid term ends instead of refunding the remainder; a plan change starts a new full-price subscription with "no refund or proration"; and yearly checkout requires the two-tap "Confirm {total} for 12 months" button. The only automatic reversals are credit refunds for failed generation jobs, which return credits, not money (see the Credits doc).
Money refund requests go to staff. Do not promise one in support.
Paid but no access or no credits
Credits and roles are granted by the Stripe webhook at /api/v1/payments/process, not at the moment of payment. Work through this order.
- Confirm the payment. Ask for the Stripe receipt email or the checkout session id from the success URL (
session_id=cs_...). For packs,/store/success?session_id=...re-checks the session and shows whether credits are still pending. - Give it a minute. Credits usually land within seconds; the success page waits 30 seconds before giving up.
- Packs: the payment must have
type: creditsmetadata and a user id. Credits are added once per payment intent and recorded instripe_paymentswithcredits_applied. A duplicate webhook is skipped, so a missing award means the first attempt failed and the event is marked failed. - Subscriptions: the webhook must resolve the Stripe customer to a profile (by stored customer id, then customer metadata, then email). Failures log "No user found for Stripe customer" or "User {id} has no discord_id". The user must be signed in with the Discord account that owns the profile and must be in the Zoov.dev Discord for the role to be assigned. Since 2026-10-02 a missing role never fails the event: the plan, credits and supersede go through and only the role is skipped (plans-and-subscriptions, "Roles, Early Adopter and is_pro").
- A subscription that is
incomplete(delayed payment method) is not activated until the invoice is paid. - A renewal between 2026-10-02 and 2026-10-09 that charged but added no credits is the known renewal bug, fixed 2026-10-09; staff restore that month (plans-and-subscriptions, "Renewals that brought no credits").
- Check the tier:
permission_rolein the user's metadata should match the product. The Early Adopter role is shared by every main plan.
Staff can replay a failed event from the stripe_events table; see Internal.
Team checkout
Teams are bought at /team and use POST /api/v1/payments/team/checkout with a team name and a seat count between 2 and 100 ("Seat count must be between 2 and 100"). Each seat is the Pro monthly price, $25, and the Stripe subscription quantity equals the seat count. Since 2026-10-13 team checkout always uses that team seat price, whatever the page sends. An owner can hold one active team; a second attempt returns "You already have an active team. Use the dashboard to manage seats." A checkout that renews an existing team works only for your own team; any other team returns "Not your team". Success returns to /team?checkout=success, cancel to /team?checkout=cancelled.
Seat changes, member management and the team's past-due handling are covered in the Teams doc (verify that doc exists). A past-due team is marked past_due; unpaid, expired or paused teams become inactive.
Custom payments for services
Development work is paid at /pay. The page lists the rates: Standard Development $65 an hour (minimum 2 hours), Urgent / Rushed $100 an hour (minimum 1 hour), Consultation $35 an hour (minimum 1 hour), Bug Fixes / Revisions $55 an hour (minimum 30 minutes). Amounts are agreed with the developer on Discord before paying.
The form takes an amount (minimum $5, maximum $10,000) and a description of up to 500 characters that appears on the Stripe invoice with " -- Self Description / Payment" appended. "Pay {amount} with Stripe" opens Stripe Checkout with an invoice; success lands on /pay/success showing "Payment Received" and "A receipt has been sent to your email." Errors: "Minimum payment amount is $5.", "Maximum payment amount is $10,000.", "A description is required for the invoice." These payments add no credits and no access.
Common questions
I paid but I am still on the Free plan.
A plan is switched on by Stripe's confirmation, which arrives a few seconds to a minute after paying, not at the moment of payment. In the workspace (/app) the plan updates by itself within about twelve seconds of coming back from checkout; elsewhere, wait a minute and reload. If it is still Free after a few minutes: check you are signed in with the same Discord account that paid, and that the payment actually completed (a delayed payment method leaves the subscription inactive until the invoice is paid). Then send staff the Stripe receipt email or the session_id=cs_... from the checkout address; see "Paid but no access or no credits".
I paid for credits and my balance has not changed.
Wait a minute and reload. Then open /store/success?session_id=... from your receipt or the checkout URL. If it still says credits are being finalized, send support the session id (starts with cs_) and staff will replay the webhook.
How do I change my card?
Open the "Billing portal" from your user menu (/api/v2/billing/portal?return=/app) and update the payment method there.
My subscription says past due. What do I do?
Pay the open invoice or add a working card in /api/v2/billing/portal?return=/app. Access and this period's credits come back automatically as soon as it is paid.
Can I get a refund? Purchases are non-refundable. You can cancel any time and keep access until the end of the paid term. Credits for failed generations are refunded automatically as credits.
Why did the one-click "Buy" button send me to Stripe instead? Either your bank required extra verification for the saved card, or the pack is on sale and the discount can only be applied in Stripe Checkout.
How do I pay a developer for custom work?
Use /pay, enter the agreed amount and a description, and pay with Stripe. A receipt is emailed to you.
Checkout starts a little sooner (2026-09-28)
The workspace's checkout routes return Stripe's address without waiting for the analytics event about it; the event is sent after the response. Nothing else about checkout changed.