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.

View as Markdown

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.

Events

EventSent when
sale.createdA sale through an affiliate is recorded (pending approval). Also a test purchase from the test link – with "test": true.
sale.approvedThe sale is approved – by you or automatically after the approval period.
sale.rejectedThe sale is rejected – by you, by a full refund or for breaking the programme rules.
sale.refundedThe sale is fully or partly refunded. Commission and fee are shown after the refund.
sale.updatedCommission 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:

sale.approvedJSON
{
  "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" }
  }
}
FieldMeaning
idThe event ID. The same on every retry – store it, and ignore an event you have already processed. Also in the Affilyflow-Event-Id header.
typeThe event (see the table above). Also in the Affilyflow-Event header.
testtrue for a test purchase from the test link and for Send test. Do not book it.
sale.order_idYour order number.
sale.statuspending, approved or rejected – after the event. previous_status is the status before.
sale.order_valueThe order total in currency (your settlement currency). commission_base is the amount the commission is calculated on.
sale.commissionThe 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_currencyThe customer's currency when the sale was converted; original_value is the amount in it.
sale.attributionclick (affiliate link) or coupon (personal discount code – the code is then in discount_code).
sale.server_confirmedtrue when your server reported the sale with an API key.
sale.refundOn a refund: type (partial or full), refunded_value, refunded_ratio, commission_before_refund and refunded_at. Otherwise null.
affiliateThe 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.
clickThe 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:

HeaderTekst
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.

Node.jsJavaScript
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));
}
ExpressJavaScript
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);
});
PHPPHP
<?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 – receive the callPHP
<?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 – see also Server-side tracking.

Missing something?

Write to us – we answer quickly, and the page gets fixed.

Contact support