# track.js

> Complete reference for the tracking script – attributes, JavaScript API, what is stored in the browser, consent and errors.

track.js is Affilyflow's tracking script for webshops and websites. It stores the click when a customer lands from an affiliate link and reports the sale when the customer reaches the receipt.

## Add the script

Add the line to `<head>` on every page – including the receipt page.

```html title="<head> · every page"
<script src="https://app.affilyflow.com/t/DIN_BUTIK.js" async></script>
```

`/t/DIN_BUTIK.js` is the store's own script: the same track.js, with the store's settings from Setup (Google Analytics, VAT, shipping, consent) added by our server. Change a setting in Setup and it applies on your site within minutes – the line never changes. The older line still works, with the settings as attributes:

```html
<script src="https://app.affilyflow.com/track.js" data-store="DIN_BUTIK" async></script>
```

`DIN_BUTIK` is your store ID. The exact line with your ID is under Setup in the store dashboard – copy it from there.

Use `async`: the script must never hold the page back. The exception is the no-code lead trigger (`data-lead-url`), where the line must be without `async` – see [Leads without code](#leads-without-code).

`https://app.affilyflow.com/s.js` is the same file as `track.js` under a neutral name. Both addresses work, now and in the future. See [Neutral addresses and your own proxy](#neutral-addresses-and-your-own-proxy).

## Attributes

| Attribute | Required | Meaning |
|---|---|---|
| `data-store` | Yes | Your store ID. Sent with every sale and must match the click's store, otherwise the sale is rejected as `store_mismatch`. |
| `data-consent="required"` | No | Nothing is stored in the browser until the visitor has consented to marketing. See [Consent](#consent). |
| `data-lead-url` | No | The thank-you page of a lead program. When the page matches, a lead is reported automatically. See [Leads without code](#leads-without-code). |
| `data-cookie-domain` | No | The domain the cookie is set on (e.g. `shop.com`). Without it, the script picks the broadest domain the browser allows. |
| `data-sale="ga4"` | No | Read the purchase from Google Analytics 4 (dataLayer or gtag). See [Google Analytics](https://www.affilyflow.com/en/docs/reference/google-analytics). |
| `data-sale-vat` | No | Only with `data-sale="ga4"`: the VAT percentage included in the Google Analytics amounts (e.g. `25`). |
| `data-sale-shipping` | No | Only with `data-sale="ga4"`: `incl` if shipping is always included in `value`, `excl` if it never is. Without it, the script guesses. |

## What the script stores

When a customer lands with `?afid=` in the URL, the script stores the click ID both as a cookie and in localStorage, so it survives either one being cleared. If the site cleans the URL before the script runs (e.g. a router with "clean URLs"), `afid` is also read from the URL the page was requested with – the browser's navigation entry. A redirect without the parameter never yields a false value.

| Key | Content |
|---|---|
| `af_click` | The click ID from `?afid=` |
| `af_store` | The store ID from `data-store` |
| `af_aff` | Only for legacy direct links (`?aff=` + `?store=`) |
| `af_t` | Cookie only: the time of the click in seconds. Same lifetime as `af_click`. |

The script also reads the `af_srv` cookie but never sets it. It is only set by the store's own server (see [Tracking from your server](https://www.affilyflow.com/en/docs/store/server-side-tracking)) and means the server set the click cookie. With `af_srv=1` the script never rewrites `af_click` or `af_t` – Safari would shorten a cookie written by JavaScript to about 7 days, even if the server set it first. localStorage is still written as a backup.

- **Lifetime:** at most 90 days from the click. The cookie's `max-age` is always what is left of the 90 days.
- **Return visits never extend the window.** On every visit the cookie and localStorage are written again with the remaining time, so a deleted cookie is restored from localStorage – but the timestamp is always the click's.
- **localStorage** stores the value as `{ v, t }` – the value and the time of the click. A value older than 90 days is deleted when read.
- **The cookie** is set with `path=/` and `SameSite=Lax`, and with `Secure` on https.
- **Domain:** the cookie is set on the broadest domain the browser allows (`shop.com` rather than `www.shop.com`), so the click follows the customer to a receipt on another subdomain. The script finds it with a short-lived test cookie (`_af_d`) that is deleted immediately. On an IP address or `localhost` the exact host is used.
- **Memory:** the click ID from the URL is also kept in the page's memory, so a sale on the same page works even when storage is blocked (e.g. a private window).
- **`af_t`** lets the store's server renew the cookie on a return visit without extending it beyond 90 days from the click.

> [!NOTE]
> The server decides the real window. Each program has its own cookie window, locked on the click. If the click is older than the program's window, the sale is rejected as `expired` – even if the browser still has it.

### Only Affilyflow click IDs

Only an `afid` that looks like ours (`c_` followed by 4–64 characters: letters, digits, `_` and `-`) is stored. If the store also uses another network with a parameter called `afid`, the foreign value can never overwrite an Affilyflow click.

## Coverage

When a customer lands with a click ID, the script reports it to Affilyflow – and again when the click is stored in the browser, and if the customer actively declines marketing. The report only contains the click ID, the store ID and the consent state – nothing about the visitor. The landing report itself stores nothing in the browser, so it is also sent while the script waits for consent.

This is what **Tracking coverage** under Setup in the store dashboard is built on: how many clicks from affiliates reached a page with the script, were stored and became a sale. See [Testing and troubleshooting](https://www.affilyflow.com/en/docs/store/testing-and-troubleshooting#tracking-coverage).

## affilyflow.conversion()

Report a sale from JavaScript. The call only works once track.js is loaded on the page.

```js title="Receipt page"
affilyflow.conversion({ order: '1008', total: 499, currency: 'DKK' });
```

| Field | Type | Meaning |
|---|---|---|
| `order` | text | The order number. Used to stop duplicates. |
| `total` | number or text | The amount commission is calculated on: goods after discount, excluding VAT and shipping. Text such as `"1.249,00"` is parsed, see [Amounts as text](#amounts-as-text). |
| `currency` | text | Currency code, e.g. `DKK` or `EUR`. Upper-cased. Without it, the store's settlement currency is used. |
| `store` | text | The store ID. Only used if the browser doesn't already have it from `data-store`. |
| `coupon` | text or list | The customer's discount code(s). Several in one text are separated by commas. See [Personal discount codes](#personal-discount-codes). |

How the call behaves:

- **Without an affiliate click in the browser nothing is sent** – and no error is logged. A normal purchase without an affiliate is not an error. The exception is a [personal discount code](#personal-discount-codes).
- The sale is sent with `navigator.sendBeacon`, so it arrives even if the page navigates away right after. Without sendBeacon an invisible image request is used.
- With `data-consent="required"` the call waits until consent is settled and is sent only then.
- For a lead program: `affilyflow.conversion({ order: 'lead_' + Date.now() })` – no amount.

### Personal discount codes

With `coupon`, a sale can be attributed to a personal discount code the store has given an affiliate:

```js title="Receipt page · with a discount code"
affilyflow.conversion({ order: '1008', total: 499, currency: 'DKK', coupon: 'ANNA10' });
```

- **With a click** the sale is sent as always, with the code included. A valid click always wins over the code. If the click has expired or is unknown, a personal code can still attribute the sale.
- **Without a click** the script fetches a list of one-way fingerprints (SHA-256) of the store's personal codes and compares. Only if a code is on the list, and there is an order number, is the sale sent – with the codes that matched. A general code or no code sends nothing.
- The code needs no storage in the browser. With `data-consent="required"` a sale with a code waits at most 3 seconds for consent; without consent the code can still attribute the sale.

Codes are compared without spaces and in upper case. A general code for all affiliates earns no commission on its own – only the link does. The sale line has the same option with `data-affilyflow-coupon`, and with `data-sale="ga4"` the `coupon` of the Google Analytics purchase is read.

> [!WARNING]
> With `async`, `window.affilyflow` may not be ready when inline code on the receipt runs. Wait for it, or use the [sale line](https://www.affilyflow.com/en/docs/reference/sale-line), which needs no JavaScript at all.

```html title="Receipt page · wait for track.js"
<script>
  (function () {
    var tries = 0;
    (function send() {
      if (!window.affilyflow) { if (tries++ < 50) setTimeout(send, 200); return; }
      affilyflow.conversion({ order: '1008', total: 499, currency: 'DKK' });
    })();
  })();
</script>
```

### Amounts as text

A number is used as is. Text that is a valid machine number is also used as is. Other text is cleaned: everything except digits, dot, comma and minus is removed, and the **last** separator is the decimal mark. If there is only one separator with exactly three digits after it, it is a thousands separator.

| Sent as `total` | Read as |
|---|---|
| `499` | 499 |
| `"1249.5"` | 1249.5 |
| `"249.125"` | 249.125 |
| `"1.249,00"` | 1249 |
| `"1.249,-"` | 1249 |
| `"499,00 kr."` | 499 |
| `"kr. 499"` | 499 |
| `"1,234.56"` | 1234.56 |

> [!WARNING]
> The text `"1.249"` is a valid machine number and `conversion()` reads it as 1.249. Send whole amounts as numbers (`1249`) or with decimals (`"1.249,00"`). The [sale line](https://www.affilyflow.com/en/docs/reference/sale-line), on the other hand, reads `"1.249"` as 1249, because the value comes from page text.

### Rejected placeholders

If the template's placeholder is still in `order`, the sale is sent **without** an order number – a placeholder must never take up an order number, or every later sale would be rejected as a duplicate. These values count as placeholders (case-insensitive):

- `DIN_ORDRE_ID`, `DIT_ORDRE_ID`, `ORDRE_ID`, `ORDER_ID`, `YOUR_ORDER_ID`, `ORDERID`
- `XX`, `XXX` etc. (two or more X)
- Anything in `{{ … }}`, `[ … ]`, `< … >` or `% … %` – a template variable that wasn't filled in

The server applies the same rule.

### Duplicate guard in the browser

The server never counts the same order number twice in the same program. The browser also remembers what it has sent, so a reloaded receipt doesn't send again:

- **With an order number:** kept in localStorage (`af_sent`) for 30 days.
- **Without an order number:** kept in sessionStorage (`af_sent_s`) as page and amount – the same receipt with the same amount is sent once per tab.

## affilyflow.consent()

The store's own consent solution can control the script directly. Only relevant with `data-consent="required"`.

```js title="After the visitor's choice"
affilyflow.consent(true);  // store the click from the URL and send waiting sales
affilyflow.consent(false); // delete af_click, af_store, af_aff, af_t, af_srv and af_sent
```

If the visitor says yes again on the same page, the click from the URL is stored again.

## Consent

With `data-consent="required"` the script stores nothing in the browser – no cookie, no localStorage or sessionStorage, no test cookie – until marketing consent is given. Until then the click only lives in the page's memory and is not used. Without the attribute the script behaves as before.

```html title="<head> · every page"
<script src="https://app.affilyflow.com/track.js" data-store="DIN_BUTIK" data-consent="required" async></script>
```

The script reads consent itself from:

| Source | What counts as yes |
|---|---|
| `affilyflow.consent(true)` | The call |
| `window.affilyflowConsent` | The value `true` |
| Shoporama's banner | `shoporamaConsent.isAllowed('marketing')` |
| Cookie Information | The category `cookie_cat_marketing` |
| Cookiebot | `Cookiebot.consent.marketing` |
| IAB TCF v2 | Publisher consent to purposes 1 and 7 (or GDPR doesn't apply) |
| DanDomain Classic's banner | `CookiePolicy.allowTrackingCookies` is `true` (or `cookiesRequireConsent` is `false`) |
| Smartweb/HostedShop's banner | The `cookie_consent` cookie contains `MARKETING` |
| Google Consent Mode v2 | `ad_storage: 'granted'` in `dataLayer` (`consent default` or `update`) |

- A yes works whether the banner answers before or after the script. Cookie Information's, Cookiebot's and Shoporama's events and Consent Mode updates in `dataLayer` are listened to throughout. DanDomain's and Smartweb's banners reload the page after the choice, so the answer is ready on load. `window.affilyflowConsent` is checked on load and for the first 10 seconds, and TCF is used when its API is present within those 10 seconds.
- A no only deletes on an active action (Cookie Information's and Cookiebot's events, TCF's `useractioncomplete`, a Consent Mode `update` with `ad_storage: denied` or `affilyflow.consent(false)`) – never because the banner hasn't read its own cookie yet.
- If you set `window.affilyflowConsent` later than 10 seconds after load, call `affilyflow.consent(true)` instead.

See [Consent and cookies](https://www.affilyflow.com/en/docs/store/consent-and-cookies) for the setup.

## Neutral addresses and your own proxy

Ad blockers like to remove files and calls with "track" in the name. So the script sends its calls to neutral addresses next to itself:

| Address | Used for |
|---|---|
| `/e/l` | Landing and coverage |
| `/e/c` | The sale (same as `/api/track/conversion`) |
| `/e/k` | The list of fingerprints of the store's personal discount codes |
| `/s.js` | The script itself – the same file as `/track.js` |

The old addresses keep working. If the store serves the script from its own domain through a proxy – e.g. `https://shop.com/af/s.js` forwarded to `https://app.affilyflow.com/` – all calls go through the store's domain too, so an ad blocker only sees the store's own addresses. Snippets for nginx, Apache and Next.js are in [Tracking from your server](https://www.affilyflow.com/en/docs/store/server-side-tracking#your-own-proxy-optional).

## Leads without code

For lead programs the script can report a lead by itself when the customer reaches the thank-you page. Put the address in `data-lead-url` and remove `async`, so the lead is sent before the thank-you page possibly redirects.

```html title="<head> · every page · lead program"
<script src="https://app.affilyflow.com/track.js" data-store="DIN_BUTIK" data-lead-url="/thanks"></script>
```

- `/thanks` matches `/thanks` and `/thanks/…`, but not `/thanksgiving`.
- A full URL (`https://shop.com/thanks`) is read as its path.
- If parameters are given (`/thanks?type=lead`), all of them must be in the page's URL.
- The front page alone (`/`) only matches when parameters are given too.
- The lead is reported once per session and only with an affiliate click in the browser.

> [!NOTE]
> A lead (an `order` starting with `lead_`) on a program that pays per sale is rejected as `stale_lead_trigger`. Remove the lead trigger when the program has switched to sales.

## Console errors

track.js only writes to the browser console when a sale from the [sale line](https://www.affilyflow.com/en/docs/reference/sale-line) or [Google Analytics](https://www.affilyflow.com/en/docs/reference/google-analytics) could not be reported:

| Message | Cause |
|---|---|
| `Affilyflow: data-affilyflow-order must be the order number – the sale was not recorded.` | The sale line's order number is missing or a placeholder. |
| `Affilyflow: data-affilyflow-total must be the amount (goods excl. VAT and shipping) – the sale was not recorded.` | The sale line's amount is empty or 0. |
| `Affilyflow: the purchase event has no amount – the sale was not recorded.` | The Google Analytics purchase had no amount. |

What each response from the server means is in [Status codes](https://www.affilyflow.com/en/docs/reference/status-codes). See [Testing and troubleshooting](https://www.affilyflow.com/en/docs/store/testing-and-troubleshooting) to test the whole chain with the test link.

## Safari and iPhone

Safari (ITP) deletes cookies and localStorage set from JavaScript after about 7 days without a visit to the site. track.js rewrites its storage on every visit, but cannot and should not get around the limits. Only a cookie set by the store's own server – such as the WooCommerce plugin or [your own server](https://www.affilyflow.com/en/docs/store/server-side-tracking) – lives longer. This applies to every network, not just Affilyflow.

## Legacy direct links

Links with `?aff=` (or `?aff_id=`) and `?store=` instead of `?afid=` still work. The script counts the click and stores `af_aff` and `af_store`. `data-store` always wins over `?store=` in the URL, so a store that uses `?store=` itself doesn't get its sales rejected.

## Stripe Payment Links

If the page has links to `buy.stripe.com`, the script adds the click id to them as `client_reference_id` – on load and again the moment a link is clicked, so links rendered later get it too. Only when a click is stored, and never on top of a `client_reference_id` you set yourself. The [Stripe webhook](https://www.affilyflow.com/en/docs/store/stripe) then sees the sale without any server code.

Source: https://www.affilyflow.com/en/docs/reference/track-js
