# Polar payments in an Astro app

> Polar payments in Astro for one-time purchases and subscriptions: server-side checkout routes, lazy customer creation, and entitlement checks in D1.

Author: Flavio Copes | Published: 2026-07-17 | Canonical: https://flaviocopes.com/polar-payments-astro/

[StackPlan](https://stackplan.dev) sells two things: a $20 one-time report unlock, and a $20/month Pro subscription.

Both run through [Polar](https://polar.sh). 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 purchase
- `POLAR_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:

```ts
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:

```ts
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:

```ts
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.
