Attribution

External checkout attribution: how to preserve UTMs across domains

The biggest attribution problem for anyone selling through an external checkout: the sale happens on a different domain (ClickFunnels, SamCart, ThriveCart, Hotmart, Kiwify) and UTM parameters disappear in the redirect. Result: broken attribution, wrong ROAS, inflated CAC. See how to fix it in 7 minutes.

Direct answer

A 3-part strategy: (1) capture the URL's UTMs in JS on the landing page and persist them in localStorage; (2) when the user clicks the buy button, append those UTMs to the checkout link (ClickFunnels/SamCart accept utm_* natively; Hotmart accepts sck); (3) when the checkout platform fires the approved-sale webhook, your server sends the Purchase event to Meta CAPI with the original UTMs. Closes the loop 100%.

The external checkout black hole

You run Meta Ads to a landing page. The user clicks, lands with ?utm_source=meta&utm_medium=cpc&utm_campaign=product_x. So far so good.

Then they hit "Buy now", which sends them to the external checkout: checkout.example.com/A12345B?ref=.... The UTMs vanish on that redirect. The landing page pixel fires ViewContent, but the Purchase happens on the checkout's domain — outside the pixel's scope.

Practical result in a typical operation that uses an external checkout (info products, courses, digital downloads, high-ticket SaaS sold via checkout platform):

  • You spent $10k on Meta Ads on campaign X;
  • The checkout platform shows 80 sales came through in that period;
  • Meta only attributes 25 of those sales to campaign X (the rest landed as "organic" or "direct" because UTMs were lost);
  • You think campaign X has 2.5× ROAS, when reality is 8×.

You pause the campaign for "poor performance". You were actually killing the goose that lays the golden eggs.

The strategy: 3 parts that close the loop

  1. Capture UTMs on the landing (the moment the user arrives);
  2. Propagate UTMs to the checkout link (the moment they click);
  3. Attribute via webhook when the sale is approved (server → Meta CAPI).

Each part is simple alone, but you need all 3 working together for complete attribution.

Part 1: capture UTMs on the landing

JS in the header of your landing page (runs before the user interacts):

// landing-utm-capture.js
(function() {
  const params = new URLSearchParams(window.location.search);
  const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'fbclid', 'gclid', 'ttclid'];

  const captured = {};
  let hasNew = false;

  utmKeys.forEach(key => {
    const value = params.get(key);
    if (value) { captured[key] = value; hasNew = true; }
  });

  if (hasNew) {
    // Persist for 30 days
    const data = { ...captured, capturedAt: Date.now() };
    localStorage.setItem('trakvo_attribution', JSON.stringify(data));
    // Cookie as fallback (in case localStorage is cleared)
    document.cookie = `trakvo_attribution=${encodeURIComponent(JSON.stringify(data))}; max-age=${30 * 24 * 60 * 60}; path=/; SameSite=Lax`;
  }
})();

Why persist to both localStorage AND cookie? Because some mobile browsers wipe one or the other. Two redundant stores = more robust.

Part 2: propagate UTMs to the checkout link

For ClickFunnels, SamCart, ThriveCart, Kiwify (native UTM support)

function buildCheckoutUrl(checkoutBase) {
  const attribution = JSON.parse(localStorage.getItem('trakvo_attribution') || '{}');

  const url = new URL(checkoutBase);

  // These platforms accept UTMs natively — just pass them through
  ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'].forEach(key => {
    if (attribution[key]) url.searchParams.set(key, attribution[key]);
  });

  // Click IDs as custom query (most platforms store them in metadata)
  if (attribution.fbclid) url.searchParams.set('fbclid', attribution.fbclid);
  if (attribution.gclid)  url.searchParams.set('gclid',  attribution.gclid);

  return url.toString();
}

document.querySelector('.btn-buy').href = buildCheckoutUrl('https://checkout.example.com/product-x');

For Hotmart (uses the sck parameter)

// Hotmart's sck accepts up to 80 chars — encode key attribution info:
function buildHotmartCheckoutUrl(productCode) {
  const attribution = JSON.parse(localStorage.getItem('trakvo_attribution') || '{}');

  const sck = [
    attribution.utm_source || 'direct',
    attribution.utm_campaign || 'none',
    attribution.utm_content || 'none',
    attribution.fbclid ? attribution.fbclid.slice(0, 16) : ''
  ].join('-');

  const url = new URL(`https://pay.hotmart.com/${productCode}`);
  url.searchParams.set('sck', sck);

  // Hotmart also accepts standard UTMs:
  Object.entries(attribution).forEach(([key, value]) => {
    if (key.startsWith('utm_')) url.searchParams.set(key, value);
  });

  return url.toString();
}

Part 3: server-side webhook → Meta CAPI

Modern checkout platforms expose a webhook when a sale is approved. Configure it on the panel of each one (Settings → Webhooks → Add endpoint).

