# Webhooks

> Få hvert salg via en affiliate på jeres egen server, når det sker – og igen, når det godkendes, afvises eller refunderes. Signeret, med genforsøg og log.

En webhook er en adresse på jeres server, som Affilyflow kalder, hver gang der sker noget med et af jeres salg. Brug den til at opdatere jeres ERP eller lager, bogføre provisionen, give besked i Slack eller tjekke salget mod jeres egne ordrer – uden at hente noget selv.

- Hvert kald er **signeret** med en hemmelighed, kun I og Affilyflow kender.
- Et kald, der ikke får et svar 2xx, **prøves igen** i op til 24 timer.
- Hvert kald står i en **log** med svarkode og svar, og I kan sende det igen.
- **Send test** viser svaret fra jeres server med det samme.

## Opret en webhook

1. Gå til **Indstillinger → Webhooks** i butiks-dashboardet.
2. Tryk **Ny webhook**, indsæt adressen, og vælg hændelserne.
3. Kopier hemmeligheden. Den vises kun en gang – læg den i en miljøvariabel på serveren, fx `AFFILYFLOW_WEBHOOK_SECRET`.
4. Tryk **Send test**, og tjek, at jeres server svarer 200.

Ejere og administratorer kan oprette og ændre webhooks; læsere kan se dem og loggen. I kan have op til 5 adresser.

> [!NOTE]
> Adressen skal starte med `https://` og må kun bruge port 443, 80, 8080 eller 8443. Vi sender ikke til IP-adresser, interne navne eller adresser, der peger ind i et internt net, og vi følger ikke omdirigeringer – brug den endelige adresse.

## Hændelser

| Hændelse | Sendes når |
|---|---|
| `sale.created` | Et salg via en affiliate er registreret (afventer godkendelse). Også et testkøb fra testlinket – med `"test": true`. |
| `sale.approved` | Salget er godkendt – af jer eller automatisk efter godkendelsesperioden. |
| `sale.rejected` | Salget er afvist – af jer, ved en hel refusion eller ved brud på programmets regler. |
| `sale.refunded` | Salget er refunderet helt eller delvist. Provision og gebyr står efter refusionen. |
| `sale.updated` | Provision eller gebyr er ændret på anden vis, fx en refusion, der fortrydes, eller et afvist salg, der sættes tilbage til afventer. |

Et salg, der afvises allerede ved registreringen (fx brud på programmets regler), kommer som `sale.created` med `"status": "rejected"`. En hel refusion giver både `sale.refunded` (med `"type": "full"`) og `sale.rejected`. Rækkefølgen er ikke garanteret – brug `status` og `created_at` i kroppen.

## Kroppen

Hvert kald er en `POST` med `Content-Type: application/json`:

```json title="sale.approved"
{
  "id": "evt_6f1c2d0e8b9a4c3d9e7f1a2b3c4d5e6f",
  "type": "sale.approved",
  "created_at": "2026-11-12T10:14:03.512+00:00",
  "test": false,
  "data": {
    "sale": {
      "id": "0b6b2d8e-3f1a-4c55-9d2e-7a1b2c3d4e5f",
      "order_id": "100482",
      "status": "approved",
      "previous_status": "pending",
      "currency": "DKK",
      "order_value": 999.00,
      "commission_base": 759.20,
      "commission": 75.92,
      "network_fee": 18.98,
      "cost": 94.90,
      "original_currency": null,
      "original_value": null,
      "created_at": "2026-10-13T18:22:41.087+00:00",
      "approved_at": "2026-11-12T10:14:03.512+00:00",
      "approval_due_at": "2026-11-12T18:22:41.087+00:00",
      "rejection_reason": null,
      "attribution": "click",
      "discount_code": null,
      "recurring": false,
      "billing_cycle": null,
      "server_confirmed": true,
      "refund": null
    },
    "affiliate": { "af_number": "AF-03528F", "label": "Hyggelig Hverdag" },
    "click": {
      "click_id": "c_8Kq2mXo1Tz9w",
      "clicked_at": "2026-10-13T17:58:02.114+00:00",
      "landing_path": "/produkter/uldplaid",
      "affiliate_page": "https://hyggelighverdag.dk/bedste-plaider",
      "channel": null,
      "device": "mobile",
      "country": "DK"
    },
    "program": { "id": "5d1e2f3a-4b5c-4d6e-8f70-819a2b3c4d5e", "store": "Nordlys Bolig" }
  }
}
```

