How to attribute Paddle revenue to the marketing channel behind each sale
Paddle revenue attribution: carry UTMs in customData, book transaction.completed, credit total minus tax, and net out refunds and the 5% + 50¢ fee.
Muzahid Maruf, FounderUpdated
On this page
- 01What does Paddle record about each sale?
- 02How does Paddle revenue attribution work end to end?
- 03Which events count as a sale?
- 04Which amount should you credit to a channel?
- 05How do renewals carry the first source?
- 06How do refunds and chargebacks reduce a channel?
- 07What does a worked example of five sales show?
- 08What happens when a sale arrives without a source?
- 09What does TrackRev do with a Paddle connection?
Explore with AI
Opens this article inside the chosen assistant with a ready-made prompt.
Paddle revenue attribution is the practice of putting the visitor's source into the custom data of a Paddle transaction when checkout opens, then reading the source back from the transaction.completed webhook and grouping sales by source.
Paddle Billing records no campaign on its own.
Its custom data guide names capturing UTM information as one use for fields you fill in yourself, and Paddle copies the fields from the transaction to the subscription and on to each renewal.
According to Paddle's migration guide, accounts opened before August 8, 2023 run on Paddle Classic and later accounts run on Paddle Billing. Classic used a passthrough parameter for the same job, which Paddle's data mapping guide maps to custom_data.
On a $29 plan, the first payment earns 4.2% of the revenue by month 24, yet the first checkout decides the credit for every later month.
| Month on a $29 plan | Revenue so far | Share earned by the first payment |
|---|---|---|
| 1 | $29 | 100% |
| 3 | $87 | 33.3% |
| 6 | $174 | 16.7% |
| 12 | $348 | 8.3% |
| 24 | $696 | 4.2% |
| 36 | $1,044 | 2.8% |
The first checkout earns every later month's credit, so by month 24 the first payment is 4.2% of the revenue.
Key takeaways
- Paddle Billing keeps a campaign only if you send it: put 3 UTM values in customData when checkout opens, and Paddle copies them to the subscription and to every renewal.
- Paddle Billing sends 10 transaction events. Book revenue on transaction.completed, because transaction.created fires when checkout opens and transaction.paid can arrive before the fee exists.
- Credit a channel with the total minus tax. Paddle's 5% + 50¢ fee takes 10% of a $10 sale and 5.5% of a $99 sale, so keep earnings beside revenue.
- Refunds and chargebacks arrive as adjustments with a transaction_id. Paddle approves many live refunds of 400 USD or less on creation, so subtract each approved one from the original channel.
What does Paddle record about each sale?
A Paddle transaction is the record Paddle Billing creates for every sale as merchant of record. A transaction holds the customer, line items, tax, totals, any discount and the subscription the sale started. None of those fields holds a campaign.
The pay-as-you-go plan costs 5% + 50¢ per checkout transaction as of October 2026. The Checkouts report adds source_domain and referrer columns for each session and has no UTM column, while the Transactions report exports whatever you stored in custom_data.
For example, a newsletter buyer and a buyer who typed the URL both appear as plain sales until your own code writes utm_source into custom_data.
Paddle Billing never invents a source, so the export is only as good as the values sent at checkout. Store the source in 1 place, and read the source from 1 webhook.
How does Paddle revenue attribution work end to end?
Paddle revenue attribution works in 3 moves: save the source on landing, send the source at checkout, and read the source back from the webhook. On Paddle Billing, checkout opens from a page on your own approved website.
Overlay checkout lets customers pay without leaving your site, and the default payment link should be a page on an approved website that includes Paddle.js.
A script on the page can read your first-party cookie when the script calls Paddle.js and pass the values along.
An overlay or inline checkout takes customData in the checkout call, and a server-created transaction takes custom_data in the POST body. Both routes store the same object.
For a renewal or an upgrade, you send nothing, because Paddle copies the subscription's custom data to each new transaction. For a $29 plan renewed 12 times, 1 stored source credits $348.
Every field name in this guide is a Paddle Billing name.
| How the sale starts | Where the source goes | What Paddle does with it |
|---|---|---|
| Overlay or inline checkout | customData in Paddle.Checkout.open() | Holds it on the transaction, copies it to the subscription |
| Transaction your server creates | custom_data in POST /transactions | Same as overlay checkout |
| Renewal or upgrade | Nothing to send | Copies the subscription's custom data to each new transaction |
From Paddle's custom data, overlay checkout and default payment link guides, October 2026.
How do you get the source into custom_data?
Getting the source into custom_data starts with a first-party cookie written on landing. Save the UTM values lowercased: per the Google Analytics Help page on campaign URLs, values are case sensitive, so "Newsletter" and "newsletter" report as 2 sources.
At checkout, read the cookie and pass the values as customData, which the Paddle.Checkout.open reference says must be valid JSON with at least 1 key, so send the object only when a source exists.
If your server creates the transaction, put custom_data in the POST /transactions body, because Paddle can't edit a transaction once the transaction is billed, canceled or completed.
The passthrough walkthrough has the code for both paths, and the UTM parameters guide covers naming rules.
For example, a visitor who lands from a newsletter on Monday and buys on Friday still checks out with the Monday values, because the script reads the cookie at checkout, not at landing.
Which events count as a sale?
A sale is a payment that Paddle Billing has fully processed, and a sale counts on transaction.completed and no earlier event. Paddle's webhook reference lists 10 transaction events as of October 2026.
Of those, transaction.created, transaction.paid and transaction.completed decide when a sale counts, and the adjustment events cover money going back out. Paddle Billing has no payment.created event.
The event transaction.created precedes any payment, so a writer subscribed to the event credits channels for every abandoned checkout. The event transaction.paid fires when payment is captured, before processing ends, so fee and subscription_id may be missing.
The event subscription.created fires once per subscription, so a handler on the event never sees renewal 2 or later. The event transaction.completed arrives last, with the fee and earnings filled in, and fires once per payment.
| Event | When it fires | Book revenue here? |
|---|---|---|
| transaction.created | A customer opens checkout, or a renewal is created before its charge | No, nothing is paid |
| transaction.paid | Payment is captured, before processing ends | No, fee and subscription_id may be missing |
| transaction.completed | Payment logged, fee and earnings added, subscription and invoice number created | Yes, once per payment |
| adjustment.created | A refund, credit or chargeback is recorded | Subtract it |
| adjustment.updated | A refund moves from pending_approval to approved or rejected | Apply it once approved |
From Paddle's webhook reference, October 2026.
How do you verify a Paddle webhook?
Verifying a Paddle webhook means checking the Paddle-Signature header before parsing the body.
Per Paddle's signature guide, the header carries ts and h1, and h1 is an HMAC-SHA256 of the timestamp, a colon and the raw body, keyed with your notification destination's secret.
Paddle's delivery guide promises at-least-once delivery, so store event_id under a unique index, and says order isn't guaranteed, so compare occurred_at before overwriting a row. Reply with a 200 within 5 seconds.
A live account retries 60 times over 3 days, with 20 retries in hour 1 and 47 by day 1, while a sandbox account retries 3 times within 15 minutes.
Allowlist the 6 source IP addresses Paddle publishes, and keep the signature timestamp tolerance at 5 seconds, the default in Paddle's SDK helpers.
In the sandbox, refund approval runs every 10 minutes, so a test refund can sit as pending_approval for a while.
| Rule | Live account | Sandbox account |
|---|---|---|
| Reply deadline | 200 within 5 seconds | 200 within 5 seconds |
| Retries | 60 over 3 days: 20 in hour 1, 47 by day 1 | 3 within 15 minutes |
| Refund approval | Automatic at 400 USD or less, otherwise Paddle reviews it | Every 10 minutes |
| Signature timestamp tolerance | 5 seconds in the SDK helpers | 5 seconds in the SDK helpers |
| Source IPs to allowlist | 6 addresses | 6 addresses |
From Paddle's delivery, signature and refund guides, October 2026.
// app/api/paddle/route.ts (saveSale and saveAdjustment write to your own database)import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5; // the default in Paddle's SDK helpers function isValid(header: string, rawBody: string): boolean { const parts = Object.fromEntries(header.split(";").map((p) => p.split("="))); const { ts, h1 } = parts as { ts?: string; h1?: string }; if (!ts || !h1) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", process.env.PADDLE_WEBHOOK_SECRET!) .update(ts + ":" + rawBody) .digest("hex"); return ( h1.length === expected.length && timingSafeEqual(Buffer.from(h1), Buffer.from(expected)) );} export async function POST(req: Request) { const rawBody = await req.text(); // the unmodified bytes if (!isValid(req.headers.get("paddle-signature") ?? "", rawBody)) { return new Response("invalid signature", { status: 400 }); } const event = JSON.parse(rawBody); const data = event.data; if (event.event_type === "transaction.completed") { const totals = data.details.totals; await saveSale({ event_id: event.event_id, // unique index: Paddle delivers at least once transaction_id: data.id, subscription_id: data.subscription_id, origin: data.origin, custom_data: data.custom_data, currency: data.currency_code, total_minor: Number(totals.total), tax_minor: Number(totals.tax), fee_minor: Number(totals.fee), occurred_at: event.occurred_at, }); } if ( event.event_type === "adjustment.created" || event.event_type === "adjustment.updated" ) { await saveAdjustment({ adjustment_id: data.id, // upsert, and skip rows with an older occurred_at transaction_id: data.transaction_id, action: data.action, status: data.status, subtotal_minor: Number(data.totals.subtotal), occurred_at: event.occurred_at, }); } return new Response("ok");}Which amount should you credit to a channel?
The amount to credit is total minus tax. Paddle returns money as strings in the currency's lowest denomination, so 65215 in USD is $652.15, as in the example payload in the transaction.completed reference.
In that payload the subtotal is $599.00 and the tax is $53.15, so a channel report credits $599.00. Tax isn't revenue, and tax varies by buyer location.
With tax added on top, a $29 plan sold at 20% VAT arrives as a $34.80 total, so a UK newsletter list looks 20% richer in a totals report than an identical audience in a state with no sales tax.
Totals arrive in the transaction's currency, and 29.00 USD plus 29.00 EUR is a meaningless number. details.payout_totals repeats the figures in your payout currency with the exchange_rate Paddle applied, which is 1.0 when the currencies match.
| Field in details.totals | Payload value | In dollars | Meaning |
|---|---|---|---|
| subtotal | 59900 | $599.00 | Price before discount and tax |
| tax | 5315 | $53.15 | Tax charged on top |
| total | 65215 | $652.15 | What the buyer paid |
| fee | 3311 | $33.11 | Paddle's fee, null until completed |
| earnings | 56589 | $565.89 | Earnings after the fee, null until completed |
From the example payload in Paddle's transaction.completed reference, October 2026.
How does the Paddle fee change small plans?
The Paddle fee is 5% + 50¢ per checkout transaction, and the fixed 50 cents weighs most on small plans.
In the example payload the fee equals 5% of the $652.15 total plus 50 cents, which points to a charge on the tax-inclusive figure, and the earnings equal the $599.00 subtotal minus the $33.11 fee.
A $10 plan pays $1.00 in fees, or 10.0%, while a $99 plan pays $5.45, or 5.5%.
A channel that sends buyers of the $10 plan looks worse after fees than before fees, so keep earnings in the report next to revenue.
For a $29 plan the fee is $1.95, which is 6.7% of the price and leaves $27.05.
The table runs the same arithmetic for 7 price points, from $10 to $499, and the fee column is arithmetic on the published rate, not a quote from Paddle.
| Price before tax | Fee at 5% + 50¢ | Fee as a share of price | Left after fee |
|---|---|---|---|
| $10 | $1.00 | 10.0% | $9.00 |
| $19 | $1.45 | 7.6% | $17.55 |
| $29 | $1.95 | 6.7% | $27.05 |
| $49 | $2.95 | 6.0% | $46.05 |
| $99 | $5.45 | 5.5% | $93.55 |
| $199 | $10.45 | 5.3% | $188.55 |
| $499 | $25.45 | 5.1% | $473.55 |
Prices from Paddle's pricing page, October 2026. Fees are arithmetic on the 5% + 50¢ rate for a buyer who pays no tax.
How do renewals carry the first source?
A renewal carries the first source because a renewal inherits the subscription's custom data. Each Paddle transaction has 1 of 6 origins.
The origin web is a Paddle.js checkout and api is a transaction your server created, so both carry only what you sent.
The other 4 come from a subscription and inherit the original source: subscription_recurring for a renewal, subscription_update for a plan change, subscription_charge for a one-time charge, and subscription_payment_method_change, which can be a zero-value transaction.
Store origin to split first purchases from renewals and expansion, as LTV by channel needs, and skip totals of 0 when you count sales.
For example, a $29 plan that renews 11 times after 1 newsletter checkout books 12 payments, or $348, to the newsletter channel, with 1 of the 12 marked web.
Origin is the only field that separates new business from recurring revenue in the report.
How do refunds and chargebacks reduce a channel?
A refund reduces a channel through an adjustment, because Paddle never edits a completed transaction. A refund is an adjustment with action: "refund" and a transaction_id pointing at the sale.
In Paddle's refund guide, most live refunds start as pending_approval and become approved or rejected.
Paddle approves a refund on creation when the account is verified, the amount is 400 USD or less and below your balance, and the buyer didn't pay by bank transfer.
Listen for both adjustment events, because adjustment.updated fires only for refunds that needed approval. Once an adjustment is approved, subtract its totals.subtotal from the channel of the original transaction.
In the adjustment.created example, a partial refund of $1.00 has a $0.92 subtotal and $0.08 of tax. Key the row on the adjustment id. Chargebacks use the same record with action chargeback or chargeback_warning, and chargeback_reverse returns the money.
What does a worked example of five sales show?
A worked example shows how grouping changes the ranking of channels. Take 5 completed transactions from 1 week. 2 buyers paid 20% VAT, 1 sale arrived with no custom data, and Paddle later approved a $29.00 refund on sale 2.
By total, newsletter beats YouTube $69.60 to $58.00. By revenue the 2 channels tie at $58.00 from 2 payments each, and after the refund YouTube nets $29.00, half of the newsletter's $58.00.
The unattributed sale is 53% of the $186.00 net, and shrinking that row is the first job, because a channel can only be judged on sales that carry a source.
The 3 channel rows rank differently by total, by revenue and by net revenue. Once adjustments are stored, 1 query reports revenue and refunds per channel, as in the Postgres snippet after the tables.
| Sale | Origin | utm_source | Total | Tax | Revenue | Fee | Earnings |
|---|---|---|---|---|---|---|---|
| 1 | web | newsletter | $34.80 | $5.80 | $29.00 | $2.24 | $26.76 |
| 2 | web | youtube | $29.00 | $0.00 | $29.00 | $1.95 | $27.05 |
| 3 | subscription_recurring | newsletter | $34.80 | $5.80 | $29.00 | $2.24 | $26.76 |
| 4 | subscription_recurring | youtube | $29.00 | $0.00 | $29.00 | $1.95 | $27.05 |
| 5 | web | none | $99.00 | $0.00 | $99.00 | $5.45 | $93.55 |
Made-up sales. Revenue is total minus tax, and fee is 5% of the total plus 50¢.
| Channel | Sales | Sum of totals | Revenue | Approved refunds | Net revenue | Share of net |
|---|---|---|---|---|---|---|
| newsletter | 2 | $69.60 | $58.00 | $0.00 | $58.00 | 31% |
| youtube | 2 | $58.00 | $58.00 | $29.00 | $29.00 | 16% |
| unattributed | 1 | $99.00 | $99.00 | $0.00 | $99.00 | 53% |
The same 5 sales, grouped by utm_source, with the refund subtracted from its channel.
with refunds as ( select transaction_id, sum(subtotal_minor) as refunded_minor from paddle_adjustments where status = 'approved' and action = 'refund' group by 1)select coalesce(s.custom_data->>'utm_source', 'unattributed') as channel, count(*) filter (where s.origin = 'web') as first_purchases, count(*) filter (where s.origin = 'subscription_recurring') as renewals, sum(s.total_minor - s.tax_minor) / 100.0 as revenue, coalesce(sum(r.refunded_minor), 0) / 100.0 as refundedfrom paddle_sales sleft join refunds r using (transaction_id)where s.currency = 'USD' -- run once per currencygroup by 1order by revenue desc;What happens when a sale arrives without a source?
A sale without a source is a transaction whose custom_data is empty. Declined cookies and transactions created without custom data, such as invoices, arrive empty.
Fall back to email: the webhook carries customer_id and no address, so 1 extra call to GET /customers/{id} returns the email to match against signups.
A 2nd address slips through sometimes, so we use the visitor ID as the primary key. Pass customer.email to the checkout call when you know the address.
For existing subscribers, send PATCH /subscriptions/{id} with a custom_data object, and Paddle copies the object onto every transaction the subscription creates afterward. Completed transactions stay as they were, so payments already made remain unattributed.
Match on email only after the visitor ID fails, and log every email match so the fallback rate stays visible. Report the empty rows as their own unattributed channel, as the worked example does with the 53% row.
What does TrackRev do with a Paddle connection?
TrackRev is SaaS affiliate software with revenue attribution built in, and we built TrackRev to do this join for you.
You connect Paddle Billing with a read-only API key created under Developer tools, then Authentication, with Customer, Product, Subscription, Transaction and Discount read permissions. A key containing _sdbx points TrackRev at the sandbox, and Paddle Classic isn't supported.
Pass window.trk.vid as vid in customData. TrackRev joins completed transactions on that ID and falls back to the buyer's email. We use the email as the second join key because a cookie can be declined or cleared before payment.
Renewals inherit the visitor from the first transaction, a discount code assigned to a partner credits the partner without a click, and the first sync reaches back 30 days.
Sales arrive on a scheduled sync, or within seconds once a Paddle notification destination for transaction.completed is added and the secret saved. The Indie plan costs $29 a month, and the Growth plan costs $299 a year.
When does TrackRev fit a Paddle shop?
TrackRev fits when the same sales also need splitting by link and partner next to your Stripe, Polar, Lemon Squeezy, Creem or Dodo Payments revenue, with last-touch, first-touch and linear credit to compare. Two limits shape the channel report today.
TrackRev reads each transaction's grand total, which includes tax, so a $120.00 sale at 20% VAT counts as $144.00. TrackRev also doesn't read Paddle adjustments, so an approved refund stays in the channel.
For tax-free, refund-adjusted figures, use the pipeline above. We built the plans so neither Indie nor Growth caps tracked revenue. Revenue figures need a paid plan from $29 a month.
The free plan covers link tracking, 50 links and 1,000 tracked events a month, and hides revenue. The Paddle integration page has the setup steps, pricing lists the plans, and the Stripe version of this guide uses Stripe's field names.
Found this useful? Share it.
Frequently asked questions
- Paddle's totals include tax, and in the five-sale example they add up to $226.60 against $215.00 of revenue, an $11.60 gap that is all VAT. Refunds sit in separate adjustments, so they reduce a channel only after you subtract them. Sales with no custom data land in the unattributed row, and mixed currencies need converting before any sum.
- Partly. The Transactions report exports custom_data next to subtotal, discount, tax and total, so you can filter to completed transactions, pull utm_source out of custom_data and pivot on it. Export the Adjustments report too and subtract approved refunds by transaction ID. The pivot is a snapshot, so rerun it each month.
- A null means the transaction was created without any, and Paddle's own transaction.completed example payload shows exactly that. Check that customData had at least 1 key when checkout opened, that the sale didn't start from a payment link for a transaction created without it, and that the account is on Paddle Billing. The Paddle dashboard also shows a transaction's custom data, so compare it with the webhook body.
- It runs in the visitor's browser, so a closed tab loses it, and for deferred capture payment methods Paddle says it only means payment was authorized. Use it for on-page messages and analytics tags, and let the webhook define revenue.
- Whichever value you saved before checkout, because renewals copy it and nobody revisits it. Comparing models needs the visitor's whole click history stored separately and joined to the sale by an ID, which is how TrackRev's paid plans calculate all 3 models.
- Leave those sales unattributed and report them as their own row, without fingerprinting. TrackRev's pixel supports data-requires-consent, which holds the cookie until window.trk.grantConsent() is called.
- Yes. The free plan is link tracking only, with 50 links and 1,000 tracked events a month, and revenue figures stay hidden. Indie is $29 a month and Growth is $299 a year.

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
Run your affiliate program without a revenue cap.
Affiliate software for indie hackers and small SaaS teams: $29 a month flat, 0% fees, link tracking and revenue attribution built in. Free to start, no card.