Paddle checkout attribution: carry the channel in custom_data

Pass the marketing channel into a Paddle Billing checkout with customData, then read it from transaction.completed on every renewal. 7 steps, code and limits.

Muzahid Maruf — Founder of TrackRev.io

Muzahid MarufUpdated

Revenue attribution · 8 min read
On this page
  1. 01Where Paddle checkout attribution gets lost
  2. 02How to pass the channel into a Paddle checkout
  3. 03The Paddle Billing numbers that shape the setup
  4. 04Paddle Classic and passthrough
  5. 05Keep the source alive until the buyer pays
  6. 06How TrackRev reads Paddle custom data

Explore with AI

Opens this article inside the chosen assistant with a ready-made prompt.

To get Paddle checkout attribution, put the marketing source into the checkout's custom data before the buyer pays. On Paddle Billing that is a customData object in Paddle.Checkout.open(), or custom_data on a transaction your server creates.

Paddle Billing copies it to the subscription and returns it in the transaction.completed webhook, so the channel is still attached on payment 12. Paddle Classic calls the same field passthrough.

Key takeaways

  • Attach the marketing source as custom data when the checkout opens or when your server creates the transaction, and Paddle Billing copies it to the subscription and every renewal.
  • On Paddle Billing, overlay and inline checkouts run on your own page, so UTM parameters stay readable at purchase. Hosted checkout has 14 URL parameters and none takes custom data.
  • Custom data in Paddle Billing needs at least 1 key and has no published size cap. A transaction freezes once it is billed, canceled or completed, and refunds arrive as adjustments with no custom data.

Where Paddle checkout attribution gets lost

The usual explanation is a redirect that strips UTM parameters.

On Paddle Billing the overlay checkout guide has the overlay opening as a modal on your page, while an inline checkout sits in the page layout, so your script can still read the query string and cookies.

The gap is the hand-off: a Paddle Billing transaction records the buyer, price, tax and payment method, and nothing about the ad that sent them.

Checkout typeRuns onWhere the source goes
OverlayYour page, as a modalcustomData in Paddle.Checkout.open()
InlineYour page, embeddedThe same call
Payment linkYour approved page, opened with ?_ptxn=custom_data on a server-created transaction
Hosted checkoutA Paddle-hosted page built for mobile appsNowhere: its 14 URL parameters include none for custom data

From the default payment link and hosted checkout docs for Paddle Billing.

How to pass the channel into a Paddle checkout

Steps 1 and 2 cover an overlay or inline checkout. Step 3 replaces them when your server builds the transaction.

Step 1: Save the first source on the landing page

By the time someone clicks Subscribe, a page or two of navigation has usually dropped the query string. Read the 4 UTM keys you use on the first pageview and keep the first source for each visitor.

Browser, every landing page
const KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content"];const params = new URLSearchParams(location.search); if (params.get("utm_source") && !localStorage.getItem("first_touch")) {  const touch = {};  for (const key of KEYS) if (params.get(key)) touch[key] = params.get(key);  localStorage.setItem("first_touch", JSON.stringify(touch));}

Step 2: Pass it to Paddle.Checkout.open()

The custom data guide uses utm_medium, utm_source and utm_content as its own example, next to an integration_id. Keep the object flat, because Paddle Billing warns that nested values may display incorrectly in the dashboard.

When nothing was saved, send a marker such as none, since an object needs at least 1 key.

