Skip to content

Referral Tracking

Nikolaus Heger edited this page Sep 25, 2025 · 1 revision

Taskmaster Referrals — Architecture Overview

Rust-based backend to issue, attribute, and guard referral claims across iOS & Android with deferred flows, no paid SDKs.


Goals

  • Generate secure referral links/codes tied to the inviter.
  • Attribute new-user signups to a referrer with one-per-new-user idempotency.
  • Support:
    • Android: App Links + Play Install Referrer (deferred).
    • iOS: Universal Links; manual code fallback for deferred installs.
  • Be abuse-resistant (tokens, auth-bound claim, rate limits, reward gating).
  • Keep all logic in Taskmaster (Rust).

High-Level Flow

sequenceDiagram
  autonumber
  participant U as Inviter (Existing User)
  participant M as Mobile App
  participant T as Taskmaster (Rust)
  participant L as Landing Page (ref.yourapp.com)
  participant S as App Stores

  U->>M: Tap "Invite a friend"
  M->>T: POST /referral-links (inviter session)
  T-->>M: 201 { url: https://ref.yourapp.com/i/<token> }
  M->>U: Share/QR with short URL

  Note over U,L: Friend scans QR / taps link
  U->>L: Open https://ref.yourapp.com/i/<token>
  L->>M: (If installed) Universal/App Link → open app with <token>
  L->>S: (If not) Show store buttons + copyable code <token>

  Note over M,S: Install path (deferred on Android via Install Referrer)
  S-->>M: First launch
  M->>M: Capture token via:
  M->>M: - UL/AppLink param (installed case)
  M->>M: - Play Install Referrer (Android deferred)
  M->>M: - Manual paste of code (iOS deferred)

  Note over M,T: After signup completes
  M->>T: POST /referrals/claim { referral_token, install_referrer? } (auth = new user)
  T->>T: Verify token (HMAC/JWT), constraints, idempotency
  T-->>M: 200 ok
Loading

Components

1) Taskmaster (Rust)

  • Web framework: axum (or actix-web, interchangeable).
  • Auth: Your existing session/JWT (new user must be authenticated for claim).
  • Token signing: jsonwebtoken (HS256 with server secret) or HMAC via hmac + base64url.
  • DB: Postgres via sqlx (compile-time checked) or diesel.
  • Rate limiting: tower-ratelimit or in-DB counters with short TTL buckets (Redis optional).
  • Telemetry: tracing, opentelemetry exporters.

2) Landing Page (ref.yourapp.com)

  • Static/Next.js page that:
    • Tries to open the app via Universal/App Link scheme.
    • If not installed: shows App Store / Play buttons and a copy button for Invite Code (the <token>).
  • Minimal server involvement—no PII. (Optional: fetch link metadata for analytics.)

3) Mobile App

  • Android: App Links + Install Referrer API → returns original URL including /i/<token>.
  • iOS: Associated Domains (Universal Links). If not installed at click, user copies Invite Code and pastes during onboarding.

API Surface

POST /referral-links — Issue a new link (inviter authenticated)

Creates a signed referral token and returns a short URL.

Auth: inviter session
Request: none (or optional campaign tags)
Response 201:

{
  "url": "https://ref.yourapp.com/i/<token>",
  "token": "<token>",
  "expires_at": "2025-12-31T00:00:00Z"
}

Server logic

  • Read inviter_account_id from session.
  • Sign payload { referrer_account_id, type:"referral", iat, exp } with server secret.
  • Optionally persist issuance for analytics.

POST /referrals/claim — Claim attribution (new user authenticated)

Auth: new user session (post-signup)
Request:

{
  "referral_token": "<token-from-link-or-code>",
  "android_install_referrer": "https://ref.yourapp.com/i/<token>?..." // optional
}

Response 200:

{ "ok": true }

Response 409: already claimed by this new user
Response 400/401/403: invalid token / self-referral / policy violation

Server logic

  1. Derive referred_account_id from session (ignore client-supplied IDs).
  2. Verify token signature, type == "referral", exp, iss.
  3. Extract referrer_account_id.
  4. Reject self-referral and expired tokens.
  5. Optional Android check: if android_install_referrer present, ensure it includes your domain and same token and was received within a reasonable window.
  6. Insert row with idempotency on referred_account_id (first write wins).
  7. Return 200 on success; 409 if already claimed.

