Ship
Going live: taking a real payment
Everything between an approved payment account and money arriving, in the order it has to happen. Every item is anchored to the code that reads it, so nothing here is guesswork about what the app needs — and where a step exists to prevent a specific failure, the failure is named.
Replace https://your-domain throughout with your production origin.
Which provider? The product sells through the one named by
PAYMENT_PROVIDER in site/lib/payments/selected.ts: Creem by default, or
Polar. Sections 1, 4, 5 and 6 are the same for both. Section 2 is Creem's
dashboard; with Polar, do section 2b instead. Section 3's variables are
Creem's; Polar's are listed in 2b. scripts/doctor.sh shows which provider is
selected and which of its variables are set.
With Creem, the single value that flips the whole app from test to live is
CREEM_API_KEY. creemMode() in site/lib/payments/creem/client.ts derives the mode
from the creem_test_ prefix, and that one value picks the SDK's server target,
the REST host, and the mode the webhook will accept. There is no separate mode
switch to forget — deliberately, because forgetting one means either a live key
talking to the sandbox (paying customers told their licence is invalid) or a
sandbox key talking to live (test payments moving real state).
1. Before you touch the dashboard
- Work through
docs/new-project.mdfirst, at least as far as the gate. A live product created under a placeholder name cannot be un-created, only archived. - Decide
PRICING.modeinsite/lib/pricing.ts."static"writes the amounts once, in theLICENSE_TIERStable there;"live"reads them from the provider's catalogue at render time and is the only mode that cannot drift from what the card is charged. Either way, no amount appears anywhere else in the repository. - Decide the ladder itself, in the same table: how many Macs each rung
covers, what each costs, and which rung is
primary. Exactly one is — it is whatgetPrice()answers with, where a bare/buysends a buyer, and which rung the pricing card opens on. It must equalLicenseSheet.defaultDevicesin the app; the release preflight checks. The module refuses to load if the rungs do not ascend in both devices and price. - Confirm the support address.
SITE.supportEmailis shown on the site and on every receipt, and account review checks that it matches the address you gave at signup — and that it is reachable. - Confirm the seller of record.
LEGAL.entityandLEGAL.locationappear on the policy pages and in every email footer, and are the other half of what review looks for. - Leave
LAUNCH.onSaleasfalsefor now. It flips in step 5, once a real payment has gone through.
2. The provider dashboard
-
Generate a live API key. Dashboard → Developers → API keys, with the dashboard out of test mode. A live key does not carry the
creem_test_prefix, which is exactly how the app tells the two apart. -
Create the live products — one per rung. Test-mode product ids do not resolve against the live API, so the test products cannot be reused. From
site/:NEXT_PUBLIC_SITE_URL=https://your-domain CREEM_API_KEY=<live key> pnpm run creem:setup -- --liveNEXT_PUBLIC_SITE_URLis required with--live: each product's return URL (/thanks) and the webhook URL it prints are built from it, and a source project's first live run, made without it, created products with no return URL and printed a localhost webhook address.--liveis required — the script refuses a live key without it, because a live product is customer-visible, cannot be deleted, and the mistake is made while copying an env var between two terminal tabs. Four extra keystrokes against a permanent stray entry in your catalogue.It creates every rung in
LICENSE_TIERSin one run, on purpose: three separate runs is three chances to stop after the second, and a deployment with two of three product ids set renders no pricing section at all rather than two cards. Amounts come from the tier table inPRICING.mode: "static". In any other mode there is no configured amount and it will tell you to pass one per rung:CREEM_API_KEY=<live key> pnpm run creem:setup -- --live --price-1=1900 --price-2=2900 --price-3=3900 --currency=USDKeep the ids it prints — it also prints the exact
CREEM_PRODUCT_ID_n=andNEXT_PUBLIC_CHECKOUT_URL_n=lines to paste, and the hosted checkout link for each product has to be copied out of the dashboard by hand.Then, in the dashboard, for every product: turn on licence keys. The API cannot do it. Product → Delivery → Add → License key, with Limit activations on and set to that rung's device count — 1, 2 and 3. Skip the delivery and a buyer pays and receives a receipt with nothing to paste; skip the limit and a one-Mac licence activates on every Mac they own. Then prove it:
CREEM_API_KEY=<live key> pnpm run creem:setup -- --verifyIt reads each product back and fails on a missing licence-key feature, a wrong return URL, a wrong price, a wrong tax mode or a mode that disagrees with the key. The activation limit is not visible to the API, so it prints the number to check by eye — and step 4 checks it for real.
-
Register the live webhook. Developers → Webhooks, pointing at:
https://your-domain/api/webhooks/creemSubscribe it to exactly these three events:
Event Why it is subscribed checkout.completedThe only thing that turns a payment into a fact — the receipt, the revenue report, and whatever your product grants. refund.createdReverses all of it. Without it a refunded customer keeps whatever paying granted them, and the dashboard keeps counting the money. dispute.createdThe same, for a chargeback — which is the case where you find out late and by accident. Copy that endpoint's signing secret, not the test endpoint's. They are different secrets, and a secret from the wrong endpoint fails verification in a way that looks exactly like an attack.
The script does not register this for you on purpose: the API for it is not stable across accounts, and a webhook silently pointed at the wrong deployment is the failure this whole template spends the most effort preventing. Better to paste a URL you can see.
-
The return URL is
https://your-domain/thanks, set on each product bycreem:setupand checked by--verify. That page verifies the provider's redirect signature, shows the licence key, and offers the one-click<scheme>://activatelink into the app.
2b. Polar instead of Creem
Only if PAYMENT_PROVIDER is "polar". Everything below was checked against
Polar's 2026-04 API; the adapter is site/lib/payments/polar/adapter.ts, and
its header explains each difference from Creem.
Do all of it in the sandbox first (sandbox.polar.sh, a separate account and
token), then again in production. POLAR_SERVER=sandbox points the site at the
sandbox; unset it for production. A token used against the wrong environment
is rejected with a 401, which the licence routes answer as 502 ("try again
later"), never as an invalid key.
-
Organization access token (Settings → Developers) with
license_keys:write,customers:read,orders:read,checkouts:readandproducts:read→POLAR_ACCESS_TOKEN. The organization id (Settings → General) →POLAR_ORGANIZATION_ID. Every licence call carries it, so a key sold by another Polar organization can never validate here. -
One product per rung, one-time, one fixed price each.
-
A License Keys benefit on each, with activation limit set to that rung's device count (1 / 2 / 3). Without the benefit a sale grants no key; with the wrong limit a one-Mac licence activates on three. A key prefix (e.g. your product's name) makes keys recognisable in support mail. Product ids →
POLAR_PRODUCT_ID_1…3. -
One Checkout Link per rung, with the success URL
https://your-domain/thanks?checkout_id={CHECKOUT_ID}. Thecheckout_idis how the thank-you page verifies the purchase and shows the key, and how DataFast attributes the sale (Polar links take no metadata, so there is no other way). Links →CHECKOUT_URL_nandNEXT_PUBLIC_CHECKOUT_URL_n. -
Webhook endpoint
https://your-domain/api/webhooks/polar, eventsorder.paidandorder.refunded→ its secret inPOLAR_WEBHOOK_SECRET. -
Check it all from the repository, which reads the setup back and fails on any mismatch with the ladder:
```bash cd site && node --env-file=.env.local node_modules/.bin/tsx scripts/polar-setup.ts ```
Two Polar behaviours to know before launch:
- Refunds revoke licence keys by default (the dashboard's "revoke benefits" box, ticked for a full refund). A refunded customer's app stops validating at its next weekly check, and locks after the grace window.
- Polar may refund on its own, within 60 days, when a card network signals
a likely chargeback. It arrives as an ordinary
order.refunded.
3. Production environment
Set these on the Production environment specifically. A value set only for Preview will not be there when the live site builds, and the symptom is a production deploy that behaves like a preview.
| Variable | Value | What breaks without it |
|---|---|---|
NEXT_PUBLIC_SITE_URL | https://your-domain | The build fails, by design (siteUrlConfigError). Canonicals, og:url, JSON-LD @id and the checkout return URL would all point at a preview host. |
CREEM_API_KEY | the live key | Licence routes answer 502, the price lookup cannot read the product, and the webhook's mode guard stands down. Also: leaving a test key here silently keeps the whole app in test mode. |
CREEM_PRODUCT_ID_1, _2, _3 | the live product id for each rung, from step 2.2 | In PRICING.mode: "live", that rung has no price — and the pricing section is all-or-nothing, so one missing id removes the whole section. A page that sells throws (ISR keeps the last good render) and the layout renders without a number. |
CREEM_WEBHOOK_SECRET | the live endpoint's secret | The webhook answers 500 and the provider retries forever. Payments are not lost, but nothing settles until you set it. |
NEXT_PUBLIC_CHECKOUT_URL_1, _2, _3 | that rung's live hosted checkout link | That rung's button points at /#pricing instead of a checkout, and a /buy?devices=n for it redirects there too. Never a 404, and the price still renders. |
CHECKOUT_URL_1, _2, _3 | optional; the same links | Nothing. They exist because NEXT_PUBLIC_ values are inlined at build time, so these unprefixed ones can be changed without a redeploy. Worth setting the day a checkout link changes under you. |
CREEM_API_URL | leave unset | Nothing. Set to a stale host it is ignored, loudly — but delete it rather than trusting that: the host should always follow the key. |
DEV_PRICE_CENTS | leave unset | Nothing. It is a local-build escape hatch and is refused in production with an error naming itself, precisely so it cannot become the hardcoded price the codebase exists to delete. |
RESEND_API_KEY | a key, sending domain verified | Receipts are skipped and logged. The payment still settles and the webhook still answers 200 — which is why this one goes unnoticed. |
RESEND_FROM | optional | Defaults to SITE.name <SITE.supportEmail>, so replies reach a human. |
DATAFAST_API_KEY | the server-side write key | Revenue reporting no-ops silently. Every dashboard number becomes visitors-only while looking perfectly healthy — see the note below. |
NEXT_PUBLIC_DATAFAST_ID | the website id | No analytics script is rendered at all, and the AI-crawler tracker in proxy.ts stands down. |
NEXT_PUBLIC_DATAFAST_DOMAIN | your-domain | As above. Both public analytics variables or neither — the script silently rejects pageviews whose host does not match. |
GITHUB_RELEASE_REPO | owner/repo | /api/download and /api/latest-version answer 503; the thanks page and receipt offer no download. |
GITHUB_RELEASE_TOKEN | a fine-grained token, Contents: read, that repo only | Only needed while the release repository is private: without it every download — including the app's in-app updates — answers 404 from GitHub. |
There is deliberately no database in that table: this site stores nothing
(docs/modules/database.md covers adding one).
On
DATAFAST_API_KEY. This is the one in the table that fails most quietly. The browser script cannot see a payment — it completes on the provider's domain, over a webhook, with no page of ours open, and on a mobile purchase the buyer's tab may never come back at all. Without the server keyreportPaymentreturns{ sent: false }, nothing is thrown, nothing looks wrong, and every acquisition decision you make afterwards is made against revenue the dashboard never saw.
4. Verify with one real payment
Live mode has no test cards, so the first verification is a real purchase. Buy your own product, then confirm all four:
- The provider's webhook log shows the payment event (
checkout.completedfor Creem,order.paidfor Polar) delivered 200. A 400 means the signing secret is wrong; a 500 means it is missing. Both are recoverable — the delivery stays in the retry queue — but neither will fix itself. - The receipt arrived at the address used at checkout, with the right amount and an order reference. Open it on a phone and in one webmail client; that is where the plain-text part and the ASCII folding earn their place.
- The licence key arrived — on the
/thankspage and in the provider's own email — and the download worked, by the path a stranger would take rather than the one you know about. - The key activates in the app: paste it with ⌘V into the gate on one Mac. Then try it on one Mac more than the rung allows and confirm the app says the key is already on all its Macs — that is the activation limit you set by hand, and nothing else can check it.
- Analytics recorded the payment against the visitor who converted, not as
an unattributed sale. This is the end of the attribution chain that
starts with the visitor cookie in
site/lib/analytics/visitor.tsand travels through checkout metadata (Creem) or the success URL'scheckout_id(Polar); if the amount is there but the visitor is not, that link in the chain did not round-trip.
5. Verify the reversal path
The half that only gets exercised when something has already gone wrong, which is exactly why it has to be exercised on purpose, once, while you are watching.
Refund that order from the provider's dashboard, then confirm:
-
refund.createdwas delivered 200. - The licence was disabled by the provider. The app finds out at its next weekly check (or at next launch once that is due) and shows the gate as "no longer active" — the webhook deliberately does not revoke anything itself, because Creem owns the licence state.
- Analytics shows the refund, against the same transaction id as the sale. Reported revenue that never comes down means every decision made from that number is made against money you gave back.
Only then:
- Set
LAUNCH.onSale = trueinsite/lib/launch.tsand deploy. That one boolean rewrites the primary control, its note, the pricing card's availability line, the FAQ answer about paying, and theavailabilityin the JSON-LD offer — all at once, which is the point. - Run
site/scripts/assert-metadata.sh https://your-domainone more time.
6. What is already guarded, and what still is not
Three failure modes are handled in code, so they do not need to be on a launch-day watch list:
- A test payment cannot move live state. The webhook compares the payload's
mode against
creemMode()and ignores a mismatch with a 200, in both directions.testandsandboxare treated as the same thing. - An unverifiable payload is refused and never logged. A bare 400, with nothing written down — because an unverifiable delivery may be a real one with a rotated secret, carrying a customer's email and order details.
- A failed side effect cannot lose a payment. Receipts and analytics run as
effectsunderPromise.allSettled, so a bounced email or a slow vendor cannot turn a settled payment into a non-2xx and a pointless retry.
What is not guarded, and what to do about it:
- Idempotency is your handler's job. The provider retries on its own view
of failure — a timeout on our side counts, even when the work completed. As
shipped,
site/app/api/webhooks/[provider]/route.tssends a receipt and reports revenue, both carrying a stable deduplication id, so a retry is harmless. The moment there is anorderstable, settle it with the guardedUPDATE … WHERE status = 'pending' RETURNINGpattern documented insite/lib/payments/webhook.tsand return early when no row comes back. What does not work is checking whether a row exists and then writing it: two concurrent retries both read "no", both write, and the customer is charged once and recorded twice. - The settled amount can differ from the listed price. The merchant of record applies tax, so the order total will not always match what the page said. Both numbers are correct; expect them not to match, and do not "fix" it.
- Abandoned checkouts leave nothing behind here, but will once you store orders. Pending rows are harmless if every total sums only settled ones — but there is no cleanup job, so the table grows.
Next
- Shipping the app itself:
docs/releasing.md. - Changing the pricing model, downloads, or adding a database:
docs/growing.md. - The conventions all of the above depends on:
AGENTS.md.
This page is docs/go-live.md in the repository, copied 2026-09-25.