How to Receive a Referral Reward Webhook in Node.js
Receive a signed WarpLink referral webhook in Node.js with Express: verify the HMAC signature on the raw body, dedupe by event id, and test locally.
To receive a WarpLink referral webhook in Node.js, read the raw request body, verify the X-WarpLink-Signature HMAC-SHA256 over ${timestamp}.${rawBody} with the whole whsec_ secret, skip event ids you have seen, and grant the reward. This guide gives a complete Express receiver you can run and test locally.
You get a server that verifies every delivery, handles referral.claimed, referral.flagged, and referral.updated, answers inside the 10-second limit, and a script that signs a sample event so you can test without a phone. The same flow in Python is in the Flask version.
What arrives
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 body is a JSON object with four fields: id, type, created, and data. For a referral event, data holds referral_id, app_id, link (with id, slug, and url), installer_id, platform, match (method, exact, confidence), tapped_at, installed_at, claimed_at, claimed_via, and flags. The full payload is in the webhooks guide.
The signature is the lowercase hex HMAC-SHA256 of the timestamp, a period, and the raw body. The key is the whole signing secret, whsec_ prefix included. During the 24 hours after a secret rotation the header holds two signatures, current secret first.
Step 1: Create the endpoint and copy the secret
Open the app in the dashboard, go to Referral handoff, choose the Webhook tab, and add an endpoint with a public https address. Copy the signing secret. It starts with whsec_ and is shown in full once. Free plans have 1 endpoint per app and paid plans have 5.
Keep the secret in an environment variable:
export WARPLINK_WEBHOOK_SECRET="whsec_your_secret_here"
Step 2: Set up the project
mkdir warplink-webhook && cd warplink-webhook
npm init -y
npm pkg set type=module
npm install express
Node 18 or later runs both files below as written.
Step 3: Write the verifier
The verifier follows the order in the docs: check the timestamp is well formed and within 300 seconds, compute the HMAC over the raw body, and compare in constant time against every listed signature. Save this as verify.js:
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifySignature({ secret, rawBody, signatureHeader, timestamp }) {
if (!/^\d{1,12}$/.test(timestamp)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const wanted = Buffer.from(expected, 'utf8');
return signatureHeader.split(',').some((signature) => {
const given = Buffer.from(signature.trim().toLowerCase(), 'utf8');
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
});
}
Two details matter. crypto.timingSafeEqual throws when the buffers differ in length, so the length check comes first. And the some call accepts a match on any listed signature, which is what makes a secret rotation painless.
Step 4: Write the receiver
Save this as server.js. It reads the body with express.raw so the bytes stay untouched, verifies, stores the event id, and answers fast:
import express from 'express';
import { verifySignature } from './verify.js';
const SECRET = process.env.WARPLINK_WEBHOOK_SECRET; // whsec_...
if (!SECRET) throw new Error('Set WARPLINK_WEBHOOK_SECRET');
const handledEvents = new Set(); // use a database table in production
const rewards = new Map(); // installer_id -> reward record
function grantReward(event) {
const { installer_id, link } = event.data;
rewards.set(installer_id, { linkId: link.id, eventId: event.id });
console.log(`reward for installer ${installer_id}, invited via link ${link.id}`);
}
function moveReward(event) {
const { installer_id, link, previous } = event.data;
rewards.set(installer_id, { linkId: link.id, eventId: event.id });
console.log(`moved credit from link ${previous.link.id} to ${link.id}`);
}
function handleEvent(event) {
if (handledEvents.has(event.id)) return; // a retry or a resend
handledEvents.add(event.id);
switch (event.type) {
case 'referral.claimed':
grantReward(event);
break;
case 'referral.updated':
moveReward(event);
break;
case 'referral.flagged':
console.log(`late claim ${event.data.referral_id}: ${event.data.flags}`);
break;
case 'webhook.test':
console.log('test event received');
break;
}
}
const app = express();
app.post('/webhooks/warplink', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const valid = verifySignature({
secret: SECRET,
rawBody,
signatureHeader: req.get('X-WarpLink-Signature') ?? '',
timestamp: req.get('X-WarpLink-Timestamp') ?? '',
});
if (!valid) return res.status(400).send('invalid signature');
setImmediate(() => handleEvent(JSON.parse(rawBody)));
res.sendStatus(204);
});
app.listen(3000, () => console.log('listening on :3000'));
Start it:
node server.js
Four choices are worth naming:
express.rawon this route only. A globalexpress.json()hides the raw bytes the signature covers. Mount it on other routes if you need it, never before this one.- Reject with
400, not401. Any4xxends the event as failed, so an invalid signature is never retried. Only a5xxor a timeout retries. - Answer first, work after. We wait 10 seconds for a reply and count a slower answer as a timeout.
setImmediatemoves the work out of the request. In production, push the event to a queue instead. - Dedupe before you grant. Delivery is at least once. The event
idis the same on every retry and every resend.
Step 5: Test without a phone
The dashboard's Send test button sends a signed webhook.test event to a public address. For a local server, sign a sample event yourself. Save this as send-test.js:
import crypto from 'node:crypto';
const SECRET = process.env.WARPLINK_WEBHOOK_SECRET;
const event = {
id: 'evt_local_0001',
type: 'referral.claimed',
created: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
data: {
referral_id: 'ref_local_0001',
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: [],
},
};
const rawBody = JSON.stringify(event);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const send = async () => {
const response = await fetch('http://localhost:3000/webhooks/warplink', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WarpLink-Signature': signature,
'X-WarpLink-Timestamp': timestamp,
'X-WarpLink-Event-Id': event.id,
},
body: rawBody,
});
console.log(response.status);
};
await send();
await send(); // the second delivery is a repeat and grants nothing
Run it in a second terminal with the same secret:
node send-test.js
You see 204 twice, and the server prints one reward for installer ... line. The second 204 is the dedupe working: same event id, no second reward. Change one character of rawBody after signing and you get 400 invalid signature.
To reach your machine from the dashboard, expose port 3000 through any https tunnel, add that address as the endpoint, and press Send test. The test event has an id that starts evt_test_, goes out once, and is never retried.
Step 6: Move to production
- Persist the event id. Replace the
Setwith a table that has the id as its primary key and insert before you grant, in the same transaction as the reward. - Use a queue. Acknowledge the delivery and process from the queue so a slow reward system never causes a timeout.
- Handle
referral.updated. It carriesprevious, the old link and match. Take the credit from the previous link's owner and give it to the new one. See exact versus estimated matches. - Rotate with no downtime. A rotation signs with the old and the new secret for 24 hours. The verifier above already accepts either signature, so deploy the new secret at your own pace.
- Resend after an outage. A failed event can be sent again with the same id for 7 days, from the Referrals report or with
POST /v1/webhook-events/{id}/resend.
For the rules that decide whether a claim should be paid, read verify a referral before you grant the reward.
Frequently Asked Questions
How do I verify a WarpLink webhook signature in Node.js?
Read the raw request body, build the string timestamp, a period, and the body, and compute an HMAC-SHA256 with the whole whsec_ secret as the key. Compare the lowercase hex digest to each signature in the X-WarpLink-Signature header with crypto.timingSafeEqual, after checking the timestamp is within 300 seconds.
Why does my signature check fail with express.json()?
express.json() parses the body and a re-serialized copy no longer matches the bytes we signed. Use express.raw with the application/json type on the webhook route and parse the JSON yourself after the check.
Does the secret include the whsec_ prefix?
Yes. The HMAC key is the full signing secret string, including the whsec_ prefix, read as UTF-8.
How do I avoid granting a reward twice?
Store the event id with the reward and skip an id you have already handled. Delivery is at least once, and the id is the same on every retry and every resend.
What status code should the endpoint return?
Return a 2xx status within 10 seconds. A 5xx answer or a timeout triggers a retry, up to 5 attempts over 20 minutes. A 4xx or 3xx answer ends the event as failed.
Related Guides
- Reference: Receive referral webhooks and the Webhooks API.
- Same receiver in Python: Receive a referral reward webhook in Python.
- The whole path: Referrals and WarpLink SDK 1.2.0.
- Launch pass: A referral link checklist for launch.
- Use case: App referral programs.
How WarpLink Helps
We do the matching, keep the record, and send a signed event the moment an install is credited. Your receiver stays small because the hard part, proving which tap led to which sign-up, is already done.
One workflow, three pillars. Deep linking carries the invitation, install attribution credits the link that produced the new user, and real-time analytics shows referrals beside every other link. Sub-10ms redirects on the way in, one SDK on the way out.
Create a free WarpLink account and get deep linking, install attribution, and real-time analytics in one SDK, with 10,000 clicks a month on the free plan.
Try WarpLink on one real link
Deep linking, install attribution, and real-time analytics in one SDK. Free for 10K clicks a month, no sales call.
Stuck on setup? Email support@warplink.app and we will help you get one real link working.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
Receive a Referral Reward Webhook in Python with Flask
Receive a signed WarpLink referral webhook in Python with Flask: verify the HMAC signature on the raw body, dedupe by event id in SQLite, and test locally.
How to Verify a Referral Install Before You Grant the Reward
Verify a referral install on your server before paying out: check the webhook signature, read the evidence, dedupe by event id, and cap rewards per inviter.
Make a Referral Link Survive the App Store on iPhone
How a referral code survives the App Store install on iPhone: the invitation page, the system paste button, and claimReferral, with SwiftUI code.