Data Model (PostgreSQL)

-- Issued tokens (optional but recommended for analytics & abuse audits)
create table referral_links_issued (
  id uuid primary key default gen_random_uuid(),
  referrer_account_id text not null,
  token text not null unique,             -- full token or short hash
  created_at timestamptz default now(),
  expires_at timestamptz not null,
  metadata jsonb default '{}'::jsonb
);

-- Final attributions (1 per new user)
create table referrals (
  id uuid primary key default gen_random_uuid(),
  referrer_account_id text not null,
  referred_account_id text not null unique,  -- idempotency: one claim per new user
  token text not null,                       -- which link/code was used
  platform text,                             -- 'android' | 'ios' | 'web'
  install_referrer text,                     -- raw Android referrer (optional)
  created_at timestamptz default now()
);

create index on referrals (referrer_account_id);

Token Format

  • JWT (HS256) via jsonwebtoken.
  • Claims:
{
  "iss": "taskmaster",
  "type": "referral",
  "referrer_account_id": "acc_12345",
  "iat": 1732440000,
  "exp": 1739856000
}
  • Secret: REFERRAL_SECRET (env). Rotate periodically; support key IDs if desired.

For human-typeable Invite Codes you can store a short, random code that maps to the full token server-side:

code{ token, referrer_account_id, exp }
Keep code unguessable (e.g., 10–14 base32 chars) and expire it alongside the token.


Rust Endpoints (Axum Sketch)

// Cargo.toml (selected)
// axum = "0.7"
// serde = { version = "1", features = ["derive"] }
// jsonwebtoken = "9"
// sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
// tower = "0.5"
// tower-rate-limit = "0.3"
// tracing = "0.1"

#[derive(serde::Serialize, serde::Deserialize)]
struct ReferralClaims {
    iss: String,
    r#type: String,
    referrer_account_id: String,
    iat: i64,
    exp: i64,
}

#[derive(serde::Deserialize)]
struct ClaimReq {
    referral_token: String,
    android_install_referrer: Option<String>,
}

async fn issue_referral_link(
    State(state): State<AppState>,
    Auth(inviter): AuthSession, // your existing auth extractor
) -> impl IntoResponse {
    let now = chrono::Utc::now().timestamp();
    let exp = (chrono::Utc::now() + chrono::Duration::days(90)).timestamp();
    let claims = ReferralClaims {
        iss: "taskmaster".into(),
        r#type: "referral".into(),
        referrer_account_id: inviter.account_id.clone(),
        iat: now,
        exp,
    };
    let token = jsonwebtoken::encode(
        &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
        &claims,
        &jsonwebtoken::EncodingKey::from_secret(state.referral_secret.as_bytes()),
    ).map_err(internal_error)?;

    // optional persist
    sqlx::query!("insert into referral_links_issued (referrer_account_id, token, expires_at)
                  values ($1, $2, to_timestamp($3))",
                  inviter.account_id, token, exp as f64)
        .execute(&state.db).await.ok();

    let url = format!("https://ref.yourapp.com/i/{}", token);
    (StatusCode::CREATED, Json(serde_json::json!({ "url": url, "token": token, "expires_at": chrono::Utc.timestamp_opt(exp,0).unwrap() })))
}

async fn claim_referral(
    State(state): State<AppState>,
    Auth(new_user): AuthSession, // must be authenticated as the *new* user
    Json(req): Json<ClaimReq>,
    headers: HeaderMap,
) -> impl IntoResponse {
    use jsonwebtoken::{DecodingKey, Validation, Algorithm};

    let mut val = Validation::new(Algorithm::HS256);
    val.validate_exp = true;
    let data = jsonwebtoken::decode::<ReferralClaims>(
        &req.referral_token,
        &DecodingKey::from_secret(state.referral_secret.as_bytes()),
        &val,
    ).map_err(|_| (StatusCode::BAD_REQUEST, "invalid token"))?;

    if data.claims.iss != "taskmaster" || data.claims.r#type != "referral" {
        return (StatusCode::BAD_REQUEST, "bad claims").into_response();
    }
    if data.claims.referrer_account_id == new_user.account_id {
        return (StatusCode::BAD_REQUEST, "self-referral").into_response();
    }

    // Optional Android sanity check:
    if let Some(raw) = &req.android_install_referrer {
        if !(raw.starts_with("https://ref.yourapp.com/i/") && raw.contains(&req.referral_token)) {
            // don't hard fail; choose policy: warn or reject
        }
    }

    let platform = headers.get("x-platform").and_then(|v| v.to_str().ok()).unwrap_or("unknown");

    let res = sqlx::query!(
        r#"
        insert into referrals (referrer_account_id, referred_account_id, token, platform, install_referrer)
        values ($1, $2, $3, $4, $5)
        on conflict (referred_account_id) do nothing
        "#,
        data.claims.referrer_account_id,
        new_user.account_id,
        req.referral_token,
        platform,
        req.android_install_referrer
    )
    .execute(&state.db).await;

    match res {
        Ok(done) if done.rows_affected() == 0 => (StatusCode::CONFLICT, "already claimed").into_response(),
        Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(),
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(),
    }
}

