Skip to content

Payments

One interface (shared/lib/payments.ts), three providers. Dodo is the default: Merchant of Record (handles global tax), India-friendly.

Terminal window
PAYMENT_PROVIDER="dodo" # default
# PAYMENT_PROVIDER="stripe"
# PAYMENT_PROVIDER="lemonsqueezy"

No code changes — the same createCheckoutSession() and handleWebhook() work for all three.

DODO_PRODUCTS_JSON maps provider product IDs to tiers — no product IDs in code:

{"pdt_starter": {"tier": "starter", "kind": "full"},
"pdt_s2p": {"tier": "pro", "kind": "upgrade", "from": "starter"}}

Tiers and their public names: starter = Solo $129 (1 seat, 3 themes, 1yr updates) → pro = Team & Studio $249 (5 seats, 10 themes, lifetime updates) → pro_plus = Agency $399 (unlimited, all themes, lifetime). Upgrades pay the difference and require owning the lower tier — enforced server-side. The internal ids are what DODO_PRODUCTS_JSON maps; the names and prices are what buyers see on the pricing page.

  1. Checkout session created (Dodo REST shape proven in production).
  2. Webhook verifies the real signature over raw bytes — failures return 401 and grant nothing.
  3. Fulfillment writes idempotent orders + licenses rows, stamps coupon orders cashback-ineligible, records the instant-delivery waiver.
  4. Receipt email fires best-effort — email failure never fails the webhook.

Unknown product IDs, missing emails, and upgrades without ownership throw instead of granting access. Money in, license out — or nothing.

Two receivers ship, and picking the wrong one is the classic silent failure:

Deploy targetReceiverNotes
Node server (astro build + node, or any SSR host)src/pages/api/webhook.tsAstro server route
Cloudflare Pages static buildfunctions/api/webhook.tsPages Function — required, the Astro route never runs

Checkout can succeed while no receiver exists: Dodo issues a valid checkout URL, the buyer pays, and the event goes nowhere — no orders row, no licenses row, no receipt email. If you are on Pages, set the provider webhook URL to https://yourdomain.com/api/webhook and confirm the Pages Function is deployed with its secrets (see Deployment). Unknown product IDs return 422 there specifically so a broken catalog is loud instead of silent.

functions/api/license/verify.ts answers whether a key is currently entitled:

Terminal window
curl -X POST https://yourdomain.com/api/license/verify \
-H 'content-type: application/json' -d '{"key":"<license key>"}'
{"valid":true,"status":"active","tier":"starter","seats":1,"themes":["dark","light"],"updatesUntil":"2027-09-29"}

status is active or expired (200 either way — an expired key is a known key), or 404 for a key that does not exist. The response never includes the buyer’s email, and the key is only accepted in the POST body so it stays out of access logs. npm run license:verify wraps this call and writes .startplate/license.json.

Your checkout UI must send metadata.instant_delivery_waiver='true' with the text: “I agree to instant delivery and waive my right to withdraw.” Stored per order (orders.waiver).

Full or partial, from the provider dashboard — never pay buyers directly (MoR rule). Refunds should revoke the license key and remove repo access. See the refund policy pattern in src/pages/startplate/refund.astro.