How to track Polar.sh revenue by marketing channel
Set up Polar.sh revenue tracking: tag checkout links, write metadata from your server, book revenue on order.paid, and credit renewals to the first channel.
Muzahid Maruf, FounderUpdated
On this page
- 01Where Polar.sh sales lose their source
- 02Step 1: Tag your Polar checkout links
- 03Step 2: Create the checkout on your server
- 04Step 3: Subscribe to the events that carry money
- 05Step 4: Verify signatures and respect delivery limits
- 06Step 5: Store an amount that excludes tax
- 07Step 6: Credit renewals and reverse refunds
- 08Choose which touch gets the credit
- 09Step 7: Reconcile nightly and test in the sandbox
- 10Polar.sh revenue tracking with TrackRev
Explore with AI
Opens this article inside the chosen assistant with a ready-made prompt.
Polar.sh revenue tracking works by writing the visitor's source into the checkout's metadata before they leave your site, then reading it back from the order once it is paid.
Polar copies up to 50 pairs of that metadata onto the order and the subscription, so the source rides along on every webhook and API response.
Subscriptions make the loss bigger: a $29 plan kept for 22 months pays $638, so a channel credited only for the first order gets 4.5% of what it earned.
Key takeaways
- Polar's checkout runs on its own origin, so the source must travel in checkout metadata of up to 50 pairs.
- Checkout Links have saved 5 UTM parameters plus reference_id as metadata since May 19, 2025.
- Count order.paid for revenue: a renewal sends 5 events, and its order is still pending at the third.
- Store the source against subscription_id at the first paid order, because renewals are not documented to repeat the metadata.
Where Polar.sh sales lose their source
Polar is the Merchant of Record and hosts the checkout: the buyer pays on a Polar page, reached by redirect or inside an iframe from Polar's origin when you use Embedded Checkout.
Your first-party cookie cannot follow, and a Google Analytics 4 tag on your pages sees the click but not what happens inside Polar's checkout.
| Signal | Reaches the order? | Detail |
|---|---|---|
| Visitor cookie on your domain | No | Polar's origin cannot read it |
| UTMs on your landing page | No | They stay on your URL unless your code forwards them |
| UTMs on a Polar checkout link | Yes | 5 parameters, saved since May 19, 2025 |
| reference_id on a checkout link | Yes | Saved with the UTMs |
| metadata on a created checkout | Yes | Up to 50 pairs, copied to the order and subscription |
| customer_metadata | On the customer | Read it from customer.metadata |
From Polar's Checkout Links documentation and Create Checkout Session reference, October 2026.
Step 1: Tag your Polar checkout links
A Polar Checkout Link accepts 5 UTM parameters and reference_id as query parameters, and Polar saves them in the metadata of each session the link creates.
Polar's changelog dates the feature to May 19, 2025, and the Checkout Links documentation says link and session metadata propagate to the order and subscription.
Keep 1 link per product and vary the query string by placement, and use the free UTM builder so "newsletter" and "Newsletter" never become 2 sources.
If a tagged ad lands on a page whose Buy button uses the plain link, the tags are lost, so append saved UTMs to each checkout link, or use step 2.
CHECKOUT_LINK_URL?utm_source=newsletter&utm_medium=email&utm_campaign=oct-launch&reference_id=issue-42Step 2: Create the checkout on your server
When your own code starts the purchase, call POST /v1/checkouts/ with a checkouts:write token and put the source in metadata.
// app/api/checkout/route.ts (Next.js route handler)import { cookies } from "next/headers"; const KEYS = ["vid", "utm_source", "utm_medium", "utm_campaign", "landing_path"]; export async function POST(req: Request) { const { productId } = await req.json(); const jar = await cookies(); // Polar rejects empty strings, so leave missing keys out const metadata: Record<string, string> = {}; for (const key of KEYS) { const value = jar.get(key)?.value; if (value) metadata[key] = value.slice(0, 500); } const res = await fetch("https://api.polar.sh/v1/checkouts/", { method: "POST", headers: { Authorization: "Bearer " + process.env.POLAR_ACCESS_TOKEN, "Content-Type": "application/json", }, body: JSON.stringify({ products: [productId], success_url: "https://example.com/welcome?checkout_id={CHECKOUT_ID}", metadata, }), }); const checkout = await res.json(); return Response.json({ url: checkout.url });}The Create Checkout Session reference sets these limits, so store a visitor ID and a few parsed UTM values, since a long landing URL can overrun 500 characters.
| Rule | Limit | What it means |
|---|---|---|
| Pairs per object | 50 | A visitor ID, 3 UTM fields and a landing path use 5 |
| Key length | 40 characters | utm_campaign takes 12 |
| String value | 1 to 500 characters | An empty string gets the whole request rejected |
| Nested objects | None | Values are strings, integers, numbers or booleans |
| Field name | metadata or customer_metadata | The first lands on the order and subscription, the second on the customer |
Limits from Polar's API reference, October 2026.
Step 3: Subscribe to the events that carry money
Add an endpoint under Webhooks in your organization's settings, with the Raw format and a secret. Of the 30-plus events in Polar's webhook events documentation, attribution needs order.paid and order.refunded, plus subscription.created and subscription.canceled if you report churn by channel.
Skip order.created and subscription.cycled: a renewal sends 5 events in the order below, its order is still pending at event 3, and event 5 is the one to book. A failed charge moves the subscription to past_due instead.
| # | Event | Order status | Book revenue? |
|---|---|---|---|
| 1 | subscription.cycled | n/a | No |
| 2 | subscription.updated | n/a | No |
| 3 | order.created | pending | No |
| 4 | order.updated | paid | No, order.paid follows |
| 5 | order.paid | paid | Yes |
A renewal, as sequenced in Polar's webhook events documentation. Counting order.paid alone books each payment once.
The handler below uses API version 2026-10 and version 1.0.2 of the @polar-sh/sdk package.
// app/api/webhooks/polar/route.tsimport { webhooks } from "@polar-sh/sdk/2026-10"; export async function POST(req: Request) { const body = await req.text(); let event: Awaited<ReturnType<typeof webhooks.validateEvent>>; try { event = await webhooks.validateEvent( body, { "webhook-id": req.headers.get("webhook-id") ?? "", "webhook-timestamp": req.headers.get("webhook-timestamp") ?? "", "webhook-signature": req.headers.get("webhook-signature") ?? "", }, process.env.POLAR_WEBHOOK_SECRET!, ); } catch (error) { // An event type this SDK version does not know is not a bad signature if (error instanceof webhooks.PolarWebhookUnknownTypeError) { return new Response(null, { status: 202 }); } return new Response(null, { status: 403 }); } if (event.type === "order.paid") { const o = event.data; await upsertRevenue({ orderId: o.id, // unique key, so redeliveries do not duplicate subscriptionId: o.subscription_id, billingReason: o.billing_reason, netCents: o.net_amount, currency: o.currency, email: o.customer.email, source: o.metadata.utm_source ?? null, visitorId: o.metadata.vid ?? null, }); } if (event.type === "order.refunded") { await recordRefund(event.data.id, event.data.refunded_amount); } return new Response(null, { status: 202 });}Step 4: Verify signatures and respect delivery limits
Per Polar's webhook delivery guide, each delivery is signed with the Standard Webhooks headers.
Secrets generated on or after September 8, 2026 at 00:00 UTC follow that spec, and older secrets use Polar's original HMAC scheme with the full whsec_ string as the key.
Polar's SDK tries both keys, while a generic library needs an older secret base64-encoded first.
| Rule | Value | Plan for it by |
|---|---|---|
| Retries per failed delivery | 10, with exponential backoff | Making the handler idempotent on order ID |
| Request timeout | 10 seconds | Answering within 2 seconds and queuing the work |
| Endpoint disabled after | 10 consecutive non-2xx responses | Alerting yourself, since Polar emails members only after disabling |
| Responses | 202 accepted, 403 bad signature | Returning 202 for event types you skip, as in the handler |
| Recovery | Re-enable, then redeliver | Using the delivery page, plus the step 7 comparison |
Delivery behavior from Polar's webhook delivery guide, October 2026.
Step 5: Store an amount that excludes tax
Polar collects tax as Merchant of Record, so total_amount holds money that was never yours. For channel reports I'd store net_amount, which reflects a discount a campaign gave away and leaves tax out.
Polar's fees documentation works through a $30 purchase from Sweden, and the table gives the same amounts as order fields.
| Order field | Amount | Contents |
|---|---|---|
| subtotal_amount | $30.00 | Price before discounts and tax |
| net_amount | $30.00 | After discounts, before tax |
| tax_amount | $7.50 | 25% Swedish VAT |
| total_amount | $37.50 | What the buyer paid |
| Starter plan fee | $2.94 | 5% + 50¢ |
| Pro plan fee | $2.39 | 3.8% + 40¢ |
| Growth plan fee | $2.26 | 3.6% + 35¢ |
| Scale plan fee | $2.14 | 3.4% + 30¢ |
Fees apply to the $37.50 total and include the 1.5% international-card surcharge, which adds $0.56. On Starter, 5% of $37.50 plus 50¢ is $2.38. Organizations created before May 27, 2026 can still be on the Early Member rate of 4% + 40¢, plus 0.5% on subscription payments. Fees leave net_amount unchanged.
Step 6: Credit renewals and reverse refunds
Polar's documentation says checkout metadata is copied to the order and the subscription and does not say renewal orders repeat it, so save the source against subscription_id at the first paid order and credit every order with that ID to it.
Skip that and the $29 plan earns its channel $29 of $638, leaving $609 with no source (see subscription LTV attribution).
| billing_reason | Happens when | Credit it to |
|---|---|---|
| purchase or subscription_create | A one-time sale or a subscription's first payment | The order's own metadata, saved with subscription_id |
| subscription_cycle or subscription_update | A renewal or a prorated plan change | The source saved for that subscription_id |
2 groups among the 4 billing reasons in Polar's orders documentation.
order.refunded covers full and partial refunds, moving the order to refunded or partially_refunded with refunded_amount recording how much, so a $10 refund on a $29 order stores 1,000 and the channel keeps $19.
Per Polar's refund documentation the refundable maximum is the net amount, and Polar may refund a lower-value order itself within 60 days of purchase to head off a chargeback.
Refunding a subscription's order leaves the subscription running, so handle cancellations separately.
Choose which touch gets the credit
Whatever you write at checkout is a fixed answer. UTMs from a first visit give you first-touch attribution, and UTMs from the visit that clicked Buy give you last-touch, and you can store both under separate keys.
A split across several touches needs a visitor ID joined to a click log you keep, and the comparison of last-touch, first-touch and linear models covers when each fits.
In this example a buyer clicks a YouTube link, then a Hacker News post, then returns directly to buy the $29 plan and keeps it 22 months.
| Model | YouTube link | Hacker News post | Direct visit |
|---|---|---|---|
| First-touch | $638.00 | $0 | $0 |
| Last-touch | $0 | $0 | $638.00 |
| Linear | $212.67 | $212.67 | $212.67 |
Credit for $638 of lifetime revenue. Linear splits it 3 ways.
Step 7: Reconcile nightly and test in the sandbox
Handlers break after deploys, so compare orders nightly.
List Orders needs the orders:read scope, returns 10 orders per page by default, up to 100, and filters by metadata, so 1 call per source value, summing net_amount for orders whose status is paid, gives a channel report with no extra tooling.
Amounts are in cents, so 40 newsletter orders of 2,900 sum to 116,000, or $1,160.
curl -G "https://api.polar.sh/v1/orders/" \
-H "Authorization: Bearer $POLAR_ACCESS_TOKEN" \
--data-urlencode "metadata[utm_source]=newsletter" \
--data-urlencode "limit=100"Polar's sandbox is a separate environment with its own account, organization and token, served from sandbox-api.polar.sh, and production tokens fail there. Pay with Stripe's test card 4242 4242 4242 4242 and a future expiry, then check 3 things.
- The order.paid delivery in the sandbox webhook settings holds your metadata keys, spelled exactly as you wrote them.
- The net_amount and tax_amount match the checkout page.
- A refund produces order.refunded, and redelivering order.paid leaves 1 row for that order.
Polar.sh revenue tracking with TrackRev
TrackRev is SaaS affiliate software with revenue attribution built in, so it does the join and the renewal bookkeeping for Polar.sh orders.
You connect Polar with a read-only Organization Access Token with 3 scopes plus your Organization Identifier, and TrackRev asks for order.paid and order.refunded webhooks.
It joins an order to a click through a visitor ID: API checkouts take it as metadata.vid, as in step 2, and its pixel adds a vid parameter to links that point at buy.polar.sh.
Without a vid it falls back to the buyer's email. TrackRev pulls orders over the read-only API after verifying a webhook, so replays cannot double count, and an hourly sync covers workspaces with no webhook.
| Plan | Price | Revenue figures | Limits |
|---|---|---|---|
| Free | $0 | Hidden | 50 links and 1,000 events a month |
| Indie | $29 a month | Shown | 1 workspace, 1 tracked domain, owner only |
| Growth | $299 a year | Shown | Up to 10 workspaces and tracked domains, unlimited team members |
Plans from TrackRev's pricing page, October 2026. Polar revenue sync is part of every paid plan, and no plan caps the revenue or commission tracked.
With 1 or 2 channels and a few orders a week, tagged links and a script over the Orders API are enough.
TrackRev earns its price when you want click-level journeys with several touches per buyer, or affiliate commissions from the same orders.
The same plans read Stripe, Paddle Billing, Lemon Squeezy, Creem and Dodo Payments, and Indie at $29 a month and Growth at $299 a year put no limit on the revenue tracked (pricing).
Found this useful? Share it.
Frequently asked questions
- Yes, on checkout links since May 19, 2025: 5 UTM parameters plus reference_id. For server-created checkouts you copy the values into metadata yourself.
- Usually the Buy button used an untagged link, the code wrote customer_metadata, or a key was misspelled. A renewal order may also lack it.
- Use order.paid, because a renewal order starts as pending and a failed charge never pays it.
- Up to 50 pairs, with keys of 40 characters or fewer and string values of 1 to 500 characters.
- Not as documented: its analytics page lists no channel split, but List Orders accepts a metadata filter.
- Yes. Tick the sandbox option and use a token from sandbox.polar.sh with the sandbox organization's identifier, since the sandbox host rejects a live token.

Written by
Founder, TrackRev.io & Contant.io
Muzahid Maruf founded TrackRev.io, SaaS affiliate software with no limit on tracked revenue, and Contant.io. He writes about affiliate programs.
Writes about Marketing attribution · Link tracking · Revenue analytics · SaaS growth
Stop guessing where your revenue comes from.
Set up TrackRev in about five minutes. The free plan covers 1,000 events a month, no card needed.
Start free