Blog
7 min read

How to Track Paddle Revenue by Marketing Channel (2026)

Paddle's cross-domain checkout breaks cookie tracking for 100% of Paddle transactions. How to use Paddle's passthrough parameter to attribute every payment to its marketing source.

TrackRev

How to Track Paddle Revenue by Marketing Channel (2026)

Paddle's cross-domain checkout breaks cookie tracking for 100% of Paddle transactions. How to use Paddle's passthrough parameter to attribute every payment to its marketing source.

Paddle's checkout happens on checkout.paddle.com — a different domain from your site — which breaks standard cookie-based attribution for 100% of Paddle transactions unless you use the passthrough parameter to carry a session ID across the domain boundary. Paddle attribution works similarly to Lemon Squeezy: capture a session ID on your landing page, pass it through Paddle Checkout via the customData (or legacy passthrough) field, then read it back from the transaction.completed webhook to match the charge to the originating click. Paddle handles the tax/MoR side, but the attribution side is on you. This guide walks the setup, the webhook events that matter, and how Paddle compares to Stripe and Lemon Squeezy on attribution complexity.

Key takeaway

Paddle's customData field carries the TrackRev visitor ID through their hosted, MoR checkout intact. The webhook delivers it back on transaction.completed, so every subscription event maps to the originating marketing source — no second join required. Connect Paddle to TrackRev to wire this in without touching the checkout SDK.

The Paddle attribution gap

Paddle is the Merchant of Record for many SaaS products — it handles tax, compliance, refunds, and global payment methods on your behalf. The trade-off is that checkout runs on checkout.paddle.com (or a Paddle-managed domain), which is a different domain from your marketing site. First-party cookies set on yourbrand.com do not follow the visitor into Paddle's checkout.

The exact mechanics of the gap: click lands on your page → first-party cookie is set with the session ID → visitor clicks "Buy Now" → browser redirects to checkout.paddle.com → your cookie is left behind on the original domain → Paddle processes payment → the webhook fires with no attribution data attached. The Paddle dashboard then shows transactions, customers, and revenue — but not which marketing channel drove each sale, because that information never made the cross-domain trip.

Definition: Paddle's passthrough (or customData in Paddle Billing) is a string or object parameter you can attach to any checkout session that Paddle will return unchanged in the webhook payload. It is the supported bridge for attribution — the way you carry the session ID across the domain boundary without depending on cookie state at checkout completion time.

The cross-domain redirect lifecycle

Every Paddle purchase walks through the same six-stage lifecycle: marketing click → landing on your domain → first-party cookie set with the session ID → "Buy now" tap → 302 redirect to checkout.paddle.com → payment completion and webhook fire. The cookie set in stage three is bound to your origin, so it is no longer accessible the moment the browser hits stage five. Any attribution decision that depends on reading that cookie after the redirect is structurally impossible — the data is not there. The whole reason the passthrough parameter exists is to carry the session ID through the gap created by stages four and five.

Why standard cookies cannot survive the boundary

First-party cookies are scoped to the origin that set them, not to the user's session — that scoping is a browser security guarantee, not a Paddle quirk. Setting SameSite=None; Secure does not help, because the cookie is still bound to yourbrand.com rather than checkout.paddle.com. There is no browser configuration, polyfill, or Paddle dashboard toggle that would make a cookie on your domain readable from Paddle's domain. The passthrough field is the only sanctioned mechanism — anything else either depends on third-party cookies (already blocked by Safari and Firefox) or fingerprinting (legally fraught and unreliable).

Paddle's passthrough mechanism

Paddle has supported a passthrough/customData field for years across both major versions of the platform. Technically the field is either a URL parameter (when redirecting to a hosted checkout link) or a JS API parameter (when using Paddle.Checkout.open inline), accepts up to ~1,000 characters of payload, and returns unchanged in every webhook event for the lifetime of the resulting subscription or transaction.

Which field name to use depends on your Paddle account version. If you created your Paddle account after October 2022, you are on Paddle Billing (the v2 API) and should use customData — an arbitrary JSON object. Older accounts are on Paddle Classic and use passthrough — a string, typically a JSON-encoded blob you parse on the webhook side. Mixing the two is the single most common cause of broken Paddle attribution: passthrough on a Billing account is silently ignored, and customData on a Classic account simply doesn't exist.

