Receive referral webhooks
Receive a signed webhook when a referral is claimed, flagged or upgraded. Verify the signature in Node.js or Python, handle retries, and resend events.
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.
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 or the MCP tools create_webhook_endpoint, rotate_webhook_secret, send_test_webhook and list_webhook_deliveries.
- Enter a public
httpsaddress. Localhost, private networks, addresses with a user name or password, and WarpLink addresses are refused. - 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. - Keep
referral.flaggedevents on, which is the default, or turn them off for this endpoint. - 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:
{
"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": null },
"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 a number only 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 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:
{
"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 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:
- Read the raw body bytes before any JSON parser touches them. A re-serialized body does not match.
- Reject the request when the timestamp is more than 300 seconds from your clock.
- 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.
- Answer with a
2xxstatus.
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);
});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 "", 204Do 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.
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:
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.
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 covers the links, the claim, and the report.
- Webhooks API lists every endpoint route with its request and response.
- Referrals API lists the referral routes.
Referrals
Credit an install to the invitation link that brought it. Set up the iPhone paste step, claim from your app or backend, and receive a signed webhook.
Authentication
Create and manage WarpLink keys, tell an SDK key from an API key, and follow security best practices for authenticating requests.