~learn.

Truecaller Caller ID and Spam Detection, the whole design.

Caller ID and spam blocking for about 411 million daily phones, by answering from the phone first and then from one precomputed record within a single ring, and by keeping a number's spam verdict (shared, cached everywhere) apart from a person's name (sourced, kept in its jurisdiction, removable within a day).

7 diagrams · 8 sections · revise in ~10 min · Download PDF prints it in the light theme, diagrams included

SQ-26 · Truecaller Caller ID and Spam Detection · design sheethimanshukukreja.in/learn/sheets/bonus-26-truecaller-caller-id

At a glance#

Number Value What it drives
Daily users outside iOS 411.1M (Q3 2026, preliminary) ~1.2 unknown calls each a day (assumed)
Users, iOS and Android 500M+ (2026); 350M+ in India, as reported an Asia region serves most traffic
Spam calls identified 68B+ in 2025 · ~2,150 / s cross-checks ~1B lookups a day
Lookups ~35,000 / s peak · 100,000 / s target one key read with an 800 ms deadline
Directory ~6B numbers · ~0.9 TB of records replicate per region; hot set ~45 GB in Redis
Signals in ~12,000 outcomes / s vs ~22 reports / s a stream scores both; outcomes measure, reports name
On-device list ~500k numbers · ~4.5 MB · ~225 KB a day blocks offline; signed base plus deltas
iPhone lookup two queries a call · sub-100 ms goal PIR shards in memory, CPU-bound

Architecture#

DiagramA one-ring read path, fed by a streaming spam pipeline
%% title: A one-ring read path, fed by a streaming spam pipeline
flowchart TD
  AND@{ icon: "tech:android", label: "Android app<br/>contacts · device spam list" } ==> GW@{ icon: "sd:gateway", label: "Regional edge<br/>auth · rate limits" }
  IOS@{ icon: "tech:apple", label: "iPhone<br/>Live Caller ID" } ==> RELAY@{ icon: "sd:tunnel", label: "Oblivious HTTP relay<br/>run by Apple" }
  GW ==> LOOK["Lookup service<br/>one read · 800 ms deadline"]:::hot
  RELAY ==> PIR["PIR service<br/>encrypted shards in memory"]:::core
  LOOK ==> CACHE@{ icon: "tech:redis", label: "Redis<br/>hot records · 8 copies" }
  CACHE -.->|"miss"| STORE@{ icon: "tech:cassandra", label: "Cassandra<br/>serving records · per region" }
  GW --> INGEST["Intake<br/>call proof · token buckets"]:::service
  INGEST ==> BUS@{ icon: "tech:kafka", label: "Kafka-API log<br/>reports · call outcomes" }
  BUS ==> SCORE["Scorer<br/>stream plus daily batch"]:::core
  SCORE ==>|"new version"| STORE
  SCORE --> LISTS@{ icon: "sd:cdn", label: "CDN<br/>daily list · hourly deltas" }
  LISTS -.-> AND
  STORE -.->|"rebuild shards"| PIR
  class AND,IOS client
  class GW,RELAY,LISTS edge
  class CACHE cache
  class STORE store
  class BUS queue
DiagramFrom a report or a declined call to a new verdict in minutes
%% title: From a report or a declined call to a new verdict in minutes
flowchart TD
  APP@{ icon: "sd:phone", label: "Phone<br/>report · decline · block · answer" }
  IN["Intake<br/>call-log proof · per-user caps"]:::service
  LOG@{ icon: "tech:kafka", label: "Kafka-API log<br/>partitioned by number" }
  WIN["Stream windows<br/>1 h and 24 h per number"]:::core
  SCORE["Scorer<br/>trust-weighted evidence · bands"]:::hot
  BQ@{ icon: "tech:bigquery", label: "BigQuery<br/>reporter trust · daily retraining" }
  OUT@{ icon: "sd:database", label: "Serving records<br/>new version per change" }
  APP ==> IN ==> LOG ==> WIN ==> SCORE
  LOG --> BQ
  BQ -.->|"weights, daily"| SCORE
  SCORE ==> OUT
  class APP client
  class LOG queue
  class BQ service
  class OUT store

Key flows#

DiagramSunita's call on one bar of signal
%% title: Sunita's call on one bar of signal
sequenceDiagram
  autonumber
  participant P as Sunita's phone
  participant L as Device list
  participant S as Lookup service
  participant C as Redis
  P->>L: Number in the region list?
  L-->>P: No, it started calling this morning
  P->>S: Look up the E.164 number, deadline 800 ms
  S->>C: Serving record, random hot copy
  alt answer inside the deadline
    C-->>S: Likely fraud · 1,814 reports
    S-->>P: About 140 bytes
    P->>P: Red screen before the second ring
  else network too slow
    P->>P: Show Unknown number at 800 ms
    S-->>P: Late answer
    P->>P: Repaint while still ringing
  end
