# Webhooks

> Get every sale through an affiliate on your own server as it happens – and again when it is approved, rejected or refunded. Signed, with retries and a log.

A webhook is a URL on your server that Affilyflow calls whenever something happens to one of your sales. Use it to update your ERP or inventory, book the commission, post to Slack or reconcile the sale with your own orders – without fetching anything yourself.

- Every call is **signed** with a secret that only you and Affilyflow know.
- A call that does not get a 2xx response is **retried** for up to 24 hours.
- Every call is in a **log** with status code and response, and you can resend it.
- **Send test** shows the response from your server straight away.

## Create a webhook

1. Go to **Settings → Webhooks** in the store dashboard.
2. Press **New webhook**, paste the URL and choose the events.
3. Copy the secret. It is shown only once – put it in an environment variable on the server, e.g. `AFFILYFLOW_WEBHOOK_SECRET`.
4. Press **Send test** and check that your server responds 200.

Owners and admins can create and change webhooks; viewers can see them and the log. You can have up to 5 URLs.

> [!NOTE]
> The URL must start with `https://` and may only use port 443, 80, 8080 or 8443. We do not send to IP addresses, internal names or URLs that point into an internal network, and we do not follow redirects – use the final URL.

## Events

| Event | Sent when |
|---|---|
| `sale.created` | A sale through an affiliate is recorded (pending approval). Also a test purchase from the test link – with `"test": true`. |
| `sale.approved` | The sale is approved – by you or automatically after the approval period. |
| `sale.rejected` | The sale is rejected – by you, by a full refund or for breaking the programme rules. |
| `sale.refunded` | The sale is fully or partly refunded. Commission and fee are shown after the refund. |
| `sale.updated` | Commission or fee changed in another way, e.g. a refund that is reversed, or a rejected sale set back to pending. |

A sale that is rejected as soon as it is recorded (e.g. for breaking the programme rules) arrives as `sale.created` with `"status": "rejected"`. A full refund sends both `sale.refunded` (with `"type": "full"`) and `sale.rejected`. The order is not guaranteed – use `status` and `created_at` in the body.

## The body

Every call is a `POST` with `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" }
  }
}
```

| Field | Meaning |
|---|---|
| `id` | The event ID. The same on every retry – store it, and ignore an event you have already processed. Also in the `Affilyflow-Event-Id` header. |
| `type` | The event (see the table above). Also in the `Affilyflow-Event` header. |
| `test` | `true` for a test purchase from the test link and for **Send test**. Do not book it. |
| `sale.order_id` | Your order number. |
| `sale.status` | `pending`, `approved` or `rejected` – after the event. `previous_status` is the status before. |
| `sale.order_value` | The order total in `currency` (your settlement currency). `commission_base` is the amount the commission is calculated on. |
| `sale.commission` | The affiliate's commission. `network_fee` is Affilyflow's fee, and `cost` is the two together – all excluding VAT. On `rejected` they show what the sale had – you do not pay for a rejected sale. |
| `sale.original_currency` | The customer's currency when the sale was converted; `original_value` is the amount in it. |
| `sale.attribution` | `click` (affiliate link) or `coupon` (personal discount code – the code is then in `discount_code`). |
| `sale.server_confirmed` | `true` when your server reported the sale with an API key. |
| `sale.refund` | On a refund: `type` (`partial` or `full`), `refunded_value`, `refunded_ratio`, `commission_before_refund` and `refunded_at`. Otherwise `null`. |
| `affiliate` | The affiliate's display name or AF number – the same you see in the dashboard. Never a name, email or contact details. `null` for test purchases. |
| `click` | The click behind the sale: time, landing page, the affiliate's page, device and country. `null` for sales through a personal discount code. |

New fields may be added. Ignore fields you do not recognise.

## Verify the signature

Every call has the header:

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

- `t` is the time in seconds (Unix time).
- `v1` is an HMAC-SHA256 with your secret over `t`, a full stop and the raw body: `"<t>.<body>"`, as hex.
- Right after a new secret there can be two `v1=` – one with the new and one with the old. Accept the call if either matches.

Use the raw body as it arrived – not JSON you have parsed and written again. Reject calls where `t` is more than 5 minutes from your clock, so an intercepted call cannot be replayed later.

```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);
  // Store event.id – the same id comes again if your response was not 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 – receive the call"
<?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);
// Store $event['id'] – the same id comes again if your response was not 2xx.
http_response_code(200);
```

## Responses, retries and pausing

- Respond with a **2xx** status within 10 seconds. Do the heavy work afterwards (a queue) if it takes longer.
- Anything else – another status, a redirect, a timeout or a connection error – is retried after about 1, 5, 15 and 30 minutes and then after 1, 2, 4, 6 and 8 hours. After 24 hours the call is given up; it can still be resent from the log.
- After **20 failed attempts in a row** the webhook is paused, and the dashboard says so. Fix the problem, send a test and press **Turn on**. Calls from the last 24 hours are then sent.
- The same event can arrive more than once (e.g. if your response was lost). Use `id` to ignore duplicates.

## Test and log

- **Send test** sends an example of the chosen event with `"test": true` and the order number `TEST-1001`, and shows the response straight away. A test is tried only once and does not count towards pausing.
- **Log** shows the latest 20 calls per URL: event, order, status, status code, number of attempts, next attempt and the first 500 characters of your response. **Resend** sends a call again. The log is kept for 30 days.

## New secret

Press **New secret** to change it. By default calls are signed with both the new and the old secret for 24 hours, so you can update the server without rejecting anything. If the old one has leaked, turn that off – then only the new one applies from the next call.

## What about sales from your own server?

Webhooks go from Affilyflow to you. To report sales and refunds to us from your server, use [the store API with a key](https://www.affilyflow.com/en/docs/reference/conversion-api) – see also [Server-side tracking](https://www.affilyflow.com/en/docs/store/server-side-tracking).

Source: https://www.affilyflow.com/en/docs/store/webhooks
