# How I added Polar payments to an Astro app

> Add Polar payments to Astro with subscriptions, one-time purchases, Better Auth, webhooks, D1 entitlements, and local testing.

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

[StackPlan](https://stackplan.dev) sells two things:

- a $20 one-time unlock for a single report
- a $20/month Pro subscription

Both run through [Polar](https://polar.sh).

I wired Polar into an Astro app running on Cloudflare Workers, using Better Auth and D1.

The payment form was the easy part. Polar hosts it.

The interesting part was everything around it:

- creating the right checkout for each product
- connecting purchases to users and reports
- keeping a local copy of the billing state
- handling anonymous buyers
- deciding what a customer can access
- making the flow work locally

Let's see how I built it.

## The billing architecture

I use the [`@polar-sh/better-auth`](https://polar.sh/docs/integrate/sdk/adapters/better-auth) plugin, the Polar TypeScript SDK, and three Astro API routes:

- `/api/billing/checkout` creates a checkout
- `/api/billing/portal` opens the customer portal
- `/api/billing/sync` updates the local billing state after a redirect

The Better Auth plugin handles the webhook endpoint and signature verification.

D1 stores a local mirror of subscriptions and one-time purchases. The app checks this local data when it needs to decide if someone has access.

This separation is important.

Polar knows if money was paid. My app knows what that payment unlocks.

## Create two Polar products

StackPlan has two separate products in Polar.

The first is a one-time product called Report Unlock. The second is a recurring monthly product called Pro.

I store their IDs in environment variables:

```bash
POLAR_PRODUCT_ID_UNLOCK=
POLAR_PRODUCT_ID_INDIVIDUAL=
```

The second name says `INDIVIDUAL` because that was the original product name. I later renamed the plan to Pro without changing the environment variable.

Do not identify products by their display name. Names can change.

Use the product ID when creating a checkout and when checking access.

## Install the Polar packages

The integration uses Better Auth plus the Polar SDK:

```bash
npm install better-auth @polar-sh/better-auth @polar-sh/sdk
```

Then add the full Polar configuration:

```bash
POLAR_ACCESS_TOKEN=
POLAR_WEBHOOK_SECRET=
POLAR_PRODUCT_ID_UNLOCK=
POLAR_PRODUCT_ID_INDIVIDUAL=
POLAR_SERVER=sandbox
```

StackPlan runs on Cloudflare Workers, so local secrets live in `.dev.vars`.

If your Astro app uses another adapter, you might use `.env` instead. Keep all access tokens and webhook secrets on the server.

## Create the Polar client

I keep the SDK setup in one small function:

```ts
import { Polar } from '@polar-sh/sdk'

export function createPolar(env) {
  if (!env.POLAR_ACCESS_TOKEN) return null

  return new Polar({
    accessToken: env.POLAR_ACCESS_TOKEN,
    server: env.POLAR_SERVER === 'production'
      ? 'production'
      : 'sandbox',
  })
}
```

Returning `null` is intentional.

It means the rest of StackPlan can run without billing credentials. This is useful for local work that has nothing to do with payments.

Paid actions are disabled, but the free parts of the app still work.

## Add Polar to Better Auth

The Better Auth plugin gives us checkout, portal, and webhook helpers.

Here is a simplified version of the setup:

```ts
import { polar, checkout, portal, webhooks } from '@polar-sh/better-auth'
import { Polar } from '@polar-sh/sdk'

const client = new Polar({
  accessToken: env.POLAR_ACCESS_TOKEN,
  server: env.POLAR_SERVER ?? 'sandbox',
})

const polarPlugin = polar({
  client,
  createCustomerOnSignUp: false,
  use: [
    checkout({
      products: [
        {
          productId: env.POLAR_PRODUCT_ID_INDIVIDUAL,
          slug: 'pro',
        },
      ],
      successUrl: '/api/billing/sync?next=/dashboard?upgraded=1',
      authenticatedUsersOnly: true,
    }),
    portal(),
    ...(env.POLAR_WEBHOOK_SECRET
      ? [
          webhooks({
            secret: env.POLAR_WEBHOOK_SECRET,
            onOrderPaid: handleOrderPaid,
            onSubscriptionCreated: handleSubscriptionUpdated,
            onSubscriptionUpdated: handleSubscriptionUpdated,
            onSubscriptionActive: handleSubscriptionUpdated,
            onSubscriptionCanceled: handleSubscriptionUpdated,
            onSubscriptionRevoked: handleSubscriptionUpdated,
          }),
        ]
      : []),
  ],
})
```

I only add `webhooks()` when `POLAR_WEBHOOK_SECRET` exists.

The plugin exposes its webhook at:

```text
/api/auth/polar/webhooks
```

Add that URL as an endpoint in the Polar dashboard.

Polar signs each request. The plugin verifies the signature before calling our handlers.

## Why I did not use the plugin checkout route

The Better Auth plugin already provides a checkout endpoint.

I still added my own.

The plugin endpoint is a `POST` endpoint that expects JSON. My pricing page uses normal links:

```html
<a href="/api/billing/checkout">
  Upgrade to Pro
</a>
```

I wanted this link to work without shipping a billing client to the browser.

The custom route also gives me a place to:

- require a logged-in user for Pro
- allow anonymous report unlocks
- verify access to a report
- attach report metadata
- choose the success URL
- track the start of a checkout
- handle billing errors

Astro API routes make this small. The page sends a `GET`, the server creates a checkout, and Astro returns a redirect.

## Create a subscription checkout

An Astro endpoint exports a function matching the HTTP method:

```ts
import type { APIRoute } from 'astro'

export const GET: APIRoute = async (context) => {
  const user = context.locals.user

  if (!user) {
    return context.redirect('/register')
  }

  //create checkout here
}
```

Pro requires an account, so the route sends anonymous visitors to registration.

Then it creates the Polar checkout:

```ts
const checkout = await polar.checkouts.create({
  products: [env.POLAR_PRODUCT_ID_INDIVIDUAL],
  externalCustomerId: user.id,
  customerEmail: user.email,
  successUrl: `${context.url.origin}/api/billing/sync?next=${
    encodeURIComponent('/dashboard?upgraded=1')
  }`,
})

return context.redirect(checkout.url)
```

`externalCustomerId` is the Better Auth user ID.

This gives Polar a stable link to the local account. I don't need another table that maps Polar customers to users.

`customerEmail` prefills the checkout form.

After payment, Polar redirects the customer to `/api/billing/sync`. That route updates D1, then sends the customer to the dashboard.

## Create a one-time checkout

A one-time report unlock is different.

The URL includes the report ID:

```text
/api/billing/checkout?product=unlock&report=abc123
```

Before creating the checkout, the route loads the report and checks if the current visitor can access it.

This matters because the report ID becomes payment metadata. A visitor must not be able to unlock or attach a purchase to someone else's report.

Once authorized, I create the checkout:

```ts
const metadata: Record<string, string> = { reportId }

if (user) metadata.userId = user.id
if (anonToken) metadata.anonToken = anonToken

const checkout = await polar.checkouts.create({
  products: [env.POLAR_PRODUCT_ID_UNLOCK],
  metadata,
  successUrl: `${
    env.BETTER_AUTH_URL
  }/api/billing/sync?checkout_id={CHECKOUT_ID}&next=${
    encodeURIComponent(`/report/${reportId}?unlocked=1`)
  }`,
})

return context.redirect(checkout.url)
```

Polar replaces `{CHECKOUT_ID}` before redirecting back.

The checkout ID lets the sync route fetch the checkout and verify:

- the checkout status
- the product ID
- the report ID in metadata

Only then does the app write a paid purchase row.

## Supporting anonymous buyers

StackPlan lets someone create and unlock a report before registering.

For an anonymous visitor, I store a random token in a cookie. The report and purchase rows both carry that token.

The checkout metadata contains:

```ts
{
  reportId,
  anonToken,
}
```

For a logged-in user it contains:

```ts
{
  reportId,
  userId,
}
```

Anonymous checkouts do not pass `externalCustomerId`. Polar creates a guest customer when payment happens.

If the buyer registers later, the app claims both the report and its purchase:

```ts
await db
  .update(reportPurchases)
  .set({
    userId,
    anonToken: null,
    updatedAt: new Date(),
  })
  .where(eq(reportPurchases.anonToken, anonToken))
```

The purchase now belongs to the account.

This is a small detail, but forgetting it creates a bad experience: the customer pays anonymously, registers, and appears to lose the thing they bought.

## Create customers lazily

The Polar plugin can create a customer every time someone signs up:

```ts
createCustomerOnSignUp: true
```

I turned this off.

Polar validates customer emails. During development I found that addresses using domains like `@example.com` could be rejected.

More importantly, a temporary Polar problem should not stop someone from creating a StackPlan account.

Authentication and billing have different jobs. I don't want the availability of one to depend on the other.

Customers are created only when needed:

- during checkout, using `externalCustomerId`
- on the first customer portal visit

The portal route first tries to create a customer session:

```ts
const session = await polar.customerSessions.create({
  externalCustomerId: user.id,
  returnUrl: `${context.url.origin}/api/billing/sync`,
})

return context.redirect(session.customerPortalUrl)
```

If the Polar customer does not exist, the route creates it and retries:

```ts
await polar.customers.create({
  email: user.email,
  name: user.name,
  externalId: user.id,
})
```

This keeps signup independent from payments.

## Store billing state in D1

I don't call Polar every time someone loads a paid page.

Instead, D1 has two local mirror tables.

The `subscriptions` table stores:

- local user ID
- Polar customer ID
- Polar subscription ID
- product ID
- status
- current period end

The `report_purchases` table stores:

- report ID
- local user ID or anonymous token
- Polar customer ID
- Polar order ID
- product ID
- status

The Polar subscription ID and order ID are unique.

This makes the writes idempotent. If a webhook and the sync route deliver the same purchase, the second write updates the existing row.

You should expect duplicate billing events. Webhooks can be retried, and the browser redirect can race with a webhook.

An upsert is much safer than assuming each event arrives once.

## Why I added a sync route

Webhooks do not reach `localhost` by default.

Polar provides a [CLI that forwards webhooks to your local app](https://polar.sh/docs/integrate/webhooks/locally), but I still wanted the basic checkout flow to work without starting a tunnel.

The checkout success URL points to:

```text
/api/billing/sync
```

For a Pro customer, the route asks Polar for the customer state:

```ts
const state = await polar.customers.getStateExternal({
  externalId: user.id,
})
```

It upserts every active subscription into D1. It also marks a local active subscription as revoked if Polar no longer returns it as active.

For a one-time unlock, the route reads `checkout_id`, fetches that checkout, verifies it, and mirrors the purchase.

The route then redirects to the `next` path.

Never redirect to an unvalidated query parameter. This would create an open redirect.

I only accept same-site paths:

```ts
function sanitizeNext(raw: string | null) {
  if (raw && raw.startsWith('/') && !raw.startsWith('//')) {
    return raw
  }

  return '/dashboard'
}
```

This allows `/dashboard` and rejects `https://another-site.com`.

## Webhooks are still the source of truth

The sync route improves the customer experience. It is not a replacement for webhooks.

A customer can close the browser before returning to the app. Subscriptions can renew, fail, get canceled, or be revoked without another checkout redirect.

Polar sends events for those changes.

For subscriptions I listen for created, updated, active, canceled, and revoked events. The shared handler maps the Polar data into the local row:

```ts
await upsertSubscriptionFromPolar(db, payload.data)
```

For one-time purchases I listen for `order.paid`:

```ts
await upsertReportPurchaseFromPolarOrder(
  db,
  payload.data,
  env.POLAR_PRODUCT_ID_UNLOCK
)
```

The handler ignores orders with the wrong product ID or without a `reportId`.

Sync and webhooks use the same upsert functions. It does not matter which arrives first.

I also wrote a scheduled reconciliation job that can periodically pull customer state from Polar. This is useful as a final self-healing layer if a webhook is missed.

## Check entitlements in your app

A successful checkout is not the same thing as app access.

The app still needs an **entitlement check**: a function that decides what the current customer can use.

For subscriptions, I check both the product and status:

```ts
export function hasActiveSubscription(
  rows,
  expectedProductId,
  now = new Date()
) {
  if (!expectedProductId) return false

  return rows.some((subscription) => {
    if (subscription.productId !== expectedProductId) {
      return false
    }

    if (subscription.status === 'active') {
      return true
    }

    if (!subscription.currentPeriodEnd) {
      return false
    }

    if (subscription.status === 'canceled') {
      return now < subscription.currentPeriodEnd
    }

    return false
  })
}
```

Checking the product ID is essential.

Without it, any active subscription in the same Polar organization could unlock Pro. That might work while you have one product, then become a security bug when you add a cheaper product.

A canceled subscription keeps access until the paid period ends.

StackPlan also gives `past_due` subscriptions a short grace period while payment retries happen. Revoked and incomplete subscriptions get no access.

The one-time check is smaller:

```ts
export function isReportUnlocked(
  purchaseRows,
  unlockProductId
) {
  if (!unlockProductId) return false

  return purchaseRows.some((purchase) => {
    return purchase.status === 'paid'
      && purchase.productId === unlockProductId
  })
}
```

Both functions fail closed.

If the expected product ID is missing, they return `false`. A configuration error must not accidentally grant paid access.

## Gate content on the server

The report page loads subscriptions and purchases from D1:

```ts
const isSubscribed = hasActiveSubscription(
  subscriptionRows,
  env.POLAR_PRODUCT_ID_INDIVIDUAL
)

const isUnlocked = isSubscribed || isReportUnlocked(
  purchaseRows,
  env.POLAR_PRODUCT_ID_UNLOCK
)
```

Then the server decides which report candidates to render:

```ts
const visibleCandidates = isUnlocked
  ? candidates
  : candidates.slice(0, 1)
```

I don't send locked alternatives to the browser and hide them with CSS.

Anything sent to the client can be inspected. If the user has not paid for content, keep that content out of the response.

## Test in the Polar sandbox

Polar's sandbox is separate from production.

It has its own:

- organization
- access token
- products
- product IDs
- customers
- webhook secret

Set:

```bash
POLAR_SERVER=sandbox
```

Then test with Stripe's test card:

```text
4242 4242 4242 4242
```

Use any future expiry date and any CVC.

I test the full loop, not just the payment page:

1. Create a Pro checkout.
2. Complete the payment.
3. Confirm the sync route writes the subscription.
4. Confirm the dashboard shows paid access.
5. Open the customer portal.
6. Cancel the subscription and verify access rules.
7. Buy a report unlock anonymously.
8. Confirm only that report is unlocked.
9. Register and verify the purchase moves to the account.

For webhook testing, install the Polar CLI and forward events:

```bash
polar login
polar listen http://localhost:3000/
```

Copy the webhook secret printed by the CLI into your local environment.

## Move to production

Once the sandbox flow works, create the same products in the production Polar organization.

Then replace:

- the access token
- both product IDs
- the webhook secret
- `POLAR_SERVER=sandbox` with `POLAR_SERVER=production`

Also register the production webhook URL:

```text
https://your-app.com/api/auth/polar/webhooks
```

My advice is to test one real purchase after switching.

Check the Polar order, the webhook delivery, the D1 row, and the final entitlement. Then test the portal and cancellation flow too.

Payments stay manageable when each piece has one job.

Polar handles checkout and billing events. Astro handles the routes. D1 stores the local state. A small set of TypeScript functions decides access.