Use it to carry the visitor ID and any UTM context you want preserved across the cross-domain checkout boundary. A typical payload looks like { vid: "abc123", utm_source: "newsletter", utm_campaign: "q2-launch" } on Billing, or the same data JSON-encoded into a string on Classic.

Setting passthrough on a Paddle Classic checkout URL
const passthrough = JSON.stringify({
  vid: visitorId,
  utm_source: utmSource,
});
const checkoutUrl =
  `https://buy.paddle.com/product/PRODUCT_ID?passthrough=${encodeURIComponent(passthrough)}`;

Paddle Billing vs Paddle Classic field naming

The Billing API takes a typed JSON object on customData while Classic takes an opaque string on passthrough. The practical implication is that on Billing you write customData: { vid } and the webhook receives a parsed object; on Classic you write passthrough: JSON.stringify({ vid }) and the webhook receives a string you have to JSON.parse yourself. Picking the wrong field name on the wrong account version is the most common silent failure: Billing ignores passthrough entirely, and Classic does not recognise customData as a parameter. Always check your account creation date before writing the integration.

Payload structure for visitor + UTM data

Keep the passthrough payload small and flat: a session ID, plus the two or three UTM keys that matter (utm_source, utm_campaign, utm_medium). Avoid nesting because Classic's string serialization makes nested objects fragile, and Billing's object schema does not gain anything from depth. Treat the field as a routing key, not a full event payload — the real attribution data lives in your own database, joined by the visitor ID. A 100-byte payload is plenty for attribution; anything larger is usually a sign the wrong data is being threaded through checkout.

The three-step setup

Step 1 — Create a tracking link per channel. Each marketing channel (newsletter, YouTube description, partner referral, paid social campaign) gets its own short link with UTM values baked in. The pixel on your landing page reads those UTM values and writes them into a first-party cookie along with a session ID. Without this step there is no source value to record — the cookie has nothing to carry — and every conversion shows up as (direct) regardless of where the click actually originated.

Step 2 — Read the session ID on your landing page and pass it to Paddle Checkout as customData. Read the visitor ID from your first-party cookie at checkout time and include it in the Paddle Checkout config. On Paddle Billing this means setting customData: { vid: cookieValue } in the Paddle.Checkout.open call; on Paddle Classic it means JSON-encoding the data into the passthrough URL parameter on the buy-link. The crucial detail is reading the cookie at the moment of checkout creation — not earlier, when it might not be set, and not client-side after redirect, when it's already gone.

Step 3 — Read custom_data from the transaction.completed webhook and match to the click record. The webhook fires every time a payment succeeds — first charge, renewals, upgrades — and carries the customData blob through unchanged. Your handler parses the vid, looks up the originating click in your attribution store, and credits the channel that earned the conversion. The same handler runs for every recurring charge afterward, so LTV per channel updates automatically without a separate reconciliation step.

The cookie read has to happen at the exact moment you build the Paddle Checkout config — not earlier in the page lifecycle and not after the redirect. On a single-page app the visitor ID may be unset for the first few hundred milliseconds while the pixel finishes binding, so a value cached in a module-level constant on page load is often empty or stale. Read document.cookie inside the click handler that opens Paddle.Checkout, immediately before you call Paddle.Checkout.open({ customData }). That guarantees the final settled visitor ID is what gets threaded into the checkout session.

Handling renewals through the same handshake

The customData you set on the first checkout sticks to the resulting subscription for its entire lifetime. Every renewal fires another transaction.completed webhook with the same custom_data payload attached, which is what lets LTV per channel update automatically without a re-attribution step. The implication: pick a stable schema upfront, because changing the field shape later means renewals on old subscriptions still carry the old structure. A flat { vid, utm_source, utm_campaign } object handles both the first charge and the renewals that follow without further work.

Field-length limits on customData

Paddle Billing's customData object accepts up to roughly 4KB of JSON; Paddle Classic's passthrough string caps closer to 1KB after URL encoding. Stuffing a full UTM dictionary plus referrer plus campaign metadata into the field works for Billing but truncates silently on Classic — and truncated JSON parses as an empty object on your webhook. Keep the payload to a visitor ID plus at most a handful of UTM keys.

Passing customData into Paddle Checkout

Client: Paddle Checkout with customData
// Read the first-party visitor cookie
const vid = document.cookie
  .split("; ")
  .find((row) => row.startsWith("vid="))
  ?.split("=")[1];

// Open Paddle Checkout with customData attached
Paddle.Checkout.open({
  items: [{ priceId: "pri_01h...", quantity: 1 }],
  customData: {
    vid: vid ?? null,
  },
  customer: {
    email: user.email,
  },
});

