Raxx · internal docs

internal · gated ↑ index

Founders Referral Service — link generation, click attribution, conversion tracking

Status: Draft Owner: software-architect Last updated: 2026-04-23 Parent card: #207 — Generate unique referral links, track conversion attribution, grant +time Parent epic: #204 — Founders Promo Related ADRs: 0003, 0016, 0017, 0018 Cross-references: founders-trial-engine.md (FounderTrialService.add_bonus, enforceCap), auth.md §3 (audit_log schema), session-engine.md §4 (JWT tier claim)


1. Context

The Founders Promo (epic #204) includes a referral mechanism: each Founder has a unique shareable link. When someone follows that link, signs up as a Founder, and later converts to a monetized paid subscription, the referrer receives +90 days (3 months) added to their trial window. The referred person starts a 2-week (14-day) trial rather than the 90-day direct-signup trial — per the locked decision in #207.

This service lives inside Raptor (backend_v2/). It owns link generation, click attribution, conversion tracking, and the Stripe webhook handler that fires the referrer bonus. It calls FounderTrialService.add_bonus() from founders-trial-engine.md — it does not reimplement any bonus or cap logic.

Bonus trigger (locked): paid Stripe customer.subscription.created webhook for a monetized subscription. Not on click, not on signup, not on Founders-accept.

Attorney-review gate: the referral incentive structure (promotional +time for referrals) may carry disclosure requirements under securities and consumer-promotion regulations. Per #204 and #196/#197: no user-facing referral copy ships without attorney review. This is an open question (see §12).


2. Invariants that apply

  1. No stored credentials. Referral records contain no API key, broker token, or authentication secret. ADR 0002 CI grep covers backend_v2/ wholesale.
  2. GDPR by default (ADR 0003). founder_referral_links and founder_referral_conversions cascade on users.id delete. After 30-day cooling, PII fields (referred_user_id, ip_hash) are hard-deleted. audit_log rows survive with hashed actor id. bonus_granted_at columns survive (entitlement record, 7-year retention).
  3. Audit trail for every state change affecting entitlement. Every bonus grant writes an audit_log row with action namespace founder.referral.*.
  4. Referrer identity is never surfaced in user-facing copy. The referred person sees "invited by a friend" — never a handle, email, or ID. This is a product-level invariant, not just a copy suggestion.
  5. Bonus fires on monetized paid conversion only. Comp subscriptions, 100%-off coupons, and failed-payment subscriptions are explicitly excluded. The Stripe webhook handler validates this before calling add_bonus.
  6. Flag gating. FLAG_FOUNDERS_REFERRAL=off gates the entire service. Can ship and deploy before the trial engine GA's. Link generation and redirect endpoints respect this flag; Stripe webhook handler respects it.
  7. Cap enforcement is delegated. enforceCap() lives in FounderTrialService. This service calls it; it does not re-implement it.

3. Data model

New tables in backend_v2/db/. Schemas are design sketches; canonical migration in sub-card #N1 (see §11).

founder_referral_links
  id                     TEXT PK              -- uuid v4
  user_id                TEXT NOT NULL UNIQUE FK -> users.id ON DELETE CASCADE
  slug                   TEXT NOT NULL UNIQUE -- crypto-random, URL-safe, min 8 chars
  created_at             TIMESTAMP NOT NULL
  updated_at             TIMESTAMP NOT NULL
  click_count            INTEGER NOT NULL DEFAULT 0
  is_active              BOOLEAN NOT NULL DEFAULT TRUE
                                              -- admin kill switch per-link; does not cascade
  CHECK (length(slug) >= 8)

founder_referral_conversions
  id                     TEXT PK              -- uuid v4
  referral_link_id       TEXT NOT NULL FK -> founder_referral_links.id ON DELETE CASCADE
  referred_user_id       TEXT NULL    FK -> users.id ON DELETE SET NULL
                                              -- SET NULL on user erasure; row retained for audit
  clicked_at             TIMESTAMP NULL       -- when the attribution cookie was set
  signed_up_at           TIMESTAMP NULL       -- when the referred user activated as a Founder
  converted_at           TIMESTAMP NULL       -- when the referred user activated a paid subscription
  bonus_granted_at       TIMESTAMP NULL       -- idempotency gate; set when add_bonus fires
  subscription_id        TEXT NULL UNIQUE     -- Stripe subscription ID; idempotency key
  ip_hash                TEXT NULL            -- SHA-256(ip || salt); cleared at 30d cooling
  created_at             TIMESTAMP NOT NULL
  updated_at             TIMESTAMP NOT NULL
  UNIQUE (referral_link_id, referred_user_id)
  -- referred_user_id is nullable post-erasure; uniqueness constraint only enforced when non-null
  -- enforce application-layer uniqueness when referred_user_id IS NOT NULL

Key constraints: - UNIQUE (user_id) on founder_referral_links means one link per Founder — generate_link() is idempotent. - subscription_id UNIQUE on founder_referral_conversions is the idempotency key for the Stripe webhook handler. Duplicate webhook delivery cannot double-grant. - referred_user_id SET NULL on user erasure retains the conversion record for entitlement audit. ip_hash is cleared at 30-day cooling. bonus_granted_at survives indefinitely (7-year retention per ADR 0003 §retention).


4. Slug algorithm

Requirements: crypto-random, URL-safe, min 8 chars, collision-resistant.

Algorithm (to implement): 1. Generate 6 random bytes via os.urandom(6) or platform equivalent. 2. Base64url-encode (RFC 4648 §5 — uses - and _, no padding). Result is 8 characters. 3. Check for collision against founder_referral_links.slug. If collision (expected once every ~281 trillion slugs at current cohort size — negligible), retry up to 3 times, then raise. 4. Store slug. Link format: https://getraxx.com/r/{slug}.

Entropy: 6 bytes = 48 bits. At a cohort of 10,000 Founders, the birthday-paradox collision probability is approximately 1 in 2.8 × 10^8 — effectively zero. At 1,000,000 Founders it remains below 0.2%. No expansion needed for v1.

ADR 0017 records this choice (see adr/0017-referral-slug-entropy.md).


5. Click attribution

Cookie-based (primary path): - On GET /r/{slug}: set raxx_ref={slug} cookie. Max-Age=2592000 (30 days). SameSite=Lax. Secure. HttpOnly. - GDPR gate: consent required before cookie is set for EU-resident visitors. Cookie is classified as functional (referral attribution), not analytics. The consent check reads the visitor's consent state from the existing consent management surface. If no consent and no existing cookie: record the slug in the redirect URL as a query parameter (?ref={slug}) as a server-side fallback (see below). Do not set the cookie. - Cookie is read at Founders-accept time by attribute_signup() to link the new user to the conversion row.

Server-side fallback (no-cookie path): - The redirect to the signup page preserves ?ref={slug} in the URL. The signup/activation flow reads this parameter if the cookie is absent. - The ?ref parameter is NOT stored in the DB until the user completes signup — it is passed through the client flow as a form field or hidden input. - This covers private-browsing and strict-cookie-blocking cases.

ADR 0018 records the cookie-vs-server-side attribution decision.

30-day attribution window: if a referred user signs up more than 30 days after clicking the link, the cookie has expired. Attribution is lost; the signup proceeds as direct_signup cohort. The ?ref query parameter approach mitigates this if the user bookmarked the link.


6. APIs / contracts

All endpoints under /api/founders/* (user-facing) or /api/internal/founders/* (service-to-service). Session-cookie authenticated for user-facing endpoints.

User-facing

Method Path Purpose
GET /api/founders/referral-link Return caller's referral link URL + stats ({url, slug, click_count, conversions_count}). Generates on first call; idempotent on subsequent.

Public (no auth)

Method Path Purpose
GET /r/{slug} Redirect to signup page. Sets attribution cookie (with consent gate). Records click. Returns 302 to signup URL. Returns 404 if slug not found or link is inactive.

Internal / service-to-service

Method Path Purpose
POST /api/internal/founders/referral/attribute Link cookie/ref to a new user_id at Founders-accept time. Body: {new_user_id, slug}. Writes signed_up_at. Idempotent per (slug, new_user_id). Called by waitlist-conversion flow (#205).
POST /api/internal/founders/referral/convert Process paid conversion. Body: {referred_user_id, subscription_id}. Idempotent per subscription_id. Called by Stripe webhook handler. Grants referrer bonus via FounderTrialService.add_bonus().

7. Stripe webhook handler

The paid-conversion handler lives in Raptor's Stripe webhook route. It is not a new endpoint — it is new logic inside the existing Stripe webhook dispatcher.

Event: customer.subscription.created

Verification: Raptor verifies the Stripe-Signature header using STRIPE_WEBHOOK_SECRET (env var, never in code). Requests that fail signature verification return HTTP 400 immediately and write nothing.

Monetized subscription guard (all conditions must pass): 1. status in event payload is active (not trialing, incomplete, past_due, canceled, unpaid). 2. discount.percent_off is null or less than 100 (comp / 100%-off subscriptions are excluded). 3. billing_thresholds and amount checks: subscription amount_due > 0 after discounts. 4. Payment method is confirmed (not failed). Check latest_invoice.payment_intent.status = 'succeeded'.

Happy path: 1. Look up referred_user_id from subscription.customer → Raptor user mapping. 2. Look up founder_referral_conversions row where referred_user_id = X and bonus_granted_at IS NULL. 3. If no row: this is not a referred Founder. Exit silently (HTTP 200, no-op). 4. If row found and subscription_id already in DB: idempotent return (HTTP 200). 5. Call FounderTrialService.add_bonus(referrer_user_id, days=90, source='referral', source_ref_id=conversion_id). 6. Update founder_referral_conversions: set converted_at=now, bonus_granted_at=now, subscription_id. 7. Write audit_log row: action='founder.referral.bonus_granted', context includes {conversion_id, subscription_id, referrer_user_id, days_granted} — never raw PII. 8. Return HTTP 200.

Idempotency: keyed on subscription_id UNIQUE constraint. If the webhook fires twice for the same subscription, step 4 catches it.

Stripe retry behavior: Stripe retries unacknowledged webhooks (non-2xx) with exponential backoff. The handler must return 200 even for no-op cases (step 3, step 4). Returning 400/500 causes Stripe to retry; the handler must only do that for signature failures and genuine processing errors.


8. Abuse posture (v1)

Block same-email re-conversion for same referral chain: - At attribute_signup() time, check if the email already exists in users. If yes and the user already has a founder_referral_conversions row via any referral link from the same referrer, reject with a 409. Write an audit_log row with action='founder.referral.duplicate_rejected'.

Block same payment method (Stripe fingerprint) for same referrer: - At Stripe webhook convert time, look up Stripe's payment_method.card.fingerprint for the subscription's payment method. Query founder_referral_conversions for other rows whose associated subscription has the same payment fingerprint AND whose referral link belongs to the same referrer. If found and the prior row has bonus_granted_at IS NOT NULL, skip bonus grant and write audit_log with action='founder.referral.fingerprint_rejected'. - The Stripe fingerprint is not stored in Raptor's DB — it is queried from Stripe at webhook time and discarded after the check. This avoids storing a payment-adjacent identifier.

v2 (flag, do not build): same-device detection (device fingerprint) and same-IP-range clustering. These require a fraud-detection microservice; out of scope for v1. Flag the founder_referral_conversions.ip_hash field as the future hook for this detection.


9. State machine (conversion lifecycle)

stateDiagram-v2
    [*] --> link_active : generate_link() (at Founders accept)
    link_active --> click_recorded : GET /r/{slug}
    click_recorded --> attributed : attribute_signup() (Founders accept)
    attributed --> converted : Stripe webhook: paid subscription
    converted --> bonus_granted : add_bonus() succeeds
    link_active --> link_inactive : admin deactivates link
    link_inactive --> [*]
    bonus_granted --> [*]

10. Sequence diagrams

sequenceDiagram
    participant V as Visitor (browser)
    participant R as Raptor /r/{slug}
    participant DB as DB (founder_referral_links / _conversions)
    participant AL as audit_log
    participant WL as Waitlist / signup flow

    V->>R: GET /r/{slug}
    R->>DB: SELECT founder_referral_links WHERE slug=X AND is_active=true
    alt not found or inactive
        R-->>V: 302 to /signup (no cookie, no attribution)
    else found
        R->>DB: UPDATE click_count++
        R->>R: check consent (GDPR gate)
        alt EU visitor, no consent
            R-->>V: 302 /signup?ref={slug} (no cookie; ref in URL)
        else consent granted or non-EU
            R-->>V: 302 /signup + Set-Cookie raxx_ref={slug}; Max-Age=30d
        end
    end
    V->>WL: completes signup
    WL->>R: POST /api/internal/founders/referral/attribute {new_user_id, slug}
    R->>DB: INSERT founder_referral_conversions (referral_link_id, referred_user_id, signed_up_at=now)
    R->>AL: INSERT audit_log (action='founder.referral.attributed')
    WL->>WL: call FounderTrialService.initialize(user_id, cohort='referred', referrer_user_id=X)
    Note over WL: 14-day trial (referred cohort)

10.2 Paid conversion → referrer bonus

sequenceDiagram
    participant STR as Stripe
    participant RWH as Raptor webhook handler
    participant DB as DB (founder_referral_conversions)
    participant FTS as FounderTrialService
    participant AL as audit_log

    STR->>RWH: POST /webhooks/stripe (customer.subscription.created)
    RWH->>RWH: verify Stripe-Signature (STRIPE_WEBHOOK_SECRET)
    alt signature invalid
        RWH-->>STR: 400
    else valid
        RWH->>RWH: monetized subscription guard (status=active, amount_due>0, payment succeeded)
        alt not monetized
            RWH-->>STR: 200 (no-op)
        else monetized
            RWH->>DB: look up referred_user_id by customer; find conversion row
            alt no conversion row (not a referred Founder)
                RWH-->>STR: 200 (no-op)
            else conversion row found
                RWH->>DB: check subscription_id idempotency
                alt already processed
                    RWH-->>STR: 200 (idempotent)
                else first time
                    RWH->>RWH: abuse check: same-email + same-payment-fingerprint
                    alt abuse detected
                        RWH->>AL: INSERT audit_log (action='founder.referral.fingerprint_rejected')
                        RWH-->>STR: 200 (blocked, no bonus)
                    else clean
                        RWH->>FTS: add_bonus(referrer_user_id, days=90, source='referral', source_ref_id=conversion_id)
                        FTS-->>RWH: {ok, days_granted, new_expires_at}
                        RWH->>DB: UPDATE conversion row: converted_at=now, bonus_granted_at=now, subscription_id=X
                        RWH->>AL: INSERT audit_log (action='founder.referral.bonus_granted')
                        RWH-->>STR: 200
                    end
                end
            end
        end
    end

11. Migration path

Flag gate: FLAG_FOUNDERS_REFERRAL=off by default. The referral service can be deployed and dark-shipped before the trial engine GA's — the tables exist but no links are generated while the flag is off.

Steps

  1. Schema migration (backend_v2/db/migrations/NNNN_founders_referral.sql): creates founder_referral_links, founder_referral_conversions. Additive only; no existing tables modified. Runs unconditionally on deploy.
  2. Service + redirect handler land behind FLAG_FOUNDERS_REFERRAL=off. Unit tests run regardless of flag.
  3. generate_link() called at Founders accept (#205 flow) — this call is gated by FLAG_FOUNDERS_REFERRAL. If off, no link is generated; the user can still become a Founder.
  4. Stripe webhook handler integration: the convert path is a new branch in the existing webhook dispatcher. It is guarded by FLAG_FOUNDERS_REFERRAL. If the flag is off, the webhook handler returns 200 no-op for referral bonus events.
  5. Flag flip to on — all new Founders-accepts auto-generate links. Existing Founders (pre-flag-flip) get links generated on their next call to GET /api/founders/referral-link.

Rollback

Set FLAG_FOUNDERS_REFERRAL=off. No links are generated. Webhook handler no-ops. Existing founder_referral_links and founder_referral_conversions rows remain inert. Schema can be dropped with a down-migration; tables have no FK dependencies from outside the referral schema.


12. Security + GDPR checklist

Question Answer
What PII does this collect? referred_user_id (FK to PII in users); ip_hash (hashed IP, PII-adjacent); clicked_at, signed_up_at (behavioral timestamps). The referrer's identity is derivable from referral_link_id → user_id — that link must be severed at erasure.
Retention period? founder_referral_conversions: lifetime of referring user's account, then CASCADE. ip_hash: cleared at 30-day cooling. bonus_granted_at + converted_at: 7-year retention (entitlement record per ADR 0003).
Deletion on DSR? ON DELETE CASCADE on referrer's users.id deletes the founder_referral_links row and cascades to founder_referral_conversions. referred_user_id SET NULL on referred user's erasure retains the conversion record for entitlement audit. ip_hash cleared at 30-day cooling regardless of erasure.
Audit logging? Every bonus grant, attribution, abuse rejection writes an audit_log row. Context fields contain IDs and action types — never raw email, name, or IP. audit_log retention: 7 years.
Stored credential risk? None. STRIPE_WEBHOOK_SECRET is env-only. No payment instrument details stored. Stripe fingerprint queried at webhook time and discarded.
Breach response? Covered by ADR 0003 breach pipeline. Exposed data is referral metadata (behavioral, non-financial). ip_hash exposure risk is low (hashed, salted).
Where are secrets? STRIPE_WEBHOOK_SECRET, FLAG_FOUNDERS_REFERRAL, REFERRAL_SLUG_SALT — all env vars. Rotatable without redeploy.
Kill switch? FLAG_FOUNDERS_REFERRAL=off gates link generation, attribution, and bonus grant. Per-link is_active=false allows admin to deactivate a specific link without affecting others.

13. Open questions — decisions needed before sub-cards can be claimed

  1. Attorney review gate on referral copy. Per #204 and #196/#197: the referral incentive structure (+3 months for paid referrals) may carry promotional-offer disclosure requirements. No user-facing referral copy ships until this is cleared. This does NOT block the backend sub-cards, but it blocks the frontend referral link display card. Needs: Kristerpher + securities attorney (via #196 pipeline).

  2. GDPR consent surface for EU visitors. The click-attribution cookie requires consent in the EU. Does Raptor/Antlers have an existing CMP (consent management platform) integration to read from? If not, what is the v1 posture — no cookie for EU visitors (server-side fallback only), or hold the referral feature from EU regions until CMP exists? Needs: Kristerpher decision before sub-card covering the redirect endpoint is claimable.

  3. Referral leaderboard (#211 revival). The click_count and a queryable conversions_count are built into this design. Does Kristerpher want a user-facing leaderboard (referral rank among Founders) in this phase or is it strictly an admin/ops view? Per #204 it was marked non-goal for Phase 2, but the data exists here. No change to this design needed — just a scope confirmation.

  4. Self-referral: same-account check. The v1 abuse posture blocks same-email and same-payment-fingerprint. It does not block a Founder who creates a second account with a different email and payment method. Confirm: is same-email check sufficient for v1, or does Kristerpher want any additional identity-linking at launch?

  5. Stripe webhook infra existence. The handler assumes a Stripe webhook endpoint exists in Raptor. Is this already in place (from an existing billing/subscription flow), or does it need to be built as a dependency? Hard dependency for sub-card covering the paid-conversion handler.


End of doc. See ADR 0017 for the slug entropy decision and ADR 0018 for the attribution strategy.