# Server-side tracking

> Let your own server store the affiliate click and report the sale – so tracking holds up on iPhone, in Safari and with ad blockers.

On a [custom-built store](https://www.affilyflow.com/en/docs/store/custom-built), your own server can take over the two most important jobs from the script: storing the affiliate click and reporting the sale. It's optional, but it's the most robust setup you can have. The snippets are also in **Program → Setup** under **For developers: even more precise tracking**, with your store ID filled in; here, `DIN_BUTIK` stands in for it.

## Why

- **Safari and iPhone.** Safari deletes cookies and localStorage set by JavaScript after about 7 days without a visit to the site. A customer who clicks today and buys in two weeks is lost – even if the program's cookie period is 30 days. A cookie set by your own server isn't shortened in that way.
- **Ad blockers.** A blocker can stop a tracking script and its requests. It can't stop your own server from setting a cookie or reporting a paid order.
- **The order confirmation.** When the server reports the sale, it doesn't matter whether the customer ever sees the confirmation page – for example if the payment is confirmed later, or the customer closes the tab.

The script and the sale line can stay where they are. See [How it works with the script](#how-it-works-with-the-script).

## The click from your server

A small middleware stores `?afid=` from the affiliate link as first-party cookies on your domain:

| Cookie | Contents |
|---|---|
| `af_click` | The click ID from `?afid=` |
| `af_t` | When the click happened, in seconds |
| `af_store` | Your store ID |
| `af_srv` | `1` – tells the script that your server set the cookie |

- The cookies last 90 days from the click. On a return visit they are renewed with the time that's left – never beyond 90 days from the click, because the time of the click is in `af_t`. The program's own cookie period is still enforced on our server.
- Only an `afid` that looks like ours (`c_` followed by 4–64 characters: letters, digits, `_` and `-`) is stored.
- The cookies must **not** be HttpOnly: track.js reads them on the order confirmation.
- Run the code on every page before any output is sent, so the click is stored whichever page the customer lands on.
- Laravel encrypts cookies by default. Exclude the four cookies as shown at the bottom of the Laravel snippet – otherwise track.js can't read them.

```php title="Laravel · middleware" group="server-click"
<?php
// Affilyflow: gem affiliate-klikket fra jeres egen server, så det holder i hele perioden –
// også på iPhone/Safari og med annonceblokkere. Kald affilyflow_click() på hver side,
// før der sendes output. Cookierne må IKKE være HttpOnly (track.js læser dem).
function affilyflow_click(): void {
    $max = 90 * 86400;
    $set = function (string $name, string $value, int $age): void {
        setcookie($name, $value, [
            'expires' => time() + $age, 'path' => '/', 'samesite' => 'Lax',
            'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
        ]);
    };
    $afid = (string) ($_GET['afid'] ?? '');
    if (preg_match('/^c_[A-Za-z0-9_-]{4,64}$/', $afid)) {
        // Nyt klik fra et affiliate-link.
        foreach (['af_click' => $afid, 'af_t' => (string) time(), 'af_store' => 'DIN_BUTIK', 'af_srv' => '1'] as $k => $v) $set($k, $v, $max);
        return;
    }
    // Genbesøg: forny cookien – men aldrig ud over 90 dage fra klikket.
    $click = (string) ($_COOKIE['af_click'] ?? '');
    $t = (int) ($_COOKIE['af_t'] ?? 0);
    $left = $max - (time() - $t);
    if ($t > 0 && $left > 0 && preg_match('/^c_[A-Za-z0-9_-]{4,64}$/', $click)) {
        foreach (['af_click' => $click, 'af_t' => (string) $t, 'af_store' => 'DIN_BUTIK', 'af_srv' => '1'] as $k => $v) $set($k, $v, $left);
    }
}

// Laravel: kald den i en middleware (app/Http/Middleware/AffilyflowClick.php) …
//   public function handle($request, \Closure $next) { affilyflow_click(); return $next($request); }
// … og undtag cookierne fra Laravels kryptering i bootstrap/app.php:
//   ->withMiddleware(function ($middleware) {
//       $middleware->encryptCookies(except: ['af_click', 'af_t', 'af_store', 'af_srv']);
//       $middleware->web(append: [\App\Http\Middleware\AffilyflowClick::class]);
//   })
```

```php title="PHP · run on every page before output" group="server-click"
<?php
// Affilyflow: gem affiliate-klikket fra jeres egen server, så det holder i hele perioden –
// også på iPhone/Safari og med annonceblokkere. Kald affilyflow_click() på hver side,
// før der sendes output. Cookierne må IKKE være HttpOnly (track.js læser dem).
function affilyflow_click(): void {
    $max = 90 * 86400;
    $set = function (string $name, string $value, int $age): void {
        setcookie($name, $value, [
            'expires' => time() + $age, 'path' => '/', 'samesite' => 'Lax',
            'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
        ]);
    };
    $afid = (string) ($_GET['afid'] ?? '');
    if (preg_match('/^c_[A-Za-z0-9_-]{4,64}$/', $afid)) {
        // Nyt klik fra et affiliate-link.
        foreach (['af_click' => $afid, 'af_t' => (string) time(), 'af_store' => 'DIN_BUTIK', 'af_srv' => '1'] as $k => $v) $set($k, $v, $max);
        return;
    }
    // Genbesøg: forny cookien – men aldrig ud over 90 dage fra klikket.
    $click = (string) ($_COOKIE['af_click'] ?? '');
    $t = (int) ($_COOKIE['af_t'] ?? 0);
    $left = $max - (time() - $t);
    if ($t > 0 && $left > 0 && preg_match('/^c_[A-Za-z0-9_-]{4,64}$/', $click)) {
        foreach (['af_click' => $click, 'af_t' => (string) $t, 'af_store' => 'DIN_BUTIK', 'af_srv' => '1'] as $k => $v) $set($k, $v, $left);
    }
}
```

```js title="Node.js (Express)" group="server-click"
// Affilyflow: gem affiliate-klikket fra jeres egen server, så det holder i hele perioden –
// også på iPhone/Safari og med annonceblokkere. Kræver cookie-parser (app.use(cookieParser())).
// Cookierne må IKKE være httpOnly (track.js læser dem).
const AF_ID = /^c_[A-Za-z0-9_-]{4,64}$/;
const AF_MAX = 90 * 86400;

app.use((req, res, next) => {
  const set = (name, value, age) =>
    res.cookie(name, value, { maxAge: age * 1000, path: '/', sameSite: 'lax', secure: req.secure, httpOnly: false });
  const afid = String(req.query.afid || '');
  if (AF_ID.test(afid)) {
    // Nyt klik fra et affiliate-link.
    for (const [k, v] of [['af_click', afid], ['af_t', String(Math.floor(Date.now() / 1000))], ['af_store', 'DIN_BUTIK'], ['af_srv', '1']]) set(k, v, AF_MAX);
    return next();
  }
  // Genbesøg: forny cookien – men aldrig ud over 90 dage fra klikket.
  const click = String(req.cookies?.af_click || '');
  const t = Number(req.cookies?.af_t || 0);
  const left = AF_MAX - (Math.floor(Date.now() / 1000) - t);
  if (t > 0 && left > 0 && AF_ID.test(click)) {
    for (const [k, v] of [['af_click', click], ['af_t', String(t)], ['af_store', 'DIN_BUTIK'], ['af_srv', '1']]) set(k, v, left);
  }
  next();
});
```

```ts title="Next.js · middleware.ts" group="server-click"
// middleware.ts – Affilyflow: gem affiliate-klikket fra jeres egen server, så det holder i hele
// perioden – også på iPhone/Safari og med annonceblokkere. Cookierne må IKKE være httpOnly.
import { NextResponse, type NextRequest } from 'next/server';

const AF_ID = /^c_[A-Za-z0-9_-]{4,64}$/;
const AF_MAX = 90 * 86400;

export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  const set = (name: string, value: string, age: number) =>
    res.cookies.set(name, value, { maxAge: age, path: '/', sameSite: 'lax', secure: true, httpOnly: false });
  const afid = req.nextUrl.searchParams.get('afid') ?? '';
  if (AF_ID.test(afid)) {
    // Nyt klik fra et affiliate-link.
    set('af_click', afid, AF_MAX);
    set('af_t', String(Math.floor(Date.now() / 1000)), AF_MAX);
    set('af_store', 'DIN_BUTIK', AF_MAX);
    set('af_srv', '1', AF_MAX);
    return res;
  }
  // Genbesøg: forny cookien – men aldrig ud over 90 dage fra klikket.
  const click = req.cookies.get('af_click')?.value ?? '';
  const t = Number(req.cookies.get('af_t')?.value ?? 0);
  const left = AF_MAX - (Math.floor(Date.now() / 1000) - t);
  if (t > 0 && left > 0 && AF_ID.test(click)) {
    set('af_click', click, left);
    set('af_t', String(t), left);
    set('af_store', 'DIN_BUTIK', left);
    set('af_srv', '1', left);
  }
  return res;
}

export const config = { matcher: ['/((?!_next/|api/|.*\\.[a-z0-9]+$).*)'] };
```

### Waiting for consent

If **Cookie banner** is set to *Wait for consent* in Setup, the snippets in Setup get an extra function, so the cookies are only set once Cookiebot or Cookie Information reports consent to marketing. If you use a different banner, adapt that function to yours. See [Consent and cookies](https://www.affilyflow.com/en/docs/store/consent-and-cookies).

## The sale from your server

The sale is reported in two steps:

1. **At checkout** – while it's the customer's own browser calling you – store the value of the `af_click` cookie on the order, for example in an `affilyflow_click` column.
2. **When the order is paid** – for example in the payment callback or webhook – report the order to the conversion API.

```php title="Laravel · when the order is paid" group="server-sale"
<?php
use Illuminate\Support\Facades\Http;
// Affilyflow: meld salget fra jeres server, når ordren er BETALT.
// 1) Ved checkout (kundens egen browser): gem klikket på ordren, fx i en kolonne affilyflow_click:
//      $order->affilyflow_click = $_COOKIE['af_click'] ?? null;
// 2) Når betalingen er bekræftet (fx i betalings-callbacken), kald:
function affilyflow_sale(string $orderId, float $goodsExVat, string $currency, ?string $click, array $coupons = [], ?string $email = null): void {
    // Uden klik og uden en rabatkode er der intet at melde.
    if (!$click && !$coupons) return;
    $body = array_filter([
        'source'   => 'server',
        'store'    => 'DIN_BUTIK',
        'afid'     => $click,                // klik-id'et fra ordren
        'order'    => $orderId,              // samme ordrenummer som kunden ser
        'total'    => round($goodsExVat, 2), // varerne efter rabat, UDEN moms og fragt
        'currency' => $currency,             // fx DKK
        'coupons'  => array_values($coupons), // kundens rabatkoder (giver provision, hvis en er personlig)
        // Fingeraftryk af e-mailen, så selvhenvisning kan afvises. E-mailen selv sendes aldrig.
        'buyer_hash' => $email ? hash('sha256', strtolower(trim($email))) : null,
    ], fn ($v) => $v !== null && $v !== []);
    Http::timeout(5)->post('https://app.affilyflow.com/api/track/conversion', $body);
}
```

```php title="PHP · when the order is paid" group="server-sale"
<?php
// Affilyflow: meld salget fra jeres server, når ordren er BETALT.
// 1) Ved checkout (kundens egen browser): gem klikket på ordren, fx i en kolonne affilyflow_click:
//      $order->affilyflow_click = $_COOKIE['af_click'] ?? null;
// 2) Når betalingen er bekræftet (fx i betalings-callbacken), kald:
function affilyflow_sale(string $orderId, float $goodsExVat, string $currency, ?string $click, array $coupons = [], ?string $email = null): void {
    // Uden klik og uden en rabatkode er der intet at melde.
    if (!$click && !$coupons) return;
    $body = array_filter([
        'source'   => 'server',
        'store'    => 'DIN_BUTIK',
        'afid'     => $click,                // klik-id'et fra ordren
        'order'    => $orderId,              // samme ordrenummer som kunden ser
        'total'    => round($goodsExVat, 2), // varerne efter rabat, UDEN moms og fragt
        'currency' => $currency,             // fx DKK
        'coupons'  => array_values($coupons), // kundens rabatkoder (giver provision, hvis en er personlig)
        // Fingeraftryk af e-mailen, så selvhenvisning kan afvises. E-mailen selv sendes aldrig.
        'buyer_hash' => $email ? hash('sha256', strtolower(trim($email))) : null,
    ], fn ($v) => $v !== null && $v !== []);
    $ch = curl_init('https://app.affilyflow.com/api/track/conversion');
    curl_setopt_array($ch, [
        CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode($body),
    ]);
    curl_exec($ch);
    curl_close($ch);
}
```

```js title="Node.js · when the order is paid" group="server-sale"
// Affilyflow: meld salget fra jeres server, når ordren er BETALT.
// 1) Ved checkout (kundens egen browser): gem klikket på ordren, fx order.affilyflowClick = req.cookies.af_click
// 2) Når betalingen er bekræftet (fx i betalingens webhook), kald:
import { createHash } from 'node:crypto';

export async function affilyflowSale({ orderId, goodsExVat, currency, click, coupons = [], email }) {
  // Uden klik og uden en rabatkode er der intet at melde.
  if (!click && !coupons.length) return;
  await fetch('https://app.affilyflow.com/api/track/conversion', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      source: 'server',
      store: 'DIN_BUTIK',
      afid: click || undefined, // klik-id'et fra ordren
      order: orderId, // samme ordrenummer som kunden ser
      total: Math.round(goodsExVat * 100) / 100, // varerne efter rabat, UDEN moms og fragt
      currency, // fx DKK
      coupons, // kundens rabatkoder (giver provision, hvis en er personlig)
      // Fingeraftryk af e-mailen, så selvhenvisning kan afvises. E-mailen selv sendes aldrig.
      buyer_hash: email ? createHash('sha256').update(email.trim().toLowerCase()).digest('hex') : undefined,
    }),
    signal: AbortSignal.timeout(5000),
  }).catch(() => {}); // sporingen må aldrig vælte en ordre
}
```

```ts title="Next.js · when the order is paid" group="server-sale"
// Affilyflow: meld salget fra jeres server, når ordren er BETALT.
// 1) Ved checkout (kundens egen browser): gem klikket på ordren, fx order.affilyflowClick = req.cookies.af_click
// 2) Når betalingen er bekræftet (fx i betalingens webhook), kald:
import { createHash } from 'node:crypto';

export async function affilyflowSale({ orderId, goodsExVat, currency, click, coupons = [], email }: {
  orderId: string; goodsExVat: number; currency: string; click?: string | null; coupons?: string[]; email?: string | null;
}) {
  // Uden klik og uden en rabatkode er der intet at melde.
  if (!click && !coupons.length) return;
  await fetch('https://app.affilyflow.com/api/track/conversion', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      source: 'server',
      store: 'DIN_BUTIK',
      afid: click || undefined, // klik-id'et fra ordren
      order: orderId, // samme ordrenummer som kunden ser
      total: Math.round(goodsExVat * 100) / 100, // varerne efter rabat, UDEN moms og fragt
      currency, // fx DKK
      coupons, // kundens rabatkoder (giver provision, hvis en er personlig)
      // Fingeraftryk af e-mailen, så selvhenvisning kan afvises. E-mailen selv sendes aldrig.
      buyer_hash: email ? createHash('sha256').update(email.trim().toLowerCase()).digest('hex') : undefined,
    }),
    signal: AbortSignal.timeout(5000),
  }).catch(() => {}); // sporingen må aldrig vælte en ordre
}
```

### The fields

The call is a `POST` with JSON to `https://app.affilyflow.com/api/track/conversion`:

| Field | Required | Meaning |
|---|---|---|
| `source` | Recommended | `"server"` – marks the sale as coming from your server. |
| `store` | Yes | Your store ID. |
| `afid` | When there is a click | The click ID you stored on the order at checkout. |
| `order` | Yes | The order number as the customer sees it. The same number is only counted once. |
| `total` | Yes | The goods after discount, excluding VAT and shipping, as a machine number (`540.00`). |
| `currency` | No | Currency code, e.g. `DKK` or `EUR`. Without it, the store's settlement currency is used. |
| `coupons` | No | The customer's discount codes as a list, e.g. `["ANNA10"]`. If one of them is a [personal discount code](https://www.affilyflow.com/en/docs/store/personal-discount-codes), it earns commission – even without a click. |
| `buyer_hash` | Recommended | `sha256` of the buyer's email in lower case, with no leading or trailing spaces. Used only to reject self-referrals. The email itself is never sent. |

- With no click and no discount codes there is nothing to report – the snippets skip the call.
- The call has a 5-second timeout. Never let it decide whether the order goes through – call it once the order is saved as paid.
- The responses and what they mean are in [Conversion API](https://www.affilyflow.com/en/docs/reference/conversion-api) and [Status codes](https://www.affilyflow.com/en/docs/reference/status-codes).

## Your own proxy (optional)

If you also want the script and its requests off our domain, your web server can forward everything under `/af/` to Affilyflow. The script is then loaded from `/af/t/DIN_BUTIK.js` on your own domain, and because the script sends its requests to the folder it was loaded from, they go through your domain too. An ad blocker only sees your own addresses.

It's the same script as before, with your settings from Setup. Afterwards, replace the script in `<head>` with the line at the bottom of the snippet.

> [!NOTE]
> The proxy must be a path on your own host (`shop.com/af/`) – never a subdomain such as `track.shop.com` that points to us. Safari shortens cookies from a host that points on to a third party to 7 days, and an ad blocker can look the subdomain up. The proxy helps against ad blockers; you only get the full lifetime in Safari with the cookies your own server sets (above).

```text title="nginx" group="proxy"
# nginx: alt under /af/ sendes videre til Affilyflow
location /af/ {
    proxy_pass https://app.affilyflow.com/;
    proxy_set_header Host app.affilyflow.com;
    proxy_ssl_server_name on;
}

<!-- Scriptet hentes nu fra jeres eget domæne (alle sider, i <head>) -->
<script src="/af/t/DIN_BUTIK.js" async></script>
```

```text title="Apache" group="proxy"
# Apache (mod_proxy + mod_ssl): alt under /af/ sendes videre til Affilyflow
SSLProxyEngine On
ProxyPass        /af/ https://app.affilyflow.com/
ProxyPassReverse /af/ https://app.affilyflow.com/

<!-- Scriptet hentes nu fra jeres eget domæne (alle sider, i <head>) -->
<script src="/af/t/DIN_BUTIK.js" async></script>
```

```ts title="Next.js · next.config.ts" group="proxy"
// next.config.ts: alt under /af/ sendes videre til Affilyflow
async rewrites() {
  return [{ source: '/af/:path*', destination: 'https://app.affilyflow.com/:path*' }];
}

<!-- Scriptet hentes nu fra jeres eget domæne (alle sider, i <head>) -->
<script src="/af/t/DIN_BUTIK.js" async></script>
```

Your settings from Setup (Google Analytics, VAT, shipping, consent) come along, because our server adds them to the script. The older proxy line `<script src="/af/s.js" data-store="DIN_BUTIK" async></script>` still works – if you use it, keep its attributes such as `data-consent` and `data-sale`.

## How it works with the script

- **The script can stay.** Once your server has set the cookie, `af_srv=1` is present, and track.js never writes `af_click` or `af_t` again – Safari would shorten a cookie written by JavaScript to about 7 days, even if your server set it first. The script still keeps the click in localStorage as a fallback.
- **The same order only counts once.** If both your server and the browser (the script or the sale line) report the same order with the same order number, the sale is recorded once. The browser report can therefore stay in place as a fallback.
- **The click comes along.** The script reads `af_click` whether your server or the script set it, so the sale line on the order confirmation works as before.
- **A no removes everything.** If a customer actively declines marketing, the script also deletes the cookies your server set. Don't set them again without consent.

## Test tracking

1. Go to the **Test tracking** step in Setup, press **Open test link**, and place a test order in the same browser.
2. The live test shows the test click and the purchase with order number and amount. Check that the amount is the goods after discount, excluding VAT and shipping.
3. In the browser's developer tools, check that the `af_click`, `af_t`, `af_store` and `af_srv` cookies are set on your domain after the test link.

Afterwards, **Tracking coverage** in Setup shows how many affiliate clicks arrive, are stored and turn into a sale. More in [Testing and troubleshooting](https://www.affilyflow.com/en/docs/store/testing-and-troubleshooting).

Source: https://www.affilyflow.com/en/docs/store/server-side-tracking
