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:

BASH
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 #

BASH
webspresso polar:migrate
webspresso db:migrate

3. Plugin config (~3 lines) #

JS
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) #

MethodPathAuthBehavior
POST/api/v1/polar/webhookHMAC signatureProcess Polar events
GET/POST/settings/billing/checkoutsession/JWTSync → Polar checkout redirect (see POST vs GET below)
GET/settings/billing/portalsession/JWTCustomer portal redirect
GET/api/v1/billing/statussession/JWTSync + JSON billing status

Checkout: POST vs GET #

Both /settings/billing/checkout methods run the same handler:

MethodTypical useCSP 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 #

NJK
{% 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) #

JS
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():

JS
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:

RouteDefault
Webhook120 req/min (by IP)
Checkout5 req/min (by user)
Portal10 req/min (by user)
Status60 req/min (by user)
JS
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:

JS
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.

JS
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/sdk dependency
Updated