How I added Polar payments to an Astro app
By Flavio Copes
Add Polar payments to Astro with subscriptions, one-time purchases, Better Auth, webhooks, D1 entitlements, and local testing.
StackPlan sells two things:
- a $20 one-time unlock for a single report
- a $20/month Pro subscription
Both run through Polar.
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 plugin, the Polar TypeScript SDK, and three Astro API routes:
/api/billing/checkoutcreates a checkout/api/billing/portalopens the customer portal/api/billing/syncupdates 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:
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:
npm install better-auth @polar-sh/better-auth @polar-sh/sdk
Then add the full Polar configuration:
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:
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:
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:
/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:
<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:
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:
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:
/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:
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:
{
reportId,
anonToken,
}
For a logged-in user it contains:
{
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:
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:
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:
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:
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, but I still wanted the basic checkout flow to work without starting a tunnel.
The checkout success URL points to:
/api/billing/sync
For a Pro customer, the route asks Polar for the customer state:
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:
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:
await upsertSubscriptionFromPolar(db, payload.data)
For one-time purchases I listen for order.paid:
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:
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:
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:
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:
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:
POLAR_SERVER=sandbox
Then test with Stripe’s test card:
4242 4242 4242 4242
Use any future expiry date and any CVC.
I test the full loop, not just the payment page:
- Create a Pro checkout.
- Complete the payment.
- Confirm the sync route writes the subscription.
- Confirm the dashboard shows paid access.
- Open the customer portal.
- Cancel the subscription and verify access rules.
- Buy a report unlock anonymously.
- Confirm only that report is unlocked.
- Register and verify the purchase moves to the account.
For webhook testing, install the Polar CLI and forward events:
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=sandboxwithPOLAR_SERVER=production
Also register the production webhook URL:
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.
Related posts about services: