# Tellr — Complete Documentation

> Free-trial-fraud API. 267 signals computed in-house, scored deterministically,
> and persisted to a single Postgres you control.
>
> This single Markdown file mirrors the entire docs site. It is safe to feed
> verbatim to an AI assistant or to attach to a developer brief.
>
> Generated: 2026-05-20T22:19:01.833Z

## Contents

1. [Quickstart](#quickstart)
2. [Concepts](#concepts)
3. [API reference](#api-reference)
4. [SDKs](#sdks)
5. [Webhooks](#webhooks)
6. [Roles and permissions](#roles-and-permissions)
7. [Self-hosting](#self-hosting)
8. [All 267 signals](#all-signals)
9. [Changelog](#changelog)

---

## Quickstart

Wire Tellr into any app — local or production — in five steps. Total time:
about ten minutes.

### Your local Tellr endpoints

| Service | URL |
|---------|-----|
| SDK script | `https://tellr.tech/v1/c.js` |
| Collector  | `https://tellr.tech` |
| Check API  | `https://tellr.tech` |

On production these become `cdn.tellr.com`, `collect.tellr.com`, and
`api.tellr.com`. The shape of every request is identical.

### 1. Sign up and grab your keys

Create an account at the dashboard root. You will land in a fresh project.
Open the Install page; it shows your project's public key, a button to mint a
secret API key, and copy-paste snippets pre-filled for your stack.

You need two keys:

- **Public key** (`pk_…`) — embedded in your script tag. Origin-restricted, safe to commit.
- **Secret API key** (`tk_live_…`) — used server-to-server. Treat like a password. Store in env vars.

### 2. Drop the SDK into your frontend

One script tag in `<head>`. The SDK auto-initializes on page load, collects
~110 device signals in the background, and writes a 30-minute session cookie.
About 8 KB gzipped.

```html
<script
  src="https://tellr.tech/v1/c.js"
  data-key="pk_••••"
  data-collector="https://tellr.tech"
></script>
```

The `data-collector` attribute tells the SDK where to POST session payloads.
On production you can omit it; the SDK defaults to `https://collect.tellr.com`.

### 3. Call /v1/check from your backend on signup

When a user submits your signup form, read the session cookie from the request
and post it to `/v1/check` along with whatever identity fields you have.

```bash
curl -X POST https://tellr.tech/v1/check \
  -H "Authorization: Bearer tk_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "session_token": "tk_sess_xxx",
    "end_user": { "email": "alice@example.com" }
  }'
```

### 4. Act on the verdict

The response includes a verdict (`allow`, `flag`, or `block`), a 0–100
score, the 18-digit `tellr_id` to store on your user record, and a
human-readable `explanation` array.

```ts
const r = await tellr.check({ session_token, end_user });

if (r.verdict === 'block') {
  return res.status(403).end();
}

await db.users.update({
  where: { id: userId },
  data: { tellr_id: r.tellr_id },
});
```

### 5. Test from another localhost site

Save the file below as `test.html`, open it (`open test.html`), then
refresh. The SDK sets a cookie and the dashboard reflects it instantly under
Checks the moment your backend calls `/v1/check`.

```html
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <script
    src="https://tellr.tech/v1/c.js"
    data-key="pk_••••"
    data-collector="https://tellr.tech"
  ></script>
</head>
<body>
  <h1>Tellr test</h1>
  <button id="show">Show session token</button>
  <pre id="out"></pre>
  <script>
    document.getElementById('show').addEventListener('click', async () => {
      const t = await window.__tellr.getSessionToken();
      document.getElementById('out').textContent = t;
    });
  </script>
</body>
</html>
```

---

## Concepts

Four ideas to internalize before you wire Tellr in: the 18-digit ID,
composite IDs, verdicts, and thresholds. That is the entire mental model.

### tellr_id

Every tracked end user gets one stable 18-digit decimal identifier. The same
person across multiple check calls reuses the same `tellr_id` — even when
they swap email, IP, browser, and card.

Store it alongside your internal user record. From then on, the dashboard, the
API, and your support tools can all pivot on it.

```sql
ALTER TABLE users ADD COLUMN tellr_id CHAR(18);
CREATE INDEX ON users (tellr_id);
```

### Composite IDs

Tellr derives four SHA-256 hashes per check, in increasing order of identity
strength:

| Hash | Inputs | Match weight |
|------|--------|--------------|
| `hardware_id` | canvas, WebGL, audio, CPU, RAM, screen, platform | +45 |
| `browser_id`  | UA, fonts, voices, locale, permissions | +30 |
| `network_id`  | ASN, subnet, JA4, HTTP/2 fingerprint | +25 |
| `identity_id` | canonical email, E.164 phone, card fingerprint | +50 |

A new check is a **repeat** if any composite ID matches an existing
`end_user` in your project. Multiple matches stack.

### Verdicts and scores

Each check returns a 0–100 score and one of three verdicts. The score is the
sum of every signal contribution, capped at 100. Thresholds are configurable
per project.

| Verdict | Default range | Suggested action |
|---------|---------------|------------------|
| `allow` | 0–49  | Let through silently. |
| `flag`  | 50–79 | Send to manual review or extra friction (CAPTCHA, email confirmation). |
| `block` | 80–100 | Refuse the signup at the API. |

### Custom thresholds

Both thresholds are per project, editable in Settings or via the API on every
check. Lower thresholds catch more abuse but raise false positives.

```json
{
  "session_token": "tk_sess_xxx",
  "end_user": { "email": "alice@example.com" },
  "options": { "block_threshold": 70, "flag_threshold": 40 }
}
```

### Explanation array

Every check response includes an `explanation` array of signal
contributions, sorted by weight descending. It is designed to be displayed
verbatim to your support staff.

```json
"explanation": [
  { "signal": "hardware_id_match", "weight": 45, "description": "Same device hardware as user 194827361092847362" },
  { "signal": "ip_vpn",            "weight": 25, "description": "VPN: NordVPN" },
  { "signal": "email_disposable",  "weight": 30, "description": "Disposable email: mailinator.com" }
]
```

---

## API reference

Local base URL `https://tellr.tech`. JSON in and out, UTF-8, ISO 8601 timestamps.

### Authentication

Server-to-server requests use a bearer token from Dashboard → API keys.
Public keys for the browser SDK travel in a `data-key` attribute and are
scoped to a list of origins.

```
Authorization: Bearer tk_live_a8d2f4e9b1c3d5e7f9a1b3c5d7e9f1a3
Content-Type: application/json
```

### Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| POST | `/v1/check` | Run a check (server-to-server) |
| GET  | `/v1/checks/:id` | Retrieve one check |
| GET  | `/v1/checks` | List checks, cursor-paginated |
| POST | `/v1/session` | Browser SDK posts session signals here |
| POST | `/v1/users/:id/decisions` | Report ground truth back |
| GET  | `/v1/users/:id` | Get a tracked user |
| GET  | `/v1/users/by-tellr/:tellr_id` | Lookup by 18-digit ID |
| GET  | `/v1/users/:tellr_id/timeline` | Full event timeline |
| POST | `/v1/users/:tellr_id/note` | Attach an internal note |
| DELETE | `/v1/users/:id` | GDPR right-to-be-forgotten |
| GET / POST / PATCH / DELETE | `/v1/rules[/:id]` | Manage custom rules |
| GET / POST | `/v1/webhooks[/:id]` | Manage webhooks |
| POST | `/v1/webhooks/:id/test` | Send a test delivery |

### POST /v1/check

The only call you make from your backend. `session_token` is the only
required field.

#### Request

```bash
curl -X POST https://tellr.tech/v1/check \
  -H "Authorization: Bearer tk_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "session_token": "tk_sess_xxx",
    "end_user": {
      "email": "alice@example.com",
      "phone": "+14165550100",
      "card_fingerprint": "card_fp_xxx",
      "billing_country": "CA"
    },
    "context": { "trial_plan": "pro_14_day" },
    "options": { "include_signals": true, "block_threshold": 80 }
  }'
```

#### Response

```json
{
  "tellr_id": "194827361092847362",
  "check_id": "chk_2K9d3f8",
  "verdict": "block",
  "score": 87,
  "is_repeat": true,
  "previous_trials": 2,
  "matched_signals": [
    { "type": "hardware_id", "first_seen": "2026-04-12T10:14:00Z" }
  ],
  "explanation": [
    { "signal": "hardware_id_match", "weight": 45, "description": "..." }
  ],
  "thresholds": { "block": 80, "flag": 50 },
  "policy_version": "2026-05-01"
}
```

### Errors

All errors are 4xx or 5xx with a structured body. Codes are stable.

| Code | HTTP | Meaning |
|------|------|---------|
| `invalid_api_key` | 401 | Missing or revoked Authorization header. |
| `invalid_session_token` | 400 | Session token expired or never existed. |
| `missing_required_field` | 400 | A required field is absent from the body. |
| `rate_limited` | 429 | Plan rate limit exceeded. Read `Retry-After`. |
| `quota_exceeded` | 402 | Monthly check quota used up. |
| `forbidden_origin` | 403 | Browser SDK origin not on the public key allowlist. |
| `internal_error` | 500 | Tellr-side. We page on this. |

### Rate limits

Per project. Exceeding them returns `429` with a `Retry-After` header in seconds.

| Plan | Per-second | Monthly |
|------|------------|---------|
| Free | 10 / s | 1,000 |
| Starter | 50 / s | 25,000 |
| Growth | 200 / s | 150,000 |
| Enterprise | negotiated | negotiated |

### Idempotency

Pass an `Idempotency-Key` header to safely retry. Responses are cached for
24 hours and return identical bodies, including the original `check_id`.

---

## SDKs

Thin wrappers around the REST API. Each ships a single class with a single
method.

### Node.js

```ts
import Tellr from '@tellr/node';

const tellr = new Tellr({
  apiKey: process.env.TELLR_KEY!,
  baseUrl: 'https://tellr.tech', // omit on production
});

const r = await tellr.check({
  session_token: req.cookies._tellr_session,
  end_user: { email },
});

if (r.verdict === 'block') return res.status(403).end();
```

### Python

```py
import tellr
client = tellr.Client(
    api_key=os.environ['TELLR_KEY'],
    base_url='https://tellr.tech',
)

r = client.check(
    session_token=cookies['_tellr_session'],
    end_user={'email': email},
)
if r['verdict'] == 'block':
    return Response(status=403)
```

### Ruby

```rb
require 'tellr'
tellr = Tellr::Client.new(
  api_key: ENV['TELLR_KEY'],
  base_url: 'https://tellr.tech',
)

r = tellr.check(
  session_token: cookies['_tellr_session'],
  end_user: { email: params[:email] },
)
render plain: "We've seen you before.", status: 403 if r.verdict == 'block'
```

### Go

```go
tellr := tellr.New(tellr.Config{
    APIKey:  os.Getenv("TELLR_KEY"),
    BaseURL: "https://tellr.tech",
})

r, _ := tellr.Check(ctx, tellr.CheckRequest{
    SessionToken: cookie("_tellr_session"),
    EndUser:      tellr.EndUser{Email: email},
})
if r.Verdict == "block" {
    http.Error(w, "We've seen you before.", 403); return
}
```

### PHP

```php
<?php
$tellr = new \Tellr\Client(
  api_key: getenv('TELLR_KEY'),
  base_url: 'https://tellr.tech',
);
$r = $tellr->check([
  'session_token' => $_COOKIE['_tellr_session'] ?? null,
  'end_user' => ['email' => $_POST['email']],
]);
if ($r['verdict'] === 'block') {
  http_response_code(403);
  exit;
}
```

---

## Webhooks

Receive every check decision in your own infrastructure.

### Events

| Event | Fires when |
|-------|-----------|
| `check.created` | Every `/v1/check` call, regardless of verdict. |
| `check.blocked` | Score ≥ block threshold. |
| `check.flagged` | Score between flag and block thresholds. |
| `user.first_seen` | A brand-new `tellr_id` is created. |
| `user.repeat_detected` | An existing `end_user` matches a composite ID. |

### Delivery

Each event POSTs a JSON body to your URL with an `X-Tellr-Signature` header
containing an HMAC-SHA-256 of the raw body, keyed by your webhook secret.
Retries use exponential backoff up to 24 hours.

### Signature verification

```ts
import crypto from 'crypto';

const sig = req.headers['x-tellr-signature'] as string;
const computed = crypto
  .createHmac('sha256', process.env.TELLR_WEBHOOK_SECRET!)
  .update(req.rawBody)
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(computed))) {
  return res.status(400).end();
}
```

### Sample payload

```json
{
  "event": "check.blocked",
  "id": "evt_2K9d3f8",
  "created_at": "2026-05-19T18:32:11Z",
  "data": {
    "check_id": "chk_2K9d3f8",
    "tellr_id": "194827361092847362",
    "verdict": "block",
    "score": 87
  }
}
```

---

## Roles and permissions

Projects have four roles. New invitees default to **Viewer**; the project
owner upgrades them.

| Role | Can | Cannot |
|------|-----|--------|
| Owner | Everything, including delete project and change billing. | — |
| Admin | Manage members, rules, keys, webhooks. Read all checks and users. | Delete project, change billing. |
| Member | Create and edit rules, manage keys, view all checks. | Manage members or billing. |
| Viewer | Read-only access to checks, users, rules. | Create, edit, or delete anything. |

Roles are enforced at the API layer. Mutation endpoints return `403` with
`error.code = "viewer_readonly"` when the role doesn't permit the action.
In the dashboard, mutation controls are hidden (not just disabled) for viewers.

---

## Self-hosting

Enterprise customers can run Tellr inside their own VPC. The same
docker-compose used in development scales to a single-node production install
with Postgres 16 and Redis 7.

### Requirements

- Postgres 16 with `pgcrypto`, `pg_trgm`, `btree_gin`
- Redis 7
- Node 20+ for the API and worker, Go 1.22+ for the TLS proxy
- A TLS-terminating load balancer (or our Go proxy at the edge)

### Quick start

```bash
git clone https://github.com/tellr/tellr
cd tellr
docker compose up -d
psql $DATABASE_URL -f db/migrations/0001_init.sql
pnpm install
pnpm ti:seed
pnpm dev
```

### Threat-intel ingestion

The worker fetches public threat data on a schedule. By default it refreshes
Tor exits every 30 minutes, cloud ranges daily, and the disposable email list
weekly. Everything is stored in your Postgres — no outbound calls at check time.

### Data residency

Because Tellr is one Postgres database and one Redis instance, residency is
wherever you host them. EU, US, on-prem, gov-cloud — same image, different infra.

---

<a id="all-signals"></a>
## All 267 signals

Every datum we collect, with the score weight it carries when it fires.
Weights stack and are capped at 100 — the final score is the sum.

- **Server** signals are computed on our side from the connection or threat-intel tables.
- **Client** signals come from the browser SDK.
- **Derived** signals are computed from comparing the others.

### Network · S001–S059

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S001 | `ip.v4` | IPv4 of the connection | — | server |
| S002 | `ip.v6` | IPv6 of the connection | — | server |
| S003 | `ip.subnet_24` | /24 block of IPv4 | +25 | derived |
| S004 | `ip.subnet_48` | /48 block of IPv6 | +25 | derived |
| S005 | `ip.asn` | Autonomous System Number | — | server |
| S006 | `ip.asn_org` | ASN organization name | — | server |
| S007 | `ip.country` | ISO 3166-1 country | — | server |
| S008 | `ip.region` | State/province | — | server |
| S009 | `ip.city` | City | — | server |
| S010 | `ip.lat_long` | City centroid coordinates | — | server |
| S011 | `ip.timezone` | IANA timezone of IP location | — | server |
| S012 | `ip.hosting_type` | Category (residential/datacenter/vpn/proxy/mobile/...) | — | server |
| S013 | `ip.is_vpn` | Known VPN exit | +25 | server |
| S014 | `ip.is_tor` | Tor exit node | +80 | server |
| S015 | `ip.is_proxy` | Known proxy | +30 | server |
| S016 | `ip.is_residential_proxy` | Residential proxy network | +50 | server |
| S017 | `ip.is_mobile_carrier` | Mobile carrier IP | +-10 | server |
| S018 | `ip.is_datacenter` | Datacenter IP | +30 | server |
| S019 | `ip.is_cloud_provider` | Major cloud provider IP | +35 | server |
| S020 | `ip.reputation_score` | Our reputation score 0–100 | +40 | server |
| S021 | `ip.first_seen_in_system` | First time we ever saw this IP | — | derived |
| S022 | `ip.last_seen_in_system` | Most recent time we saw this IP | — | derived |
| S023 | `ip.velocity_1h` | Checks from this IP in 1h | +20 | derived |
| S024 | `ip.velocity_24h` | Checks from this IP in 24h | +25 | derived |
| S025 | `ip.velocity_7d` | Checks from this IP in 7d | +15 | derived |
| S026 | `subnet.velocity_24h` | Checks from /24 in 24h | +20 | derived |
| S027 | `asn.velocity_24h` | Checks from ASN in 24h | +25 | derived |
| S028 | `ip.concurrent_sessions` | Active sessions from this IP | +15 | derived |
| S029 | `ip.rdns` | Reverse DNS PTR | — | server |
| S030 | `ip.rdns_leaks_datacenter` | rDNS leaks datacenter | +20 | derived |
| S031 | `ip.timezone_vs_browser_tz_mismatch` | IP TZ != browser TZ | +20 | derived |
| S032 | `ip.country_vs_browser_locale_mismatch` | Browser language unusual for IP country | +10 | derived |
| S033 | `tls.ja3` | JA3 hash from ClientHello | — | server |
| S034 | `tls.ja3_text` | JA3 raw text | — | server |
| S035 | `tls.ja4` | JA4 fingerprint | — | server |
| S036 | `tls.ja4_text` | JA4 raw text | — | server |
| S037 | `tls.has_grease` | TLS GREASE extension present | +30 | server |
| S038 | `tls.ja4_known_bot` | JA4 matches known bot | +60 | server |
| S039 | `tls.ja4_matches_claimed_ua` | JA4 consistent with UA claim | +30 | derived |
| S040 | `http.http2_fingerprint` | HTTP/2 SETTINGS+priority fingerprint | — | server |
| S041 | `http.http2_fingerprint_mismatch` | HTTP/2 fingerprint inconsistent with claimed browser | +30 | derived |
| S042 | `http.header_casing_anomaly` | Header casing inconsistent with browser | +15 | server |
| S043 | `http.sec_fetch_missing` | Sec-Fetch-* missing on modern browser claim | +10 | server |
| S044 | `http.user_agent` | User-Agent header | — | server |
| S045 | `http.accept_language` | Accept-Language header | — | server |
| S046 | `webrtc.public_ip_mismatch` | WebRTC IP != connection IP | +25 | derived |
| S047 | `dns.over_https_used` | Client appears to use DoH | — | derived |
| S048 | `mtu.inferred` | Inferred MTU | — | server |
| S049 | `tcp.window_size` | TCP window size | — | server |
| S050 | `tcp.ttl_estimate` | Initial TTL estimate | — | server |
| S051 | `tcp.fingerprint_p0f` | p0f-style passive OS fingerprint | — | server |
| S052 | `tls.alpn` | ALPN list | — | server |
| S053 | `tls.sni` | SNI value | — | server |
| S054 | `tls.cipher_chosen` | Cipher suite selected | — | server |
| S055 | `tls.version` | Negotiated TLS version | — | server |
| S056 | `tls.session_resumed` | TLS session resumed | — | server |
| S057 | `http.referer` | Referer header (server-observed) | — | server |
| S058 | `http.origin` | Origin header | — | server |
| S059 | `connect.latency_ms` | TLS handshake latency | — | server |

### Browser / device · S060–S172

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S060 | `nav.user_agent` | navigator.userAgent | — | client |
| S061 | `nav.user_agent_data` | UA-CH high-entropy values | — | client |
| S062 | `nav.platform` | navigator.platform | — | client |
| S063 | `nav.languages` | navigator.languages | — | client |
| S064 | `nav.language` | navigator.language | — | client |
| S065 | `nav.hardware_concurrency` | navigator.hardwareConcurrency (CPU cores) | — | client |
| S066 | `nav.device_memory` | navigator.deviceMemory (GB bucket) | — | client |
| S067 | `nav.max_touch_points` | Touch points supported | — | client |
| S068 | `nav.do_not_track` | DNT | — | client |
| S069 | `nav.cookie_enabled` | navigator.cookieEnabled | — | client |
| S070 | `nav.online` | navigator.onLine | — | client |
| S071 | `nav.pdf_viewer_enabled` | navigator.pdfViewerEnabled | — | client |
| S072 | `nav.plugins_count` | Number of navigator.plugins entries | — | client |
| S073 | `nav.plugins_hash` | Hash of plugins list | — | client |
| S074 | `nav.mime_types_hash` | Hash of navigator.mimeTypes | — | client |
| S075 | `screen.width` | screen.width | — | client |
| S076 | `screen.height` | screen.height | — | client |
| S077 | `screen.avail_width` | screen.availWidth | — | client |
| S078 | `screen.avail_height` | screen.availHeight | — | client |
| S079 | `screen.color_depth` | screen.colorDepth | — | client |
| S080 | `screen.pixel_depth` | screen.pixelDepth | — | client |
| S081 | `screen.pixel_ratio` | window.devicePixelRatio | — | client |
| S082 | `screen.orientation` | screen.orientation.type | — | client |
| S083 | `screen.viewport_width` | innerWidth | — | client |
| S084 | `screen.viewport_height` | innerHeight | — | client |
| S085 | `locale.timezone` | Intl.DateTimeFormat resolvedOptions.timeZone | — | client |
| S086 | `locale.timezone_offset` | Date getTimezoneOffset | — | client |
| S087 | `locale.number_format` | Intl.NumberFormat resolvedOptions | — | client |
| S088 | `locale.date_format` | Intl.DateTimeFormat resolvedOptions | — | client |
| S089 | `canvas.hash` | Canvas 2D rendering hash | — | client |
| S090 | `canvas.emoji_hash` | Canvas emoji rendering hash | — | client |
| S091 | `canvas.text_hash` | Canvas text rendering hash | — | client |
| S092 | `webgl.vendor` | WebGL VENDOR | — | client |
| S093 | `webgl.renderer` | WebGL RENDERER | — | client |
| S094 | `webgl.unmasked_vendor` | WEBGL_debug_renderer_info unmasked vendor | — | client |
| S095 | `webgl.unmasked_renderer` | WEBGL_debug_renderer_info unmasked renderer (GPU) | — | client |
| S096 | `webgl.params_hash` | Hash of MAX_TEXTURE_SIZE, MAX_VIEWPORT_DIMS, etc. | — | client |
| S097 | `webgl.extensions_hash` | Hash of supported WebGL extensions | — | client |
| S098 | `webgl.shader_precision_hash` | Hash of shader precision formats | — | client |
| S099 | `webgl2.supported` | WebGL2 context available | — | client |
| S100 | `audio.fingerprint` | OfflineAudioContext rendering hash | — | client |
| S101 | `audio.sample_rate` | AudioContext sampleRate | — | client |
| S102 | `audio.channel_count_max` | destination.maxChannelCount | — | client |
| S103 | `audio.compressor_node_hash` | DynamicsCompressorNode output hash | — | client |
| S104 | `fonts.installed` | List of detected installed fonts | — | client |
| S105 | `fonts.installed_hash` | Hash of installed fonts list | — | client |
| S106 | `fonts.installed_count` | Number of installed fonts detected | — | client |
| S107 | `media.speech_voices` | speechSynthesis.getVoices() | — | client |
| S108 | `media.speech_voices_hash` | Hash of speech voices | — | client |
| S109 | `media.media_capabilities_hash` | Hash of MediaCapabilities decodingInfo results | — | client |
| S110 | `media.video_inputs` | Number of video inputs (no labels) | — | client |
| S111 | `media.audio_inputs` | Number of audio inputs (no labels) | — | client |
| S112 | `media.audio_outputs` | Number of audio outputs | — | client |
| S113 | `perms.notifications` | Permission state | — | client |
| S114 | `perms.geolocation` | Permission state | — | client |
| S115 | `perms.camera` | Permission state | — | client |
| S116 | `perms.microphone` | Permission state | — | client |
| S117 | `perms.midi` | Permission state | — | client |
| S118 | `webrtc.public_ips` | WebRTC discovered public IPs (server-reflexive) | — | client |
| S119 | `webrtc.local_ips` | WebRTC discovered local IPs | — | client |
| S120 | `math.fingerprint` | Hash of Math.* edge cases (asinh, etc.) | — | client |
| S121 | `timing.performance_now_resolution` | performance.now() effective resolution | — | client |
| S122 | `timing.date_resolution` | Date.now() resolution | — | client |
| S123 | `storage.persisted` | navigator.storage.persisted() | — | client |
| S124 | `storage.quota_gb` | navigator.storage.estimate().quota in GB | — | client |
| S125 | `storage.session_supported` | sessionStorage available | — | client |
| S126 | `storage.local_supported` | localStorage available | — | client |
| S127 | `storage.idb_supported` | IndexedDB available | — | client |
| S128 | `feature.service_worker` | navigator.serviceWorker present | — | client |
| S129 | `feature.push_manager` | PushManager available | — | client |
| S130 | `feature.payment_request` | PaymentRequest available | — | client |
| S131 | `feature.credential_management` | navigator.credentials available | — | client |
| S132 | `feature.bluetooth` | navigator.bluetooth available | — | client |
| S133 | `feature.usb` | navigator.usb available | — | client |
| S134 | `feature.hid` | navigator.hid available | — | client |
| S135 | `feature.serial` | navigator.serial available | — | client |
| S136 | `feature.share` | navigator.share available | — | client |
| S137 | `feature.locks` | navigator.locks available | — | client |
| S138 | `feature.wake_lock` | navigator.wakeLock available | — | client |
| S139 | `feature.gamepad` | getGamepads available | — | client |
| S140 | `feature.eye_dropper` | window.EyeDropper available | — | client |
| S141 | `feature.shape_detection` | BarcodeDetector etc. | — | client |
| S142 | `feature.web_assembly` | WebAssembly support | — | client |
| S143 | `feature.web_workers` | Worker support | — | client |
| S144 | `feature.shared_workers` | SharedWorker support | — | client |
| S145 | `feature.web_rtc` | RTCPeerConnection support | — | client |
| S146 | `css.media_pointer_coarse` | matchMedia pointer: coarse | — | client |
| S147 | `css.media_pointer_fine` | matchMedia pointer: fine | — | client |
| S148 | `css.media_hover_hover` | matchMedia hover: hover | — | client |
| S149 | `css.media_any_pointer` | matchMedia any-pointer | — | client |
| S150 | `css.media_prefers_dark` | matchMedia prefers-color-scheme: dark | — | client |
| S151 | `css.media_prefers_contrast` | matchMedia prefers-contrast | — | client |
| S152 | `css.media_prefers_reduced_motion` | matchMedia prefers-reduced-motion | — | client |
| S153 | `css.forced_colors` | matchMedia forced-colors | — | client |
| S154 | `css.display_mode` | matchMedia display-mode: standalone | — | client |
| S155 | `window.outer_dimensions` | outerWidth/outerHeight | — | client |
| S156 | `window.screen_x` | screenX/screenY | — | client |
| S157 | `window.dpi_inferred` | Inferred DPI from devicePixelRatio | — | client |
| S158 | `battery.charging` | Battery API charging state | — | client |
| S159 | `battery.level` | Battery API level | — | client |
| S160 | `connection.effective_type` | navigator.connection.effectiveType | — | client |
| S161 | `connection.downlink` | navigator.connection.downlink | — | client |
| S162 | `connection.rtt` | navigator.connection.rtt | — | client |
| S163 | `connection.save_data` | navigator.connection.saveData | — | client |
| S164 | `connection.type` | navigator.connection.type | — | client |
| S165 | `intl.locale` | Intl.Locale resolved values | — | client |
| S166 | `intl.collation` | Collation fingerprint via sort order | — | client |
| S167 | `intl.currency_format` | Currency format sample | — | client |
| S168 | `console.fingerprint` | console-specific quirks | — | client |
| S169 | `error.stack_format` | Error stack format fingerprint | — | client |
| S170 | `engine.v8_features` | V8-specific feature detection | — | client |
| S171 | `engine.spidermonkey_features` | SpiderMonkey-specific feature detection | — | client |
| S172 | `engine.webkit_features` | WebKit-specific feature detection | — | client |

### Anti-evasion · S173–S199

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S173 | `evasion.webdriver_flag` | navigator.webdriver === true | +90 | derived |
| S174 | `evasion.puppeteer_signature` | Puppeteer signatures present | +80 | derived |
| S175 | `evasion.playwright_signature` | Playwright signatures present | +80 | derived |
| S176 | `evasion.selenium_signature` | Selenium signatures present | +80 | derived |
| S177 | `evasion.is_headless` | Headless indicators (HeadlessChrome UA, missing plugins, etc.) | +70 | derived |
| S178 | `evasion.stealth_plugin` | Stealth plugin inconsistencies | +50 | derived |
| S179 | `evasion.canvas_noise` | Canvas fingerprint noise | +30 | derived |
| S180 | `evasion.audio_noise` | Audio fingerprint noise | +30 | derived |
| S181 | `evasion.webgl_spoofed` | WebGL renderer inconsistent with platform | +25 | derived |
| S182 | `evasion.tor_browser` | Tor Browser detected | +70 | derived |
| S183 | `consistency.ua_platform_mismatch` | UA platform conflicts with navigator.platform | +60 | derived |
| S184 | `consistency.ua_webgl_mismatch` | UA OS conflicts with WebGL renderer | +60 | derived |
| S185 | `consistency.ua_client_hints_mismatch` | UA conflicts with Client Hints | +40 | derived |
| S186 | `consistency.touch_platform_mismatch` | Touch capability conflicts with platform | +70 | derived |
| S187 | `consistency.mobile_screen_mismatch` | Mobile flag with desktop screen size | +50 | derived |
| S188 | `consistency.tz_ip_mismatch` | Browser timezone differs from IP location | +20 | derived |
| S189 | `consistency.locale_ip_mismatch` | Browser language unusual for IP country | +10 | derived |
| S190 | `consistency.browser_version_stale` | Browser version > 6 months old | +15 | derived |
| S191 | `consistency.plugins_browser_mismatch` | No plugins on claimed modern Chrome | +30 | derived |
| S192 | `consistency.languages_mismatch` | navigator.language not in navigator.languages | +40 | derived |
| S193 | `consistency.notification_inconsistent` | Notification API state inconsistent | +30 | derived |
| S194 | `evasion.window_chrome_missing` | window.chrome missing on Chrome UA | — | derived |
| S195 | `evasion.permissions_query_lies` | navigator.permissions.query returns inconsistent result | — | derived |
| S196 | `evasion.iframe_chrome_lies` | iframe contentWindow.chrome inconsistent | — | derived |
| S197 | `evasion.toString_tampered` | Function.toString returns suspicious source | — | derived |
| S198 | `evasion.battery_unsupported_in_modern_chrome` | Battery API state suggests masking | — | derived |
| S199 | `evasion.plugin_array_constructor_check` | plugins constructor name tampered | — | derived |

### Behavioral · S200–S221

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S200 | `behavior.mouse_event_count` | Number of mouse events observed before submit | — | client |
| S201 | `behavior.keystroke_count` | Number of keystrokes | — | client |
| S202 | `behavior.touch_event_count` | Touch events observed | — | client |
| S203 | `behavior.focus_blur_count` | Focus/blur events | — | client |
| S204 | `behavior.scroll_distance` | Cumulative scroll distance | — | client |
| S205 | `behavior.time_on_page_ms` | Time on page before submit | — | client |
| S206 | `behavior.copy_paste_count` | Number of paste events | — | client |
| S207 | `behavior.paste_into_email` | Email field filled by paste | — | client |
| S208 | `behavior.paste_into_password` | Password field filled by paste | — | client |
| S209 | `behavior.tab_switches` | visibilitychange events with hidden | — | client |
| S210 | `behavior.visibility_changes` | visibilitychange events count | — | client |
| S211 | `behavior.no_human_input` | No mouse movement and no keystroke variance | +60 | derived |
| S212 | `behavior.keystroke_too_uniform` | Keystroke timing too uniform | +40 | derived |
| S213 | `behavior.mouse_too_straight` | Mouse paths too straight | +30 | derived |
| S214 | `behavior.typing_speed_wpm` | Estimated typing speed | — | derived |
| S215 | `behavior.first_input_delay_ms` | Delay from page load to first user input | — | client |
| S216 | `behavior.form_fill_order` | Order in which form fields were filled | — | client |
| S217 | `behavior.form_fill_speed_ms` | Time taken to fill the form | — | client |
| S218 | `behavior.autofill_used` | Browser autofill detected | — | client |
| S219 | `behavior.devtools_open` | DevTools detected as open | — | client |
| S220 | `behavior.mouse_acceleration_variance` | Variance in mouse acceleration | — | derived |
| S221 | `behavior.click_timing_variance` | Variance in click timing | — | derived |

### Identity · S222–S260

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S222 | `email.raw` | Email as provided | — | server |
| S223 | `email.normalized` | Lowercased, trimmed | — | server |
| S224 | `email.canonical` | After +alias stripping, Gmail dot-collapse | — | derived |
| S225 | `email.local` | Local part | — | server |
| S226 | `email.domain` | Domain part | — | server |
| S227 | `email.canonical_match` | Same canonical email as previous trial | +60 | derived |
| S228 | `email.is_disposable` | Disposable email provider | +30 | derived |
| S229 | `email.is_relay` | Email relay/forwarding service (Apple, SimpleLogin, etc.) | +10 | derived |
| S230 | `email.is_freemail` | Free webmail provider | — | derived |
| S231 | `email.is_corporate` | Likely corporate email (non-freemail, has MX, > 1y old) | — | derived |
| S232 | `email.is_role_based` | Role-based local part (admin, info, ...) | +15 | derived |
| S233 | `email.is_gibberish` | Random-looking local part | +20 | derived |
| S234 | `email.local_short` | Local part length < 4 | +5 | derived |
| S235 | `email.local_numeric_ratio` | Numeric ratio > 0.5 | +5 | derived |
| S236 | `email.domain_age_days` | Days since WHOIS registration | — | server |
| S237 | `email.domain_new` | Domain age < 30 days | +25 | derived |
| S238 | `email.mx_valid` | Domain has MX records | +30 | server |
| S239 | `email.mx_count` | MX records returned | — | server |
| S240 | `email.smtp_deliverable` | SMTP RCPT TO check | +30 | server |
| S241 | `email.catch_all_domain` | Domain accepts any mailbox | +10 | server |
| S242 | `email.plus_alias_count` | Same canonical seen with multiple +aliases | +30 | derived |
| S243 | `email.gmail_dot_variant_count` | Same Gmail with dot variants | +35 | derived |
| S244 | `phone.raw` | Phone as provided | — | server |
| S245 | `phone.e164` | E.164 normalized | — | derived |
| S246 | `phone.country` | Country from E.164 prefix | — | derived |
| S247 | `phone.line_type` | mobile / fixed_line / voip / ... | — | derived |
| S248 | `phone.is_voip` | VoIP number | +20 | derived |
| S249 | `phone.is_toll_free` | Toll-free number | +10 | derived |
| S250 | `phone.canonical_match` | Same phone as previous trial user | +50 | derived |
| S251 | `phone.country_vs_ip_mismatch` | Phone country differs from IP country | +10 | derived |
| S252 | `phone.country_vs_email_mismatch` | Phone country disagrees with email TLD country | +5 | derived |
| S253 | `card.fp` | Card fingerprint from PSP | — | server |
| S254 | `card.fp_match` | Same card as previous trial user | +70 | derived |
| S255 | `card.bin` | Card BIN (first 6–8) | — | server |
| S256 | `card.is_prepaid` | Prepaid card | +20 | derived |
| S257 | `card.is_virtual_disposable` | Privacy.com / Revolut Disposable / etc. | +35 | derived |
| S258 | `card.country_vs_ip_mismatch` | Card country differs from IP country | +10 | derived |
| S259 | `card.country_vs_billing_mismatch` | Card country differs from billing country | +15 | derived |
| S260 | `card.avs_mismatch` | AVS not full match | +10 | derived |

### Composite IDs · S261–S264

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S261 | `composite.hardware_id` | Hardware-level SHA-256 over canvas+WebGL+audio+CPU+RAM+screen+platform | +45 | derived |
| S262 | `composite.browser_id` | Browser-profile SHA-256 over UA+fonts+voices+locale+permissions | +30 | derived |
| S263 | `composite.network_id` | Network SHA-256 over ASN+subnet+JA4+HTTP2 | +25 | derived |
| S264 | `composite.identity_id` | Identity SHA-256 over email canonical+phone+card | +50 | derived |

### Soft identifiers · S265–S267

| ID | Name | Description | Weight | Side |
|----|------|-------------|--------|------|
| S265 | `soft.first_party_cookie` | First-party visitor cookie (_tellr_visitor) | — | client |
| S266 | `soft.local_storage_id` | localStorage visitor ID (_tellr_v) | — | client |
| S267 | `soft.idb_id` | IndexedDB visitor ID (_tellr.v.id) | — | client |

---

## Changelog

### 2026-05-19 · v0.1

Initial release.

- 18-digit `tellr_id` issued per check, stable across attempts.
- Composite-ID matching: hardware, browser, network, identity.
- Dashboard with the user detail page, custom rules, API keys, team, webhooks.
- Self-hosted docker-compose for enterprise customers.

---

End of file.