| Felt | Betydning |
|---|---|
| `id` | Hændelsens id. Det samme ved hvert nyt forsøg – gem det, og ignorer en hændelse, I allerede har behandlet. Står også i headeren `Affilyflow-Event-Id`. |
| `type` | Hændelsen (se tabellen ovenfor). Står også i headeren `Affilyflow-Event`. |
| `test` | `true` ved et testkøb fra testlinket og ved **Send test**. Bogfør det ikke. |
| `sale.order_id` | Jeres ordrenummer. |
| `sale.status` | `pending`, `approved` eller `rejected` – efter hændelsen. `previous_status` er status før. |
| `sale.order_value` | Ordrens total i `currency` (jeres afregningsvaluta). `commission_base` er det beløb, provisionen er regnet af. |
| `sale.commission` | Affiliatens provision. `network_fee` er Affilyflows gebyr, og `cost` er de to tilsammen – alle uden moms. Ved `rejected` viser de, hvad salget havde – I betaler ikke for et afvist salg. |
| `sale.original_currency` | Kundens valuta, når salget er omregnet; `original_value` er beløbet i den. |
| `sale.attribution` | `click` (affiliatelink) eller `coupon` (personlig rabatkode – så står koden i `discount_code`). |
| `sale.server_confirmed` | `true`, når jeres server har meldt salget med en API-nøgle. |
| `sale.refund` | Ved en refusion: `type` (`partial` eller `full`), `refunded_value`, `refunded_ratio`, `commission_before_refund` og `refunded_at`. Ellers `null`. |
| `affiliate` | Affiliatens visningsnavn eller AF-nummer – det samme, I ser i dashboardet. Aldrig navn, e-mail eller kontaktoplysninger. `null` ved testkøb. |
| `click` | Klikket bag salget: tidspunkt, landingsside, affiliatens side, enhed og land. `null` ved salg via en personlig rabatkode. |

Nye felter kan komme til. Ignorer felter, I ikke kender.

## Tjek signaturen

Hvert kald har headeren:

```text title="Header"
Affilyflow-Signature: t=1762942443,v1=5b1f0c…
```

- `t` er tidspunktet i sekunder (Unix-tid).
- `v1` er en HMAC-SHA256 med jeres hemmelighed over `t`, et punktum og den rå krop: `"<t>.<krop>"`, som hex.
- Lige efter en ny hemmelighed kan der stå to `v1=` – en med den nye og en med den gamle. Godtag kaldet, hvis en af dem passer.

Brug den rå krop, som den kom – ikke JSON, I har læst og skrevet igen. Afvis kald, hvor `t` er mere end 5 minutter fra jeres ur, så et opsnappet kald ikke kan sendes igen senere.

```js title="Node.js"
import crypto from 'node:crypto';

function verifyAffilyflow(rawBody, header, secret, toleranceSec = 300) {
  if (!header) return false;
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return parts.some(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v) && crypto.timingSafeEqual(Buffer.from(v, 'hex'), expected));
}
```

```js title="Express"
app.post('/affilyflow/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8');
  if (!verifyAffilyflow(body, req.get('Affilyflow-Signature'), process.env.AFFILYFLOW_WEBHOOK_SECRET)) {
    return res.status(400).send('invalid signature');
  }
  const event = JSON.parse(body);
  // Gem event.id – samme id kommer igen, hvis jeres svar ikke var 2xx.
  res.sendStatus(200);
});
```

```php title="PHP"
<?php
function verify_affilyflow(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
    if (!$header) return false;
    $t = 0;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't') $t = (int) $v;
        if ($k === 'v1') $sigs[] = $v;
    }
    if ($t <= 0 || abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $sig) {
        if (hash_equals($expected, $sig)) return true;
    }
    return false;
}
```

```php title="PHP – modtag kaldet"
<?php
$raw = file_get_contents('php://input');
if (!verify_affilyflow($raw, $_SERVER['HTTP_AFFILYFLOW_SIGNATURE'] ?? null, getenv('AFFILYFLOW_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($raw, true);
// Gem $event['id'] – samme id kommer igen, hvis jeres svar ikke var 2xx.
http_response_code(200);
```

## Svar, genforsøg og pause

- Svar med en status **2xx** inden for 10 sekunder. Gør det tunge arbejde bagefter (en kø), hvis det tager længere tid.
- Alt andet – en anden status, en omdirigering, timeout eller en forbindelsesfejl – prøves igen efter cirka 1, 5, 15 og 30 minutter og derefter efter 1, 2, 4, 6 og 8 timer. Efter 24 timer opgives kaldet; det kan stadig sendes igen fra loggen.
- Efter **20 mislykkede forsøg i træk** sættes webhooken på pause, og det står i dashboardet. Ret fejlen, send en test, og tryk **Slå til**. Kald fra de seneste 24 timer bliver så sendt.
- Samme hændelse kan komme mere end en gang (fx hvis jeres svar gik tabt). Brug `id` til at ignorere dubletter.

## Test og log

- **Send test** sender et eksempel på den valgte hændelse med `"test": true` og ordrenummeret `TEST-1001` og viser svaret med det samme. En test prøves kun en gang og tæller ikke med i pausen.
- **Log** viser de seneste 20 kald pr. adresse: hændelse, ordre, status, svarkode, antal forsøg, næste forsøg og de første 500 tegn af jeres svar. **Send igen** sender et kald igen. Loggen gemmes i 30 dage.

## Ny hemmelighed

Tryk **Ny hemmelighed** for at skifte den. Som standard signeres kaldene med både den nye og den gamle i 24 timer, så I kan opdatere serveren uden at afvise noget. Er den gamle sluppet ud, så slå det fra – så gælder kun den nye fra næste kald.

## Hvad med salg fra jeres egen server?

Webhooks går fra Affilyflow til jer. Vil I melde salg og refusioner til os fra jeres server, så brug [butikkens API med nøgle](https://www.affilyflow.com/docs/reference/konverterings-api) – se også [Server-sporing](https://www.affilyflow.com/docs/butik/server-sporing).

Kilde: https://www.affilyflow.com/docs/butik/webhooks