Verifying Paddle's webhook signature

Paddle Billing signs every webhook with an HMAC in the Paddle-Signature header (timestamped, similar to Stripe). Verify it against your notification secret before parsing the body — an unverified handler will happily attribute attacker-crafted transactions to whatever channel they send. Paddle Classic uses a legacy public-key signature scheme on the p_signature field; the verification is different but the requirement is the same.

Reading customData from the Paddle webhook

Webhook handler: read customData on transaction.completed
// POST /api/paddle/webhook
const event = JSON.parse(rawBody);

if (event.event_type === "transaction.completed") {
  const vid = event.data?.custom_data?.vid;
  const email = event.data?.customer?.email;
  const revenue = parseFloat(event.data?.details?.totals?.total ?? "0") / 100;

  attributeTransaction({ vid, email, revenue });
}

Common mistakes in Paddle attribution setups

Three mistakes account for nearly every broken Paddle attribution we see when teams reach out for help. All three have the same surface symptom — conversions appearing as (direct) when they should be attributed — and all three are quick to fix once you know what to look for.

Mistake 1: Not URL-encoding the session ID in the passthrough parameter (Paddle Classic). Because passthrough is a URL query parameter, any unescaped ampersand or special character in the payload truncates the value. The classic failure: serialising a JSON object with JSON.stringify and dropping it straight into the URL — the inner quotes and braces break the parse on Paddle's side, the field arrives empty, and the webhook fires with no attribution. Wrap every passthrough payload in encodeURIComponent() on the client and decodeURIComponent() + JSON.parse() on the webhook.

Mistake 2: Using the Paddle Classic passthrough field on a Paddle Billing account. If your account was created after October 2022, you are on Paddle Billing — and Paddle Billing silently ignores passthrough. The data goes nowhere, the webhook arrives with an empty custom_data, and the only signal anything is wrong is the attribution gap. Use customData (object, not string) on Billing accounts. If you inherited integration code from a Classic-era Paddle setup, audit the field name first.

Mistake 3: Listening to the wrong webhook event. The correct event for payment completion on Paddle Billing is transaction.completed. payment.created fires when the payment is initiated, before the charge has actually succeeded — listening for it means you'll attribute conversions that later fail, refund, or get blocked by 3DS. subscription.created fires once per subscription, missing all subsequent renewal revenue. Always handle transaction.completed as the primary attribution signal.

Detecting silent passthrough truncation

When the passthrough payload exceeds Classic's ~1KB cap, Paddle truncates the string with no error returned at checkout creation time — the truncation only surfaces when your webhook handler tries to JSON.parse the resulting fragment and throws. Add a length assertion in your client code: if encodeURIComponent(passthrough).length exceeds 950 bytes, drop the lowest-priority keys (typically utm_term or utm_content) before sending. Logging a warning when the assertion trips gives you advance notice instead of a flood of webhook parse errors in production.

Differentiating transaction.completed from payment.created

payment.created fires at the instant Paddle accepts the payment intent, before the bank has confirmed funds. A 3DS challenge that the buyer never completes, or a card decline that arrives 200ms later, both leave you with a fired payment.created event but no actual revenue. transaction.completed only fires once funds are confirmed and the order is in a terminal success state, which makes it the only event safe to use as an attribution writer. If you absolutely need an earlier signal for funnel metrics, log payment.created to a separate event store — never to your revenue table.

Paddle vs Lemon Squeezy vs Stripe attribution

Across the three checkout providers, the mechanism is the same — pass a session ID through, read it back from the webhook — but the field name differs.

ProviderCustom data fieldWebhook eventRecurring support
Stripemetadatacheckout.session.completed✓ via subscription events
Lemon Squeezycheckout[custom][...]order_created✓ via subscription_created
PaddlecustomDatatransaction.completed✓ via subscription.created

What channel revenue looks like for Paddle products

Based on attribution data across TrackRev workspaces using Paddle, Paddle-hosted products skew toward B2B SaaS in regulated markets where Merchant-of-Record matters. The channel mix reflects that — owned, content, and partner channels dominate, with longer attribution windows than indie products.

ChannelAvg. click-to-trial rateAvg. click-to-paid rateAvg. revenue per 1,000 clicks
Newsletter (owned)3.8%2.9%$185
YouTube organic1.9%1.4%$92
Twitter/X organic0.9%0.6%$38
Affiliate / partner3.2%2.6%$168
Direct5.4%4.1%$261

