Who this is for
If you've ever been asked to "add a refer-a-friend feature" to your product, you've probably realized it's not one feature — it's four different problems wearing a trench coat:
- Generating a unique, shareable code for each user
- Tracking when someone clicks that code
- Recording when the referred person actually does the thing you care about (signs up, subscribes, buys something)
- Rewarding the person who made the referral — and sometimes the person who was referred, too
Most teams end up hacking this together with a spreadsheet, a UTM parameter, and a cron job that "reconciles" things once a week. It works until it doesn't — usually right around the time someone notices a user has 400 "referrals" from the same IP address.
This post walks through how to build this properly using a referral API, from the first POST request to the moment a user redeems their reward. The concepts here apply whether you're building:
- A SaaS product doing "invite a teammate, get a free month"
- An e-commerce store doing "share this code, both of you get 10% off"
- A mobile app doing "invite 3 friends, unlock a feature"
- A marketplace doing "refer a seller, earn a commission"
The API used in the examples is Incenta, but the pattern — code, click, conversion, reward — is the same one you'll find in nearly every referral system worth using.
The four stages of a referral, explained like you've never built one
Before touching any code, it helps to have clear mental models for the vocabulary, because referral systems have a habit of overloading the word "user."
| Term | What it means |
|---|---|
| Referrer | The existing user who is doing the inviting |
| Referee | The new (or existing) person being invited |
| Campaign | A specific referral program — you might run several at once (e.g., "Summer Promo" vs. "Enterprise Referral Program") |
| Referral code | A unique token that links a click or signup back to a specific referrer + campaign |
| Click | The moment someone follows a referral link, before they've done anything meaningful |
| Conversion | The moment the referee does the thing you actually care about — signs up, subscribes, completes a purchase |
| Reward | What the referrer (and sometimes the referee) receives for a successful conversion |
Keeping these four nouns distinct is what saves you from the classic referral-system bug: paying out a reward for a click that never converted, or losing track of who invited whom because "user" meant three different things in three different parts of your codebase.
With that out of the way, here's the full lifecycle:
Referrer Referee
│ │
│ 1. Generate code │
▼ │
POST /referrals ──────────────► "ABC123"
│
│ 2. Referee clicks the link
▼
POST /clicks
│
│ 3. Referee signs up / buys / subscribes
▼
POST /conversions
│
│ 4. Fraud check runs automatically
▼
reward created (if clean)
│
▼
POST /rewards/{id}/claim ──► Referrer gets discount code
Every box in that diagram is a real endpoint. Let's build it.
Step 0: Authentication
Every request needs an API key, generated per-app from your dashboard, sent as a Bearer token:
Authorization: Bearer rk_xxxxx
A few rules that will save you a security review later:
- Never put this key in client-side JavaScript, mobile app bundles, or anywhere a user could inspect it. It belongs on your server.
- Store it in an environment variable, not in source control.
- Each app gets one API key — if you're running staging and production, use separate apps (and separate keys) for each.
// server-side only
const INCENTA_API_KEY = process.env.INCENTA_API_KEY;
const res = await fetch('https://incenta.dev/api/v1/referrals', {
method: 'POST',
headers: {
Authorization: `Bearer ${INCENTA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ /* ... */ }),
});
If you forget the header, or the key is wrong, you'll get a 401. If you exceed your plan's monthly request limit, you'll get a 429. Both are worth handling explicitly rather than letting them surface as a generic "something went wrong" to your users — more on that at the end.
Step 1: Generate a referral code
This is the entry point. Whenever a user wants to invite someone — clicking "Invite a friend" in your app — your backend calls:
curl -X POST https://incenta.dev/api/v1/referrals \
-H "Authorization: Bearer rk_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"campaignId": "camp_abc123",
"referrerId": "user_456"
}'
campaignId tells the API which referral program this belongs to (useful if you're running more than one — say, a general referral program plus a seasonal promo). referrerId is just your own internal user ID — Incenta doesn't need to know anything else about the user.
The response gives you a shareable code:
{
"referralCode": "ABC123",
"referralId": "ref_789",
"status": "PENDING",
"warning": null,
"reasons": null,
"riskScore": null
}
That referralCode is what you embed in a shareable link, e.g. https://yourapp.com/signup?ref=ABC123.
A note on those warning / reasons / riskScore fields: they're null here because nothing suspicious has happened yet — a code was just created. But you'll see them populated at the conversion step, which is where fraud detection actually has something to evaluate. Keep this in mind now; it matters later.
In a SaaS product, this call typically happens the moment a user lands on an "Invite teammates" page — you generate the code (or fetch an existing one) and render the shareable link.
In e-commerce, you might generate a code automatically for every customer right after their first purchase, so it's ready to share without them having to ask for it.
Step 2: Track the click
When someone follows the referral link — but before they've signed up or bought anything — record the click:
curl -X POST https://incenta.dev/api/v1/clicks \
-H "Authorization: Bearer rk_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"referralCode": "ABC123",
"refereeId": "user_789"
}'
refereeId is optional here — often you don't know who this person is yet. If they're a brand-new visitor, you might not have a user ID for them at all; in that case, omit it and attach the code to a cookie or query parameter instead, then pass the refereeId later once they actually sign up.
{
"success": true,
"clickId": "click_123",
"referralCode": "ABC123",
"clickedAt": "2026-06-18T12:00:00.000Z"
}
Why bother tracking clicks separately from conversions at all? Two reasons that matter in practice:
- Attribution debugging. When a user says "I used my friend's link but never got credit," a click record lets you prove whether the link was ever followed in the first place, versus the conversion step simply never firing.
- Funnel analytics. Clicks-to-conversion ratio is one of the most useful numbers for evaluating whether a referral program is actually working, versus just generating traffic that goes nowhere.
A common implementation pattern: your /signup?ref=ABC123 landing page fires this request server-side (or via an API route) as soon as it loads, then stores ABC123 in a cookie or session so it's available when the user actually completes signup.
Step 3: Record the conversion
This is the moment that actually matters to your business: the referee did the thing. For a SaaS product, that's usually "completed signup" or "upgraded to a paid plan." For e-commerce, it's "completed a purchase."
curl -X POST https://incenta.dev/api/v1/conversions \
-H "Authorization: Bearer rk_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"referralCode": "ABC123",
"refereeId": "user_789",
"amount": 49.99,
"metadata": { "orderId": "order_xyz" }
}'
amount is optional but worth sending whenever there's a real monetary value attached — it powers your campaign analytics later (more on /stats below). metadata is a free-form object for anything you want attached to the conversion record for your own reference — an order ID, a plan name, whatever helps you reconcile later.
{
"success": true,
"referralId": "ref_789",
"conversionId": "conv_456",
"rewardAmount": 5.00,
"rewardCreated": true,
"status": "COMPLETED",
"refereeId": "user_789",
"referrerId": "user_456"
}
Two things happen automatically the moment you call this endpoint:
- A reward is created for the referrer (
rewardCreated: true,rewardAmount: 5.00), based on whatever reward rules your campaign has configured. - Fraud detection runs. If something looks off — the referrer and referee sharing a device fingerprint, an unusual velocity of conversions, a duplicate account pattern — the response will include
warning,reasons, and ariskScoreinstead of a cleanCOMPLETEDstatus.
This is the single most important integration point to get right, and it's worth calling out explicitly: the conversion endpoint should only be called from your backend, at the exact moment the qualifying action happens — not from client-side JavaScript, and not "eventually" via a batch job. If you call it too early (e.g., on page load instead of on completed checkout) you'll pay out rewards for people who never actually converted. If you call it from the client, you're trusting the browser to honestly report a monetary event, which is exactly the kind of thing referral fraud exploits.
A concrete SaaS example: in your backend's "complete onboarding" handler — the code that runs after a new user finishes account setup — read the referral code from the cookie/session set back in Step 2, then fire the conversion call as part of that same transaction.
Step 4: Claim the reward
Once a conversion is recorded and a reward exists, the referrer needs a way to actually get it. Fetch their available rewards:
curl "https://incenta.dev/api/v1/users/user_456/rewards?limit=10" \
-H "Authorization: Bearer rk_xxxxx"
Then let them claim one:
curl -X POST https://incenta.dev/api/v1/rewards/rew_xyz/claim \
-H "Authorization: Bearer rk_xxxxx"
{
"success": true,
"discountCode": "SAVE20",
"expiresAt": "2026-07-06T12:00:00.000Z"
}
This is the "money moment" of the whole flow — show this on a "Your Rewards" page in your app, or email it to the user directly. If your reward is a discount code applied at checkout rather than something claimed proactively, you'd instead validate it at the point of use:
curl "https://incenta.dev/api/v1/rewards/validate?code=DISCOUNT123" \
-H "Authorization: Bearer rk_xxxxx"
Which tells you whether the code is still valid before you apply it to an order.
Putting it together: a minimal Next.js example
Here's roughly how the four steps map onto a real app. This assumes a Next.js app with API routes, but the shape translates directly to any backend framework.
// /app/api/referrals/route.js — called when a user clicks "Invite a friend"
export async function POST(req) {
const { userId } = await req.json();
const res = await fetch('https://incenta.dev/api/v1/referrals', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INCENTA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
campaignId: process.env.INCENTA_CAMPAIGN_ID,
referrerId: userId,
}),
});
const data = await res.json();
return Response.json({ shareLink: `https://yourapp.com/signup?ref=${data.referralCode}` });
}
// /app/signup/route.js — landing page for ?ref=ABC123, records the click
export async function GET(req) {
const ref = new URL(req.url).searchParams.get('ref');
if (ref) {
await fetch('https://incenta.dev/api/v1/clicks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INCENTA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ referralCode: ref }),
});
// stash `ref` in a cookie so it's available at signup completion
}
// ...render signup page
}
// /app/api/onboarding-complete/route.js — fires after signup finishes
export async function POST(req) {
const { userId, referralCode } = await req.json();
if (!referralCode) return Response.json({ ok: true }); // no referral involved
const res = await fetch('https://incenta.dev/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INCENTA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ referralCode, refereeId: userId }),
});
const data = await res.json();
if (data.warning) {
// log it, flag for manual review — don't silently swallow this
console.warn('Referral flagged:', data.reasons, data.riskScore);
}
return Response.json({ ok: true });
}
Notice the pattern: generation and click-tracking can happen on lightly-authenticated, high-traffic paths (a signup page gets hit by anyone), while conversion recording happens exactly once, from a trusted backend event that you control completely. That boundary is the difference between a referral system that's easy to abuse and one that isn't.
Handling errors like you mean it
The API returns conventional HTTP status codes:
| Code | Meaning | What to actually do |
|---|---|---|
400 |
Bad request | Log the response body — it tells you which field was wrong |
401 |
Invalid/missing API key | This should never happen in production; treat it as a deploy-config bug |
404 |
Resource not found | Usually means a stale or mistyped referral code — surface a friendly "this invite link isn't valid" message, not a stack trace |
429 |
Rate limit exceeded | Back off and retry; don't hammer the endpoint in a tight loop |
500 |
Server error | Retry with backoff; alert if it persists |
A referral code that doesn't resolve (404) is an expected, user-facing scenario — someone's invite link expired, or they mistyped it — so don't let that error crash your signup flow. Catch it explicitly and show a normal "hmm, that invite link doesn't look right" message instead of a generic error page.
What "done" actually looks like
By the time you've wired up all four steps, you should be able to answer, for any user:
- How many people has this user referred? (
GET /users/{userId}/stats) - How many of those converted, and what's the conversion rate for a whole campaign? (
GET /stats?campaignId=...) - What rewards has this user earned, and have they claimed them? (
GET /users/{userId}/rewards)
That's the whole loop — and once it's in place, expanding it is mostly additive: tiered/multi-level referrals, gamified rewards (points, badges, leaderboards), or a cross-app rewards marketplace all build on top of this same code → click → conversion → reward backbone.
Next up: a deep dive into the warning, reasons, and riskScore fields you saw in the conversion response — what fraud detection is actually looking for, and how to build a review queue around it instead of either ignoring it or auto-rejecting everything.
Ready to add referrals to your product? Get your API key and have a working invite flow running in under an hour.
Track, validate, and reward referrals from one API — fraud prevention and real-time analytics included.
