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

_Last updated: 2026-10-26_

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](https://warplink.app/blog/referral-reward-webhook-python).

## 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](https://warplink.app/docs/guides/webhooks).

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:

```bash
export WARPLINK_WEBHOOK_SECRET="whsec_your_secret_here"
```

## Step 2: Set up the project

```bash
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`:

```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:

```js
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:

```bash
node server.js
```

Four choices are worth naming:

- **`express.raw` on this route only.** A global `express.json()` hides the raw bytes the signature covers. Mount it on other routes if you need it, never before this one.
- **Reject with `400`, not `401`.** Any `4xx` ends the event as failed, so an invalid signature is never retried. Only a `5xx` or a timeout retries.
- **Answer first, work after.** We wait 10 seconds for a reply and count a slower answer as a timeout. `setImmediate` moves 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 `id` is 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`:

```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:

```bash
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 `Set` with 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 carries `previous`, 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](https://warplink.app/blog/referral-attribution-exact-vs-estimated).
- **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](https://warplink.app/blog/verify-referral-before-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](https://warplink.app/docs/guides/webhooks) and the [Webhooks API](https://warplink.app/docs/api/webhooks).
- **Same receiver in Python:** [Receive a referral reward webhook in Python](https://warplink.app/blog/referral-reward-webhook-python).
- **The whole path:** [Referrals](https://warplink.app/docs/guides/referrals) and [WarpLink SDK 1.2.0](https://warplink.app/blog/warplink-sdk-1-2-0).
- **Launch pass:** [A referral link checklist for launch](https://warplink.app/blog/referral-link-checklist-for-launch).
- **Use case:** [App referral programs](https://warplink.app/use-cases/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](https://dash.warplink.app/auth/signup) and get deep linking, install attribution, and real-time analytics in one SDK, with 10,000 clicks a month on the free plan.
