Skip to content

Latest commit

 

History

History
125 lines (75 loc) · 7.92 KB

File metadata and controls

125 lines (75 loc) · 7.92 KB

Referrals and invite links

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.


Concepts

Referral program

  • What: A referred user signs up with someone’s referral code. We store a referral_signups row 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). See REFERRAL_REVENUE_SPLITS in packages/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.

Affiliate program

  • What: Users share an affiliate link (?affiliate=CODE) or API clients send X-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.


How users get and share an invite link

Automatic code creation

  • Each full account can have at most one row in referral_codes. The string code is generated by referralsService.getOrCreateCode (prefix from user id + random suffix) unless you add custom tooling.

  • GET /api/v1/referrals calls getOrCreateCode on 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_id is UNIQUE so at most one row per user. Concurrent first requests hit Postgres unique violations—handled inside getOrCreateCode by re-reading user_id. A purist alternative is POST to create + GET read-only.

Where the link appears

  1. 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.
  2. /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.

Share URL shape

  • Referral invite: Use buildReferralInviteLoginUrl(origin, code) from packages/lib/utils/referral-invite-url.ts so all surfaces stay aligned. Equivalent: {origin}/login?ref={encodeURIComponent(code)}. Login also accepts referral_code as 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.

Inactive codes

  • If referral_codes.is_active is false, POST /api/v1/referrals/apply rejects 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/referrals again (concurrent clicks dedupe on one in-flight request). is_active reflects 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.


API reference

GET /api/v1/referrals

  • Auth: Session cookie or API key via requireAuthOrApiKeyWithOrg (same family as GET /api/v1/affiliates).

  • Success (200) — flat JSON (do not nest under { code: { ... } }; Why: Fewer client parser bugs and matches TypeScript ReferralMeResponse):

{
  "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 in referral_signups where this user is the referrer).

  • is_active — Whether the code can be used for new signups.

  • Errors

    • 401 — Not authenticated. The route uses getErrorStatusCode from @/lib/api/errors (typed ApiError / legacy message heuristics) plus explicit handling for wallet plain Error strings (Invalid wallet signature, Wallet authentication failed) that requireAuthOrApiKey still throws outside AuthenticationError.
    • 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).
  • CORS / rate limit: OPTIONS + getCorsHeaders; withRateLimit(..., STANDARD). Why: Same cross-origin and abuse posture as other authenticated GET v1 routes.

POST /api/v1/referrals/apply

  • 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.

Applying a code at login

  • Query params ref or referral_code on /login are stored and, after authentication, posted to /api/v1/referrals/apply. Why: Lets marketing links be simple; attribution survives OAuth redirects via sessionStorage.

Related code (for maintainers)

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

Signup codes (distinct)

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=signup on invite URLs for analytics or UX.
  • Centralized client cache (SWR/React context) to dedupe GET across header + page—currently two GETs are acceptable because getOrCreateCode is idempotent.