Landing Page Behavior

  • Path: /i/<token>
  • Try open app via UL/App Link:
    • iOS: <a href="https://ref.yourapp.com/i/<token>">Open</a> (UL autohandled)
    • Android: same (App Links).
  • Show:
    • “Get the app” buttons (Store / Play).
    • Invite Code: <token> with “Copy” button (for iOS deferred).
  • Optional: capture minimal analytics (UA, locale).

Mobile App Integration Notes

Android

  • Dependencies:
    • App Links: manifest <intent-filter> for https://ref.yourapp.com/*
    • Install Referrer:
      implementation "com.android.installreferrer:installreferrer:2.2"
  • First run: fetch referrer URL and parse last segment → token.

iOS

  • Associated Domains: applinks:ref.yourapp.com
  • Deferred: user copies Invite Code (<token>) from landing, app prompts to paste during onboarding.

Claim Timing

  • Store token locally on first app open.
  • After the user successfully signs up (has session), call /referrals/claim.

Security & Abuse Controls

  • Server-signed tokens only (clients cannot mint).
  • Auth-bound claiming: rely on server session for referred_account_id.
  • Idempotency: UNIQUE (referred_account_id).
  • Self-referral block.
  • Rate limits:
    • /referral-links: e.g., 60/min/IP, 500/day/account.
    • /referrals/claim: e.g., 30/min/IP, 5/day/device/install_id.
  • Token TTL: 60–90 days.
  • Reward gating: pay out only after meaningful server-verified actions (e.g., first on-chain transfer, KYC ok).
  • Anomaly flags: burst detection per IP/ASN/device; optional Play Integrity basic verdict on Android.

Config & Secrets

  • REFERRAL_SECRET (HS256)
  • DATABASE_URL
  • (Optional) RATE_LIMIT_*
  • (Optional) AASA hosting (iOS UL JSON at https://ref.yourapp.com/apple-app-site-association)

AASA example (snippet)

{
  "applinks": {
    "apps": [],
    "details": [{
      "appID": "ABCDE12345.com.your.bundle",
      "paths": [ "/i/*" ]
    }]
  }
}

Observability

  • Add structured logs on:
    • link issuance (referrer_account_id, token hash, exp)
    • claim attempts (status, reason, platform)
  • Metrics:
    • issued links/day, claims/day, claim success rate, conflict rate
    • time from install → claim
    • rewards triggered after validation

Rollout Plan (Fast)

  1. DB migration → deploy.
  2. Issue endpoint in Taskmaster.
  3. Landing page (copy-to-clipboard + store buttons).
  4. Mobile:
    • Android: App Links + Install Referrer.
    • iOS: Universal Links + “Enter invite code” UI.
  5. Claim endpoint in Taskmaster + rate limits.
  6. Feature flag: soft-launch with internal accounts.
  7. Enable rewards only after stability.

Risks & Mitigations

  • iOS deferred lacks automatic link carryover → manual code (token) solves it.
  • Token leakage (screenshots/forwards) → tokens expire; reward gating reduces value.
  • Bot signups → auth-bound claim, per-device/IP throttles, reward post-verification.
  • Clock skew → use server time for exp checks.

Open Questions (Future Enhancements)

  • Short-code service (map code → token) for friendlier iOS typing.
  • Optional Redis for rate limiting and token-issuance quotas.
  • Play Integrity / DeviceCheck checks before reward issuance.
  • Partner campaign/UTM metadata in token claims.

Done. Drop this into /docs/referrals_architecture.md and we can iterate with concrete types and handlers from your existing Taskmaster crate structure.