---
title: "Paddle Affiliate Tracking: Commission on Gross or Net"
description: "Paddle affiliate tracking on Billing v2: the custom_data join key, which events move a balance, and why total, earnings and tax are three numbers."
canonical: https://www.myappaffiliate.com/blog/paddle-affiliate-tracking
published: 2026-09-15
author: "Cuma Ali Kesici"
category: "RevenueCat"
tags: [Paddle, Stripe, Web, iOS]
source: MyAppAffiliate
---

# Paddle Affiliate Tracking: Commission on Gross or Net

Paddle affiliate tracking has one join key and three events, and then a decision nobody warns you about. Paddle is the merchant of record, which Paddle's own blog defines as "a legal entity responsible for selling goods or services to an end customer". So the amount your customer paid, the amount Paddle sends you, and the amount a creator should be paid a percentage of are three different numbers on one webhook.

Below: where the user id goes, which Billing v2 events move a balance, and a twelve month ledger with a refund in it. Every field name was copied from Paddle's documentation on 2026-09-14.

## Key takeaways

- Paddle is the merchant of record, so `total`, `earnings` and payout are three numbers.
- The join key is `custom_data.customer_user_id`, and Paddle copies it onto every renewal.
- `origin: subscription_recurring` is the only thing separating a rebill from a first sale.
- `adjustment.created` carries credits and chargebacks too. Only `action: refund` reverses money.
- Adjustments carry no `custom_data`. Join a refund on `transaction_id`, and reverse only on `status: approved`.

## Does Paddle have a built-in affiliate program?

Not in Paddle Billing. Paddle's [help centre](https://www.paddle.com/help/grow/upsell/do-you-integrate-with-3rd-party-affiliates) says Paddle "supports a number of third-party affiliate networks", then links to a guide noting "this documentation is exclusively for Paddle Classic". Verified on 2026-09-14. Billing's developer documentation has no affiliate section.

