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

_Last updated: 2026-10-26_

To verify a referral install before you pay a reward, check five things on your server: the webhook signature, the event id you have not seen before, the match evidence, the flags, and your own limits per inviter. Only then grant the credit. WarpLink records the referral and signs the event. Your backend decides.

This guide turns a `referral.claimed` event into a safe reward, step by step, with code. It assumes the referral link and claim are already working. If they are not, start with [Referrals](https://warplink.app/docs/guides/referrals).

## Where verification fits

A referral reward has three moving parts. Your app claims the install after sign-up. WarpLink matches it to an invitation link, records it, and sends a signed webhook. Your backend receives the event and grants the reward in your own system.

The first valid claim for an app and installer id wins, so the same installer id cannot be credited twice by us. The id names an install or a device, so one person who reinstalls or signs up on a second device can arrive with a new id. Everything after the event is yours: the amount, the caps, the review queue. That is deliberate. Your rules about what counts stay in your code, and the facts that feed them arrive signed.

## Step 1: Claim from your backend

An app can claim by itself with `claimReferral`, and the record is the same. For rewards of value, send the claim from your server. Your backend holds your rules and your records, so it can claim only after a sign-up passes your own checks.

Pass the `attributionId` from the SDK's attribution result and the new user's installer id to your server, then call the claims route:

```bash
curl -X POST https://api.warplink.app/v1/referrals/claims \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY" \
  -H "Idempotency-Key: signup-8f21" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "installer_id": "d1c2f9a0-7b43-4f0e-a6d5-3c2b8e1f7a90",
    "install_id": "INSTALL_ID_FROM_THE_APP"
  }'
```

The key needs the `referrals:write` scope. The `Idempotency-Key` header makes a retry safe: send the same key for the same sign-up and the second request returns the first answer. The installer id is a dedicated random id for the new user, up to 128 characters. It is never an email address or a phone number.

## Step 2: Read the claim result

The answer carries a `status`:

| `status` | What you do |
|----------|-------------|
| `claimed` | Recorded inside the credit window. Wait for the webhook, or grant now by your rules |
| `flagged` | Recorded after the window closed, with the flag `outside_window`. Your rules decide |
| `ignored` | This installer already holds a credit. The answer names the referral in `duplicate_of` |
| `none` | No referral link brought this install. Nothing is recorded, and no reward is due |

`none` is an ordinary answer, not an error. A claim that names the `link_id` of an ordinary link answers `409 LINK_NOT_REFERRAL` and records nothing.

## Step 3: Verify the webhook signature

Every delivery is a `POST` with three headers: `X-WarpLink-Signature`, `X-WarpLink-Timestamp`, and `X-WarpLink-Event-Id`. The signature is a lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the whole `whsec_` secret including its prefix. Verify it before you read a single field:

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

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

export function verify(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');

  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);
  });
}
```

Use the raw body bytes. A framework that parses JSON first hides them, and a re-serialized body does not match. The header can hold two signatures for 24 hours after you rotate the secret, so accept a match on any of them. The complete receiver, with the Express route, is in [receive a referral reward webhook in Node.js](https://warplink.app/blog/referral-reward-webhook-node) and the Flask version is in [Python](https://warplink.app/blog/referral-reward-webhook-python).

## Step 4: Deduplicate by event id

We deliver each event at least once. A retry, a resend, or a slow answer can put the same event in front of you twice, and the event `id` is how you tell. Record it before you grant anything, in the same transaction as the reward:

```sql
CREATE TABLE webhook_events (id TEXT PRIMARY KEY, received_at TIMESTAMPTZ DEFAULT now());

INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING;
-- 0 rows inserted: the event is a repeat, so stop here.
```

The id is the same on every retry and every resend. An upgrade from an estimate to an exact match arrives as `referral.updated` with the original id plus `_u`, so it is a different event and is handled once as well.

## Step 5: Read the evidence

The event `data` tells you how strong the match is:

```json
{
  "id": "evt_9d2f41ab",
  "type": "referral.claimed",
  "data": {
    "referral_id": "ref_9d2f41ab",
    "link": { "id": "b7a1f2e4-52c8-4a38-8f64-1d7a0c9e3a55", "slug": "invite-ana" },
    "installer_id": "d1c2f9a0-7b43-4f0e-a6d5-3c2b8e1f7a90",
    "platform": "ios",
    "match": { "method": "install_token", "exact": true, "confidence": 1 },
    "claimed_via": "sdk",
    "flags": []
  }
}
```

`match.exact` is true for `install_token` (an invitation pasted on iPhone), `android_referrer` (the invitation inside the Play referrer), and `link_open`, all with a `confidence` of `1`. It is false for `probabilistic`, an estimated iPhone match whose `confidence` is the matcher's score. A rule that pays full value only for exact matches, and holds estimates for the exact upgrade, is a reasonable default. [Exact versus estimated matches](https://warplink.app/blog/referral-attribution-exact-vs-estimated) defines each field.

Check `flags` as well. `["outside_window"]` arrives on a `referral.flagged` event, which goes to every endpoint unless you turn it off.

## Step 6: Apply your own limits

The signature proves WarpLink sent the event and that nobody changed it on the way. It does not prove the install came from the invitation: an event can carry an estimated match, so read `match.exact` first (and `match.confidence` when it is `false`). Whether the reward is earned is then a business rule, and these are the usual ones to keep in your code:

- **A cap per inviter.** Count rewards by `link.id`, which you stored with the inviting user, and stop at your limit per day or per month.
- **A minimum action.** Grant the reward when the friend finishes a first purchase or a first session, not at install. Hold the event until your own condition is met.
- **A review queue for estimates.** Send `exact: false` referrals to a queue and pay when `referral.updated` upgrades them.
- **A late-claim policy.** Decide whether `referral.flagged` earns the full reward, a smaller one, or none.

```js
async function handle(event) {
  const first = await db.insertEventIfNew(event.id);
  if (!first) return;

  const { installer_id, link, match, flags } = event.data;
  const inviter = await db.inviterForLink(link.id);
  if (!inviter) return;
  if (!match.exact || flags.length > 0) return db.holdForReview(event);
  if (await db.rewardsThisMonth(inviter.id) >= MONTHLY_CAP) return;

  await db.grantReward(inviter.id, installer_id, event.id);
}
```

Answer the webhook with a `2xx` within 10 seconds and do the slow work from a queue. A `5xx` answer or a timeout triggers a retry, up to 5 attempts over 20 minutes.

## Step 7: Check the record

Open **Referral handoff**, then **Referrals**, in the dashboard. Each row shows the link, the installer id, the match, and the webhook status: `claimed`, `delivered`, `retrying`, `failed`, `flagged`, or `duplicate`. Open a referral to see its claims, the payloads we sent, and every delivery attempt. A failed event can be sent again, with the same id, for 7 days, so an outage on your side does not lose a reward.

## Frequently Asked Questions

### How do I verify a referral install on the server?

Verify the signature on the referral.claimed webhook, check the event id against the ones you have already handled, read match.exact and the flags, and apply your own rules before granting anything. WarpLink records the referral and sends the event. Your backend decides whether the reward is earned.

### Can the same person earn a referral reward twice?

Not for the same installer id. The first valid claim for an app and installer id holds the credit. The id names one install or device, so a person who reinstalls or uses a second device can arrive with a new id. A repeat claim answers ignored and names the referral that holds it, and no second webhook is sent.

### What does a flagged referral mean?

A flagged referral was claimed after the credit window closed. We record it with the flag outside_window and send a referral.flagged event, so your rules decide whether a late claim still earns a reward.

### Should I claim from the app or from my backend?

For rewards of value, claim from your backend. It holds your rules and your records, so it is the right place to decide when a sign-up has earned the reward. Call POST /v1/referrals/claims with an API key that has referrals:write and an Idempotency-Key header.

### What is the installer id?

It is your own random id for the new user, such as a UUID stored in its own column. It is opaque, up to 128 characters, and never an email address, phone number, or device identifier.

## Related Guides

- **The webhook reference:** [Receive referral webhooks](https://warplink.app/docs/guides/webhooks) lists every payload, retry, and limit.
- **The claim API:** [Referrals API](https://warplink.app/docs/api/referrals) and [Webhooks API](https://warplink.app/docs/api/webhooks).
- **The iPhone install step:** [Make a referral link survive the App Store install](https://warplink.app/blog/ios-referral-link-after-install).
- **Launch readiness:** [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 keep the part of a referral that is hard to host yourself: the invitation link, the install match, the record of which tap led to which sign-up, and a signed event that proves it. You keep the part that is yours, which is the reward and the rules around it.

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 next to 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.
