Skip to content
FLAVIO COPES
flaviocopes.com
2026

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:

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.

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:

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.

Tagged: Services · All topics
~~~

Related posts about services: