Polar payments in an Astro app
By Flavio Copes
Polar payments in Astro for one-time purchases and subscriptions: server-side checkout routes, lazy customer creation, and entitlement checks in D1.
StackPlan sells two things: a $20 one-time report unlock, and a $20/month Pro subscription.
Both run through Polar. I wired it into Astro with the @polar-sh/better-auth plugin plus a few thin first-party routes.
Two products, one billing stack
The one-time unlock opens full content on a single report. Pro unlocks everything, saves apps, and enables re-evaluation.
Polar models these as two separate products. You store both product IDs in env vars:
POLAR_PRODUCT_ID_UNLOCK— one-time purchasePOLAR_PRODUCT_ID_INDIVIDUAL— monthly subscription
The Better Auth Polar plugin handles webhooks and customer portal hooks. Checkout itself goes through your own routes.
Why? You need metadata on unlock checkouts (which report?), and you want server-side checks before sending someone to Polar.
Checkout routes, not naked Polar links
The pricing page links to /api/billing/checkout. That route does the Polar API call and returns a 302 to Polar’s hosted checkout page.
For Pro:
const checkout = await polar.checkouts.create({
products: [env.POLAR_PRODUCT_ID_INDIVIDUAL],
externalCustomerId: user.id,
customerEmail: user.email,
successUrl: `${origin}/api/billing/sync?next=/dashboard?upgraded=1`,
})
return context.redirect(checkout.url)
For a one-time unlock, add query params: ?product=unlock&report=<id>. The route verifies the viewer owns that report, then passes metadata: { reportId } into Polar.
The @polar-sh/better-auth plugin also exposes POST /api/auth/checkout. That’s JSON-only. I kept our GET routes because plain <a href> links work and we control the flow.
Lazy customer creation
Polar can create a customer when someone signs up. I turned that off.
createCustomerOnSignUp: true makes signup fail when Polar rejects the email. @example.com addresses fail deliverability checks. Polar downtime would block account creation.
That’s a bad trade. Auth should not depend on billing.
Instead, the customer is created lazily:
- at checkout completion, via
externalCustomerId: user.id - on first portal visit, if the session call fails, create the customer and retry
Anonymous unlock buyers skip externalCustomerId. Polar creates a guest customer at payment time.
Sync after payment
Webhooks don’t reach localhost. So I added /api/billing/sync.
After Polar redirects back, sync pulls subscription state into D1. For unlock checkouts it reads checkout_id from the query string and mirrors the purchase row.
Then it redirects to next — the report page with ?unlocked=1, or the dashboard with ?upgraded=1.
In production, webhooks are still the source of truth. Sync and webhooks share the same upsert helpers, so double-writes are safe.
Check access server-side
Never trust the client to know if someone paid. Query D1 and decide in plain TypeScript.
For subscriptions:
export function hasActiveSubscription(rows, expectedProductId, now = new Date()) {
if (!expectedProductId) return false
return rows.some((s) => {
if (s.productId !== expectedProductId) return false
if (s.status === 'active') return true
if (s.status === 'canceled') return now < s.currentPeriodEnd
return false
})
}
For one-time unlocks:
export function isReportUnlocked(purchaseRows, unlockProductId) {
if (!unlockProductId) return false
return purchaseRows.some(
(p) => p.status === 'paid' && p.productId === unlockProductId
)
}
Both functions fail closed when the product ID env var is missing. Local dev without billing stays functional.
On the report page, locked alternatives never reach the client payload. The server slices candidates before rendering.
Sandbox vs production
Set POLAR_SERVER to sandbox or production. Sandbox tokens come from sandbox.polar.sh.
Test checkout with Stripe’s 4242 4242 4242 4242, any future expiry, any CVC.
Unset POLAR_ACCESS_TOKEN and billing disables cleanly. The site keeps working on the free tier.
My advice: ship sandbox first, verify the full loop (checkout → sync → entitlement → portal), then flip to production keys and add POLAR_WEBHOOK_SECRET.
Payments are boring when the routes are thin and entitlements live in your database. Polar handles the card form. You own who gets what.
Related posts about services: