Reward recipes
Grant referral rewards from signed webhooks: exact matches, held estimates, two-sided rewards, free months, caps per inviter, and safe repeats.
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 covers that, and the posts on Node and 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.
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.
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.
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:
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 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.
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.
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.
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.
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 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.
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 walks through both.
Next
- Referrals guide for the full path from link to report
- Receive referral webhooks for signatures and retries
- Referral link checklist for launch before you go live
Test referrals
Test a referral end to end on a real phone: the iPhone paste path, the Android Play install referrer, the test webhook, and the Referrals report.
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.