-
Notifications
You must be signed in to change notification settings - Fork 0
Referral Tracking
Rust-based backend to issue, attribute, and guard referral claims across iOS & Android with deferred flows, no paid SDKs.
- 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).
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
-
Web framework:
axum(oractix-web, interchangeable). - Auth: Your existing session/JWT (new user must be authenticated for claim).
-
Token signing:
jsonwebtoken(HS256 with server secret) or HMAC viahmac+base64url. -
DB: Postgres via
sqlx(compile-time checked) ordiesel. -
Rate limiting:
tower-ratelimitor in-DB counters with short TTL buckets (Redis optional). -
Telemetry:
tracing,opentelemetryexporters.
- 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.)
-
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.
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_idfrom session. - Sign payload
{ referrer_account_id, type:"referral", iat, exp }with server secret. - Optionally persist issuance for analytics.
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
- Derive
referred_account_idfrom session (ignore client-supplied IDs). - Verify token signature,
type == "referral",exp,iss. - Extract
referrer_account_id. - Reject self-referral and expired tokens.
-
Optional Android check: if
android_install_referrerpresent, ensure it includes your domain and same token and was received within a reasonable window. - Insert row with idempotency on
referred_account_id(first write wins). - Return
200on success;409if already claimed.
-- 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);-
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
codethat maps to the full token server-side:
code→{ token, referrer_account_id, exp }
Keepcodeunguessable (e.g., 10–14 base32 chars) and expire it alongside the token.
// 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(),
}
}-
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).
- iOS:
- Show:
- “Get the app” buttons (Store / Play).
-
Invite Code:
<token>with “Copy” button (for iOS deferred).
- Optional: capture minimal analytics (UA, locale).
-
Dependencies:
- App Links: manifest
<intent-filter>forhttps://ref.yourapp.com/* - Install Referrer:
implementation "com.android.installreferrer:installreferrer:2.2"
- App Links: manifest
- First run: fetch referrer URL and parse last segment → token.
-
Associated Domains:
applinks:ref.yourapp.com -
Deferred: user copies Invite Code (
<token>) from landing, app prompts to paste during onboarding.
- Store token locally on first app open.
- After the user successfully signs up (has session), call
/referrals/claim.
- 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.
-
REFERRAL_SECRET(HS256) DATABASE_URL- (Optional)
RATE_LIMIT_* - (Optional)
AASAhosting (iOS UL JSON athttps://ref.yourapp.com/apple-app-site-association)
AASA example (snippet)
{
"applinks": {
"apps": [],
"details": [{
"appID": "ABCDE12345.com.your.bundle",
"paths": [ "/i/*" ]
}]
}
}- Add structured logs on:
- link issuance (
referrer_account_id, token hash, exp) - claim attempts (status, reason, platform)
- link issuance (
- Metrics:
- issued links/day, claims/day, claim success rate, conflict rate
- time from install → claim
- rewards triggered after validation
- DB migration → deploy.
- Issue endpoint in Taskmaster.
- Landing page (copy-to-clipboard + store buttons).
-
Mobile:
- Android: App Links + Install Referrer.
- iOS: Universal Links + “Enter invite code” UI.
- Claim endpoint in Taskmaster + rate limits.
- Feature flag: soft-launch with internal accounts.
- Enable rewards only after stability.
- 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.
- 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.