Browser, Paddle Billing (Paddle.js v2)
function openCheckout() {  const saved = JSON.parse(localStorage.getItem("first_touch") || "null");  Paddle.Checkout.open({    items: [{ priceId: "pri_01gs59hve0hrz6nyybj56z04eq", quantity: 1 }],    customData: saved || { utm_source: "none" }, // an empty object is invalid  });}

Step 3: Or create the transaction on your server

Put custom_data in the POST /transactions body, then pass the returned ID to Paddle.Checkout.open({ transactionId }) or send the buyer to checkout.url. I'd set anything you act on, such as discounts or plan access, here, since a browser can send anything.

Server, Paddle Billing API (sandbox-api.paddle.com while testing)
const res = await fetch("https://api.paddle.com/transactions", {  method: "POST",  headers: {    Authorization: "Bearer " + process.env.PADDLE_API_KEY,    "Content-Type": "application/json",  },  body: JSON.stringify({    items: [{ price_id: "pri_01gs59hve0hrz6nyybj56z04eq", quantity: 1 }],    custom_data: { utm_source: "newsletter", utm_medium: "email", utm_campaign: "oct-launch" },  }),});const { data } = await res.json();// data.id is the txn_... ID for Paddle.Checkout.open({ transactionId }).// data.checkout.url is the payment link for the same transaction.

Step 4: Read it from transaction.completed

The transaction.completed event fires once a transaction is fully paid and processed, carrying custom_data, subscription_id, currency_code and the totals, so write one row per payment.

The checkout.completed event in Paddle.js carries the same object for on-page tracking such as a Google Analytics 4 purchase hit.

Webhook endpoint (Next.js route handler)
export async function POST(req: Request) {  const raw = await req.text(); // verify Paddle-Signature against this raw body first  const event = JSON.parse(raw);  if (event.event_type !== "transaction.completed") return new Response("ok");   const t = event.data;  await saveSale({    eventId: event.event_id, // unique per event: skip repeats    transactionId: t.id,    subscriptionId: t.subscription_id, // null for one-time prices    currency: t.currency_code,    subtotalMinor: t.details.totals.subtotal, // string in minor units    source: t.custom_data?.utm_source ?? "missing",  });  return new Response("ok");}

Verify the Paddle-Signature header first, as the signature verification guide lays out, then answer within 5 seconds.

A handler that stores the sale but replies late gets the event again, and 3 deliveries would count a $49 payment 3 times and credit its channel with $147. Dedupe on event_id.

Why the subscription needs the value too

Paddle Billing copies a transaction's custom data to the subscription it creates, and a subscription's to every transaction created from it: renewals, upgrades, downgrades and one-time charges. Take a $49 monthly plan sold through one newsletter link.

Over 12 payments that adds up to $588.

Where the source is storedPayments creditedNewsletter revenueShare of the year
First transaction only1$498.3%
Subscription as well12$588100%

One $49 monthly plan across 12 payments.

That carry-forward is what makes lifetime value by channel possible. Credit the pre-tax amount, because the total includes tax. The transaction in the custom data guide has a 20% rate:

Paddle Billing fieldMinor unitsAmount in GBPAgainst the subtotal
details.totals.subtotal30000300.00100%
details.totals.tax600060.0020%
details.totals.total36000360.00120%

The totals in the Paddle Billing custom data guide example.

Step 5: Subtract refunds from the channel

A refund never edits the sale. The adjustments guide records it as an adjustment against the completed transaction, announced by adjustment.created and later by adjustment.updated.

The payload carries transaction_id and subscription_id but no custom_data, so join on the transaction ID to the row you saved in step 4.

Live refunds start as pending_approval, and Paddle Billing approves some itself, such as 400 USD or less on a verified account. Subtract a refund once it is approved.

SalePaymentSourceApproved refundNet for the channel
Sale 1$49newsletternone$49
Sale 2$49newsletter$10$39
Sale 3$99affiliatenone$99
Sale 4$29youtube$29$0

A worked example with 4 sales.

ChannelSalesGrossRefundsNetShare of net
newsletter2$98$10$8847.1%
affiliate1$99$0$9952.9%
youtube1$29$29$00%
Total4$226$39$187100%

The same 4 sales grouped by the source saved at checkout.

Step 6: Fix subscribers who already paid

A billed, canceled or completed transaction cannot be edited, but a subscription can. Send PATCH /subscriptions/{subscription_id} with a custom_data object and every later transaction created from it carries the value.

Match existing subscribers to your own click records by email and write the source onto each one. Payments already taken stay blank.

For 1,000 subscribers that is 5 list calls plus 1,000 updates, about 4.2 minutes at 240 requests a minute. If your handler was down, Paddle Billing can replay a failed notification through its API.

Step 7: Test in the sandbox

Buy once in the sandbox with utm_source=newsletter in the landing URL and look for it in 3 places: the checkout.completed callback, the transaction.completed body and the subscription.created body. Absent from all 3 means the call never sent it.

Present in the webhook but absent from your database means the handler reads the wrong field.

The Paddle Billing numbers that shape the setup

WhatValueWhy it matters
Keys in a custom data objectAt least 1An empty object is invalid, so send a marker
Published size cap on custom dataNoneStore an ID and a few short keys
Kinds of entity that accept custom data8Only transactions and subscriptions are documented as copying to each other
Transaction statuses7Edits stop once a transaction is billed, canceled or completed
API rate limit240 requests a minute per IP addressAfter a 429, that address waits 60 seconds
Rows per page in list callsTransactions 30, the default and the maximum; subscriptions 50 by default and 200 at most; adjustments 10 by default and 50 at most1,000 rows take 34, 5 and 20 pages
Chargeback adjustment actions3chargeback_warning, chargeback and chargeback_reverse
Webhook response deadline5 secondsA 200 inside the window stops retries
Live webhook retriesUp to 60 over 3 days20 in the first hour and 47 in the first day
Sandbox webhook retries3 within 15 minutesSame deadline, far fewer attempts
Active notification destinations10Deactivate one before you add an 11th
Paddle Classic passthrough cap1,000 charactersA string, so JSON you encode yourself
Accounts created before August 8, 2023Paddle ClassicLater accounts get Paddle Billing

From the custom data, rate limiting, pagination, webhook delivery and migration docs for Paddle Billing and the Paddle Classic checkout parameters, read in October 2026.

Paddle Classic and passthrough

On Paddle Classic the field is passthrough, a string sent with every webhook for the order, so the habit is JSON inside it, parsed on the way back.

The data mapping turns it into both transaction.custom_data and subscription.custom_data on Paddle Billing, so keep your key names when you migrate.

Keep the source alive until the buyer pays

Step 1 keeps the source in localStorage, and Safari Intelligent Tracking Prevention limits it.

StorageSafari rule
Cookies created in JavaScript, plus localStorage, IndexedDB and sessionStorageDeleted after 7 days without user interaction
Script-written cookies on a landing page with link decorationCapped at 24 hours

From WebKit's tracking prevention policy.

Someone who clicks a newsletter link on Monday and returns directly 9 days later arrives with nothing saved and sends the marker.

Set the visitor ID from your server's response header, since the 7-day rule is written about script-created storage, and pass the buyer's email into checkout as a second match key.

The Safari ITP post goes deeper, and Stripe users do the same job with checkout metadata.

How TrackRev reads Paddle custom data

TrackRev is SaaS affiliate software with revenue attribution built in. Its pixel keeps a visitor ID in a first-party cookie named vid and exposes it as window.trk.vid. Passing it to Paddle Billing is the whole page-side integration.

Browser, Paddle Billing with the TrackRev pixel
Paddle.Checkout.open({
  items: [{ priceId: "pri_01gs59hve0hrz6nyybj56z04eq", quantity: 1 }],
  customer: { email: user.email },
  customData: { vid: window.trk.vid },
});
SettingWhat TrackRev does
Visitor IDCookie vid lasts 365 days and is written by JavaScript, so Safari's 7-day rule applies
Join keycustom_data.vid on each transaction, with the buyer's email as the fallback
API keyRead-only, with Customer, Product, Subscription, Transaction and Discount access
SyncEvery hour, or within seconds with a notification destination on transaction.completed and transaction.refunded
Partner codesA Paddle discount code assigned to an affiliate credits that partner without a click
Paddle versionPaddle Billing only
PlansRevenue attribution starts at $29 a month on Indie, with Growth at $299 a year

How TrackRev handles Paddle Billing sales.

If all you need is the channel in your own database, the handler above does it with 1 webhook and 1 table. Sellers on Paddle Classic need another route.

The Paddle integration page, pricing and Paddle revenue attribution by marketing channel have the setup screens, plans and reporting.

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