> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# The referral conversion loop: clicks, conversions, leaderboard, and payouts

> Follow a referral from the public /r/:code click through the attributed signup, the conversion queue, the leaderboard, and reward approval, with the exact response each read returns.

# The referral conversion loop

The [Referral programs](/guides/referral-programs) guide walks the create-and-mint side of referrals: authoring a program, issuing per-contact codes, and the dashboard equivalents in [Set up a referral campaign](/guides/referrals-campaign-setup). This guide picks up where those stop, at the moment a referrer actually shares their link. It walks the read side of the loop end to end and shows the response bodies you get back at each step, the part of the API the reference pages name but never illustrate.

## 1. The loop in one request flow

One referred signup moves through six requests:

1. **Create the program.** `POST /referrals/programs` defines the offer, including the per-side reward config (`referrer_reward`, `referee_reward`). Covered in [Referral programs](/guides/referral-programs).
2. **Mint a code per referrer.** `POST /referrals/programs/:id/codes` returns the contact's code and its public short link, `https://api.orbit.devotel.io/r/AbCdEf23`.
3. **The prospect clicks.** The public redirect counts the visit and 302s to your landing page with the code attached as a `ref_code` query parameter ([section 2](#2-watch-the-click-land)).
4. **Your signup flow passes `ref_code` on.** When your backend creates the new contact in Orbit, it includes the parameter, and Orbit records a conversion ([section 3](#3-confirm-the-signup-attribute)).
5. **You read the funnel.** `GET /referrals/programs/:id/conversions` lists attributed signups; `GET /referrals/programs/:id/leaderboard` ranks referrers ([section 4](#4-read-the-leaderboard)).
6. **You approve the payout.** `POST /referrals/programs/:id/conversions/:conversionId/pay` flips the conversion to `paid` and fulfills both sides of the reward ([section 5](#5-approve-the-payout-end-to-end)).

Steps 1 and 2 are program setup; everything from step 3 on is the conversion loop this guide covers.

## 2. Watch the click land

`GET /r/:code` is public. It needs no API key because it is opened in a prospect's browser, never by your server. Each hit bumps the code's visit counter and answers with a redirect:

```http theme={null}
GET /r/AbCdEf23 HTTP/1.1
Host: api.orbit.devotel.io
```

```http theme={null}
HTTP/1.1 302 Found
Location: https://your-product.example/signup?ref_code=AbCdEf23
Set-Cookie: ref_code=…; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
Cache-Control: private, no-store
```

Two carriers leave this response, and they matter differently:

* **The `ref_code` query parameter** is the one your signup page actually sees. `Location` points at your own domain, so hold the parameter through your signup form (a hidden field or a query passthrough) and forward it in the create-contact call in [section 3](#3-confirm-the-signup-attribute). This is the attribution path that works when your signup flow lives outside Orbit.
* **The `ref_code` cookie** is scoped to the Orbit API origin. It only round-trips when a signup never leaves Orbit (a program with no landing URL configured); for an external `target_url` it never reaches your domain. Treat the query parameter as primary and the cookie as a fallback Orbit reads on its own origin.

When a code cannot resolve (mistyped, deleted, or minted in another environment) a browser receives a small branded "link unavailable" page while an API or SDK caller receives the JSON error envelope, so a shared link never presents a raw server error to a prospect. The redirect is rate-limited per client IP, so a circulated link picked up by a scraper does not inflate your visit counts.

Visits need no code on your side: the redirect records them server-side, and they surface per code in the leaderboard below.

## 3. Confirm the signup attribute

When your backend creates the referred contact, pass the code through with the same `ref_code` parameter your landing page received. Orbit matches it to the code, records a conversion, and leaves it at `pending` until you approve the reward:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/referrals/programs/rpg_8Hq2mX/conversions?status=pending" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "rows": [
      {
        "id": "rcv_3Tz81Kq",
        "program_id": "rpg_8Hq2mX",
        "referrer_contact_id": "con_referrer_abc123",
        "referee_contact_id": "con_referee_xyz789",
        "converted_at": "2026-10-14T09:12:44.000Z",
        "rewarded_at": null,
        "reward_status": "pending",
        "reject_reason": null,
        "referrer_name": "Maya Okafor",
        "referee_name": "Jonas Weber"
      }
    ],
    "total": 1,
    "limit": 25,
    "offset": 0
  },
  "request_id": "req_5Nw02Ld"
}
```

Read the row as the state of one attributed signup:

* `reward_status` moves `pending` → `paid` on approval, or straight to `rejected` when an anti-abuse guard declines it (a signup from the referrer's own IP, or a referrer and referee created minutes apart). Rejected rows carry a `reject_reason` and are never payable; the signup still counts toward the referrer's funnel because the visit and signup genuinely happened.
* `referrer_contact_id` / `referee_contact_id` join the row back to your contacts; `referrer_name` / `referee_name` are resolved display labels for rendering directly.
* `converted_at` is when the referred contact was created; `rewarded_at` is filled only when the payout lands.

Filter with `status=pending` for the approval queue, `status=paid` for reconciled payouts, and page with `limit` / `offset` (max 100 per page).

## 4. Read the leaderboard

`GET /referrals/programs/:id/leaderboard` ranks every issued code in the program by signups, then visits:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/referrals/programs/rpg_8Hq2mX/leaderboard?limit=10" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "referrer_contact_id": "con_referrer_abc123",
      "code": "AbCdEf23",
      "visits": 480,
      "signups": 63,
      "rewards_paid": 58
    },
    {
      "referrer_contact_id": "con_referrer_def456",
      "code": "K2mNpQ7r",
      "visits": 310,
      "signups": 21,
      "rewards_paid": 19
    }
  ],
  "request_id": "req_7Pq44Xb"
}
```

Three counters per referrer, each with different semantics:

* `visits` counts 302s the redirect served for this code ([section 2](#2-watch-the-click-land)).
* `signups` counts every recorded conversion for the code, including rejected ones: a referred signup that a fraud guard declined is still a signup for funnel purposes. Reward eligibility is tracked separately by each conversion's `reward_status`.
* `rewards_paid` counts conversions that reached `paid`, incremented only when a payout is approved. It is a count of paid rewards, not an amount; the amounts paid per side come from the program's reward config ([section 5](#5-approve-the-payout-end-to-end)).

The leaderboard caps at 100 rows (`limit` defaults to 25), ordered so your strongest referrers rank first. For whole-program aggregates (total referrers, total signups, total rewards paid) call `GET /referrals/programs/:id/totals` instead; that is what the dashboard stat cards read, and it is not capped the way the leaderboard is.

## 5. Approve the payout end to end

Payouts are an operator decision: a conversion stays `pending` until you approve it. Approve with the pay endpoint:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/referrals/programs/rpg_8Hq2mX/conversions/rcv_3Tz81Kq/pay" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "id": "rcv_3Tz81Kq",
    "program_id": "rpg_8Hq2mX",
    "referrer_contact_id": "con_referrer_abc123",
    "referee_contact_id": "con_referee_xyz789",
    "converted_at": "2026-10-14T09:12:44.000Z",
    "rewarded_at": "2026-10-15T08:02:11.000Z",
    "reward_status": "paid",
    "reject_reason": null,
    "fulfillments": [
      {
        "id": "ipo_9Wd17Rn",
        "provider_id": "wallet",
        "status": "delivered",
        "recipient_contact_id": "con_referrer_abc123"
      },
      {
        "id": "ipo_9Wd17Rp",
        "provider_id": "wallet",
        "status": "delivered",
        "recipient_contact_id": "con_referee_xyz789"
      }
    ]
  },
  "request_id": "req_2Kc61Mf"
}
```

The response is your receipt. The conversion flips to `paid` with a `rewarded_at` timestamp, the referrer's `rewards_paid` counter increments, and the `fulfillments` array carries one fulfillment record per rewarded side, so a two-sided program returns a referrer entry and a referee entry. What each fulfillment does depends on the program's reward type:

* **`credit`** applies the configured amount per side, drawn from the reward pool your organization funds. The split comes from the program's `referrer_reward` and `referee_reward` config, so a "10 for the referrer, 5 for the referee" offer pays exactly that on every approval.
* **`discount_code`** mints a redeemable code per side through the incentives engine; the minted code is returned in the response but kept out of durable logs, exactly because it is redeemable. Fulfillment providers for non-credit rewards are configured as described in the [Incentives ledger](/guides/incentives-ledger) guide.
* **`custom`** records a tracked payout for your own fulfillment process and reports it back in `fulfillments` so your system has a durable reference to reconcile against.

A payout that fails partway (for example a wallet credit that could not be applied) leaves the conversion marked `paid` but surfaces the failure in structured logs on the platform side; the `fulfillments` statuses in the response tell you what actually delivered. Rejected conversions are never payable: the pay endpoint 404s on them, and the `reject_reason` stays on the row for your review.

## 6. Idempotency and re-tries

Every write in the loop is safe to replay, by design rather than by header:

* **Minting is mint-or-get.** `POST /referrals/programs/:id/codes` with a `contact_id` that already holds a code returns the existing code, so a nightly cohort job can call it for every member without checking first, and a retried mint never produces a second link for the same referrer.
* **Attribution deduplicates per referee.** A conversion is unique per program and referee contact: if your signup webhook fires twice for the same referred signup, the second create-contact call does not record a second conversion.
* **Pay is pending-only.** The pay endpoint transitions `pending` → `paid` and nothing else. If your client times out and retries, the retry finds no pending conversion and returns 404 instead of paying twice; the correct move after any pay timeout is to `GET` the conversions list and read `reward_status` rather than to re-issue the pay call blindly.

Because these three guarantees live server-side, you do not need client-generated idempotency keys to drive the referral loop safely: design your retry logic around reading state back (`conversions`, `leaderboard`) after any uncertain write, and a duplicated request collapses into the same result as a single one.

## 7. Related reading

* [Referral programs](/guides/referral-programs) — the create-and-mint side of this loop, driven through the API.
* [Set up a referral campaign from the dashboard](/guides/referrals-campaign-setup) — the same workflow without code.
* [Referral program model](/concepts/referral-program-model) — the concepts behind programs, codes, and conversions.
* [Incentives ledger](/guides/incentives-ledger) — fulfillment providers for non-credit rewards.
* [Referrals API reference](/api-reference/referrals) — per-endpoint request and response contracts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.