This document describes the referral program (signup attribution, bonuses, 50/40/10 purchase splits), how it differs from affiliates, and how users obtain and share invite links. It includes API details and design WHYs.
-
What: A referred user signs up with someone’s referral code. We store a
referral_signupsrow linking referrer and referred user. On signup, both sides can receive bonus credits (minted as marketing spend, not taken from purchase revenue). When the referred user buys credits (Stripe checkout or x402), 100% of that purchase is split 50% Eliza Cloud / 40% app owner / 10% creator (with optional multi-tier rules for creator vs editor). SeeREFERRAL_REVENUE_SPLITSinpackages/lib/services/referrals.ts. -
Why separate from affiliates: Referrals attach to purchase revenue and one-time/qualified bonuses. Affiliates attach a markup on specific flows (auto top-up, MCP). Same transaction must not apply both (see README). Why: Predictable economics—referral splits always sum to 100% of the purchase; affiliate cost is passed through to the customer.
-
What: Users share an affiliate link (
?affiliate=CODE) or API clients sendX-Affiliate-Code. Linked users pay a markup on marked-up flows; the affiliate earns from that markup. -
Why not call affiliates “referrals” in the UI: The word “referral” in this codebase means the 50/40/10 + signup bonus program. Affiliate signups are a different ledger and product surface (
/dashboard/affiliates). Why: Prevents users and integrators from confusing two URLs (?ref=vs?affiliate=) and two payout models.
-
Each full account can have at most one row in
referral_codes. The stringcodeis generated byreferralsService.getOrCreateCode(prefix from user id + random suffix) unless you add custom tooling. -
GET /api/v1/referralscallsgetOrCreateCodeon first success. Why GET creates a row: One round trip for the dashboard—no separate “create my code” step. The operation is idempotent; duplicate calls are safe. -
REST / safety: Strictly speaking, GET should not mutate state; here we trade that for UX. Mitigations: auth required, rate limiting,
force-dynamic(no CDN cache). A caller with a valid session could create a row;user_idis UNIQUE so at most one row per user. Concurrent first requests hit Postgres unique violations—handled insidegetOrCreateCodeby re-readinguser_id. A purist alternative isPOSTto create +GETread-only.
-
Dashboard header — “Invite”
- Visible only when the user is not anonymous and not in
authGraceActive(session still settling). Why: Avoids 401s and confusing errors during Privy grace; keeps anonymous users on sign-up CTAs only.
- Visible only when the user is not anonymous and not in
-
/dashboard/affiliates— “Invite friends” card- Placed above the affiliate link card, with a distinct visual treatment. Why: Both programs live under Monetization, but mixing orange “affiliate” styling with “invite friends” caused low-signal UIs to merge them mentally.
-
Referral invite: Use
buildReferralInviteLoginUrl(origin, code)frompackages/lib/utils/referral-invite-url.tsso all surfaces stay aligned. Equivalent:{origin}/login?ref={encodeURIComponent(code)}. Login also acceptsreferral_codeas a query param for the same purpose. -
Why
encodeURIComponent: Codes are alphanumeric + hyphen; encoding stays correct if the format ever changes and matches browser URL rules.
-
If
referral_codes.is_activeisfalse,POST /api/v1/referrals/applyrejects the code. The GET endpoint still returns the row so the owner can see status. -
UI: Header Invite does not copy an inactive link (toast explains). The Affiliates page shows an inactive state with read-only URL and no Copy. Why: Stops sharing links that will fail at apply time; still lets support/debug see which code is paused.
-
Invite button freshness: Each header Invite click calls
GET /api/v1/referralsagain (concurrent clicks dedupe on one in-flight request).is_activereflects the server at click time, not a stale page-load cache. -
Clipboard: Copy helpers use the Clipboard API when available, then fall back to
document.execCommand('copy')for plain HTTP or older browsers. Production dashboard should use HTTPS (or localhost); some environments still block clipboard access entirely.
-
Auth: Session cookie or API key via
requireAuthOrApiKeyWithOrg(same family asGET /api/v1/affiliates). -
Success (200) — flat JSON (do not nest under
{ code: { ... } }; Why: Fewer client parser bugs and matches TypeScriptReferralMeResponse):
{
"code": "ABCD-A1B2C3",
"total_referrals": 0,
"is_active": true
}-
code— The user's unique referral code string. -
total_referrals— Count of successful referral signups (rows inreferral_signupswhere this user is the referrer). -
is_active— Whether the code can be used for new signups. -
Errors
- 401 — Not authenticated. The route uses
getErrorStatusCodefrom@/lib/api/errors(typedApiError/ legacy message heuristics) plus explicit handling for wallet plainErrorstrings (Invalid wallet signature,Wallet authentication failed) thatrequireAuthOrApiKeystill throws outsideAuthenticationError. - 403 — Authenticated but forbidden (e.g. missing org /
ForbiddenError). Why: Distinguishes “who are you?” from “you can’t use this feature yet.” - 500 — Unexpected server error (logged).
- 401 — Not authenticated. The route uses
-
CORS / rate limit:
OPTIONS+getCorsHeaders;withRateLimit(..., STANDARD). Why: Same cross-origin and abuse posture as other authenticated GET v1 routes.
-
Auth: Required (session or API key with org).
-
Body:
{ "code": "..." }(trimmed and uppercased server-side). -
WHYs: One referral signup per referred user; idempotent replay of the same code returns success; self-referral and inactive codes are rejected. See implementation in
referralsService.applyReferralCode.
- Query params
reforreferral_codeon/loginare stored and, after authentication, posted to/api/v1/referrals/apply. Why: Lets marketing links be simple; attribution survives OAuth redirects viasessionStorage.
| Area | Path |
|---|---|
| Referral service (splits, apply, getOrCreateCode) | packages/lib/services/referrals.ts |
| Schemas | packages/db/schemas/referrals.ts |
| GET me + OPTIONS | app/api/v1/referrals/route.ts |
| Apply | app/api/v1/referrals/apply/route.ts |
| Response typing / parse helper | packages/lib/types/referral-me.ts |
| Share URL helper | packages/lib/utils/referral-invite-url.ts |
| Header invite | packages/ui/src/components/layout/header-invite-button.tsx |
| Affiliates + invite card | packages/ui/src/components/affiliates/affiliates-page-client.tsx |
Campaign codes (SIGNUP_CODES_JSON, POST /api/signup-code/redeem) are not referral codes. Why: Signup codes are one-off org bonuses; referrals drive ongoing split + referral bonuses. See signup-codes.md.
Roadmap / out of scope (see ROADMAP.md)
- Vanity / custom referral strings (needs validation, uniqueness, and possibly admin).
- Optional
intent=signupon invite URLs for analytics or UX. - Centralized client cache (SWR/React context) to dedupe GET across header + page—currently two GETs are acceptable because
getOrCreateCodeis idempotent.