Build Mac AppDocsMenu

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.md first, at least as far as the gate. A live product created under a placeholder name cannot be un-created, only archived.
  • Decide PRICING.mode in site/lib/pricing.ts. "static" writes the amounts once, in the LICENSE_TIERS table 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 what getPrice() answers with, where a bare /buy sends a buyer, and which rung the pricing card opens on. It must equal LicenseSheet.defaultDevices in 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.supportEmail is 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.entity and LEGAL.location appear on the policy pages and in every email footer, and are the other half of what review looks for.
  • Leave LAUNCH.onSale as false for now. It flips in step 5, once a real payment has gone through.

2. The provider dashboard

  1. 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.

  2. 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 -- --live
    

    NEXT_PUBLIC_SITE_URL is 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.

    --live is 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_TIERS in 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 in PRICING.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=USD
    

    Keep the ids it prints — it also prints the exact CREEM_PRODUCT_ID_n= and NEXT_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 -- --verify
    

    It 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.

  3. Register the live webhook. Developers → Webhooks, pointing at:

    https://your-domain/api/webhooks/creem
    

    Subscribe it to exactly these three events:

    EventWhy 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.

  4. The return URL is https://your-domain/thanks, set on each product by creem:setup and checked by --verify. That page verifies the provider's redirect signature, shows the licence key, and offers the one-click <scheme>://activate link 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:read and products: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}. The checkout_id is 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_n and NEXT_PUBLIC_CHECKOUT_URL_n.

  • Webhook endpoint https://your-domain/api/webhooks/polar, events order.paid and order.refunded → its secret in POLAR_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.

VariableValueWhat breaks without it
NEXT_PUBLIC_SITE_URLhttps://your-domainThe 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_KEYthe live keyLicence 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, _3the live product id for each rung, from step 2.2In 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_SECRETthe live endpoint's secretThe 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, _3that rung's live hosted checkout linkThat 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, _3optional; the same linksNothing. 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_URLleave unsetNothing. 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_CENTSleave unsetNothing. 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_KEYa key, sending domain verifiedReceipts are skipped and logged. The payment still settles and the webhook still answers 200 — which is why this one goes unnoticed.
RESEND_FROMoptionalDefaults to SITE.name <SITE.supportEmail>, so replies reach a human.
DATAFAST_API_KEYthe server-side write keyRevenue reporting no-ops silently. Every dashboard number becomes visitors-only while looking perfectly healthy — see the note below.
NEXT_PUBLIC_DATAFAST_IDthe website idNo analytics script is rendered at all, and the AI-crawler tracker in proxy.ts stands down.
NEXT_PUBLIC_DATAFAST_DOMAINyour-domainAs above. Both public analytics variables or neither — the script silently rejects pageviews whose host does not match.
GITHUB_RELEASE_REPOowner/repo/api/download and /api/latest-version answer 503; the thanks page and receipt offer no download.
GITHUB_RELEASE_TOKENa fine-grained token, Contents: read, that repo onlyOnly 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 key reportPayment returns { 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.completed for Creem, order.paid for 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 /thanks page 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.ts and travels through checkout metadata (Creem) or the success URL's checkout_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.created was 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 = true in site/lib/launch.ts and deploy. That one boolean rewrites the primary control, its note, the pricing card's availability line, the FAQ answer about paying, and the availability in the JSON-LD offer — all at once, which is the point.
  • Run site/scripts/assert-metadata.sh https://your-domain one 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. test and sandbox are 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 effects under Promise.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.ts sends a receipt and reports revenue, both carrying a stable deduplication id, so a retry is harmless. The moment there is an orders table, settle it with the guarded UPDATE … WHERE status = 'pending' RETURNING pattern documented in site/lib/payments/webhook.ts and 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.