# Verify your Dub + Stripe referral tracking before launch: one test customer, four records

Maya clicks your partner Acme's link on a Tuesday, signs up with Google, pays for your plan through Stripe Checkout, and asks for her money back nine days later. That one customer leaves four records behind: a click, a lead, a sale and a refund. If any of them is missing, or lands on the wrong partner, you'll hear about it from Acme, probably in an email with a screenshot attached.

So before a real partner sends you traffic, verify your referral tracking end to end: push one test customer through all four on Dub and Stripe, and write down the IDs at each hop.

I'm a Dub affiliate. I checked every step against Dub's docs on Nov 11, 2026, but haven't run it in a live or staging workspace myself. Maya, Acme and every ID below are made up. The "Observed" and "Pass/Fail" columns in the table are blank on purpose, for your own run.

## What you need first

This is for a SaaS running its own partner program on Dub with Stripe billing, and it covers one path: Stripe Checkout in subscription mode. Other payment setups are listed at the end. Run them as separate traces.

- A Dub staging workspace. Staging is on Business plans and above, and only when the workspace has a partner program. Free and Pro workspaces don't get one ([testing docs](https://dub.co/docs/testing)). Business is $90 a month, or $81 a month billed yearly ([pricing](https://dub.co/pricing)). Staging itself isn't billed ([testing docs](https://dub.co/docs/testing)). Dub's 14-day trial charges your card for the first month when it ends, and you can cancel any time before that ([free trial](https://dub.co/help/article/free-trial)). The docs don't say whether a workspace still on its trial gets staging, so check first.
- Staging keys, not production ones. Staging API keys start with `dub_test_` and staging publishable keys with `dub_pk_test_`. API keys, publishable keys, webhooks and integration connections are all separate from production (same page).
- Stripe in test mode or a Stripe sandbox, connected to the staging workspace. Live mode is rejected in staging.
- The Dub Analytics script on your site and Dub's server-side SDK in your backend ([Stripe integration](https://dub.co/docs/integrations/stripe)).
- Test partner email addresses. Staging payouts are simulated, but "Partners still receive the payout email for staging payouts" ([testing docs](https://dub.co/docs/testing)).

## The cast

- Acme is a test partner in your staging program, with a staging partner link.
- Maya is a test user. She clicks Acme's link, signs up, subscribes, then asks for a refund.
- Your app is on `app.example.com` and your marketing site on `example.com`.

Write down every ID as you go. The trace is only as good as what you record.

## The ID chain

Fill this in before you click anything. Each row has to hand off to the next.

| Hop | Where it lives | Example value | Has to match |
|:--|:--|:--|:--|
| Click ID | `dub_id` cookie set by the Analytics script | `clk_EXAMPLE` | Sent as `clickId` in the lead event |
| Your user ID | Your database | `user_maya_001` | Sent as `customerExternalId` in the lead event |
| Checkout link-back | `metadata.dubCustomerExternalId` on the Checkout Session | `user_maya_001` | Exactly the `customerExternalId` above |
| Stripe customer | `cus_...` | `cus_EXAMPLE` | Created by Checkout for Maya |
| Subscription and invoice | `sub_...`, `in_...` | `sub_EXAMPLE`, `in_EXAMPLE` | The paid invoice behind the sale |
| Charge | `ch_...` | `ch_EXAMPLE` | The charge you refund |

The rule that's easiest to break: the value you send as `customerExternalId` at signup has to be the same value you put in the Checkout Session's `metadata.dubCustomerExternalId`. If one is your user ID and the other is an email or a team ID, the sale can't tie back to the click ([Stripe integration](https://dub.co/docs/integrations/stripe)).

## The trace, step by step

**1. Check the script, and that `dub_id` reaches signup.** On a page where the Analytics script is installed, open the browser console and type `_dubAnalytics`. You should see the object. Then load that page with `?dub_id=test` and check that a `dub_id` cookie appears. If either check fails, Dub's docs say to make sure the script is in the page's `<head>` and to purge any CDN cache ([install checks](https://dub.co/docs/sdks/client-side/installation-guides/manual)). On a partner program, also set the script's `data-domains` property to your referral link domain; the same page says you need it "to enable client-side click-tracking." The check that matters is whether the `dub_id` cookie reaches your signup handler.

Pass: `_dubAnalytics` is defined and the `?dub_id=test` cookie appears, with `data-domains` set.

**2. Click Acme's staging link as Maya**, in a fresh browser profile. Staging links are on `{your-workspace-slug}.staging.dub.link` ([testing docs](https://dub.co/docs/testing)), so write that domain down along with the `dub_id` value and the domain the cookie is set on.

The cookie lasts 90 days by default, and Dub gives the sale to the last partner link clicked unless you switch to first-click ([how attribution works](https://dub.co/docs/concepts/attribution)).

Pass: the click shows up in the staging workspace, and the `dub_id` cookie can be read on your signup domain, so the handler can send it as `clickId`.

**3. Sign Maya up.** Your backend sends a lead event with `clickId` from the cookie, an event name like `Sign Up`, and `customerExternalId` set to Maya's user ID. Dub calls server-side tracking "the recommended way to track conversions" ([server-side tracking](https://dub.co/docs/conversions/quickstart)).

If sign-in runs through a callback route, like OAuth or a magic link, send the lead inside that callback, where the `dub_id` cookie is still available. Dub's Supabase example tracks the lead only when the cookie is there and the user was created in the last 10 minutes, and then deletes the cookie ([Supabase guide](https://dub.co/docs/conversions/leads/supabase)). You don't need Supabase to copy the idea.

Dub records only the first lead for each `customerExternalId` and `eventName` pair ([how attribution works](https://dub.co/docs/concepts/attribution)). So a retried callback shouldn't double-count, as long as every signup path sends the same event name and the same user ID. Test it: fire the callback twice and confirm one lead.

If a signup alone isn't the lead you want to pay for, say a partner should only earn once a user does something real in your product, Dub has deferred lead tracking, which creates the lead event in a later request ([deferred lead tracking](https://dub.co/docs/conversions/leads/deferred)). Trace that as its own run.

Pass: one lead for Maya in staging, on Acme's link, with customer external ID `user_maya_001`.

**4. Start a subscription Checkout.** Create the Checkout Session with `mode: "subscription"` and `metadata.dubCustomerExternalId` set to Maya's user ID. Pay with one of Stripe's test cards ([Stripe testing docs](https://docs.stripe.com/testing)).

Pass: a sale on Maya's customer in staging, on Acme's link, with a commission for Acme.

**5. Check the amount.** Dub leaves tax out of the sale amount. For a Checkout Session it records `amount_total - total_details.amount_tax`. For invoices it records `amount_paid × total_excluding_tax / total`, so "A fully paid invoice records exactly `total_excluding_tax`," and a partly paid one, say with a credit balance applied, records the paid amount without its tax share (same page). If an invoice has several line items, Dub works out the commission line by line. Compare Dub's sale amount to that number and to the reward you set.

Say Maya's plan is $49 a month and 8% sales tax brings the invoice to $52.92. She pays it in full, so either way Dub should record $49.00: $52.92 minus $3.92 of tax on the session, or $52.92 × $49.00 / $52.92 on the invoice. At a 20% reward, Acme's commission is $9.80.

Pass: sale amount $49.00, commission $9.80.

**6. Refund the charge in full, before the commission is paid out, and to be safe before its payout starts processing.** With the Stripe integration, "refunds are automatically tracked without any additional setup" ([refunds docs](https://dub.co/docs/conversions/sales/refunds)), and Dub's payouts help says the commission "will also be marked as refunded" ([payouts help](https://dub.co/help/article/partner-payouts)).

Pass: Acme's commission shows refunded. Then check whether it's still in the pending payout. The docs say a refunded commission is left out of pending payouts on the manual path, but don't spell that out for Stripe refunds, so record what you see.

Dub's docs don't say how a partial refund changes the commission. If you offer partial refunds, run one as its own row and record what happens.

**7. Test a refund after the payout.** Staging payouts are simulated, with no bank charge and no money moving ([testing docs](https://dub.co/docs/testing)), so you can confirm one, refund the charge after it, and record whether the commission changes. If your program uses external or hybrid payouts, staging sends `external_payout.confirmed`, and the payout stays processing until a test handler responds, so point that webhook at one first (same page). Dub's [refunds docs](https://dub.co/docs/conversions/sales/refunds) say "Commissions that are already paid cannot be refunded." They suggest a payout holding period that matches your refund policy, so a 30-day refund window gets a 30-day hold, and a clawback for anything already paid. Pick your holding period and write the clawback steps down now.

**8. Go live on purpose.** Swap every `dub_test_` and `dub_pk_test_` key for production keys, connect live Stripe to the production workspace, and send one real conversion through a production link. Dub's docs say to confirm it lands in production "before you treat the integration as live." Staging data stays in staging ([testing docs](https://dub.co/docs/testing)).

## The test table

Copy this, run each row in staging and fill in the blank columns.

| Scenario | Expected | Observed | IDs to record | Pass/Fail | Fix if it fails |
|:--|:--|:--|:--|:--|:--|
| Email signup | One lead on Acme's link, `customerExternalId` = your user ID | | `dub_id`, user ID, lead event | | Lead not sent, or sent without the cookie value. Check the signup handler reads `dub_id`. |
| OAuth or callback signup | One lead, even if the callback runs twice | | `dub_id`, user ID, event name, callback time vs. user created time | | Cookie lost on the provider redirect, or a new-user check filtered the wrong person. A second lead means a different `eventName` or `customerExternalId` on this path. |
| Marketing site to app subdomain | `dub_id` set on `example.com` is readable on `app.example.com` | | Cookie domain on each host | | If the script is only on the subdomain, set the cookie domain to `.example.com` ([cross-domain](https://dub.co/docs/sdks/client-side/features/cross-domain-tracking)) |
| Two different domains | Click ID carried from `example.sh` to `example.com` | | URLs before and after the hop | | Use the outbound-domains script on both domains (same page) |
| Paid subscription | Sale on Maya's customer, on Acme's link, commission created | | `cus_`, `sub_`, `in_`, Checkout Session ID | | `dubCustomerExternalId` missing, or not equal to the lead's `customerExternalId` |
| Full refund before payout | Commission marked refunded | | `ch_`, refund ID, commission ID | | Stripe app not connected to the staging workspace, wrong charge, or the commission was already paid (or its payout was processing) |
| Refund after a staging payout | Per the refunds docs, no automatic change. You handle it with a clawback | | Payout ID, commission ID | | Set a holding period to match your refund window and write down the clawback steps |
| Staging vs. production | Staging events stay in staging. Production uses `dub_` and `dub_pk_` keys and live Stripe | | Key prefixes in each environment | | A `dub_test_` key shipped to production. Replace it and redeploy. |

## When something's off

- Clicks show up, leads don't. The lead event isn't firing, or it has no click ID. Check that the cookie exists at signup time on the signup domain, and that every signup method runs the lead call on the server.
- Leads show up, sales don't. Check the Checkout metadata key is `dubCustomerExternalId` and that its value equals the lead's `customerExternalId`. Then check the Stripe app is connected to the same workspace, staging or production ([Stripe integration](https://dub.co/docs/integrations/stripe)).
- A one-time purchase didn't track. In guest checkout with `mode: "payment"`, the Checkout customer can be null and "sales won't be tracked on Dub." Dub's fix is `customer_creation: "always"` (same page). It's off the subscription path, and it still catches people.
- The sale amount looks low. Tax is left out on purpose, as in step 5's $52.92 invoice that records $49.00.
- Leads counted twice. Dub dedupes on `customerExternalId` plus `eventName`, so look for two signup paths sending different event names, like "Sign Up" and "Signup", or one sending an email where the other sends the user ID.
- A refund didn't void the commission. Look at the payout status. Dub's refunds docs say "Commissions that are already paid cannot be refunded" ([refunds docs](https://dub.co/docs/conversions/sales/refunds)). For disputes, the Stripe docs go further: Dub doesn't change a commission "after its payout starts processing or is paid" ([Stripe docs](https://dub.co/docs/integrations/stripe)). Assume the same for refunds until your test shows otherwise.
- Real partners got emails during testing. Staging payouts still email partners. Use test addresses only.

## Other payment paths

Each of these needs its own trace. All from [Dub's Stripe docs](https://dub.co/docs/integrations/stripe):

- Stripe Customers. Put `dubCustomerExternalId` and `dubClickId` in the customer's metadata. Leads and sales are tracked, but lead rewards aren't generated.
- Coupons and promotion codes. No code to write, but you lose geolocation, device, referrer and UTM data.
- Payment Links. Add `?dub_client_reference_id=1` to the Payment Link when you shorten it on Dub, and Dub appends the click ID as `client_reference_id` for you. If visitors land on your marketing site first, append the `dub_id` yourself, prefixed with `dub_id_`. Lead webhooks and lead rewards aren't generated on this path.
- Pricing Tables. Pass the click ID, prefixed with `dub_id_`, in the `client-reference-id` attribute.
- Free trials. Dub can track subscription free trials as lead events; turn on Track Free Trials in the Stripe integration settings. Trace that run through to the first paid invoice.
- Disputes. Holding a commission during an open dispute needs Advanced or above. "A lost dispute refunds the commission on every plan, the same as a Stripe refund," unless the payout is already processing or paid. For those, use a clawback.

## What to keep

Save the filled-in table, the IDs and the date you ran it. Run the paid subscription and refund rows again whenever you change auth, checkout or domains.

If you're still picking a tool, I compared [Dub vs Rewardful vs PartnerStack](https://referraltrackingnotes.hashnode.dev/dub-vs-rewardful-vs-partnerstack) and [Tolt alternatives](https://referraltrackingnotes.hashnode.dev/tolt-alternatives) on price. Once tracking passes, the next thing that bites is money: what happens to a partner's commission on [refunds, mid-cycle upgrades and Black Friday coupons](https://referraltrackingnotes.hashnode.dev/stripe-affiliate-tracking-refunds-upgrades-coupons).

If you're choosing a tool for this, Dub is the one this guide is built around; the test needs its $90 Business plan. Affiliate link - I may earn a commission: <https://refer.dub.co/shamil>

***

*Sources: Dub docs pages and Stripe's testing page, linked inline, read Nov 11, 2026. Checked against the docs, not run hands-on. All names and IDs are made up.*
