Polar sandbox vs live webhooks — why checkout works and entitlements do not
Checkout can succeed and your app can still treat the buyer as a free user. On Polar that usually means the webhook landed in the wrong world — sandbox secret on a live deploy, production endpoint still pointed at last night’s preview URL, or a signature check that never ran.
Article one covered the whole Next.js / Vercel go-live pass. This one is only Polar: sandbox vs production, and the webhook path that turns a paid order into an entitlement in your database.
Official Polar docs win when they disagree with anything here. Links below were fetched live from polar.sh/docs.
Polar does not have a Stripe-style “test mode”
Polar’s sandbox is a separate environment, not a toggle on your live org. From Sandbox Environment:
It's a dedicated server, completely isolated from the production instance…
Why? Polar is a Merchant of Record. They keep live money movements clean by isolating test data so it never interferes. You get unlimited sandbox accounts and orgs for experiments.
| Sandbox | Production | |
|---|---|---|
| UI | sandbox.polar.sh | polar.sh |
| API | https://sandbox-api.polar.sh/v1 |
https://api.polar.sh/v1 |
| Tokens | Minted in sandbox only | Minted in live org only |
| Products / customers / webhooks | Isolated | Isolated |
| Card for happy-path tests | Stripe test 4242 4242 4242 4242
|
Real cards (or your live discounts) |
API Overview is blunt: production tokens cannot talk to the sandbox API, and the reverse is also true. Create separate tokens in each environment.
Sandbox rate limits are lower (100 req/min vs 500 in production). That rarely bites a checkout flow; it does bite scripts that hammer the sandbox while you debug.
One sandbox limitation that surprises people (same sandbox doc): customer-facing emails in sandbox only go to members of your organization. Sub-addressing like you+test@example.com is accepted. Do not expect a random Gmail to get order confirmation mail from sandbox.
The four secrets that must travel together
Treat these as a matched set. Mixing any one row across worlds is how you get “checkout works, entitlement missing.”
| Secret / ID | Preview & laptop | Production |
|---|---|---|
POLAR_ACCESS_TOKEN |
Token from sandbox.polar.sh | Token from the live org |
POLAR_SERVER / SDK server or environment
|
sandbox |
production (or omit — SDKs default to production) |
POLAR_WEBHOOK_SECRET |
Secret from the sandbox webhook endpoint | Secret from the live webhook endpoint |
POLAR_PRODUCT_ID |
Sandbox product UUID | Live product UUID |
The official Next.js guide tells you to start on sandbox and later flip server: "sandbox" to "production" (Integrate Polar with Next.js). The Next adapter’s Checkout, CustomerPortal, and Webhooks helpers take the same idea (Next.js adapter).
Newer SDK previews use environment: "sandbox" on createPolar (API Overview). Older @polar-sh/nextjs examples use server: "sandbox". Either way: token origin must match the flag. A sandbox token with server: "production" fails in confusing 401s, not a clean error message.
Never prefix any of these with NEXT_PUBLIC_. Organization access tokens are server-only; Polar will revoke leaked OATs via secret scanning (API Overview).
Webhooks are the source of truth for entitlements
A success redirect proves the browser finished checkout. It does not prove your database granted access. Polar’s own Next.js guide frames webhooks as how you keep your DB in sync with checkouts, orders, and subscriptions (guide).
Minimum production shape with the official adapter:
// app/api/webhook/polar/route.ts
import { Webhooks } from "@polar-sh/nextjs";
export const POST = Webhooks({
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: async (order) => {
// Idempotent upsert: grant entitlement keyed by order.id
},
onCustomerStateChanged: async (customerState) => {
// Mirror Polar's view of benefits / subscriptions
},
});
Prefer granular handlers (onOrderPaid, onBenefitGrantCreated, …) over a giant onPayload switch once you know which events you subscribed to. The adapter documents the full list (Next.js adapter).
Register two endpoints, not one
Setup Webhooks walks the dashboard flow: Add Endpoint → absolute URL → Raw delivery → secret → event subscription.
Do that twice:
- Sandbox org → tunnel or a long-lived staging URL while you develop.
-
Live org →
https://your.domain/api/webhook/polar(stable custom domain).
Polar cannot reach localhost. For laptop work use either:
-
polar listen http://localhost:3000/(Polar CLI tunnel — prints a forwarding secret; see Setup Webhooks), or -
ngrok http 3000/ Cloudflare Tunnel and paste the https URL into the sandbox webhook (Next.js guide).
Never register the production webhook against a Vercel preview URL. Preview hostnames change every commit. Deliveries will 404 by Thursday while Polar still shows “checkout succeeded.”
Signature verification is non-negotiable
Polar signs payloads so you can reject forgeries. Handle & monitor webhook deliveries shows validateEvent from @polar-sh/sdk/webhooks (and the Python equivalent). The @polar-sh/nextjs Webhooks() helper does this for you when you pass webhookSecret.
Important timing note from Polar (as of docs fetched 13 Sep 2026):
Secrets generated on or after 8 September 2026, 00:00 UTC follow Standard Webhooks. Older secrets use Polar HMAC.
If you roll your own verification, read that page carefully — older secrets need different key handling than new whsec_… Standard Webhooks secrets. SDK versions 1.0.0-alpha.19+ try both. Prefer the official helper over hand-rolled HMAC.
Delivery rules that break entitlements in production
From the same delivery doc:
| Rule | What it means for you |
|---|---|
| Timeout 10s (aim for <2s) | Ack fast; queue heavy work (grant email, CRM sync) |
| Retry up to 10× with backoff | Handlers must be idempotent — replay must not double-grant |
| 10 consecutive failures → endpoint disabled | Org members get email; you must re-enable manually after fixing |
| No redirect following |
www ↔ apex 301s count as failure. Point Polar at the final URL |
| Cloudflare Bot Fight Mode | Can 403 Polar even if you allowlist IPs — disable BFM or fix WAF |
| Auth middleware | Exclude /api/webhook/polar so Polar is not asked for a session |
Also allowlist Polar’s egress IPs if your firewall requires it (list on the delivery page — Polar called out a new production IP 3.134.178.243).
A concrete failure timeline (and how to read the dashboard)
Monday: PR preview works. You register a sandbox webhook on https://my-app-git-feat-xyz.vercel.app/api/webhook/polar. Sandbox checkout → webhook → row in DB. Green.
Wednesday: You merge to main, set live POLAR_ACCESS_TOKEN, forget to create a live webhook (or you copy the preview URL into the live org). Customer pays on the custom domain. Polar shows a paid order. Your production DB never hears about it.
Thursday: Preview deployment from Monday is gone. Even the sandbox endpoint is 404ing. You look at Vercel Runtime Logs and see nothing. Polar’s webhook delivery page shows the red rows.
First move every time: Polar org (the one that took the money) → Settings → Webhooks → delivery history. You want recent 2xx. Then Vercel Runtime Logs for POST /api/webhook/polar. If Polar has 2xx and your DB is empty, the handler is wrong or not idempotent. If Polar has 404/403/3xx, fix the URL / middleware / Cloudflare before touching business logic.
Redeliver from the dashboard after you fix the route — Polar supports manual redelivery (delivery doc).
Go-live sequence for Polar only
Do this as its own checklist the evening you flip live payments — separate from “does the homepage render.”
- Confirm you are logged into the live org on polar.sh (not sandbox.polar.sh).
- Recreate products in live if you only built them in sandbox. Copy live product IDs into Production-scoped env — do not paste sandbox UUIDs.
- Mint a live organization access token. Scope it Production-only in Vercel.
- Set
POLAR_SERVER=production(or removeserver: "sandbox"/environment: "sandbox"). - Add webhook
https://your.domain/api/webhook/polaron the live org. Paste the new signing secret into ProductionPOLAR_WEBHOOK_SECRET. Subscribe at least to order-paid / benefit-grant events you actually handle. - Set
POLAR_SUCCESS_URLto the production origin with{CHECKOUT_ID}. - Redeploy so server env (and any bake-time public config) matches.
- Run one live test: cheap product, 100% discount, or a payment you are willing to refund. Confirm: Polar delivery 2xx, Runtime Log line, entitlement row, customer portal sees the benefit.
- Watch deliveries for 30 minutes. Keep Instant Rollback one click away.
Until that 2xx exists, do not post the buy link as “live.”
Soft sell (kit, not a live guarantee)
I packed the Polar scope cheat sheet, the full ~60-point launch checklist, five .env.example packs (including 02-polar.env.example), and a preview→prod runbook into a $19 download:
https://buy.polar.sh/polar_cl_uxzixqNM81nkPoIW3um9aacYzAmA1bXQbWqUe3EQE75
Checkout may still be enabling payments on Polar’s side — treat the link as the kit storefront once KYC / payouts are fully live, not as a promise that the card form works this second. Everything above still works as free reading tonight.
Educational material only. Re-read Polar’s sandbox, webhook setup, delivery, and Next.js guides before you take real money. Vendor names belong to their owners.
Ship Polar when sandbox checkout → webhook → entitlement already passed on a non-production URL, live products and secrets are Production-scoped, and the live webhook delivery page shows a recent 2xx for an order you caused on purpose.
Top comments (0)