Polar billing — production checklist
Use this before going live with polarPlugin. Each item maps to a common production failure from real integrations.
Environment #
POLAR_PRO_PRODUCT_IDis a product UUID from Polar dashboard — not a price IDPOLAR_ACCESS_TOKENmatches environment (production OAT vs sandbox)POLAR_WEBHOOK_SECRETis set (whsec_…) and matches Polar dashboardPOLAR_ORG_SLUGmatches your Polar organization slug (portal URLs)BASE_URLis the canonical public URL (checkout success/return redirects)POLAR_SANDBOX=falsein production (orsandbox: falsein plugin options)
Plugin wiring #
const { polarPlugin, rateLimitPlugin } = require('webspresso/plugins');
const { quickAuth } = require('webspresso/core/auth');
createApp({
db,
auth: quickAuth({ db, jwt: true }),
plugins: [
rateLimitPlugin(), // MUST be before polarPlugin
polarPlugin({ db, plans, tierMapping }),
],
});
rateLimitPlugin()registered beforepolarPlugin— otherwise Polar rate limiters are skipped (or userateLimit: falseintentionally)dbpassed topolarPlugin(orcreateApp({ db })so ctx.db is available)
Webhook #
- Webhook URL in Polar dashboard:
https://your-domain.com/api/v1/polar/webhook(or your customroutes.webhook) - Raw body middleware mounted before
express.json()for strict HMAC:
const { createWebhookRawBodyMiddleware } = require('webspresso/plugins/polar/src/webhooks');
app.use(createWebhookRawBodyMiddleware('/api/v1/polar/webhook'));
app.use(express.json());
- Events enabled:
customer.state_changed,subscription.*,order.*,checkout.updated
User ID & metadata #
- Primary key type: checkout sends
metadata.user_idas a string (supports nanoid/UUID, not only integers) public_idpopulated if you rely onexternal_customer_idfallback- Run
webspresso polar:migrate+webspresso db:migratefor Polar columns onusers
External subscription table (optional) #
If paid tier lives outside users.tier (e.g. subscriptions.plan_tier):
hooks.isPaidUser— checkout “already subscribed” checkhooks.onSubscriptionChange— write webhooks to your app DBsyncBeforeCheckout: falseif Polar API sync would false-positive block checkout
polarPlugin({
db,
syncBeforeCheckout: false,
hooks: {
async isPaidUser({ user, knex }) {
const sub = await knex('subscriptions').where('user_id', user.id).first();
return sub?.plan_tier === 'pro';
},
async onSubscriptionChange({ user, tier, knex, update }) {
await knex('subscriptions').where('user_id', user.id).update({ plan_tier: tier });
return knex('users').where('id', user.id).update(update);
},
},
});
Dashboard page-load sync:
const polar = ctx.usePlugin('polar');
await polar.api.syncBillingForAppUser(user, knex, { hooks: { /* same as above */ } });
Checkout UX #
| Method | Use case |
|---|---|
| POST | HTML <form method="post"> — requires form-action CSP to include 'self' |
| GET | Simple <a href="…"> links — no form submission, fewer CSP constraints |
Both routes run the same handler (sync → paid check → Polar redirect).
- CSP
form-actionincludes'self'(built intopolarCspDirectives(); usemergePolarCspDirectives()with Helmet) - Success/return URLs configured via
urls.success,urls.return,urls.alreadySubscribed
Smoke tests (local) #
node -e "require('webspresso/core/auth'); require('webspresso/plugins/polar/src/webhooks'); console.log('exports OK')"
npm test -- tests/unit/package-exports.test.js tests/integration/polar-production.test.js