DiagramRohit's iPhone asks without revealing who or which number
%% title: Rohit's iPhone asks without revealing who or which number
sequenceDiagram
  autonumber
  participant A as Truecaller app
  participant I as iPhone
  participant R as Apple's relay
  participant T as PIR service
  A->>T: Signed in, fetch single-use tokens
  Note over I: a call from an unknown number
  I->>I: Number maps to shard 3, bucket 11
  I->>R: Block query and identity query, one token each
  R->>T: Both, without the IP or device
  T->>T: Tokens unspent · encrypted dot product over shard 3
  T-->>R: Encrypted results
  R-->>I: Relayed back
  I->>I: Decrypt, block or show the name
  Note over T: learns only that a user queried shard 3
DiagramA report becomes evidence, or is dropped
%% title: A report becomes evidence, or is dropped
sequenceDiagram
  autonumber
  participant P as Reporter's phone
  participant N as Intake
  participant G as Log
  participant W as Stream scorer
  participant D as Store and deltas
  P->>N: Report spam, with the call event it refers to
  alt call proof matches the device's outcome stream
    N->>G: Report event, keyed by number
    G->>W: Windows update · distinct reporters · trust weights
    W->>D: Band crossed · new record version
    D->>D: Overwrite cache copies · add to next hourly delta
  else no matching call, or over the daily cap
    N->>N: Drop before the log
    Note over N: a burst of these is a brigading signal
  end

Data model#

DiagramCore entities
%% title: Core entities
classDiagram
  class ServingRecord {
    +numberHash : 8 bytes of E.164
    +displayName : string
    +category : person · business · spam type
    +spamBand : NONE to LIKELY_FRAUD
    +reportCount : int
    +verified : bool
    +version : int
  }
  class NameCandidate {
    +name : string
    +source : owner · business · community
    +basis : collection basis
    +region : jurisdiction
    +collectedAt : date
  }
  class Report {
    +reporter : user id
    +callEvent : proof of the call
    +category : spam · fraud
    +label : free text
  }
  class Reporter {
    +trust : 0 to 1 · daily batch
    +dailyCap : int
  }
  class Listing {
    +state : UNKNOWN to UNLISTED
    +ownerSince : date
  }
  class SpamList {
    +region : telecom circle
    +version : int
    +keys : sorted 8-byte hashes
  }
  ServingRecord "1" --> "many" NameCandidate : best name from
  ServingRecord "1" --> "many" Report : scored from
  Reporter "1" --> "many" Report : files
  ServingRecord "1" --> "1" Listing : governed by
  SpamList "1" --> "many" ServingRecord : built from

Lifecycle#

DiagramA number's listing, from first call to unlisted
%% title: A number's listing, from first call to unlisted
stateDiagram-v2
  [*] --> UNKNOWN: first seen in a call
  UNKNOWN --> COMMUNITY_NAMED: trusted suggestions agree
  UNKNOWN --> REGISTERED: owner signs up and verifies
  COMMUNITY_NAMED --> REGISTERED: owner signs up
  UNKNOWN --> SPAM_FLAGGED: score crosses a band
  COMMUNITY_NAMED --> SPAM_FLAGGED: score crosses a band
  SPAM_FLAGGED --> UNKNOWN: verdict cleared or disputed
  SPAM_FLAGGED --> SPAM_FLAGGED: unlist refused
  COMMUNITY_NAMED --> UNLISTED: owner unlists
  REGISTERED --> UNLISTED: owner deletes and unlists
  REGISTERED --> UNKNOWN: number recycled
  UNLISTED --> [*]
  class REGISTERED ok
  class UNLISTED ok
  class SPAM_FLAGGED bad
  class COMMUNITY_NAMED queue

Decisions#

Decision Chose Over Because
Read path One precomputed record, one read, 800 ms deadline Scoring per request the deadline is one ring on a weak network
Partitioning Hash of the E.164 number Prefix ranges new number blocks get hot together
Hot numbers 8 cache copies, versioned overwrite Delete on update delete-then-miss is a herd at 50 lookups / s
Spam evidence Distinct reporters with call proof, trust-weighted, plus call outcomes Counting reports brigading; outcomes outnumber reports ~500 to 1
Rate limits Per-pod token buckets of limit ÷ N A shared Redis counter extra network calls and latency on every request
Offline Regional list, signed base plus hourly deltas The whole directory on the phone ~1 TB, mostly people's names
iPhone OHTTP relay, Privacy Pass, PIR Plain lookups the server must not learn who asked about which number
Names Lineage per candidate, jurisdiction as tenant One merged name table consent law is still settling

When it breaks#

Failure What users see Contain it with
A hot number escapes detection slow or failed lookups copies from the call-velocity counter; alert at 3× node skew
Brigading against a shop a wrong red label call proof, reporter trust, verified-owner dispute
A bank's number is spoofed reports against a real bank verified floor; "often spoofed" warning
A phone offline over 7 days stale spam list fresh base; visible list age
Regional server outage no names device list still blocks; "Unknown number"
A recycled number a stranger's name ownership period; older candidates ignored

Say it in the interview#