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 — Founder of TrackRev.io

Muzahid MarufUpdated

Revenue attribution · 7 min read
On this page
  1. 01Where Polar.sh sales lose their source
  2. 02Step 1: Tag your Polar checkout links
  3. 03Step 2: Create the checkout on your server
  4. 04Step 3: Subscribe to the events that carry money
  5. 05Step 4: Verify signatures and respect delivery limits
  6. 06Step 5: Store an amount that excludes tax
  7. 07Step 6: Credit renewals and reverse refunds
  8. 08Choose which touch gets the credit
  9. 09Step 7: Reconcile nightly and test in the sandbox
  10. 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.

SignalReaches the order?Detail
Visitor cookie on your domainNoPolar's origin cannot read it
UTMs on your landing pageNoThey stay on your URL unless your code forwards them
UTMs on a Polar checkout linkYes5 parameters, saved since May 19, 2025
reference_id on a checkout linkYesSaved with the UTMs
metadata on a created checkoutYesUp to 50 pairs, copied to the order and subscription
customer_metadataOn the customerRead it from customer.metadata

From Polar's Checkout Links documentation and Create Checkout Session reference, October 2026.

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.

A tagged checkout link
CHECKOUT_LINK_URL?utm_source=newsletter&utm_medium=email&utm_campaign=oct-launch&reference_id=issue-42

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

Server: create a Polar checkout with attribution 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.

RuleLimitWhat it means
Pairs per object50A visitor ID, 3 UTM fields and a landing path use 5
Key length40 charactersutm_campaign takes 12
String value1 to 500 charactersAn empty string gets the whole request rejected
Nested objectsNoneValues are strings, integers, numbers or booleans
Field namemetadata or customer_metadataThe 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.

#EventOrder statusBook revenue?
1subscription.cycledn/aNo
2subscription.updatedn/aNo
3order.createdpendingNo
4order.updatedpaidNo, order.paid follows
5order.paidpaidYes

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.

Webhook handler: verify, then record paid and refunded orders
// 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.

RuleValuePlan for it by
Retries per failed delivery10, with exponential backoffMaking the handler idempotent on order ID
Request timeout10 secondsAnswering within 2 seconds and queuing the work
Endpoint disabled after10 consecutive non-2xx responsesAlerting yourself, since Polar emails members only after disabling
Responses202 accepted, 403 bad signatureReturning 202 for event types you skip, as in the handler
RecoveryRe-enable, then redeliverUsing 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 fieldAmountContents
subtotal_amount$30.00Price before discounts and tax
net_amount$30.00After discounts, before tax
tax_amount$7.5025% Swedish VAT
total_amount$37.50What the buyer paid
Starter plan fee$2.945% + 50¢
Pro plan fee$2.393.8% + 40¢
Growth plan fee$2.263.6% + 35¢
Scale plan fee$2.143.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_reasonHappens whenCredit it to
purchase or subscription_createA one-time sale or a subscription's first paymentThe order's own metadata, saved with subscription_id
subscription_cycle or subscription_updateA renewal or a prorated plan changeThe 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.

ModelYouTube linkHacker News postDirect 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.

Orders tagged with one source
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.

PlanPriceRevenue figuresLimits
Free$0Hidden50 links and 1,000 events a month
Indie$29 a monthShown1 workspace, 1 tracked domain, owner only
Growth$299 a yearShownUp 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.

PostLinkedIn

Frequently asked questions

Muzahid Maruf — Founder of TrackRev.io

Written by

Muzahid Maruf

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