Billing
Mock to money is a config change.
One BillingDriver contract, three implementations. The driver is picked by which
env keys exist — code never changes: Lemon Squeezy → Stripe → mock.
LS first because a merchant of record handles payouts and taxes where the operator can't open Stripe.
Selection
import { billingEnv, getBillingDriver } from "@tollgate/sdk"; const config = billingEnv(env); // lifts the env vars below const driver = getBillingDriver(config); // ls | stripe | mock const { url } = await driver.createCheckout({ planId: "credits_1k", // $9 → 1,000 credits accountId, email, successUrl: site + "/dashboard?purchased=1", cancelUrl: site + "/dashboard", });
Environment variables
| variable | provider | what it is |
|---|---|---|
| LEMONSQUEEZY_API_KEY | LS | API key — its presence turns LS on |
| LEMONSQUEEZY_STORE_ID | LS | store id |
| LEMONSQUEEZY_VARIANT_CREDITS_1K | LS | variant id of the $9 block product |
| LEMONSQUEEZY_WEBHOOK_SECRET | LS | signing secret of your webhook |
| STRIPE_SECRET_KEY | Stripe | secret key — presence turns Stripe on |
| STRIPE_PRICE_CREDITS_1K | Stripe | price id of the $9 block |
| STRIPE_WEBHOOK_SECRET | Stripe | endpoint signing secret |
| SITE_URL | all | base URL for redirect/checkout links |
Secrets go in wrangler secret put / .dev.vars, never in code or config files you commit.
Webhooks
Both providers post to your server; verify before fulfilling. Verification helpers do constant-time HMAC checks (Stripe also gets a 5-minute replay window), and fulfillment is idempotent on the provider's reference — retries can't double-credit.
const event = await verifyLemonSqueezyWebhook(config, rawBody, req.headers.get("x-signature")); const billing = event && lsEventToBillingEvent(event); if (billing) await tollgate.store.addCredits( billing.accountId, billing.credits, billing.externalRef, billing.amountUsdCents, billing.provider);
verifyStripeWebhook / stripeEventToBillingEvent mirror this for
checkout.session.completed.
Provider setup, concretely
- Lemon Squeezy: create a product "1,000 credits" with one $9 variant → copy the variant id.
Settings → Webhooks → add
https://your.dev/api/webhooks/lemonsqueezy, subscribe toorder_created, set the signing secret. - Stripe: create a $9 one-time price → copy the price id. Developers → Webhooks → add
https://your.dev/api/webhooks/stripeforcheckout.session.completed.
The mock driver
No keys configured → checkout URLs point at a local /billing/mock-checkout page whose
"pay" button calls a completion endpoint. That endpoint is hard-gated on the mock driver being active,
so it cannot mint credits once real billing exists. This is how the whole flow stays testable in CI
and on a laptop with zero accounts.
Refunds & disputes
Not automated in MVP — by policy, not by accident: the purchase ledger keeps the provider reference, so a refund is "subtract the block, note the ref" in one SQL statement. Automate it when volume justifies it.