Your PHP endpoint receives a POST like (example shape — actual fields vary by platform):

// Approved-sale webhook (simplified)
{
  "data": {
    "purchase": {
      "transaction": "TX12345...",
      "status": "APPROVED",
      "approved_date": 1716640000000,
      "tracking": {
        "sck": "meta-campaign123-adset456-fbclid_abc",
        "utm_source": "meta",
        "utm_medium": "cpc",
        "utm_campaign": "product_x"
      },
      "price": { "value": 297.00, "currency_value": "USD" }
    },
    "buyer": {
      "email": "[email protected]",
      "name": "John Doe",
      "checkout_phone": "+15551234567"
    }
  }
}

On your server, validate the webhook's HMAC signature, then dispatch CAPI:

// webhook-handler.php
$payload = json_decode(file_get_contents('php://input'), true);
$purchase = $payload['data']['purchase'];
$buyer = $payload['data']['buyer'];

// Decode sck to recover the original UTMs (Hotmart-style)
$sck_parts = explode('-', $purchase['tracking']['sck'] ?? '');
$utm_source   = $sck_parts[0] ?? 'direct';
$utm_campaign = $sck_parts[1] ?? null;
$fbclid_short = $sck_parts[3] ?? null;

// Build Meta CAPI event
$event = [
    'event_name' => 'Purchase',
    'event_time' => intval($purchase['approved_date'] / 1000),
    'event_id' => $purchase['transaction'],  // transaction id = event_id
    'action_source' => 'website',
    'event_source_url' => 'https://yourlanding.com/product-x',
    'user_data' => [
        'em' => [hash('sha256', strtolower(trim($buyer['email'])))],
        'ph' => [hash('sha256', preg_replace('/[^0-9]/', '', $buyer['checkout_phone']))],
        'fn' => [hash('sha256', strtolower(explode(' ', $buyer['name'])[0]))],
    ],
    'custom_data' => [
        'value' => $purchase['price']['value'],
        'currency' => $purchase['price']['currency_value'],
        'content_name' => 'Product X',
    ]
];

// Send to Meta CAPI
$ch = curl_init("https://graph.facebook.com/v19.0/{$pixel_id}/events");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'data' => [$event],
    'access_token' => META_ACCESS_TOKEN
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);

// Respond 200 to the checkout platform
http_response_code(200);
echo 'OK';
Pro tip: store UTMs in your own DB alongside the order. That way you can run your own reports later (ROAS by campaign, by ad, by audience) without depending solely on Meta.

How to test before going to production

  1. Visit your landing with fake UTMs: ?utm_source=test&utm_campaign=debug;
  2. Open DevTools → Application → Local Storage. Check that trakvo_attribution was saved;
  3. Click the "Buy" button. Check that the checkout URL carries the UTMs/sck (inspect the link's href);
  4. Complete a test purchase (most checkout platforms have a sandbox mode);
  5. Check that the webhook fired (your server's log);
  6. In Meta Events Manager → Test Events: check that the Purchase showed up with the right fields;
  7. EMQ for the event should be 6+ (with hashed email + phone).

FAQ

Which external checkout platforms accept UTMs natively?

Most modern ones: ClickFunnels, SamCart, ThriveCart, PayKickstart, Kiwify and Eduzz accept utm_source, utm_medium, utm_campaign, utm_content and utm_term directly in the checkout URL. Hotmart uses a proprietary parameter called "sck" that can hold up to 80 characters of custom attribution data.

Do I need a webhook if I'm already passing UTMs?

Not strictly, but a server-side webhook is what closes the loop. Without it, you know the UTMs on the checkout platform side but Meta never receives the Purchase event via CAPI — attribution stays broken on the ads side.

Does the UTM expire? How long does it last?

Browser session by default. That's why you should capture the UTM immediately when the user lands (via JavaScript in the header) and persist it in localStorage or a cookie for 30-90 days. Otherwise, if the user returns tomorrow, the UTM is gone.

What about Stripe Payment Links and other minimal checkouts?

Stripe Payment Links accept query string metadata via "client_reference_id" or custom params, but you have less flexibility. The recommended approach is to use Stripe Checkout (your own integration) instead of pre-built links when you need full attribution.

Will this work for Hotmart, Kiwify, Eduzz (Brazilian platforms)?

Yes. The same logic applies. Hotmart accepts the "sck" parameter; Kiwify and Eduzz accept standard UTMs. All three platforms expose approved-sale webhooks. The code samples below use Hotmart as an example because of its sck parameter, but the pattern is universal.

Trakvo handles this automatically

Connect your checkout platform to Trakvo via OAuth and you're done — UTMs propagated, webhooks integrated, CAPI synced. No code.

Talk to the team
Trakvo Assistant
Reply in real time