# Reward recipes

WarpLink records who invited whom and sends a signed webhook. Your backend decides what the reward is and grants it. Every recipe below runs in your own code, driven by three events: `referral.claimed`, `referral.updated` and `referral.flagged`.

Each recipe assumes your endpoint already verifies the signature and answers fast. [Receive referral webhooks](https://warplink.app/docs/guides/webhooks) covers that, and the posts on [Node](https://warplink.app/blog/referral-reward-webhook-node) and [Python](https://warplink.app/blog/referral-reward-webhook-python) show a full handler.

## What you have to work with

Every event carries `data.referral_id`, `data.installer_id`, `data.link.id` and `data.match`. The installer id is the id you sent when you claimed, so it points at your new user. The link id points at the invitation, so your own records say who owns it.

```ts
type ReferralEvent = {
  id: string;
  type: 'referral.claimed' | 'referral.updated' | 'referral.flagged';
  data: {
    referral_id: string;
    installer_id: string;
    link: { id: string };
    match: { method: string; exact: boolean; confidence: number };
    flags: string[];
  };
};
```

## Recipe 1: grant on an exact match

An exact match comes from the invitation itself: `install_token` on iPhone or `android_referrer` on Android. Nothing is guessed, so the reward can be granted at once.

```ts
async function onClaimed(event: ReferralEvent) {
  const { installer_id, link, match, referral_id } = event.data;
  if (!match.exact) return holdForReview(referral_id);

  const inviter = await inviterForLink(link.id);
  await grant({ userId: inviter.userId, referralId: referral_id, kind: 'inviter' });
}
```

Key every reward on `referral_id`. That id stays the same when an estimate later becomes exact, so one referral can never pay twice.

## Recipe 2: hold estimated matches

On an iPhone with no pasted invitation, the best match is an estimate: `match.exact` is `false` and `confidence` is below 1. Treat an estimate as a lead. Record it, show nothing to the inviter yet, and wait.

```ts
async function holdForReview(referralId: string) {
  await db.referrals.upsert({ referralId, state: 'pending' });
}
```

Then act on the upgrade. When the same installer claims with an exact match, we send `referral.updated` with the same `referral_id`:

```ts
async function onUpdated(event: ReferralEvent) {
  const { referral_id, link, match } = event.data;
  if (!match.exact) return;

  const inviter = await inviterForLink(link.id);
  await moveToGranted({ referralId: referral_id, userId: inviter.userId });
}
```

The event also carries `data.previous`, the link and match it replaced. If the estimate already paid the owner of `previous.link`, take that reward back before you pay the new owner.

If you prefer to pay on a confident estimate, set your own bar on `confidence` and keep it in one place. [Exact and estimated matches](https://warplink.app/blog/referral-attribution-exact-vs-estimated) explains the trade.

## Recipe 3: two-sided rewards

Reward the new user and the inviter from one event. The installer id maps to the new user, and the link owner is the inviter. Grant both in one transaction, each with its own idempotency key.

```ts
async function grantBoth(event: ReferralEvent) {
  const { installer_id, link, referral_id } = event.data;
  const inviter = await inviterForLink(link.id);
  const invitee = await userForInstallerId(installer_id);

  await db.transaction(async (tx) => {
    await grantOnce(tx, `${referral_id}:inviter`, inviter.userId);
    await grantOnce(tx, `${referral_id}:invitee`, invitee.userId);
  });
}
```

## Recipe 4: a free month for a subscription app

If your app sells subscriptions, the reward is often a free month. Grant it through your subscription provider, such as RevenueCat promotional entitlements, from the same handler. The webhook says who earned it, and your billing code says how.

```ts
async function giveFreeMonth(userId: string, key: string) {
  if (await billing.alreadyGranted(key)) return;
  const endTimeMs = Date.now() + 30 * 24 * 60 * 60 * 1000;
  await billing.grantPromotionalEntitlement({
    userId,
    entitlement: 'pro',
    endTimeMs,
  });
  await billing.recordGrant(key);
}
```

Your wrapper stores the key and skips a grant it has already made, so a retried event extends nothing a second time. The wrapper sends `end_time_ms` to the entitlement call.

## Recipe 5: caps per inviter

A cap stops one person from collecting without limit. Count what the inviter already holds before you grant, and record the skipped referral so support can answer questions.

```ts
const MAX_REWARDS_PER_INVITER = 10;

async function grantWithinCap(inviterId: string, referralId: string) {
  const held = await db.rewards.countForInviter(inviterId);
  if (held >= MAX_REWARDS_PER_INVITER) {
    return db.referrals.mark(referralId, 'capped');
  }
  await grant({ userId: inviterId, referralId, kind: 'inviter' });
}
```

Do the count and the insert in one transaction, or put a unique constraint on the reward row. Two events at the same moment then cannot both pass.

## Recipe 6: handle flagged claims

`referral.flagged` means the claim came after the credit window closed. The flag is `outside_window`. A flagged referral is recorded but is not a normal credit, so most apps skip the reward and keep the row for review.

```ts
async function onFlagged(event: ReferralEvent) {
  await db.referrals.upsert({
    referralId: event.data.referral_id,
    state: 'flagged',
    flags: event.data.flags,
  });
}
```

If you want a later claim to count, choose a longer credit window on the app instead of accepting flagged rewards in code. [Verify a referral before you reward it](https://warplink.app/blog/verify-referral-before-reward) lists other checks worth adding.

## Recipe 7: safe repeats

We deliver each event at least once. The same event can reach you twice, after a timeout or after a resend, and it keeps the same `id` each time. Record the id before you act, and stop when it is already there.

```ts
async function handle(event: ReferralEvent) {
  const isNew = await db.events.insertIfNew(event.id);
  if (!isNew) return; // a repeat: nothing to do

  switch (event.type) {
    case 'referral.claimed':
      return onClaimed(event);
    case 'referral.updated':
      return onUpdated(event);
    case 'referral.flagged':
      return onFlagged(event);
  }
}
```

Use the event id for the event, and the `referral_id` plus a role for each reward. The two layers together cover a retry, a resend, and an estimate that becomes exact.

Answer within 10 seconds. Write the event to a queue, answer `200`, and grant the reward from the queue.

## Test a recipe

Send a `webhook.test` event to check the signature and reply, then run a real referral on a phone to check the whole path. [Test referrals](https://warplink.app/docs/guides/referrals/testing) walks through both.

## Next

- [Referrals guide](https://warplink.app/docs/guides/referrals) for the full path from link to report
- [Receive referral webhooks](https://warplink.app/docs/guides/webhooks) for signatures and retries
- [Referral link checklist for launch](https://warplink.app/blog/referral-link-checklist-for-launch) before you go live