Source: TrackRev attribution data for Paddle-connected workspaces, 2026.

A few things are worth flagging in the table. A healthy revenue-per-1,000-clicks figure for B2B Paddle products sits in the $150–$200 range for owned channels; anything materially below that usually signals either an audience-fit problem or a leak in the trial-to-paid conversion step.

Why "Direct" is rarely truly direct on Paddle dashboards

"Direct" is rarely truly direct — it represents dark social (DMs, Slack, Telegram), word of mouth from existing customers, and brand search where the visitor already knew your name. It consistently produces the highest revenue per click because the buyer is already pre-qualified by the time they land on the checkout page. On B2B Paddle workspaces the gap is pronounced: direct traffic converts at roughly double the rate of the next-best channel, which is why it dominates the revenue column even when its click volume sits below newsletter and organic search.

Why YouTube outperforms on revenue per click

YouTube is the channel that surprises teams the most — the absolute click volume looks small next to social, but the per-click economics ($92 per 1,000) routinely beat paid channels on the same dashboard once attribution is properly wired up. The reason is audience intent: a viewer who watches a five-minute demo and then clicks the link in the description is further along the decision curve than someone scrolling past a promoted tweet. Low volume, high conversion, high revenue per click — a pattern that only becomes visible once attribution is in place.

Cross-currency rounding on MoR payouts

Because Paddle is the Merchant of Record, a transaction billed in EUR or GBP gets converted to your payout currency on settlement — and the FX rate moves between transaction time and webhook time. Use the details.totals.total in the buyer currency for attribution (it's stable across retries), and reconcile against payout reports in your home currency on a daily batch. Mixing the two in the same channel report produces ROI numbers that drift 1–3% week-over-week with no underlying change.

Watch out

transaction.created fires before the charge succeeds and subscription.created fires only on the first cycle. Always attribute on transaction.completed — it's the only Paddle event that means money actually moved and that re-fires for every renewal, keeping per-channel LTV accurate. TrackRev's Paddle integration subscribes to the right event by default.

TrackRev and Paddle attribution

TrackRev integrates with both Paddle Billing (v2) and Paddle Classic. Paste your Paddle API key into TrackRev's integration settings, and the customData handshake is wired into the checkout SDK automatically — no manual JavaScript changes.

Related reading: Lemon Squeezy revenue attribution for the parallel LS setup; Stripe revenue attribution for the simpler Stripe-side flow; Polar revenue attribution if you also sell through Polar. TrackRev's free tier covers 1,000 events.

External references: Paddle transactions API; Paddle webhooks documentation; Paddle CAC research.

Frequently asked questions

How do I attribute Paddle revenue to a marketing channel?
Capture a session ID in a first-party cookie when the visitor lands on your site, pass that session ID into Paddle Checkout using the customData field (or the legacy passthrough field on Paddle Classic), then read it back from the transaction.completed webhook and match it to the originating click. Paddle returns the customData unchanged on every event for the life of the subscription, so renewals roll up to the same channel automatically. TrackRev wires this customData handshake into the Paddle checkout SDK for you.
Does Paddle support UTM tracking?
Paddle does not natively parse or store UTM parameters. You capture the UTM values on your own site at landing time, store them against a session ID in a first-party cookie, and carry that identifier through Paddle Checkout via customData (Paddle Billing) or passthrough (Paddle Classic). The UTM context then comes back in the transaction.completed webhook for attribution.
Why does Paddle's merchant-of-record checkout break channel tracking?
Because Paddle is the Merchant of Record, its checkout runs on checkout.paddle.com rather than your own domain. First-party cookies set on your marketing site do not follow the visitor across that domain boundary, so the click context is left behind unless you explicitly pass a session ID into the checkout. The customData (or passthrough) field is the supported bridge that carries the identifier across the cross-domain redirect.
Can TrackRev track Paddle revenue automatically?
Yes. TrackRev integrates with both Paddle Billing (v2) and Paddle Classic. After you paste your Paddle API key into TrackRev's integration settings and install the pixel, TrackRev handles the customData passing and webhook matching automatically, so every Paddle transaction is attributed to the channel that drove it alongside your Stripe, Lemon Squeezy, and Polar revenue.

Related articles

Stop guessing where your revenue comes from.

Set up TrackRev in 5 minutes. Free tier covers 1,000 events / month.