The Classic feature is real, and older than most assume. Paddle announced third-party affiliate integrations on 18 April 2018 for [seven platforms](https://updates.paddle.com/en/introducing-third_party-affiliate-integrations-80988252): iDevAffiliate, CJ Affiliate, HasOffers, Impact Radius, Tapfiliate, WebGains and Voluum. The mechanism passed sale value, currency and product name out of Paddle's checkout. Paddle Billing, released in 2023, did not carry it forward.

One thing Paddle Billing does ship natively, and does better than a bolted-on tracker: discount codes. Paddle's [discounts documentation](https://developer.paddle.com/build/products/offer-discounts-promotions-coupons) says "You can assign a discount code, or can have Paddle automatically generate one for you", with expiry dates and redemption caps enforced by Paddle rather than your code, and `discount_id` on the transaction. Verified on 2026-09-14. For a flat bounty per code redemption, that is complete with no second system in it.

It stops in two places. A code cannot tell you who clicked and did not buy, and it forces a price cut on every sale you attribute. Our normalizer ignores `discount_id`, which is a gap on our side rather than a criticism of Paddle's.

## The join key is `custom_data.customer_user_id`, and Paddle copies it forward

Put your own user id into `custom_data` on the transaction, under the key `customer_user_id`. Paddle copies that object onto every entity downstream, so one write at checkout attributes the whole subscription.

This sample from our [webhook reference](https://docs.myappaffiliate.com/webhooks) creates a transaction with the id attached:

```typescript
await paddle.transactions.create({
  items: [{ priceId: "pri_…", quantity: 1 }],
  customData: { customer_user_id: user.id },
});
```

Every `transaction.completed` for that customer then arrives carrying `data.custom_data.customer_user_id`, and so does the subscription Paddle creates from it.

The propagation is documented rather than lucky. Paddle's [custom data page](https://developer.paddle.com/build/transactions/custom-data) states that where a transaction has custom data, "that data is copied to the created subscription", and a subscription's custom data "is copied to any transactions created from it for things like renewals, upgrades and downgrades, and one-time charges". Verified on 2026-09-14. That beats the equivalent on RevenueCat or Adapty, where the id has to be re-asserted by the SDK every session.

If you cannot thread your id through checkout, we fall back to `data.customer_id`, prefixed with `ctm_`, which works only if `ctm_...` is also the string your attribution layer identified with. Our opinion, unchanged across five billing providers: identify with the provider's own id when the checkout is hard to modify, and never invent a third identifier to reconcile later.

One caveat that returns in the refund section: `custom_data` exists on transactions and subscriptions, and not on adjustments.

## Which Paddle Billing events change what a creator is owed

Three of the twenty-one transaction, adjustment and subscription events Paddle documents. `transaction.completed` creates a commission. `adjustment.created` with `action: refund` reverses one. `subscription.canceled` ends future commission and touches no past one. The other eighteen are state, and a ledger reading any of them double-counts.

![Paddle Billing event map showing which webhook events create, reverse or ignore an affiliate commission](https://www.myappaffiliate.com/blog-images/paddle-affiliate-tracking/paddle-affiliate-tracking-event-map.webp "Three of twenty-one Paddle events move a creator balance. The rest are state.")

| Event | Paddle's description | Moves a balance | Deciding field |
| --- | --- | --- | --- |
| `transaction.completed` | "A customer completed a purchase" | Yes | `data.origin` |
| `transaction.paid` | A transaction payment was received | No | `fee` and `earnings` are still null |
| `transaction.billed` | A transaction has been billed to the customer | No | Nothing has been paid |
| `transaction.past_due` | A transaction payment is overdue | No | A recovery arrives as `completed` |
| `transaction.payment_failed` | A transaction payment attempt failed | No | Pays twice if treated as a charge |
| `transaction.revised` | A transaction was revised | No | Customer detail changes, not money |
| `adjustment.created` | A new adjustment was created | Only when `action` is `refund` | `data.action` |
| `adjustment.updated` | An adjustment was updated | Approval outcome only | `data.status` |
| `subscription.canceled` | "Occurs when a subscription is canceled" | No | Past commissions stand |
| `subscription.trialing` | A subscription entered its trial period | No | Nothing charged yet |
| `subscription.past_due` | A subscription payment is overdue | No | Flag the renewal at risk |
| `subscription.updated` | "Catch-all for subscription state changes" | No | Fires constantly. Ignore it |

Names checked against Paddle's [webhook overview](https://developer.paddle.com/webhooks/overview) and the per-event references on 2026-09-14.

The reason to wait for `completed` rather than `paid` is in the field definitions, not the names. Paddle documents `details.totals.fee` and `details.totals.earnings` as "null until the transaction is `completed` and the fee is processed". Commission on `transaction.paid` and the two numbers a net rate needs are still null.

### How do you tell a first purchase from a scheduled rebill?

`data.origin`. Paddle sets it to one of six values describing how the transaction was created, and `subscription_recurring` means a renewal. Get this wrong and a monthly subscriber pays a new-customer bounty twelve times a year.

| `origin` | What created the transaction | Our mapping today |
| --- | --- | --- |
| `web` | Paddle.js checkout | `purchase` |
| `api` | Created through the Paddle API | `purchase` |
| `subscription_recurring` | A subscription renewal | `renewal` |
| `subscription_charge` | A one-time charge on a subscription, billed now | `purchase` |
| `subscription_update` | A change to recurring items, billed immediately | `purchase` |
| `subscription_payment_method_change` | A zero-value transaction to update card details | `purchase` |

Values from the [`transaction.completed` reference](https://developer.paddle.com/webhooks/transactions/transaction-completed), verified on 2026-09-14. Paddle split `subscription_charge` out of `subscription_update` on 10 July 2023 so one-time charges could be told apart from proration.

Read that right-hand column as a warning rather than a recommendation. We branch on `subscription_recurring` and treat the other five as a purchase, right for `web` and `api` and too generous for the rest. An immediate upgrade arrives as `subscription_update` and books a second first-sale commission for a customer already attributed. If your deal pays a bounty on first purchase, branch on all six.

## `total`, `earnings` and `tax` are three different numbers on one transaction

Paddle gives you the breakdown in `data.details.totals`, and the field you charge on decides how much of your margin the creator takes. `total` is what the customer paid. `earnings` is `total` minus Paddle's fee. Neither reaches your bank account, because tax is remitted separately.

| Field | Paddle's definition, verbatim | What it is good for |
| --- | --- | --- |
| `details.totals.subtotal` | "Subtotal before discount, tax, and deductions" | The list price actually charged |
| `details.totals.tax` | "Total tax on the subtotal" | Money Paddle remits, never yours |
| `details.totals.total` | "Total after discount and tax" | What the customer paid |
| `details.totals.fee` | "Total fee taken by Paddle for this transaction" | Paddle's cut |
| `details.totals.earnings` | "Total earnings for this transaction. This is the total minus the Paddle fee" | Net of the fee, still includes tax |
| `details.payout_totals.earnings` | "Total earnings for this payout. This is the subtotal minus the Paddle fee" | The closest field to real net |
| `details.payout_totals.currency_code` | The currency "used for the payout for this transaction" | Not the customer's currency |

Definitions verified on 2026-09-14. Paddle defines the two `earnings` fields differently, and the difference is exactly the tax.

![Paddle transaction totals broken into tax, Paddle fee and seller earnings with commission calculated three ways](https://www.myappaffiliate.com/blog-images/paddle-affiliate-tracking/paddle-affiliate-tracking-gross-vs-net.webp "The same $53.00 transaction produces three defensible commission figures.")

Take a $50.00 plan sold to a US customer in a state charging 6 percent sales tax. Paddle's published rate is 5 percent plus 50 cents per checkout transaction, verified on [Paddle's pricing page](https://www.paddle.com/pricing) on 2026-09-14, and the figure arrives in `fee` so you never derive it. The payload carries `subtotal: "5000"`, `tax: "300"`, `total: "5300"`, `fee: "315"`, `earnings: "4985"` and `payout_totals.earnings: "4685"`.

Paddle's [reconciliation formula](https://developer.paddle.com/build/finance/reports/payout-reconciliation) subtracts tax, the Paddle fee, retained fees, FX fees and chargeback fees from the gross total. Ignoring FX, 5300 minus 300 minus 315 is 4685 cents. The seller receives **$46.85** of the $53.00 the customer paid.

At 20 percent, that one transaction supports three defensible commissions:

| Basis | Amount | Commission | Share of the $46.85 you received |
| --- | --- | --- | --- |
| `total` (gross) | $53.00 | $10.60 | 22.6% |
| `details.totals.earnings` | $49.85 | $9.97 | 21.3% |
| `payout_totals.earnings` (real net) | $46.85 | $9.37 | 20.0% |

**We read `total` today, so a Paddle seller using us pays $10.60 where the deal says $9.37.** That is $1.23 per transaction, 13.1 percent more than the creator agreement describes, on money that reached tax authorities and Paddle's balance rather than yours. The per-app `commission_basis: gross | net` setting that fixes it is planned and not shipped. The general argument is in [net versus gross commission](/blog/affiliate-commission-net-vs-gross-app-store), and it lands differently here, because a merchant of record puts tax inside the number the webhook hands you.

Building this yourself, read `payout_totals.earnings` rather than `details.totals.earnings` for a net deal. The second still contains tax.

## A refund arrives as an adjustment, and `adjustment.created` is not always a refund

Paddle sends no event named `refund`. Refunds, credits and chargebacks all arrive as `adjustment.created`, separated only by `data.action`, which Paddle documents with seven values. Reverse on the wrong one and you claw back money a creator earned.

| `action` | What Paddle says it does | Reverse a commission? |
| --- | --- | --- |
| `refund` | "Refunds some or all the related transaction" | Yes |
| `credit` | "Credits some or all the related transaction. Doesn't require Paddle approval" | No. Credit against a future invoice |
| `chargeback` | Created when a customer disputes a charge | Your call. Money did leave |
| `chargeback_warning` | Warning of an upcoming chargeback | No. Nothing has happened yet |
| `chargeback_reverse` | Reversal of a successfully contested chargeback | Restore, if you reversed on `chargeback` |
| `chargeback_warning_reverse` | Reversal of a chargeback warning | No |
| `credit_reverse` | Reversal of a credit | No |

Values from the [`adjustment.created` reference](https://developer.paddle.com/webhooks/adjustments/adjustment-created), verified on 2026-09-14. We map only `refund` and drop the other six, which is the right default and leaves chargebacks uncounted.

Two properties of the payload matter more than the action list, and both surfaced while checking this post against our shipped normalizer.

The first is identity. There is no `custom_data` on an adjustment: the entity carries `id`, `action`, `type`, `transaction_id`, `subscription_id`, `customer_id`, `reason`, `credit_applied_to_balance`, `currency_code`, `status`, `items`, `totals`, `payout_totals`, `tax_rates_used` and timestamps. So a refund cannot carry the id you stamped at checkout, and the only join Paddle gives you is `transaction_id`.

Store the transaction id next to the purchase, then look the purchase up by it when the refund arrives. We read `custom_data.customer_user_id` on an adjustment until 2026-09-14, which was reading a field Paddle does not send: every refund fell through to the `customer_id` fallback, and a seller who stamped their own id at checkout got refunds keyed on a `ctm_` id that matched no attribution, so the commission that should have reversed stayed payable and nothing errored. The fallback is still there and still correct for a seller who never set `custom_data`, because then both sides are keyed on `ctm_` ids and they meet.

The second is timing. Paddle's [adjustments guide](https://developer.paddle.com/build/transactions/create-transaction-adjustments) says "Most refunds for live accounts must be approved by Paddle, but some are automatically approved", automatic approval covering verified accounts under roughly $400 with a sufficient balance. A refund that needs review is created as `pending_approval` and the outcome arrives later as `adjustment.updated`. Verified on 2026-09-14.

So read `status` before touching a balance, and subscribe to `adjustment.updated` in the same change. Gating on status without handling the update is the worse of the two bugs: every refund that needs review stops reversing at all, instead of some reversing early. We reverse on `approved` only, from either event, and key the stored event on the adjustment id rather than the notification id, so an auto-approved refund that fires both `created` and `updated` reverses once.

The rest is the problem [every subscription payout has](/blog/affiliate-commissions-payouts-subscriptions): hold new commissions so a reversal inside the window is a status change rather than a clawback, and match the reversal to a period rather than the whole chain. We default to a 14 day hold, set per app.

## Verify the `Paddle-Signature` header in five steps

Paddle signs every webhook with an HMAC-SHA256 over the timestamp and raw request body, keyed on the destination's secret. The header looks like `ts=1728381600;h1=<hex>`. The most common failure is parsing the JSON and re-serializing it before hashing.

1. Create a notification destination at **Developer tools > Notifications**, choose your events, and save the endpoint secret key. Paddle allows unlimited destinations but "only 10 can be active at once".
2. Read the `Paddle-Signature` header and split it on `;` into `ts` and one or more `h1` values. Paddle's [reference](https://developer.paddle.com/webhooks/signature-verification) says "Signatures contain at least one `h1`" and that more than one appears during a secret rotation, so loop over all of them.
3. Build the signed payload as `ts`, a colon, then the body exactly as received. Paddle's instruction is blunt: "Don't transform or process the raw body of the request, including adding whitespace or applying other formatting."
4. Compute `HMAC-SHA256(secret, signedPayload)`, hex encode it, and compare against each `h1` in constant time.
5. Check `ts` against your clock and reject anything stale. Paddle's SDK helpers default to a five second tolerance.

Step 5 is where we deliberately differ. We allow 300 seconds rather than five, because five is tight enough that a slow cold start or a drifting host clock turns a valid event into a 401, and a rejected webhook on a commission endpoint costs a creator money.

Two delivery details belong on the same handler. Paddle expects HTTP `200` "within five seconds", and retries 3 times in 15 minutes on sandbox and 60 times in 3 days on live, per its [delivery guide](https://developer.paddle.com/webhooks/about/respond-to-webhooks) verified on 2026-09-14. Store the raw body, answer 200, process afterwards. Deduplicate on `event_id`, never `notification_id`, which Paddle defines as "Unique ID for this delivery attempt": it changes on every retry, and a ledger keyed on it pays the same renewal sixty times.

## What happens when Paddle bills a customer in EUR

Paddle prices "in over 30 different currencies" and pays out in five: USD, EUR, GBP, AUD and CAD. Verified on 2026-09-14. The webhook reports the customer's currency in `data.currency_code` and the amount in that currency's minor units, as a string.

Paddle's [data types reference](https://developer.paddle.com/api-reference/about/data-types) is explicit: "Monetary values are returned as strings in the lowest denomination for a currency", with $24.99 arriving as `"2499"` and ¥1000 as `"1000"`. Two traps in one sentence. The value is a string, so arithmetic without an integer parse concatenates instead of adding. And decimal places belong to the currency, so a hard-coded divide by 100 turns ¥1000 into ¥10.00.

Our own gap is the field name rather than the parse. The normalized amount field is called `amountUsdCents` and receives minor units in the transaction currency, with the real currency in a separate `currency` field beside it. A €9.99 purchase is stored as `999` with `currency: "EUR"`. Nothing is converted: a creator owed 20 percent of it is owed 199.8 EUR cents, and a payout run treating that as dollars is wrong by whatever the euro did that week. Renaming the field and snapshotting a rate is planned alongside the commission basis work, because both ask what a commission is denominated in.

Until it ships, price in one currency or run payouts per currency.

## A twelve month Paddle ledger for one subscriber with one refund

One subscriber on the $50.00 plan above, a 20 percent rate, twelve completed transactions and one refund in month five. Every credit is 5300 minor units times 20 percent, or **$10.60**, because we commission on `total`. The creator finishes the year at $116.60.

| Month | Event | `origin` or `action` | Commission | Balance |
| --- | --- | --- | --- | --- |
| 1 | `transaction.completed` | `web` | +$10.60 | $10.60 |
| 2 to 4 | `transaction.completed` × 3 | `subscription_recurring` | +$31.80 | $42.40 |
| 5 | `transaction.completed` | `subscription_recurring` | +$10.60 | $53.00 |
| 5 | `adjustment.created` | `refund`, approved | -$10.60 | $42.40 |
| 6 to 12 | `transaction.completed` × 7 | `subscription_recurring` | +$74.20 | $116.60 |

Twelve credits at $10.60 is $127.20, one reversal takes $10.60 off, and $116.60 is left. Eleven of the twelve charges survive.

Now price the gap. On `payout_totals.earnings` those eleven charges pay $9.37 each, or $103.07. Commissioning on gross costs this seller **$13.53 more per subscriber per year**, 13.1 percent of the correct figure, out of margin already thinned by tax and a 5 percent fee. At a hundred subscribers, $1,353 a year.

The refund row decides whether an implementation is right. It lands nine days after the charge it reverses, inside even the default 14 day hold, so nothing is clawed back from a creator already paid. Reverse a matured commission and you are invoicing a creator for money you sent.

## Where MyAppAffiliate fits

Paddle is a shipped source in our ingest, listed on our public [webhook reference](https://docs.myappaffiliate.com/webhooks) beside RevenueCat, Stripe, Adapty and Superwall. The endpoint is `/webhooks/paddle/<appId>`, the signature check is the one above, and the three events normalize to the same vocabulary every other provider does. That is the point of [one attribution engine across mobile and web](/blog/one-attribution-engine-mobile-and-web): a Paddle web subscription and an iOS purchase land in the same ledger.

Two things are not finished, and both are in this post rather than a changelog nobody reads: commission is charged on Paddle's gross `total`, and the amount field is named for USD while holding minor units in the transaction currency. We charge a flat monthly fee and no percentage of tracked revenue, which is the argument on [flat pricing](/blog/why-flat-pricing-no-revenue-share) and the reason the gross basis costs you rather than us.

## Send one sandbox transaction before you trust the ledger

Do this next, in this order. Create a sandbox notification destination, subscribe it to `transaction.completed`, `adjustment.created` and `adjustment.updated`, and push one checkout through with `customData: { customer_user_id: "test-user-1" }`. Confirm the stored event carries that string and an `origin` of `web`, then issue a sandbox refund and confirm the reversal finds the original commission. If it does not, the join key is your bug. If the refund lands as `pending_approval` and nothing ever reverses, you are missing `adjustment.updated`.

If you sell through a website tracker rather than a billing webhook, check the same reversal logic against [Rewardful and in-app purchases](/blog/rewardful-in-app-purchases-mobile-apps) first. If you also bill mobile subscriptions, wire that side from [the RevenueCat attribution setup](/blog/revenuecat-affiliate-tracking) and deduplicate across both.

## FAQ

### Which Paddle event should create an affiliate commission?

`transaction.completed`, and no earlier one. Paddle documents `fee` and `earnings` as null until the transaction is completed, so `transaction.paid` cannot tell you what the sale was worth. Read `data.origin` to decide first sale or renewal.

### Where do I put the affiliate user id on a Paddle checkout?

In `custom_data` on the transaction, as `customer_user_id`. Paddle copies custom data from a transaction to the subscription it creates, and from a subscription to every transaction created from it, so one write at checkout survives every later renewal.

### Should a Paddle commission be charged on `total` or `earnings`?

On earnings if you want the creator paid from money you actually received. `total` includes tax Paddle remits and the fee Paddle keeps. On a $50 plan with $3.00 tax and a $3.15 fee, 20 percent is $10.60 on `total` and $9.37 on the real payout.

### Why did my Paddle refund never reverse a commission?

Probably the join key. Paddle sends no `custom_data` on an adjustment, so the only identity on a refund is the Paddle `customer_id` and the `transaction_id`. If you attributed the purchase with your own user id from `custom_data`, the refund matches nothing.
