# Sporing fra jeres server

> Lad jeres egen server gemme affiliate-klikket og melde salget – så holder sporingen på iPhone, i Safari og med annonceblokkere.

Til en [specialbygget webshop](https://www.affilyflow.com/docs/butik/specialbygget) kan jeres egen server overtage de to vigtigste opgaver fra scriptet: at gemme affiliate-klikket og at melde salget. Det er valgfrit, men det er den mest robuste opsætning. Kodestumperne står også under **Program → Opsætning** med jeres butiks-id sat ind; her står `DIN_BUTIK` i stedet.

## Hvorfor

- **Safari og iPhone.** Safari sletter cookies og localStorage, som er sat af JavaScript, efter cirka 7 dage uden besøg på siden. En kunde, der klikker i dag og køber om to uger, er så væk – også selvom programmets cookie-periode er 30 dage. En cookie, som jeres egen server sætter, forkortes ikke på den måde.
- **Annonceblokkere.** En blokker kan stoppe et sporingsscript og dets kald. Den kan ikke stoppe jeres egen server i at sætte en cookie eller melde en betalt ordre.
- **Kvitteringen.** Når serveren melder salget, betyder det ikke noget, om kunden ser kvitteringssiden – fx hvis betalingen bliver bekræftet senere, eller kunden lukker fanen.

Scriptet og salgslinjen kan blive, hvor de er. Se [Sådan spiller det sammen med scriptet](#saadan-spiller-det-sammen-med-scriptet).

## Klikket fra jeres server

En lille middleware gemmer `?afid=` fra affiliate-linket som førstepartscookies på jeres domæne:

| Cookie | Indhold |
|---|---|
| `af_click` | Klik-id'et fra `?afid=` |
| `af_t` | Klikkets tidspunkt i sekunder |
| `af_store` | Jeres butiks-id |
| `af_srv` | `1` – fortæller scriptet, at jeres server har sat cookien |

- Cookierne lever 90 dage fra klikket. Ved et genbesøg fornyes de med den resterende tid – aldrig ud over 90 dage fra klikket, fordi tidspunktet står i `af_t`. Programmets egen cookie-periode håndhæves stadig på vores server.
- Kun et `afid`, der ligner vores (`c_` efterfulgt af 4–64 tegn: bogstaver, tal, `_` og `-`), gemmes.
- Cookierne må **ikke** være HttpOnly: track.js læser dem på kvitteringen.
- Kør koden på hver side, før der sendes output, så klikket gemmes, uanset hvilken side kunden lander på.

### Laravel

```php title="Laravel · middleware"
<?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]);
//   })
```

Laravel krypterer cookies som standard. Undtag de fire cookies som vist nederst i kodestumpen – ellers kan track.js ikke læse dem.

### PHP

```php title="PHP · kør på hver side før output"
<?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);
    }
}
```

### Node.js (Express)

```js title="Node.js (Express)"
// 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();
});
```

### Next.js

```ts title="middleware.ts"
// 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]+$).*)'] };
```

### Vent på samtykke

Står **Cookiebanner** på *Vent på samtykke* i Opsætning, får kodestumperne i Opsætning en ekstra funktion, så cookierne først sættes, når Cookiebot eller Cookie Information viser samtykke til marketing. Bruger I et andet banner, skal den funktion rettes til jeres. Se [Samtykke og cookies](https://www.affilyflow.com/docs/butik/samtykke-og-cookies).

## Salget fra jeres server

Salget meldes i to trin:

1. **Ved checkout** – mens det er kundens egen browser, der kalder jer – gemmer I værdien af cookien `af_click` på ordren, fx i en kolonne `affilyflow_click`.
2. **Når ordren er betalt** – fx i betalingens callback eller webhook – melder I ordren til konverterings-API'et.

### Laravel

```php title="Laravel · når ordren er betalt"
<?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

```php title="PHP · når ordren er betalt"
<?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);
}
```

### Node.js (Express)

```js title="Node.js · når ordren er betalt"
// 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
}
```

### Next.js

```ts title="Next.js · når ordren er betalt"
// 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
}
```

### Felterne

Kaldet er en `POST` med JSON til `https://app.affilyflow.com/api/track/conversion`:

