Skip to content

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.

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

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:power
  • hrdex: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-d4740b79b552
POLAR_PRODUCT_PRO_LIVE=...

Pick the right one based on which gateway app credentials your product is using.

POST /v1/payments
X-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_id is required. Polar’s products array on POST /v1/checkouts/ takes Product UUIDs, not Price UUIDs — passing a price returns 422 Product does not exist. The gateway has a legacy fallback that also accepts metadata.polar_price_id for older integrations, but new code should use polar_product_id.
  • amount_minor is cents (2900 = $29.00). currency is 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:

eventAction
payment.succeededFirst charge cleared. Activate the subscription, set current_period_end, persist metadata.polar_subscription_id for renewal matching.
subscription.canceled / subscription.revokedDowngrade or cut access at current_period_end.

The gateway enriches the outbound metadata envelope with Polar event fields you’ll need:

Outbound metadata keySource
polar_subscription_iddata.id when type=subscription.*; data.subscription_id when type=order.*
current_period_enddata.current_period_end (RFC3339 timestamp)
polar_event_typeoriginal 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.

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

  1. Route NEW signups to Polar immediately.
  2. Existing subscribers stay on the old rail (Paddle / etc.) through their current cycle.
  3. At cycle end, prompt them to renew on Polar — fresh checkout, fresh polar_subscription_id.
  4. Track per-tenant migration state on your side (old_active, polar_active, grace_period_until).
  5. Plan a hard cutover date by which old-rail webhooks stop driving tier decisions.
  6. 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.