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, FounderUpdated
On this page
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 type | Runs on | Where the source goes |
|---|---|---|
| Overlay | Your page, as a modal | customData in Paddle.Checkout.open() |
| Inline | Your page, embedded | The same call |
| Payment link | Your approved page, opened with ?_ptxn= | custom_data on a server-created transaction |
| Hosted checkout | A Paddle-hosted page built for mobile apps | Nowhere: 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.
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.
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.
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.
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 stored | Payments credited | Newsletter revenue | Share of the year |
|---|---|---|---|
| First transaction only | 1 | $49 | 8.3% |
| Subscription as well | 12 | $588 | 100% |
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 field | Minor units | Amount in GBP | Against the subtotal |
|---|---|---|---|
| details.totals.subtotal | 30000 | 300.00 | 100% |
| details.totals.tax | 6000 | 60.00 | 20% |
| details.totals.total | 36000 | 360.00 | 120% |
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.
| Sale | Payment | Source | Approved refund | Net for the channel |
|---|---|---|---|---|
| Sale 1 | $49 | newsletter | none | $49 |
| Sale 2 | $49 | newsletter | $10 | $39 |
| Sale 3 | $99 | affiliate | none | $99 |
| Sale 4 | $29 | youtube | $29 | $0 |
A worked example with 4 sales.
| Channel | Sales | Gross | Refunds | Net | Share of net |
|---|---|---|---|---|---|
| newsletter | 2 | $98 | $10 | $88 | 47.1% |
| affiliate | 1 | $99 | $0 | $99 | 52.9% |
| youtube | 1 | $29 | $29 | $0 | 0% |
| Total | 4 | $226 | $39 | $187 | 100% |
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
| What | Value | Why it matters |
|---|---|---|
| Keys in a custom data object | At least 1 | An empty object is invalid, so send a marker |
| Published size cap on custom data | None | Store an ID and a few short keys |
| Kinds of entity that accept custom data | 8 | Only transactions and subscriptions are documented as copying to each other |
| Transaction statuses | 7 | Edits stop once a transaction is billed, canceled or completed |
| API rate limit | 240 requests a minute per IP address | After a 429, that address waits 60 seconds |
| Rows per page in list calls | Transactions 30, the default and the maximum; subscriptions 50 by default and 200 at most; adjustments 10 by default and 50 at most | 1,000 rows take 34, 5 and 20 pages |
| Chargeback adjustment actions | 3 | chargeback_warning, chargeback and chargeback_reverse |
| Webhook response deadline | 5 seconds | A 200 inside the window stops retries |
| Live webhook retries | Up to 60 over 3 days | 20 in the first hour and 47 in the first day |
| Sandbox webhook retries | 3 within 15 minutes | Same deadline, far fewer attempts |
| Active notification destinations | 10 | Deactivate one before you add an 11th |
| Paddle Classic passthrough cap | 1,000 characters | A string, so JSON you encode yourself |
| Accounts created before August 8, 2023 | Paddle Classic | Later 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.
| Storage | Safari rule |
|---|---|
| Cookies created in JavaScript, plus localStorage, IndexedDB and sessionStorage | Deleted after 7 days without user interaction |
| Script-written cookies on a landing page with link decoration | Capped at 24 hours |
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.
Paddle.Checkout.open({
items: [{ priceId: "pri_01gs59hve0hrz6nyybj56z04eq", quantity: 1 }],
customer: { email: user.email },
customData: { vid: window.trk.vid },
});| Setting | What TrackRev does |
|---|---|
| Visitor ID | Cookie vid lasts 365 days and is written by JavaScript, so Safari's 7-day rule applies |
| Join key | custom_data.vid on each transaction, with the buyer's email as the fallback |
| API key | Read-only, with Customer, Product, Subscription, Transaction and Discount access |
| Sync | Every hour, or within seconds with a notification destination on transaction.completed and transaction.refunded |
| Partner codes | A Paddle discount code assigned to an affiliate credits that partner without a click |
| Paddle version | Paddle Billing only |
| Plans | Revenue 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.
Frequently asked questions
- Passthrough is the Paddle Classic field, a string of up to 1,000 characters. Custom_data is the Paddle Billing JSON object, accepted by 8 kinds of entity, and the Paddle Billing migration mapping turns the first into the second.
- Paddle Billing publishes none for keys or total size. It requires at least 1 key and rejects a number above an unstated maximum with a 400 error.
- Use transaction.completed. The earlier transaction.paid event fires before Paddle Billing finishes processing the transaction.
- Paddle Billing documents copying only between transactions and subscriptions. Customers have a custom_data field of their own.
- No. The connect screen asks for a Paddle Billing API key and warns against a Classic key.
- No. The free plan has 50 links and 1,000 tracked events a month, covers link tracking only and hides revenue figures.

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