Skip to main content

Referral program model

A referral program in Orbit is a named, tenant-owned reward definition that turns your contacts’ shared links into tracked signups. The model has four artifacts — program → code → conversion → reward — and two halves: a public, unauthenticated redirect (GET /r/:code) that anonymous prospects click, and an authenticated controller (/api/v1/referrals) that you use to define, watch, and pay out the program. This page explains that model; the Referral programs guide walks the operator workflow end to end.

1. What a program is

A program is the reward definition: one row inside your tenant that names the offer (name), what each side earns (reward_type, reward_value, and the optional per-side referrer_reward / referee_reward configs), where referred visitors land (target_url), and when the program is live (start_at / end_at, plus the enabled flag). Programs never cross tenant boundaries — every row lives in your workspace’s schema, and a projected-out prospect never sees another tenant’s program. The reward type decides which rails a payout rides: Omit referee_reward for a single-sided program; set it and the referee is rewarded too when you approve the conversion. A program created with enabled: false accepts no conversions until you enable it; deleting a program cascade-removes its codes and conversions, so prefer disabling over deleting when the program might come back.

2. Codes: minted per contact, idempotent by design

A code is one referrer’s shareable identity — an 8-character, base58 string without ambiguous glyphs — minted with POST /api/v1/referrals/programs/{id}/codes. The endpoint is mint-or-get: call it twice for the same contact and program and you get the same row back, so the “issue my link” button in your product can call it on every page load without fear of duplicates. Each code row carries its own visits, signups, and rewards_paid counters, so a weak channel and a strong one surface separately on the leaderboard. The code’s public face is GET /r/:code — a root-mounted redirect that lives outside /api/v1 auth because the person clicking is a prospect, not an operator. The Public pixels, redirects, and short links concept documents the pixel-level contract of this surface (always-rendered responses, per-IP rate limits, branded error pages). The referral model’s claim on that redirect is what it does with the code: resolve it to a tenant and program, record a visit (IP, user-agent, geo), set an HttpOnly ref_code cookie with a 30-day TTL, then 302 to the program’s target_url with ?ref_code=<code> appended. The two /r/ route shapes coexist on one prefix — a single-segment code is a referral and a two-segment signed link is an email tracker — and the router disambiguates by path-segment count.

3. Lifecycle: program → issue code → conversion → reward

The artifacts chain through four states:
  1. Program — defined once, enabled/disabled over its start_atend_at window. New conversions are rejected outside the window with program_not_started / program_ended reasons and disabled programs accept none.
  2. Code issued — mint-or-get per contact; the code’s counters start at zero.
  3. Conversion recorded — when your signup flow creates a new contact with the ref_code query parameter or cookie present, the create-contact handler records a conversion under the program with reward_status: "pending". Fraud guards reject same-IP visit+signup and rapid-fire referrer/referee creation with a structured reject_reason (recorded, not silently dropped).
  4. Reward paid — an operator approves the pending conversion through POST /programs/{id}/conversions/{conversionId}/pay, which flips the status to paid and fulfills the reward. rejected rows never pay out.
Three states exist per conversion (pending, paid, rejected), and gated writes (create/update/delete program, issue code, pay reward) require the owner, admin, or developer role — the same guard the dashboard’s Marketing → Referrals screen enforces as owner/admin/billing. Reads (list, leaderboard, totals, conversions) need only authentication. Leaderboard vs totals. The leaderboard answers “who is driving signups” as a ranked top-N page of codes (limit default 25, max 100 — a cap, not a complete enumeration). The totals endpoint answers “how big is the program” by aggregating every code with no page-size limit (distinct referrers, total signups, total paid rewards). Never sum the leaderboard to drive a stat card; it under-counts for programs with more than a page of referrers.

4. Attribution carriers: the ref_code param

Two carriers move the code from click to signup:
  • ?ref_code=<code> query parameter — the primary carrier, appended by the /r/:code redirect. It survives a jump to your external signup page, where the cookie never round-trips back to this origin.
  • ref_code cookie (HttpOnly, 30-day TTL) — the redundancy carrier. It protects attribution when your landing page strips query parameters, and it carries the code, program id, and tenant schema.
At conversion time the create-contact handler reads the ref_code (or short alias ref) query parameter first, falls back to the ref_code cookie, and calls the referral conversion logic with the new contact id plus the referee’s IP and user agent. There is no separate “record a referral” endpoint — conversion is a side effect of creating the contact with the referral context present. No dedicated webhook exists either; the standard contact.created event fires, and GET /programs/{id}/conversions is the audit-grade enumeration when you need the linkage explicitly.

5. Abuses and failsafes: caps and approved payouts

A referral program is a public offer with a real reward behind it, so the model fails safe at three points:
  • Program caps. Bound the program’s life with start_at / end_at and disable it when a promotion ends, so an old shared link does not keep converting. Credits draw from your wallet balance, so a runaway program spends your budget rather than Orbit’s.
  • Approved payouts. No conversion pays out on its own. POST /programs/{id}/conversions/{conversionId}/pay (the payReward handler) is the only path from pending to paid, it is role-gated, and the update is scoped by program id — a conversion that belongs to another program returns 404, so a payout action can never fulfill the wrong program’s conversion. Every payout is audit-logged with referrer, referee, reward type, and fulfillment summary; the minted discount-code string itself is kept out of the audit trail.
  • Fraud rejects and rate limits. Same-IP visit+signup, rapid-fire referrer/referee creation, and self-refer attempts are recorded as rejected with a machine-readable reason rather than paid. The public /r/:code redirect rides the same family of per-IP limits as the other public surfaces (see Rate-limit and cooldown taxonomy), and distributing links over SMS or email still passes the normal send gates — the referral mechanic never bypasses the compliance surface (see Send Gates).

6. Which product surfaces issue on referrals: the incentives engine

Referral payouts do not mint rewards beside the reward rails — they ride the shared Incentives engine, the same fulfillment machinery behind surveys and loyalty redemption. The engine routes each reward to a provider (orbit_code for discount codes, orbit_manual for custom payouts, or a connected external gift-card network), resolves it to a lifecycle status (issued, manual, pending/delivered/failed), and appends it to the tenant’s append-only incentive ledger. The pay response carries a fulfillments array with each reward’s provider and delivery status, and the ledger query GET /api/v1/incentives/issued?source=referral is the audit view over every referral-sourced reward you have handed out. The one exception is credit reward types, which the engine refuses and the billing ledger handles directly.

Route outline

All routes are mounted at /api/v1/referrals behind auth; the public /r/:code redirect mounts separately at the app root so it bypasses auth. Writes are gated to the owner, admin, or developer role.