Polar
Merchant-of-record billing for software — Polar is the seller of record and handles sales tax, so there is no direct charge endpoint and every purchase starts at a hosted checkout.
Polar sells your software on your behalf. It is the merchant of record: the legal seller on the customer's statement, and the party that registers for, calculates and remits sales tax and VAT worldwide. You get paid net. That is a business arrangement, not a library feature — this package does not model it, and choosing Polar over Stripe is a decision about who carries the tax liability, not about which API is nicer.
It shapes the driver, though. A merchant of record has to control the purchase surface, so Polar has no "charge this card for this amount" endpoint. Prices live on products you create in Polar, and money moves through Polar's own checkout.
- Methods: credit_card, undefined. Polar's checkout takes cards plus the wallets and
local methods it enables per country (Apple Pay, Google Pay, iDEAL, Bancontact, BLIK,
EPS, Przelewy24, Bizum, UPI). There is no API field to request one — the create
payload has no payment-method parameter at all, and Polar picks what to show from the
customer's location. So
methodon a charge changes nothing here, and the driver only claims what it can honestly route. No Pix and no boleto:brlis a valid presentment currency, but nothing in the API reference produces either instrument. - Setup:
payments.polar({ accessToken, currency, sandbox, webhookSecret })—POLAR_ACCESS_TOKEN(an Organization Access Token,polar_oat_...).currencyis required (lowercase ISO 4217): Polar bills in whatever you hand it, so a default would be a guess at the app's country and a wrong guess charges instead of failing.sandboxdefaults toNODE_ENV !== 'production'and switches the host tohttps://sandbox-api.polar.sh— sandbox tokens are not interchangeable with production ones, because the two environments hold separate organizations. idempotencyKey: sent as Polar'sIdempotency-Keyrequest header. Polar documents it for mutating requests (POST,PATCH,DELETE) and deduplicates on nothing else, so a key written intometadatawould be echoed back and protect nothing. The driver sends it oncreateCheckout,refund,createCustomer,createSubscriptionandupdateSubscription; omitting the key omits the header entirely.- API version: pinned to
2026-04via thePolar-Versionheader on every request. Polar defaults to that version when the header is absent, so pinning only protects you from the day it promotes a new default. - Webhooks: Standard Webhooks —
webhook-id/webhook-timestamp/webhook-signature, base64 HMAC-SHA256 over{id}.{timestamp}.{body}, with a ±5 minute replay window and support for the space-separated rotation list. NeedswebhookSecret(POLAR_WEBHOOK_SECRET). externalReference: written tometadata.external_referenceon the checkout and on a subscription, and read back out of the same key. Polar copies checkout metadata onto the order and the subscription it produces, which is what makes it survive to the webhook. It is the only thing tying anorder.paidback to your own row.- Subscriptions: created by a checkout.
createSubscription()callsPOST /v1/subscriptions/, which Polar allows only for free products — a paid plan has to go through checkout so Polar can collect and validate the payment method. - Invoices: Polar issues one per paid order and puts
invoice_numberon the order itself.listInvoices(customerId)maps a customer's orders;hostedPdfUrlis left unset because the PDF sits behind a separate per-order call.
Webhook verification is required at boot
This driver reports webhookVerification: 'unconfigured' — and the app refuses to boot — when
webhookSecret is not configured (env fallback POLAR_WEBHOOK_SECRET).
Set it before you deploy, or — only when verification genuinely happens upstream — name this
provider in allowUnverifiedWebhooks. An empty credential slot is not "skip verification": with nothing to check against,
POST /payments/webhook/polar would accept any body anyone posted to it — including one
that marks a payment paid. The app refuses to start instead. See Configuration.
What it refuses
Every one of these throws a [payments] error rather than reporting a change Polar never
made.
charge()— there is no direct charge endpoint. Polar does have a two-step off-session route (POST /v1/orders/for a draft, thenPOST /v1/orders/{id}/finalize), but it is gated behind theoff_session_charges_enabledpreview flag, is paid-plan only, and needs a saved payment method on the customer. The driver does not pretend that is the general case. UsecreateCheckout().createSubscription({ amount })and{ cycle }— the price and the interval belong to the Polar product. Silently dropping anamountwould bill a figure nobody chose.createSubscription({ trialDays })— Polar sets trials on the checkout (trial_interval), not on the subscriptions endpoint. PasstrialDaystocreateCheckout().createSubscription({ card })— there is no tokenized-card input. A merchant of record collects the card itself.updateSubscription({ amount })and{ description }— a Polar subscription has neither field. See the plan switch below.
Checkout is the way in
const session = await payments.driver('polar').createCheckout({
planId: 'prod_01HX...', // a Polar product id
amount: 1990, // cents; ignored unless the price is pay-what-you-want
successUrl: 'https://app.test/thanks?checkout_id={CHECKOUT_ID}',
cancelUrl: 'https://app.test/pricing',
customerId: 'cus_01HX...',
externalReference: 'order:1234',
})
return response.redirect(session.url)amount is sent as Polar's amount, whose reference says: "Amount in cents, before
discounts and taxes. Only useful for custom prices, it'll be ignored for fixed and free
prices." So it sets the figure on a pay-what-you-want product and is discarded on a fixed
one — it never overrides a product price.
cancelUrl maps to Polar's return_url. Polar has no cancel_url; return_url puts
a back button on the checkout page pointing at that URL, which is the closest equivalent
the API offers.
Orders are the payments
Polar has no "payment" resource in the sense this library means. An order is the record
of a single paid transaction, so that is what the driver maps onto Payment —
findPayment(orderId), and refund() takes an order id.
Payment.amount is the order's total_amount: after discounts, including tax. All
Polar amounts are already integers in the currency's smallest unit, so nothing is converted
on the way in or out.
Refunds
refund(orderId) with no amount reads the order's refundable_amount and refunds exactly
that, rather than assuming the total. The distinction matters: Polar's refund amount is
the net figure excluding tax, and Polar refunds the tax alongside it — in full for a
full refund, prorated for a partial one. Passing total_amount would over-refund.
Partial refunds work: refund(orderId, 500). The reason is sent as other, because the
driver is not told why and Polar's enum has no merchant-initiated member.
Switching plans
Polar changes what a customer pays by moving them to a different product. The shared
UpdateSubscriptionInput has no field for that, so it rides in metadata:
await payments.driver('polar').updateSubscription('sub_01HX...', {
metadata: {
productId: 'prod_enterprise',
prorationBehavior: 'prorate', // invoice | prorate | next_period | reset
},
})Cancelling
cancelSubscription(id) sets cancel_at_period_end — the subscription stays active and
keeps its benefits until the paid period runs out. cancelSubscription(id, { atPeriodEnd: false }) issues DELETE /v1/subscriptions/{id}, Polar's revoke: access ends now, there
is no refund, and it cannot be undone.
Webhook events
Mapped onto the canonical types:
| Polar event | Normalized |
|---|---|
order.paid | payment.succeeded |
order.refunded, refund.created | payment.refunded |
order.created, order.updated, refund.updated | payment.updated |
subscription.created | subscription.created |
subscription.active, .updated, .uncanceled, .past_due, .cycled, .paused, .resumed | subscription.updated |
subscription.canceled, subscription.revoked | subscription.canceled |
Everything else (checkout.*, customer.*, benefit_grant.*, product.*, discount.*,
organization.*) passes through under its Polar name, so an app handler can subscribe to
it directly.
Two behaviours worth knowing, because they are not obvious:
- The event id lives in the
webhook-idheader, not the body. Polar's payload is{ type, timestamp, data }with no id in it. The driver uses the header, which is what the idempotency ledger deduplicates on. subscription.canceleddoes not mean it ended. Polar fires it when a cancellation is scheduled; the subscription is stillactivewithcancel_at_period_end: true.subscription.revokedis the one that fires when access actually stops. Both normalize tosubscription.canceled, so readevent.rawif you need to tell them apart.
A refund event is keyed by its order id, not the refund id — the order is the row your handler is holding.
Paused subscriptions are paused, not active
subscription.paused and a paused status from findSubscription now report
Subscription.status === 'paused'. Reporting it as active would hand entitlement to a
subscriber who is not paying: a paused subscription exists and will bill again, but it is
not billing now. Anything gating access on status === 'active' will start refusing
paused subscribers, which is the point. The raw value is still on
subscription.payload.status.
Disputes: your merchant of record absorbs them
Polar has no dispute or chargeback event, and this driver does not invent one. That is not a gap in the integration — it is the deal. The two questions every other provider page answers both have the same answer here:
- Who fights the dispute? Polar. As merchant of record Polar is the seller on the buyer's
statement, so the chargeback is raised against Polar and Polar runs it end to end —
including Rapid Dispute Resolution to settle disputes before they escalate.
capabilities.disputesisfalseand there is nosubmitDisputeEvidence, because there is nothing to submit. What you carry is the cost: "$15 fee per incident, deducted directly from the merchant balance… non-refundable regardless of the dispute outcome." - What reaches you? Nothing dispute-shaped. Polar's event catalogue is checkouts,
customers, orders, refunds, subscriptions, benefit grants, benefits, products, discounts
and the organization; none of it is a dispute. Nor is there a dispute status to read: an
Order is
pending,paid,refunded,partially_refundedorvoid, and none of those means charged back.
The first you hear of a chargeback on Polar is your balance. There is no
actionableUntil on this provider because there is no window that belongs to you and no
event that would carry one.
So payment.disputed, payment.dispute_warning and payment.dispute_closed never fire on
this provider — there is a test asserting that no Polar event produces any of them, so a
well-meaning future mapping cannot quietly add one.
If your application has a "revoke access on chargeback" path, order.refunded /
refund.created is what drives it here — and note that this is not purely a fallback:
Polar's RDR auto-refunds a dispute, so a chargeback genuinely can arrive as an ordinary
refund. It stays payment.refunded, because that is what happened to the money. Forcing an
unrelated event into payment.disputed would invent a notification that does not exist.
The webhook secret is used verbatim
Polar's secret derivation is not the Standard Webhooks default. @polar-sh/sdk
base64-encodes the secret before handing it to the reference library, which base64-decodes
it right back — so the HMAC key is the raw UTF-8 bytes of the secret string, whsec_
prefix and all. It is not stripped and not base64-decoded. (Dodo Payments, on the
same spec, does the opposite.) If you verify Polar webhooks yourself somewhere else, this
is the detail that will cost you an afternoon.