Subscriptions via Polar
For SaaS-style products billing recurring USD/EUR/GBP. Polar handles VAT + chargebacks + dunning globally; the gateway records the first charge and lifecycle events.
When this guide applies
Section titled “When this guide applies”- Your product sells a subscription (monthly / yearly recurring) — not a one-shot.
- Buyer pays in USD/EUR/GBP — not IDR.
- It’s a digital product or SaaS — not a service. Services are AUP-rejected by Polar; use PayPal instead.
Examples in scope: HRDex tier subscriptions, Quay monthly/yearly plans, fotoyu Pro/Power.
Step 1: Create your Polar products
Section titled “Step 1: Create your Polar products”Polar products live on Polar’s side, NOT in the gateway. Each product team owns its own Polar OATs (one for sandbox, one for live) and uses Polar’s API or dashboard directly.
Naming convention (don’t invent your own — see Polar product naming):
<product-slug>:<plan>Examples:
fotoyu:pro,fotoyu:powerhrdex:t1-monthly,hrdex:t1-yearly,hrdex:t2-monthly, …quay:monthly,quay:yearly
Create the same product names in BOTH the sandbox org (sandbox.polar.sh) and the live org (polar.sh) so smoke tests catch shape regressions before they hit live. The Polar product UUIDs differ across orgs, so each product needs to maintain both:
POLAR_PRODUCT_PRO_SANDBOX=8250c6c9-54e3-4735-a25a-d4740b79b552POLAR_PRODUCT_PRO_LIVE=...Pick the right one based on which gateway app credentials your product is using.
Step 2: Issue a checkout
Section titled “Step 2: Issue a checkout”POST /v1/paymentsX-PAY-Key: pk_<your app's public key>X-PAY-Timestamp: <unix seconds>X-PAY-Signature: <hex hmac>Content-Type: application/json
{ "external_user_id": "<your user id>", "email": "buyer@example.com", "amount_minor": 2900, "currency": "USD", "method": "polar", "metadata": { "polar_product_id": "<polar product uuid for the chosen plan>" }, "return_url": "https://<your-domain>/payment/return?id=...", "invoice_prefix": "QUAY-MO"}Required quirks:
metadata.polar_product_idis required. Polar’sproductsarray onPOST /v1/checkouts/takes Product UUIDs, not Price UUIDs — passing a price returns422 Product does not exist. The gateway has a legacy fallback that also acceptsmetadata.polar_price_idfor older integrations, but new code should usepolar_product_id.amount_minoris cents (2900= $29.00).currencyis uppercase ISO (USD/EUR/GBP).
Response:
{ "payment_id": "abc123…", "status": "initiated", "checkout_url": "https://sandbox.polar.sh/checkout/polar_c_…", "invoice_number": "QUAY-MO-000042"}Redirect the buyer to checkout_url. Polar handles the card form, 3DS, VAT calc, and the success/failure redirect back to your return_url.
Step 3: Handle the gateway’s outbound webhook
Section titled “Step 3: Handle the gateway’s outbound webhook”Implement a POST /api/payment-webhook handler on your side. Verify HMAC-SHA256 (<ts>.<rawBody> under your webhook_secret). See Verifying webhooks for code samples.
Subscription event types you care about:
event | Action |
|---|---|
payment.succeeded | First charge cleared. Activate the subscription, set current_period_end, persist metadata.polar_subscription_id for renewal matching. |
subscription.canceled / subscription.revoked | Downgrade or cut access at current_period_end. |
The gateway enriches the outbound metadata envelope with Polar event fields you’ll need:
Outbound metadata key | Source |
|---|---|
polar_subscription_id | data.id when type=subscription.*; data.subscription_id when type=order.* |
current_period_end | data.current_period_end (RFC3339 timestamp) |
polar_event_type | original Polar event name (debugging) |
Plus your original metadata round-trips verbatim. Your handler should match subscription.active renewal events against your row by metadata.polar_subscription_id.
Step 4: Renewal handling
Section titled “Step 4: Renewal handling”The gateway does NOT create a new payment row on each renewal. The original payment row stays succeeded; renewals are tracked product-side via the subscription.active events the gateway forwards (when wired) or via your own polling.
Recommended state on your side per subscription:
create table subscriptions ( id uuid primary key, user_id uuid references users(id), plan_code text, status text, -- 'pending' | 'active' | 'canceled' | 'revoked' polar_subscription_id text, -- from metadata.polar_subscription_id current_period_end timestamptz, -- from metadata.current_period_end -- ...);On subscription.active, do update subscriptions set current_period_end = $new where polar_subscription_id = $1.
Refunds
Section titled “Refunds”Refunds go through the gateway’s refund API (POST /v1/refunds). Polar’s driver doesn’t yet implement programmatic refunds — until it does, the operator processes the refund manually in Polar’s dashboard, and the gateway picks up the resulting order.refunded event and forwards refund.succeeded to your product.
Migration plan if you’re switching from another rail
Section titled “Migration plan if you’re switching from another rail”Don’t auto-migrate active subscriptions — Polar can’t import them. Instead:
- Route NEW signups to Polar immediately.
- Existing subscribers stay on the old rail (Paddle / etc.) through their current cycle.
- At cycle end, prompt them to renew on Polar — fresh checkout, fresh
polar_subscription_id. - Track per-tenant migration state on your side (
old_active,polar_active,grace_period_until). - Plan a hard cutover date by which old-rail webhooks stop driving tier decisions.
- After cutover, ask the operator to revoke the old rail’s webhook auth.
Test the migration logic with seeded “old subscriber” rows. Don’t let them get double-charged through both rails during transition.