| Felt | Påkrævet | Betydning |
|---|---|---|
| `source` | Anbefalet | `"server"` – markerer, at salget kommer fra jeres server. |
| `store` | Ja | Jeres butiks-id. |
| `afid` | Når der er et klik | Klik-id'et, I gemte på ordren ved checkout. |
| `order` | Ja | Ordrenummeret, som kunden ser det. Samme nummer tælles kun en gang. |
| `total` | Ja | Varerne efter rabat, uden moms og fragt, som et maskintal (`540.00`). |
| `currency` | Nej | Valutakode, fx `DKK` eller `EUR`. Standard er `DKK`. |
| `coupons` | Nej | Kundens rabatkoder som en liste, fx `["ANNA10"]`. Er en af dem en [personlig rabatkode](https://www.affilyflow.com/docs/butik/personlige-rabatkoder), giver den provision – også uden klik. |
| `buyer_hash` | Anbefalet | `sha256` af køberens e-mail med små bogstaver og uden mellemrum foran og bagved. Bruges kun til at afvise selvhenvisning. E-mailen selv sendes aldrig. |

- Uden klik og uden rabatkoder er der intet at melde – kodestumperne springer kaldet over.
- Kaldet har en timeout på 5 sekunder. Lad det aldrig afgøre, om ordren gennemføres – kald det, når ordren er gemt som betalt.
- Svarene og deres betydning står i [Konverterings-API](https://www.affilyflow.com/docs/reference/konverterings-api) og [Statuskoder](https://www.affilyflow.com/docs/reference/statuskoder).

## Egen proxy (valgfri)

Vil I også have scriptet og dets kald væk fra vores domæne, kan jeres webserver sende alt under `/af/` videre til Affilyflow. Scriptet hentes så fra `/af/s.js` på jeres eget domæne, og fordi scriptet sender sine kald ved siden af sig selv, går de også gennem jeres domæne. En annonceblokker ser kun jeres egne adresser.

`s.js` er den samme fil som `track.js` – navnet er blot neutralt. Bagefter skifter I scriptet i `<head>` ud med linjen nederst i kodestumpen.

### nginx

```text title="nginx"
# 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/s.js" data-store="DIN_BUTIK" async></script>
```

### Apache

```text title="Apache"
# 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/s.js" data-store="DIN_BUTIK" async></script>
```

### Next.js

```ts title="next.config.ts"
// 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/s.js" data-store="DIN_BUTIK" async></script>
```

Bruger scriptet `data-consent`, `data-sale` eller andre attributter i dag, så behold dem på den nye linje.

## Sådan spiller det sammen med scriptet

- **Scriptet kan blive.** Har jeres server sat cookien, står `af_srv=1`, og så skriver track.js aldrig `af_click` eller `af_t` igen – en cookie, som JavaScript skriver, ville Safari forkorte til cirka 7 dage, også selvom jeres server satte den først. Scriptet gemmer stadig klikket i localStorage som reserve.
- **Samme ordre tæller kun en gang.** Melder både jeres server og browseren (scriptet eller salgslinjen) samme ordre med samme ordrenummer, registreres salget en gang. Browserens melding kan derfor blive stående som reserve.
- **Klikket følger med.** Scriptet læser `af_click`, uanset om jeres server eller scriptet satte den, så salgslinjen på kvitteringen virker som før.
- **Et nej sletter alt.** Siger en kunde aktivt nej til marketing, sletter scriptet også de cookies, jeres server har sat. Sæt dem ikke igen uden samtykke.

## Test

1. Tryk **Åbn testlink** under testtrinnet i Opsætning, og læg en testordre i samme browser.
2. Live-testen viser testklikket og købet med ordrenummer og beløb. Tjek, at beløbet er varerne efter rabat, uden moms og fragt.
3. Tjek i browserens udviklerværktøjer, at cookierne `af_click`, `af_t`, `af_store` og `af_srv` er sat på jeres domæne efter testlinket.

Bagefter viser **Sporingens dækning** i Opsætning, hvor mange klik fra affiliates der når frem, bliver gemt og bliver til et salg. Mere i [Test og fejlfinding](https://www.affilyflow.com/docs/butik/test-og-fejlfinding).

Kilde: https://www.affilyflow.com/docs/butik/server-sporing
