# Receive referral webhooks

A referral webhook tells your backend the moment a friend's install is credited to an invitation link. You grant the reward on your side, in your own system, from a signed event that arrives within seconds of the claim. This page covers the events, how to verify them, and what to do when something does not arrive. To create the invitation links and claim installs, read [Referrals](https://warplink.app/docs/guides/referrals).

## Add an endpoint

Open the app in the dashboard, go to **Referral handoff**, choose the **Webhook** tab, and add an endpoint. You can also use the [Webhooks API](https://warplink.app/docs/api/webhooks) or the MCP tools `create_webhook_endpoint`, `rotate_webhook_secret`, `send_test_webhook` and `list_webhook_deliveries`.

1. Enter a public `https` address. Localhost, private networks, addresses with a user name or password, and WarpLink addresses are refused.
2. Copy the signing secret. It starts with `whsec_` and is shown in full once, when you create the endpoint or rotate it. Later reads show a masked form.
3. Keep `referral.flagged` events on, which is the default, or turn them off for this endpoint.
4. Send a test event.

Free plans have 1 endpoint per app and paid plans have 5. Only an owner or admin can manage endpoints, and an API key that manages them needs the `referrals:write` scope and a creator who is an owner or admin.

## Events

| Event | Sent when |
|-------|-----------|
| `referral.claimed` | An install is credited to a referral link inside the credit window |
| `referral.flagged` | An install is credited after the credit window closed. Sent to every endpoint unless you turn it off |
| `referral.updated` | An exact match replaced an earlier estimate for the same installer |
| `webhook.test` | You pressed **Send test** or called the test route |

Every event is a JSON object with the same four fields:

```json
{
  "id": "evt_9d2f41ab",
  "type": "referral.claimed",
  "created": "2026-10-19T15:30:02Z",
  "data": {
    "referral_id": "ref_9d2f41ab",
    "app_id": "3f6c1c0e-6a7e-4d0e-9b1e-0a5a8f0c2b11",
    "link": {
      "id": "b7a1f2e4-52c8-4a38-8f64-1d7a0c9e3a55",
      "slug": "invite-ana",
      "url": "https://acme.aplnk.to/invite-ana"
    },
    "installer_id": "d1c2f9a0-7b43-4f0e-a6d5-3c2b8e1f7a90",
    "platform": "ios",
    "match": { "method": "install_token", "exact": true, "confidence": 1 },
    "tapped_at": "2026-10-19T15:24:41Z",
    "installed_at": "2026-10-19T15:29:55Z",
    "claimed_at": "2026-10-19T15:30:01Z",
    "claimed_via": "sdk",
    "flags": []
  }
}
```

| Field | Meaning |
|-------|---------|
| `id` | The event id. It is the same on every retry and every resend |
| `data.referral_id` | The referral. It stays the same when an exact match upgrades an estimate |
| `data.link` | The invitation link that earned the credit, with its `id`, `slug` and full `url` |
| `data.installer_id` | The id you sent when you claimed. We store it as written |
| `data.match` | How we matched. `method` is `install_token`, `android_referrer`, `device_id`, `link_open` or `probabilistic`. `exact` is `false` for an estimate, and `confidence` is `1` for an exact match and the matcher's score for an estimate |
| `data.claimed_via` | `sdk` or `api` |
| `data.flags` | Empty, or `["outside_window"]` for a `referral.flagged` event |

The `installer_id` is the id you chose for the new user, never a device identifier. See [Referrals](https://warplink.app/docs/guides/referrals#installer-id) for what to send.

### Updated referrals

An estimate is the best match we can make from device signals when an iPhone has no invitation to read. When the same installer later claims with an exact match, the referral keeps its `referral_id`, moves to the exact link, and we send `referral.updated`. It carries the new data plus the reason and what it replaced:

```json
{
  "id": "evt_9d2f41ab_u",
  "type": "referral.updated",
  "created": "2026-10-19T16:02:11Z",
  "data": {
    "referral_id": "ref_9d2f41ab",
    "reason": "exact_match",
    "previous": {
      "link": { "id": "0c4d9e21-1f8b-4e55-a0c7-6b2e7d3f1a44", "slug": "invite-ben", "url": "https://acme.aplnk.to/invite-ben" },
      "match": { "method": "probabilistic", "exact": false, "confidence": 0.82 }
    }
  }
}
```

The example shows the fields that differ. The real event also repeats every field of `data` from the first example, with the new link and match. Treat `previous` as the old state to undo: take the credit away from the previous link's owner and give it to the owner of the new link. An exact claim is never replaced by a later estimate.

### One credit per installer

The first valid claim for an installer and app earns the credit. A later claim for the same installer, through the same link or another, is a duplicate. We record it in the report with a link to the first referral, and we send no webhook for it. The one exception is the upgrade above: an exact claim replaces an earlier estimate and sends `referral.updated`. A second estimate stays a duplicate. See [Referrals](https://warplink.app/docs/guides/referrals#claim-results) for the claim answers.

## Verify the signature

Every delivery is a `POST` with `Content-Type: application/json` and three headers:

| Header | Value |
|--------|-------|
| `X-WarpLink-Signature` | One or more lowercase hex signatures, separated by commas |
| `X-WarpLink-Timestamp` | The send time in seconds since the Unix epoch |
| `X-WarpLink-Event-Id` | The event id, the same value as `id` in the body |

The signature is an HMAC-SHA256 of the timestamp, a period, and the raw request body: `${timestamp}.${rawBody}`. The key is the whole signing secret, including the `whsec_` prefix. Follow these steps in order:

1. Read the raw body bytes before any JSON parser touches them. A re-serialized body does not match.
2. Reject the request when the timestamp is more than 300 seconds from your clock.
3. Compute the HMAC and compare it to each signature in the header with a constant-time comparison. The header holds a second signature during the 24 hours after a rotation, so accept a match on any of them.
4. Answer with a `2xx` status.

```js
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.WARPLINK_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

function verify(rawBody, signatureHeader, timestamp) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return signatureHeader.split(',').some((signature) => {
    const given = Buffer.from(signature.trim(), 'utf8');
    const wanted = Buffer.from(expected, 'utf8');
    return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
  });
}

const app = express();

app.post('/webhooks/warplink', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const ok = verify(
    rawBody,
    req.get('X-WarpLink-Signature') ?? '',
    req.get('X-WarpLink-Timestamp') ?? '',
  );
  if (!ok) return res.status(400).send('invalid signature');

  const event = JSON.parse(rawBody);
  // Hand the event to your own queue or handler here.
  res.sendStatus(204);
});
```

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

SECRET = os.environ["WARPLINK_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300

app = Flask(__name__)

def verify(raw_body: bytes, signature_header: str, timestamp: str) -> bool:
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return any(
        hmac.compare_digest(signature.strip(), expected)
        for signature in signature_header.split(",")
    )

@app.post("/webhooks/warplink")
def warplink_webhook():
    raw_body = request.get_data()
    if not verify(
        raw_body,
        request.headers.get("X-WarpLink-Signature", ""),
        request.headers.get("X-WarpLink-Timestamp", ""),
    ):
        return "invalid signature", 400

    event = json.loads(raw_body)
    # Hand the event to your own queue or handler here.
    return "", 204
```

Do not parse the body before you verify it. Frameworks that decode JSON for you, such as `express.json()`, hide the raw bytes the signature covers. Use the raw body reader on this route.

## Handle events once

We deliver each event at least once. A retry, a resend, or a slow answer from your server can put the same event in front of you twice, and the `id` is how you tell. Store the event id with the reward you granted, and skip an id you have seen.

```js
async function handle(event) {
  const seen = await db.webhookEvents.insertIfAbsent({ id: event.id });
  if (!seen.inserted) return; // a repeat: nothing to do

  if (event.type === 'referral.claimed') {
    await grantReward(event.data.installer_id, event.data.link.id);
  } else if (event.type === 'referral.updated') {
    await moveReward(event.data.referral_id, event.data.link.id);
  }
}
```

Look up the person by `installer_id`, then find the owner of the invitation from `link.id`. Both come from your own records, so no personal data travels in the event.

Answer quickly. We wait 10 seconds for a reply, and a slower answer counts as a timeout. Do the slow work after you answer, from a queue.

## Retries

A `2xx` answer marks the event delivered. We send an event up to 5 times:

| Attempt | Sent |
|---------|------|
| 1 | At once |
| 2 | 30 seconds after the first |
| 3 | 2 minutes after the first |
| 4 | 8 minutes after the first |
| 5 | 20 minutes after the first |

A `5xx` answer, a timeout, or a failed connection triggers the next attempt. Any other answer, a `4xx` included, ends the event as failed, and so does the fifth attempt. We do not follow redirects, so a `3xx` answer fails the attempt. Point the endpoint at its final address.

The event keeps the same `id` through every attempt. The dashboard shows each attempt with its status code, duration and error.

## Resend an event

A failed or finished event can be sent again, with the same event id and the same payload, for 7 days after its last attempt. Open the referral in the Referrals report and choose **Resend**, or call the API:

```bash
curl -X POST https://api.warplink.app/v1/webhook-events/EVENT_ROW_ID/resend \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY"
```

`POST /v1/referrals/{id}/resend` sends the newest event of every endpoint for one referral, and takes an optional `endpoint_id`. An event still in flight answers `409 CONFLICT`, and an event older than 7 days answers `410 GONE`. Resending needs the `referrals:write` scope and a key whose creator is an owner or admin.

## Test your endpoint

**Send test** sends one signed `webhook.test` event to the saved address and shows what your server answered. The event has the same shape and the same headers as a real one, with an id that starts `evt_test_`. We send it once and never retry it. It appears in the delivery log.

```bash
curl -X POST https://api.warplink.app/v1/webhook-endpoints/ENDPOINT_ID/test \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY"
```

## Rotate the secret

Rotate from the endpoint's menu in the dashboard, or with `POST /v1/webhook-endpoints/{id}/rotate`. The response carries the new secret once. The old secret keeps signing beside the new one for 24 hours, and the signature header lists both, so deploy the new secret to your receiver at your own pace. A second rotation inside that window ends the first old secret at once.

## Limits

| Limit | Value |
|-------|-------|
| Endpoints per app | 1 on Free, 5 on paid plans |
| Attempts per event | 5, over 20 minutes |
| Reply timeout | 10 seconds |
| Timestamp tolerance | 300 seconds |
| Resend window | 7 days after the last attempt |
| Secret overlap after rotation | 24 hours |
| Address | Public `https` only |

If a plan change allows fewer endpoints, the extra ones pause and stop receiving events until you choose which to keep.

## Next

- [Referrals](https://warplink.app/docs/guides/referrals) covers the links, the claim, and the report.
- [Webhooks API](https://warplink.app/docs/api/webhooks) lists every endpoint route with its request and response.
- [Referrals API](https://warplink.app/docs/api/referrals) lists the referral routes.
