DEV Community

Cover image for A practical guide to implementing referral programs — using the Incenta API.
Incenta
Incenta

Posted on • Edited on

A practical guide to implementing referral programs — using the Incenta API.

The Referral Funnel API: Code → Click → Conversion → Reward

A practical guide to implementing referral programs in any product — SaaS, marketplaces, mobile apps, or e-commerce — using the Incenta API.


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:

  1. Generating a unique, shareable code for each user
  2. Tracking when someone clicks that code
  3. Recording when the referred person actually does the thing you care about (signs up, subscribes, buys something)
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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({ /* ... */ }),
});
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Why bother tracking clicks separately from conversions at all? Two reasons that matter in practice:

  1. 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.
  2. 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" }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Two things happen automatically the moment you call this endpoint:

  1. A reward is created for the referrer (rewardCreated: true, rewardAmount: 5.00), based on whatever reward rules your campaign has configured.
  2. 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 a riskScore instead of a clean COMPLETED status.

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"
Enter fullscreen mode Exit fullscreen mode

Then let them claim one:

curl -X POST https://incenta.dev/api/v1/rewards/rew_xyz/claim \
  -H "Authorization: Bearer rk_xxxxx"
Enter fullscreen mode Exit fullscreen mode
{
  "success": true,
  "discountCode": "SAVE20",
  "expiresAt": "2026-07-06T12:00:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}` });
}
Enter fullscreen mode Exit fullscreen mode
// /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
}
Enter fullscreen mode Exit fullscreen mode
// /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 });
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)