Polar.sh Billing Plugin for Webspresso
Production-ready Polar subscription billing for Webspresso. Zero extra dependencies (crypto + native fetch only).
Quick start #
1. Environment #
Copy plugins/polar/.env.example → .env:
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_PRO_PRODUCT_ID=<product-uuid-not-price-id>
POLAR_ORG_SLUG=your-org
POLAR_SANDBOX=false
BASE_URL=https://example.com
2. Migration #
webspresso polar:migrate
webspresso db:migrate
3. Plugin config (~3 lines) #
const { polarPlugin, rateLimitPlugin } = require('webspresso/plugins');
const { quickAuth } = require('webspresso/core/auth');
createApp({
db,
auth: quickAuth({ db, jwt: true }),
plugins: [
rateLimitPlugin(), // optional but recommended — polar applies per-route limits
polarPlugin({
db,
userModel: 'User',
plans: { pro_monthly: process.env.POLAR_PRO_PRODUCT_ID },
tierMapping: { pro: ['pro_monthly'], free: [] },
}),
],
});
That's it — routes, webhooks, checkout, portal, and status API are auto-mounted.
4. Polar Dashboard webhook #
- URL:
https://your-domain.com/api/v1/polar/webhook - Format: Raw (not Slack/Discord)
- Events:
customer.state_changed,subscription.*,order.*,checkout.updated
Routes (defaults, all configurable) #
| Method | Path | Auth | Behavior |
|---|---|---|---|
| POST | /api/v1/polar/webhook | HMAC signature | Process Polar events |
| GET/POST | /settings/billing/checkout | session/JWT | Sync → Polar checkout redirect (see POST vs GET below) |
| GET | /settings/billing/portal | session/JWT | Customer portal redirect |
| GET | /api/v1/billing/status | session/JWT | Sync + JSON billing status |
Checkout: POST vs GET #
Both /settings/billing/checkout methods run the same handler:
| Method | Typical use | CSP note |
|---|---|---|
| POST | <form method="post" action="…"> | Requires form-action 'self' (included in polarCspDirectives()) |
| GET | <a href="…">Upgrade</a> | No form submission — simpler when CSP is strict |
Template usage #
{% if user.tier == 'pro' %}
<p>Pro until {{ user.polar_current_period_end | dateLabel }}</p>
<a href="/settings/billing/portal">Manage subscription</a>
{% else %}
<form method="post" action="/settings/billing/checkout">
<button type="submit">Upgrade to Pro</button>
</form>
{% endif %}
SDK exports (plugin.api or direct imports)
#
const polar = polarPlugin({ db });
polar.api.verifyPolarWebhook(raw, headers, secret);
polar.api.syncPolarBillingForUser(user, knex);
polar.api.syncBillingForAppUser(user, knex, { hooks }); // dashboard page-load sync
polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
polar.api.handlePolarWebhookEvent(event, knex);
polar.api.generateMigration();
polar.api.parseEnv(process.env);
Webhook raw body (production) #
For strict HMAC verification, mount before express.json():
const { createWebhookRawBodyMiddleware } = require('webspresso/plugins/polar/src/webhooks');
app.use(createWebhookRawBodyMiddleware('/api/v1/polar/webhook'));
app.use(express.json());
Without this, the plugin falls back to JSON.stringify(req.body) (works for most cases; key order edge cases possible).
Rate limiting #
Enabled by default (rateLimit: true). Requires rateLimitPlugin() registered before polarPlugin:
| Route | Default |
|---|---|
| Webhook | 120 req/min (by IP) |
| Checkout | 5 req/min (by user) |
| Portal | 10 req/min (by user) |
| Status | 60 req/min (by user) |
polarPlugin({ db, rateLimit: false }); // disable
polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
Optional hooks #
Billing columns on users work out of the box. If your paid tier lives elsewhere (e.g. subscriptions.plan_tier), use hooks:
polarPlugin({
db,
syncBeforeCheckout: false, // skip Polar API sync before redirect (default: true)
hooks: {
/** Override paid check at checkout (default: users.tier vs tierMapping) */
async isPaidUser({ user, knex }) {
const sub = await knex('subscriptions').where('user_id', user.id).first();
return sub?.plan_tier === 'pro';
},
onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
// Sync external tables when webhooks fire
return knex('users').where('id', user.id).update(update);
},
},
syncMiddleware: {
when: (req) => req.query.tab === 'billing',
},
});
Checkout metadata sends user_id as a string (supports nanoid/UUID primary keys, not only integers).
CSP (Helmet) #
polarCspDirectives() always includes 'self' so local login/register/checkout forms keep working. Polar domains are added for hosted checkout redirects.
const { mergePolarCspDirectives } = require('webspresso/plugins/polar/src/urls');
createApp({
helmet: {
contentSecurityPolicy: {
directives: mergePolarCspDirectives({
formAction: ["'self'"],
scriptSrc: ["'self'"],
}),
},
},
});
Or rely on the plugin's built-in csp export — the plugin manager unions sources per directive (does not replace your Helmet config).
Non-goals (v1) #
- Admin UI (use Polar dashboard)
- Multi-org / team seat billing
@polar-sh/sdkdependency