Integrating Heedeo
Heedeo tells you who is behind an action: the person across their browsers (the visitor, one id however many browsers they use, a Safari private window included when it presents the same email), what that person already did with you (kept 13 months, so a second trial is a repeat with no table on your side), and how suspicious this action is (how many emails and accounts the person has shown you, what the email and network look like). It returns signals; your code decides. You need three things: the script and identify served from your own domain (through Cloudflare, on a hostname of yours, or by proxying two paths yourself), one identify() call in the browser at the action, and one server call to POST /v1/events.
Sign up for your keys. Before going live, add the privacy sentence in section 7 to your privacy policy.
Heedeo's address is https://app.heedeo.com (called HEEDEO below). Sign up at https://app.heedeo.com/review/sign-up; the workspace is named from your email's domain (rename it under Settings). You get two keys: a publishable key pk_... (public, goes in the script tag) and a secret key sk_... (server only, never in the browser). The examples read them from NEXT_PUBLIC_HEEDEO_KEY and HEEDEO_SECRET_KEY (in Next, .env.local).
Connect your app
Until your first event arrives, Home (https://app.heedeo.com/review) is the setup, with two ways in. Both end in the same live check: it counts the identifications and events received and when the last of each came, and Home turns live with the first event.
- The CLI. In your project, run
npx @heedeo/sdk init. It prints a link and a code and opens the link: sign in, check that the code matches your terminal, and choose Connect. It then writes both keys to.env.local(other lines are kept, a second run replaces the values), adds.env.localto.gitignore, and prints a prompt for your coding agent. The secret is written to the file and never printed. Each run makes a new secret key; keys your app already uses keep working. A code works once, for 10 minutes.--no-browseronly prints the link;--api <url>(orHEEDEO_API_URL) points it at another Heedeo. - By hand. Choose By hand on the setup. Copy prompt gives your coding agent this guide's plain-text version with your publishable key filled in, never the secret. Or follow the four steps: the
.env.locallines (the secret is masked until you show it, and is shown on that first visit only; Rotate under Settings makes a new one), the install (npm install @heedeo/sdk),identify()in the browser, andevent()on your server. The sections below are those steps in full.
1. Serve Heedeo from your domain
The script and identify must come from your own domain, so the visitor cookie is first-party. Safari, Firefox and Brave drop third-party cookies, so a script loaded from our domain would lose the visitor on every visit. Two ways need no code and are set up on the Domains page, https://app.heedeo.com/review/domains: through Cloudflare, or your own hostname. The third is a route in your own app, by hand.
Both zero-code ways serve two paths under your workspace's path prefix (like /k9m2x, shown on the Domains page; a workspace made before the page has /hd, and Regenerate there gives it a random one) and nothing else: <prefix>/i.js and <prefix>/v1/identify. Only the _idd cookie goes up to Heedeo; your own session cookies never reach us. Both fail open: while a host is pending, removed, over quota or pointed wrong, the script does not load or identify() rejects, and your page goes on without signals (section 2).
Through Cloudflare, for a site Cloudflare already serves. On the Domains page, paste your Cloudflare account id and an API token with three permissions: Zone › Zone › Read, Account › Workers Scripts › Edit and Zone › Workers Routes › Edit. Press List zones, pick the zone your pages are on, and Deploy. Heedeo deploys a Worker named heedeo-k9m2x (your prefix) into your account, on the route *customer.com/k9m2x/*. The page shows the endpoint on the zone, https://customer.com/k9m2x, and the Worker answers the same paths on every host under it. Use the host your pages are on, like https://www.customer.com/k9m2x, and your page and the script are one origin. Heedeo keeps the token sealed and shows it nowhere. It uses the token only for this Worker: to list your zones, deploy it, redeploy it and delete it. On Workers Free the Worker serves 100,000 requests a day; past that Cloudflare skips it and your page goes on without signals.
Your own hostname, for any other site. On the Domains page, type a hostname on the domain your pages are on, like id.customer.com (three parts, up to five a workspace, on a domain you own and not a shared one like vercel.app; it cannot start with a word blocking lists match, such as track or analytics). Add one record at your DNS provider, id.customer.com CNAME connect.heedeo.com. Cloudflare then issues the certificate, and the hostname turns from Pending to Active (Check asks at once; Heedeo also asks every hour). Your endpoint is https://id.customer.com/k9m2x. Your page and the host are the same site but not the same origin, so the script posts with credentials, and identify answers CORS for your domain's origins, https://customer.com and https://*.customer.com. Other pages that identify go under Origins on the same page.
One rule for both: the host shares a registrable domain with your pages. A hostname on customer.com serves pages on customer.com and its subdomains, not pages on customer.io.
What each way gives:
- Chrome, Edge and Firefox keep the visitor cookie 400 days, either way.
- Safari keeps it 400 days through Cloudflare. On your own hostname Safari keeps it 7 days, because the hostname is a CNAME to another site; after that the matcher finds the visitor again by its characteristics, at lower confidence.
- Ad blockers: either way the script comes from your own host under your own prefix, not from a Heedeo host or a fixed path, so a list that names Heedeo does not match it. A blocker that reads CNAMEs (uBlock Origin on Firefox can) sees that your hostname points at Heedeo; the Cloudflare way has no CNAME. When a blocker stops the script, sign the user up without signals.
Then install with the endpoint. The Install card on the Domains page shows both lines with your key and endpoint filled in. Add no proxy route.
<script src="https://id.customer.com/k9m2x/i.js?key=pk_YOUR_PUBLISHABLE_KEY" async></script>
Or with the package (see the package quickstart, https://app.heedeo.com/docs/sdk):
import { createHeedeo } from "@heedeo/sdk/browser";
export const heedeo = createHeedeo({ publishableKey, endpoint: "https://id.customer.com/k9m2x" });
endpoint needs @heedeo/sdk 0.1.0-pilot.12 or later. Section 2 holds as written, with this src in the tag.
Installed through the proxy before adding the domain? Change only the browser line, the script's src or createHeedeo's endpoint; the server's event call stays the same, with its client_ip, and the proxy can go once the endpoint answers. The Domains page shows the switch with your endpoint filled in.
By hand: proxy two paths through your own site
The developer route, for any host. Serve /i.js (the script) and /v1/identify from your own origin and forward them to Heedeo. The proxy must forward at least the browser's cookie, user-agent, content-type and accept-language, and should forward its client hints (sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile, and the high-entropy sec-ch-ua-platform-version, -model, -arch, -bitness and -full-version-list), which the matcher reads beside the bundle (forwarding every header, as the Next example does, is fine; Heedeo reads only its own _idd cookie); return Heedeo's set-cookie and accept-ch, and set x-heedeo-client-ip to the end user's real IP, the same value your server later sends as client_ip. Without it Heedeo sees your server's IP and flags every event network_changed.
The script finds identify next to itself: mounted at /i.js it calls /v1/identify; mounted at /hd/i.js it calls /hd/v1/identify.
Next.js (proxy.ts at the project root on Next 16; on Next 15 name it middleware.ts and the function middleware). Plain rewrites() in next.config cannot set the client IP header, so use this instead:
// proxy.ts
import { type NextRequest, NextResponse } from "next/server";
const HEEDEO = "https://app.heedeo.com";
// The end user's IP: the first x-forwarded-for entry your host sets (Vercel, a load balancer).
export const clientIp = (h: Headers) => h.get("x-forwarded-for")?.split(",")[0]?.trim() || "127.0.0.1";
export function proxy(request: NextRequest) {
const headers = new Headers(request.headers);
headers.set("x-heedeo-client-ip", clientIp(request.headers));
const { pathname, search } = request.nextUrl;
return NextResponse.rewrite(new URL(pathname + search, HEEDEO), { request: { headers } });
}
export const config = { matcher: ["/i.js", "/v1/identify"] };
Node / Express (Node 20 or later, for fetch and crypto.randomUUID):
const HEEDEO = "https://app.heedeo.com";
app.set("trust proxy", true); // so req.ip is the client, not your load balancer; match your setup
app.use(["/i.js", "/v1/identify"], express.raw({ type: "*/*", limit: "64kb" }), async (req, res) => {
const headers = { "x-heedeo-client-ip": req.ip.replace(/^::ffff:/, "") };
for (const h of ["cookie", "user-agent", "content-type", "accept-language", "sec-ch-ua", "sec-ch-ua-platform",
"sec-ch-ua-mobile", "sec-ch-ua-platform-version", "sec-ch-ua-model", "sec-ch-ua-arch", "sec-ch-ua-bitness",
"sec-ch-ua-full-version-list"])
if (req.headers[h]) headers[h] = req.headers[h];
const up = await fetch(HEEDEO + req.originalUrl, {
method: req.method,
headers,
body: req.method === "POST" ? req.body : undefined,
});
for (const h of ["content-type", "cache-control", "accept-ch"]) if (up.headers.has(h)) res.set(h, up.headers.get(h));
if (up.headers.getSetCookie().length) res.set("set-cookie", up.headers.getSetCookie());
res.status(up.status).send(Buffer.from(await up.arrayBuffer()));
});
Check it: https://your.site/i.js?key=pk_... returns JavaScript. Test on https; on http://localhost Chrome works, Safari drops the Secure visitor cookie.
2. The script tag and identify() at the action
Put the tag on every page with an action, in the <head> (in the Next App Router, a plain <script> in app/layout.tsx's <head>):
<script src="/i.js?key=pk_YOUR_PUBLISHABLE_KEY" async></script>
In TypeScript, declare it once in any module (for example app/layout.tsx): declare global { interface Window { heedeo?: { identify(): Promise<{ token: string; visitor_id: string; confidence: number }> } } }.
Loading it sends nothing; it installs window.heedeo and warms up (it reads the browser so the first call is quick, and keeps nothing). What it reads is the collector profile you choose under Settings, Advanced, Collection: every source by default, or the EU profile, which leaves out canvas, audio and fonts (section 7). Call await heedeo.identify() in the signup handler, just before you submit. Each call is one identification and returns a receipt (token) that lives five minutes and is good for one event. It resolves with { token, visitor_id, confidence } and also sets the token in a first-party cookie _idt (readable by your page and server). The visitor itself is kept in an HttpOnly cookie _idd set by identify, with copies in localStorage and IndexedDB; a browser that clears all of them is usually still recognised by its characteristics, so a new cookie jar is not a new visitor. Carry the receipt to your server either way:
- The
_idtcookie: a same-origin form post orfetchcarries it; your server reads cookie_idt. - The resolved token: put it in the form (a hidden field) or your JSON body.
identify() can reject (an ad blocker, a network error) or window.heedeo can be missing (the script loads async). Never stop a signup for that: submit without a token.
async function onSubmit(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const id = await window.heedeo?.identify();
if (id) form.set("heedeo_token", id.token);
} catch {} // no token: sign the user up anyway, without signals
await signup(form); // your server action or fetch to your own endpoint
}
3. One server call: POST /v1/events
On your server, in the signup handler, send the receipt with the action. token, action, client_ip and headers are required; send email, and user_id once the account exists. When an agent acts for one of your users, send that user's account id as acting_for (the principal; actingFor in the package): hashed on arrival like user_id, never stored raw. Always send a request_id, made once per signup attempt and reused if you retry the call: a retry with the same request_id returns the stored answer, while a retry without one spends the receipt again and records a second, phantom event flagged receipt_reused. headers are the end user's request headers (minus cookie and authorization), plus host, :method and :path of that request, which agent signatures (Web Bot Auth) cover. If there is no token, or Heedeo errors twice, skip the signals and proceed.
Once per visitor. Some actions a person should take once: signup, trial_start, redeem:SPRING (a coupon by its code), payout. Turn on the action's switch under Settings, Once per person, or send "once": true with the call (once in the package; "once": false turns the setting off for that call). Heedeo remembers what each visitor did for 13 months, across all of its browsers, so a second trial_start by the same person, from a Safari private window with the same email as much as from the first browser, answers repeat_action (section 4) at review. One trial per visitor is then this one call, with the helper below, and nothing stored on your side: no table of trials, no column of visitor ids.
const signals = await heedeoEvent({ token, action: "trial_start", once: true, email, clientIp, incoming, method, path });
if (signals?.suggested === "review" || signals?.suggested === "block") return noTrial(); // a repeat, or anything else as strong
One helper for any Node server, with one retry under the same request_id:
// heedeo.js
const HEEDEO = "https://app.heedeo.com";
// incoming: the end user's request headers as [name, value] pairs; method and path: that request's.
export async function heedeoEvent({ token, action, email, userId, clientIp, incoming, method, path, once }) {
if (!token) return null; // identify() did not run: no signals, proceed
const headers = { ":method": method, ":path": path };
for (const [k, v] of incoming) if (k !== "cookie" && k !== "authorization" && v != null) headers[k] = String(v);
const body = JSON.stringify({
token, action, email, user_id: userId, client_ip: clientIp, headers, once, // once: true for a once-per-visitor action
request_id: crypto.randomUUID(), // once per attempt: the retry below reuses it
});
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const res = await fetch(`${HEEDEO}/v1/events`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.HEEDEO_SECRET_KEY}`, "content-type": "application/json" },
body,
signal: AbortSignal.timeout(5000),
});
if (res.ok) return await res.json();
console.error("heedeo", res.status, await res.text());
if (res.status < 500) return null; // your bug, not worth a retry
} catch (err) {
console.error("heedeo", attempt, err); // a timeout or a network error
}
}
return null; // fail open: sign the user up without signals
}
Express:
app.post("/signup", express.urlencoded({ extended: false }), async (req, res) => {
const signals = await heedeoEvent({
token: req.body.heedeo_token || req.cookies?._idt, // cookie-parser for req.cookies
action: "signup", email: req.body.email, clientIp: req.ip.replace(/^::ffff:/, ""),
incoming: Object.entries(req.headers), method: req.method, path: req.originalUrl,
});
// act on signals.suggested (section 5), then create the account
});
Next.js server action, in app/actions.ts, called from your "use client" form component (clientIp is the proxy's function; adjust the import paths to your layout):
"use server";
import { cookies, headers } from "next/headers";
import { heedeoEvent } from "../heedeo";
import { clientIp } from "../proxy";
export async function signup(form: FormData) {
const incoming = await headers();
const signals = await heedeoEvent({
token: (form.get("heedeo_token") as string) || (await cookies()).get("_idt")?.value,
action: "signup", email: String(form.get("email") ?? ""), clientIp: clientIp(incoming),
incoming: [...incoming], method: "POST",
path: "/signup", // the path of the page whose form calls this action
});
if (signals?.suggested === "review") { console.log("signup held", signals.event_id); return { ok: false, message: "We could not create your account. Contact support." }; }
// create the account (with your verification step when signals?.suggested === "verify")
return { ok: true };
}
The helper is plain JS; in a strict TypeScript project name them .ts and type signals as any or as section 4's shape. To check your key and the call shape: curl -s -H "authorization: Bearer $HEEDEO_SECRET_KEY" -H "content-type: application/json" -d '{"token":"x","action":"signup","client_ip":"127.0.0.1","headers":{}}' https://app.heedeo.com/v1/events answers 400 invalid_token.
Errors come as { "error": { "code", "message" } }: 401 for a missing or wrong key, 400 invalid_request or invalid_token for a malformed call. An expired receipt is not an error: you get a 200 with visitor: null and the note token_expired.
Evidence: what your platform knows about the person
The same call takes an optional evidence object (evidence in the package's event()): what your backend knows about the person, sent server to server, never through the browser. Every section and field is optional, and a field Heedeo does not know is refused with 400 like any other. The phone number (in E.164, +14155550123) and the card fingerprint are hashed with your account on arrival, as emails are, and never stored or logged raw; the rest is stored as sent, with the event. Send what you know on the event where you learn it (signup, the first payment, a check you ran); Heedeo keeps each section's latest value per visitor for as long as it keeps events (30 days), so the visitor's later events and its standing score keep what it earned.
"evidence": {
"email": { "verified": true, "verified_at": "2026-10-26T09:00:00Z" },
"phone": { "number": "+14155550123", "verified": true, "line_type": "mobile", "country": "US" },
"oauth": { "provider": "google", "email_verified": true, "hosted_domain": "acme.com" },
"account": { "created_at": "2025-06-01T00:00:00Z", "plan": "paid", "paid_since": "2026-08-01T00:00:00Z" },
"payment": { "funding": "credit", "card_fingerprint": "Xt5EWLLDS7FJjR1c", "cvc_check": "pass", "wallet": "apple_pay",
"three_ds": "authenticated", "country": "US", "radar_risk_level": "normal" },
"verification": { "kind": "otp", "result": "passed", "at": "2026-10-26T09:01:00Z" }
}
The values: phone.line_type is mobile, landline, fixed_voip, non_fixed_voip, toll_free, prepaid or unknown (send phone.hash, your own hash of the number, instead of number if you prefer: one or the other, and always the same one); oauth.provider google, apple, github, microsoft or other, with real_user_status (Apple's 0, 1 or 2), private_relay, account_created_at and two_factor where the provider says them; account.plan free, trial or paid; payment.funding credit, debit or prepaid, cvc_check pass or fail, wallet apple_pay, google_pay, link or none, three_ds authenticated, failed or not_attempted, and radar_risk_level and free_trial_abuse_risk normal, elevated, highest, not_assessed or unknown; verification.kind otp, email_link, 3ds, id_document, liveness or captcha, result passed or failed. Times are ISO 8601; countries two letters. Leave out what a source says outside these values (Stripe's unknown funding, Twilio's personal line).
What Heedeo makes of it, each a reason in reasons with its own weight under Settings, Advanced, Weights:
- Suspicion, on the event that brought the evidence (the same evidence again adds nothing):
voip_phone(80) a non-fixed VoIP or toll-free number;prepaid_card(80) a prepaid card;verification_failed(40) a check you ran failed, its kind in the text;email_unverified(20) once you have sentemail.verifiedfor a day, an address new to the browser that no event verified;card_mismatch(20) the card's country is not the country of the event's address (IPinfo's, or your edge'scf-ipcountryorx-vercel-ip-countryheader when you forward it);relay_email(10) the sign-in provider's private relay address. - Trust, whenever it holds:
verified_id(100) a passedid_documentorlivenesscheck;paid_history(80)planpaidwithpaid_sincemore than 30 days ago and no abuse report;wallet_card(60) Apple Pay, Google Pay, Link or a passed 3-D Secure;real_user(60) Apple's real-user status 2, a Google Workspace or Microsoft Entra account on its organisation's verified domain (hosted_domainwithemail_verified), or a GitHub account older than a year with two-factor on;legit_outcome(60) you reported the visitor, one of its events or its account as legit;verified_phone(40) a mobile line verified by you or by a passedotp;card_on_file(40) a card that passed its CVC check;tenure(40) an account created more than 90 days ago. A prepaid card earns no card trust, and a phone or card another of your browsers also presented earns none: the same phone or card on several accounts is suspicion, through theshared_phoneandshared_cardlinks. - Phones and cards join the ledger: two browsers that presented one phone or card are related (
related[].linkshared_phoneorshared_card, weight 1), and the link lends the identities every browser holding that phone or card holds together, so one card on three accounts weighs what three accounts on one browser do. A phone or card is never an identity of its own: one person with two cards is normal.
The one-liner from each source:
Stripe
From the PaymentMethod pm and, after a payment, its charge ch:
const c = pm.card, tds = ch?.payment_method_details?.card?.three_d_secure?.result;
evidence.payment = { funding: c.funding === "unknown" ? undefined : c.funding, card_fingerprint: c.fingerprint, cvc_check: ["pass", "fail"].includes(c.checks?.cvc_check) ? c.checks.cvc_check : undefined, wallet: pm.type === "link" ? "link" : { apple_pay: "apple_pay", google_pay: "google_pay" }[c.wallet?.type] ?? "none", three_ds: tds === "authenticated" || tds === "failed" ? tds : "not_attempted", country: c.country, radar_risk_level: ch?.outcome?.risk_level };
Twilio Lookup
Line type intelligence on the number the person gave, and whether your Verify code passed:
const r = await twilio.lookups.v2.phoneNumbers(number).fetch({ fields: "line_type_intelligence" });
evidence.phone = { number: r.phoneNumber, country: r.countryCode, verified: codePassed, line_type: { mobile: "mobile", landline: "landline", fixedVoip: "fixed_voip", nonFixedVoip: "non_fixed_voip", tollFree: "toll_free" }[r.lineTypeIntelligence?.type] ?? "unknown" };
Google sign-in
The verified ID token's claims; hd is present only for a Google Workspace account:
evidence.oauth = { provider: "google", email_verified: claims.email_verified === true, hosted_domain: claims.hd };
Sign in with Apple
The ID token's claims (Apple sends real_user_status on the first sign-in only, and the booleans sometimes as strings):
evidence.oauth = { provider: "apple", real_user_status: claims.real_user_status, email_verified: String(claims.email_verified) === "true", private_relay: String(claims.is_private_email) === "true" };
For Microsoft Entra, { provider: "microsoft", email_verified: claims.xms_edov === true, hosted_domain: email.split("@")[1] } (the xms_edov optional claim says the email's domain is verified by the tenant).
GitHub
GET https://api.github.com/user with the person's token:
const u = await (await fetch("https://api.github.com/user", { headers: { authorization: `Bearer ${token}` } })).json();
evidence.oauth = { provider: "github", account_created_at: u.created_at, two_factor: u.two_factor_authentication === true };
Paid email intelligence (SEON, Emailage)
Heedeo does not call SEON or Emailage (LexisNexis Risk); if you have an account, fill the evidence from it like any other source. Their phone line or carrier type goes in phone.line_type, mapped to the values above as Twilio's is (mobile, landline, fixed or non-fixed VoIP, toll-free, prepaid; anything else unknown), with phone.country; a phone or email verification you ran through them goes in verification (otp or email_link, passed or failed). Their email age, first-seen dates, domain age, social-profile counts and risk scores have no evidence field: Heedeo reads domain age itself (RDAP) and an address's age from breach history when you turn it on, and a vendor's score belongs in your own rule beside score and reasons, not inside Heedeo's.
Your own records
evidence.account = { created_at: user.createdAt, plan: user.plan, paid_since: user.paidSince } from your users table; evidence.email = { verified: true, verified_at } once the person clicks your link; evidence.verification = { kind: "otp", result: "passed", at } after any check you run (3ds, id_document, liveness, captcha the same way).
Email depth: domain age, free providers, roles and breach history
Heedeo reads more from the email you send, with no lookup of yours. Phones get no lookup at all: the line type is what you send in evidence.phone.
- Domain age. The email's registrable domain (
mail.acme.comisacme.com;acme.co.ukstays itself) is looked up by RDAP, the registries' public protocol, through IANA's bootstrap of which registry serves each TLD, and cached 30 days. A domain registered under 30 days before the event isyoung_domain(80) on the event that adds the address to the browser: throwaway domains are bought and used at once. When the registry cannot say (its TLD has no RDAP server, it does not answer in 400 ms, or it gives no registration date), the answer saysemail.domain_age_unknown: trueand nothing fires. A free provider's domain is never looked up, nor a disposable one (on the disposable list, or receiving its mail at a disposable provider's host): it already weighsdisposable_email, and its age would add nothing. - Free providers and roles.
email.free_providersays the address is at a large free mail provider (Gmail, Outlook, Yahoo, iCloud, Proton, GMX and the like), a fact for your rule.email.role_addresssays the local part names a function, not a person (admin,info,billing,support,sales,contact,hello,teamand the like, before any+tag); on a signup it isrole_address(20). - Breach history, off by default. Under Settings, Advanced, Weights, Check breach history sends each new address (its canonical form) to Have I Been Pwned, a third party, to see whether it appeared in a known data breach: once, on the event that adds the address to the browser, never again on the browser's later events; the answer is cached 30 days under a keyed hash, never the address, and later events read it from there. An address in a breach more than two years old has existed that long:
email_tenure, trust 20. One in no breach isemail_never_seen(20) on the event that adds it: a new address, or a careful person. It stays off until you turn it on, and works only where Heedeo's server holds a Have I Been Pwned key; mention it in your privacy policy if you do (section 7, For your privacy policy).
4. The fields to act on
{ "flag": true,
"score": 120,
"threshold": 100,
"thresholds": { "verify": 60, "review": 100, "block": null, "action": null },
"suggested": "review",
"reasons": [{ "code": "repeat_action", "text": "trial_start already done by this visitor on 12 Oct from another browser (same email)", "weight": 100 },
{ "code": "vpn", "text": "IPinfo reports the client_ip as a VPN", "weight": 20 }],
"visitor": { "id": "8855…", "confidence": 1, "emails": { "24h": 3, "7d": 3, "all": 3 }, "accounts": { "24h": 0, "7d": 0, "all": 0 },
"browsers": [{ "id": "8855…", "kind": "stored", "confidence": 1, "merged_by": "this browser", "evidence": {}, "first_seen": "…", "last_seen": "…" },
{ "id": "c3a0…", "kind": "declared", "confidence": 1, "merged_by": "same email", "evidence": { "kind": "email" }, "first_seen": "…", "last_seen": "…" }],
"aliases": ["c3a0…"],
"actions": { "trial_start": { "count": 2, "first": "2026-10-12T09:14:03Z", "last": "…" },
"login": { "count": 7, "first": "…", "last": "…" } } },
"listed": null,
"activity": { "last_hour": 4, "hourly_norm": 0.29, "burst": false },
"event_id": "7504b755-…" }
The response also carries visitor.first_seen and last_seen; counts (this browser's events of this action, 24h and 7d); related (browsers in other visitors related to this visitor's browsers, one entry per edge, the strongest first; the visitor's own browsers are in visitor.browsers, so a rule that read related to find the person's other browsers reads visitor.browsers now: each with link, how much the link proves as weight, and the other browser's own identities; link is shared_account or shared_email (the two presented the same account id or canonical email, weight 1), shared_phone or shared_card (the two presented the same phone or card in evidence, weight 1), shared_principal (the two acted for the same person: an agent's acting_for, or the person's own browser presenting that account id, weight 1), probable (identify found one a probable match of the other, weight the match score) or ip (seen on the same address in the last 7 days, the exact address for IPv4 and the /64 for IPv6: 0.5 on a home or office line, 0.2 in a hosting network; a carrier, Private Relay or Tor address makes no link)); account, when you sent a user_id (visitors, the browsers that have presented this account over 24h, 7d and all, this one included, and new_browser, whether this browser is new to it; null without a user_id), the account-side read for a login rule; email (disposable, alias_of_existing, pattern; free_provider, the domain is a large free mail provider's; role_address, the local part names a role such as admin@, info@ or billing@; young_domain, the reason fired; domain_registered, when the email's registrable domain was registered by RDAP, null for a free provider's, which is never asked, or when the registry could not say; domain_age_unknown, true when the registry was asked and could not say, which is never a reason; and breach, Have I Been Pwned's earliest breach holding the address as { "first": "2013-10-04" }, first null in none, null while breach history is off); network (link_weight, what a shared address proves, then datacenter, vpn, proxy, tor, private_relay, anonymous, network_type, asn); agent (operator, verified, declared, operator_network, automation), null when nothing says agent: a headless or automated browser shows in agent.automation, and weighs 10 as the automation reason only when no agent is declared or verified; and environment, what the identification's browser showed of itself (null without an identification): tampered (its own readings disagree: a navigator getter that is not the browser's own, a Web Worker that reports another core count, memory, language, platform, time zone or user agent than the page, a user agent, client hints and platform that name different systems, or a GPU that system cannot have; null from a collector before 1.2.0) with tamper, the checks that failed; virtual_machine (a hypervisor's or software renderer's GPU, SwiftShader, llvmpipe, VMware SVGA, VirtualBox or Parallels, with two of low core count, no touch and no audio context) with vm, the renderer and the tells; and private_mode (a private window, read from the storage the browser offers: Chrome's memory-sized quota, Safari's and Firefox's refused private file system; null when it cannot be told, as in Brave, which offers every window the same).
flag: true whenscorereaches yourthreshold, the review threshold that applied (100 by default, under Settings, Advanced). It means the event deserves a look; it is not a verdict.thresholdsandsuggested: your numbers under Settings, Advanced: Verify at score (60 by default), Flag at score (100, the same asthreshold) and Block at score (nulluntil you set one, always above the flag), and the bandscorefalls in:allowbelow verify,verifyfrom there,reviewfrom the flag threshold,blockfrom the block threshold. An action can have its own three (Settings, Advanced, Actions): thenthresholds.actionnames it, and it isnullwhen your workspace's applied. The band is computed from nothing but those numbers; Heedeo enforces nothing, and your code may read it or ignore it.version: when your weights last changed, ornullwhile you use the defaults: the weight table that scored this answer. Weights are the defaults below until you accept a recommendation under Settings, Advanced, Weights, fitted on your own outcomes (section 9); once you have your own, a reason added later weighs 0 for you until you accept again, and a reason that never fired on your labelled events keeps its default when you accept.score: the reasons' weights added up, less the strongest trust reason (evidence, above), never below 0, and never below alisted_blockor an abuse report on this visitor itself, which trust does not offset (it does offset what related browsers lend). A strong reason weighs 100 and flags alone:listed_block,receipt_reused,disposable_email,no_mx,alias_of_existing. An unverified agent claim (declared_agent) weighs 20 and does not flag alone at the default threshold. Identity evidence grows with the count: each email or account a browser uses beyond the first adds your identity step (under Settings, Advanced, Weights; 50 by default, which is 100 ÷ (n − 1) with n = 3), so the second identity scores 50, the third 100 (the flag) and the fourth 150. A related browser (related: a shared account, email or principal, a probable match, or an address shared in the last 7 days) lends the identities the two hold together with this event's, counted once each, weighed the same way and discounted by the link: in full for a shared account, email or principal, by the match score for a probable match, by half on a shared home or office line and a fifth in a hosting network (most VPN exits). Browsers of one visitor (below) lend nothing to each other: their identities are the visitor's own, andprobable_matchfires only for a match below the merge threshold, between two visitors. The identity reasons (many_identities,probable_match,related_identities,confirmed_abuse) count once together, at the highest.datacenter(a cloud provider's published ranges) weighs 80, so it flags with a pattern or a privacy network. A burst or a rate weighs how far above its own norm it is, and nothing below it:network_burst,email_patternandoperator_burstweigh a decayed count of new identities (this hour's in full, older ones fading over an hour, so a burst fades with quiet time) against five, or three times the network's, pattern's or operator's hourly mean the week before;action_rateweighs this visitor's 24-hour count against ten, or five times your median visitor's week. Each weighs 80 at that mark, 80 more for every three beyond it, and nothing from three under it. A privacy network is how many honest people browse, so it never flags alone:tor25;vpn,proxy,anonymizer,hostingandnetwork_changed20. Those six count once together, at the highest weight that fired.reasons: codes, each with one line of text for you, never for the end user, itsweightandbase, what it would weigh at the defaults (the two differ only once you own weights).many_identitiesthis event raised the visitor's distinct emails or accounts to two or more, weighing 100 × (count − 1) ÷ (n − 1), so your limit n (3 by default) scores 100;probable_matchthe visitor probably matches another one, and the identities the two hold with this event's weigh the same way, times how sure the match was;related_identitiesanother of your browsers is related to this one by a shared account, email, principal or address, and the identities the two hold together with this event's weigh the same way, times the link, on an event that added an identity or made a new link (a login that adds nothing restates nothing;GET /v1/visitorskeeps the evidence);account_new_browser(20) this browser is new to an account another browser already presented: a new device on a login, or someone else holding the account;account_browsersmany browsers presented this account in the last 24 hours, weighing nothing at two, 27 at three, 53 at four, 80 at five and 80 more for every three beyond, on the event that brought a new browser to it;confirmed_abuseyou reported this visitor, or an account it has presented or presents now (byuser_id), as abuse (100), or a browser related to this one (100 times the link);disposable_email;no_mxthe domain has no mail server;alias_of_existingthe email is a dot or plus-tag alias of one already linked;email_patterngenerated-looking addresses arriving above the pattern's own norm;listed_blockyou labelled this visitor, email or network block;datacentera cloud provider's published ranges;hostingIPinfo calls the network hosting, as it does most VPN exits;vpn,proxy,tor,anonymizerthe network hides the person's own address;network_burstnew identities on this /24 above its own norm, weighed by how far;action_ratethis visitor takes the action more than your median visitor, weighed by how much;declared_agentthe user agent claims an agent with no signature;operator_bursta crowd from one agent operator above its own norm;receipt_reusedthe receipt was already spent by an earlier event;young_domain(80) the email's domain was registered under 30 days ago, on the event that adds the address to the browser (domain age by RDAP, under Email depth in section 3);role_address(20) a signup with a role address (admin@,info@,billing@,support@,sales@and the like), never for a declared or verified agent;email_never_seen(20) breach history is on and Have I Been Pwned knows the address from no breach, on the event that adds it;email_tenure(trust, 20, itsweightandbasenegative) breach history is on and the address was in a breach more than two years ago, so it has existed that long;network_changedidentify and the event came from different networks;browser_tampered(80) the browser's own readings disagree (environment.tampernames how), reported at weight 0 until you turn its scoring on under Settings, Advanced, Weights (record-only while you watch it: we suggest two weeks);virtual_machine(25) the browser draws with a virtual or software GPU beside two supporting tells, never for a declared or verified agent, whose operator runs it in a cloud;private_mode(10) a private window added a new email or account (a private window alone is ordinary, and says nothing);automation(10) BotD's automation tells with no agent declared or verified (an agent that says what it is gets no reason); the evidence codes (voip_phone,prepaid_card,verification_failed,email_unverified,card_mismatch,relay_email, and the trust codesverified_id,paid_history,wallet_card,real_user,legit_outcome,verified_phone,card_on_file,tenure, whoseweightandbaseare negative) are listed under Evidence in section 3. Two notes ride inreasonswith weight 0:token_expiredandunknown_visitor, which mean no visitor facts in this response.visitor.id: the visitor, one id for the person across their browsers, decided by Heedeo on every event: your code stores nothing to group them. Browsers that present the same email or account become one visitor under the oldest one's id, and so does a browser that matches one of them by its characteristics at or above your merge threshold (0.86 by default); a later shared email or account makes a match certain. Section 7, Merges, has how, the threshold, the alias and the undo. A browser on its own keeps its own id as its visitor's, so an id you stored before a merge is the visitor's id or one of itsaliases.visitor.confidence: how sure Heedeo is that every browser invisitor.browsersis this person, the least confidence among their placements: 1 when every browser is placed by its own cookie or a shared identity, the match score (0.88) when one is placed by a device match.visitor.browsers: the person's browsers, the oldest first, each withid,kind(storedits own cookie,declareda shared identity,matcheda device match),confidence,merged_by(what placed it, in words a support person can read: "this browser", "same email", "same account", "device match 0.88"),evidence(the same without words:{}for its own cookie, the identity'skindfor a shared one, the match'sscorefor a device match, never a hash or a raw value),first_seenandlast_seen. A browser of the person is here, not inrelated.visitor.aliases: the ids a merge retired into this one, the newest first (up to 100). Each still resolves:GET /v1/visitors/<id>andPOST /v1/outcomeswith a retired id answer the visitor it joined, withmerged_intoset to its id (section 10), so a customer who stored the old id learns the new one without a webhook.visitor.actions: what the visitor did, by action name, across its browsers, this event included:count, and thefirstandlasttime. Kept 13 months (section 7).repeat_action(100): the action is once per visitor (Once per person under Settings, oronceon the call) and this visitor already did it. Every repeat is a repeat, however long ago within 13 months; the text says when, and whether from this browser or another (and what joins the two, "same email"). Its weight is the default times the confidence of the link between this browser and the one that did it first, so the same repeat reaches review through a shared email (100) and lands on verify through a device match at 0.85 (85). Identities do not count across a device match:many_identitiescounts the browsers joined by cookies and shared identities, so a matched browser's new email is not the person's second identity, and the repeat is what the match carries.scorecounts it beside the identity reasons, not with them.visitor.emails.allandvisitor.accounts.all: distinct canonical emails and account ids this visitor has shown you across its browsers, ever (also24h,7d), this event included.many_identitiesweighs the larger of the two; at signup, before a user id exists, emails is the one to use.visitoris null without a usable receipt.- The principal (
acting_for): for a browser with a declared or verified agent (its user agent names one, it signs with Web Bot Auth, or your edge verified it), the people it acts for are expected and do not count toward its identities: an operator acting for fifty users is fifty principals and one identity. For a browser with no agent, each principal counts towardmany_identitieslike an account, because an anonymous browser acting for many people is multi-accounting under another name. listed: your own label for this visitor, email or network:"block","allow"ornull.activity: your traffic for this action in the last hour against its weekly hourly norm, andburstwhen it is well above. It is about your traffic, not this visitor. On a new or quiet account a few test signups are already a burst.
Testing: test traffic flags. An example.com address has no mail server (no_mx), and repeated numbered addresses from one network give email_pattern and network_burst. Check what your rule does, not flag. On localhost the proxy's clientIp falls back to 127.0.0.1 for every tester, so everyone's local signups share one network and soon make a network_burst. To be a new visitor again, use another browser or another time zone (Playwright's timezoneId is enough); clearing cookies is not enough.
Default weights
Every reason's default, as your Settings, Advanced, Weights table shows it before you accept your own. A trust reason's weight is what it takes off the score (shown negative in reasons); the trust reasons count once together, at the strongest, as the identity reasons and the privacy networks do.
| Reason | Default | Kind |
|---|---|---|
listed_block | 100 | strong; flags alone |
receipt_reused | 100 | strong |
disposable_email | 100 | strong |
no_mx | 100 | strong |
alias_of_existing | 100 | strong |
declared_agent | 20 | an unverified agent claim |
many_identities | 100 | the identity curve: at your identity limit n (3), each identity beyond the first adding the identity step |
probable_match | 100 | the identity curve, times the match score |
related_identities | 100 | the identity curve, times the link |
confirmed_abuse | 100 | your abuse report; a related browser's times the link |
repeat_action | 100 | a once action the visitor already did, times the confidence of the link to the browser that did it |
account_browsers | 80 | at five browsers on one account in a day (velocity's ramp) |
datacenter | 80 | abnormal pattern |
network_burst | 80 | at its mark (velocity's ramp) |
email_pattern | 80 | at its mark (velocity's ramp) |
young_domain | 80 | abnormal pattern |
operator_burst | 80 | at its mark (velocity's ramp) |
action_rate | 80 | at its mark (velocity's ramp) |
tor | 25 | privacy network, counted once with the five below |
vpn | 20 | privacy network |
proxy | 20 | privacy network |
anonymizer | 20 | privacy network |
hosting | 20 | privacy network |
network_changed | 20 | privacy network |
account_new_browser | 20 | weak tell |
role_address | 20 | weak tell, signups only |
email_never_seen | 20 | weak tell, breach history on |
browser_tampered | 80 | record-only (0) until you turn its scoring on |
virtual_machine | 25 | weak tell |
private_mode | 10 | weak tell, with a new identity only |
automation | 10 | weak tell, never for a declared or verified agent |
voip_phone | 80 | evidence |
prepaid_card | 80 | evidence |
verification_failed | 40 | evidence |
email_unverified | 20 | evidence |
card_mismatch | 20 | evidence |
relay_email | 10 | evidence |
verified_id | 100 | trust: subtracted |
paid_history | 80 | trust |
wallet_card | 60 | trust |
real_user | 60 | trust |
legit_outcome | 60 | trust |
verified_phone | 40 | trust |
card_on_file | 40 | trust |
tenure | 40 | trust |
email_tenure | 20 | trust, breach history on |
5. Act on the score
Two things carry the integration, and neither needs a table on your side. once: true on an action a person should take once (or its Once per person switch under Settings): Heedeo remembers the person's actions across their browsers for 13 months, and a repeat answers repeat_action at review, so "one trial per visitor" is one field on the call (section 3). suggested, the band your thresholds under Settings, Advanced make of score, with the repeat in it beside every other reason: act on it, and log reasons beside what you did:
// in the signup handler, after heedeoEvent(): four bands, the highest first
if (signals?.suggested === "block") { console.log("signup refused", signals.event_id, signals.reasons); return genericRefusal(); }
if (signals?.suggested === "review") { console.log("signup held", signals.event_id, signals.reasons); return holdForReview(); }
if (signals?.suggested === "verify") requireEmailVerification(); // your phone or email step
createAccount();
block only appears once you set Block at score under Settings, Advanced (no block band by default), and only for scores at or above it; review then means a person looks before the account works, and block that your code refuses outright. Signup, login and an in-app action can each have their own three lines (Settings, Advanced, Actions): the answer's thresholds.action says which applied.
What goes into the score is yours too: under Settings, Advanced, Weights you set what each reason adds (0 switches one off and it still shows in reasons at weight 0), the identity step, and the two thresholds, or accept a recommendation fitted on your own outcomes (section 9). listed is already in the score through listed_block; an allow label is how you let a real person through after a false flag, by reading it before the band:
if (signals?.listed === "allow") return createAccount();
When you refuse, show a generic message ("We could not create your account. Contact support."), never a reason: the end user must never see a reason code or its text. Log event_id: it is how we find the event together. For the first two weeks of the pilot, log what the band would do and refuse nothing.
Rules on raw fields stay available, as the optional path: every count and fact in section 4 is in the answer for your own code, exactly as you would write a rule against any device-intelligence API. For example, refusing the second account on one person whatever the score: if (signals?.visitor?.emails.all >= 2) …, using visitor.accounts.all once a user id exists, or a hard line on a repeat whatever its link: if (signals?.visitor?.actions?.trial_start?.count >= 2) …. A rule like that bypasses your weights, the confidence of a device match and the related-browser evidence, so prefer the band unless you need a hard line. If you kept your own table of visitor ids to allow one trial each, it keeps working (a merged id resolves through visitor.aliases and merged_into), and once: true replaces it.
6. The review page
Sign in at https://app.heedeo.com/review with the account you signed up with (an email code or Google); your email and sessions are under Settings, Advanced, Manage account. The app has four places. Every label on them is fixed per reason or link, and every value is read from your data.
- Home: the setup until your first event, then the last 7 days: events, people, repeats of once-only actions and open flags, a row per action, and the newest flags.
- Review: the flagged events (
suggestedat review or block), Open and Done, filtered by action. Each row names its strongest reason with a value (Repeat action · 2nd time,Many identities · 3 emails); the panel shows the event, its signals and the person's browsers. Confirm flag (keyc) records the outcomeabuseand Dismiss (keyd) recordslegit, the same outcomes asPOST /v1/outcomes(section 9); either resolves the flag.jandkmove between flags. - Visitors: one row per person, searchable by id or event id. A person's page shows their browsers, each with how it joined (First seen, Same email, Same account, Similar device with its score) and how confident Heedeo is that they are one person; Not them takes a device-matched browser back out (section 7, Merges). Activity counts what the person did over 13 months and Recent shows their newest events, OK or Flagged. Trust and Block label the person: a label comes back as
listedon their next event, and Heedeo enforces nothing. More details holds the browser's readings, networks, evidence, old ids and Delete this person. - Settings: Once per person, a switch per action a person should take once (the same as sending
once: true); your keys, with the secret masked and Rotate (the old secret stops at once); the workspace's name and usage. Advanced, closed by default, holds everything else: the Verify, Flag and Block thresholds and per-action thresholds, Weights with Recommend weights and Reset to defaults, the identity step, Merge on, the Merge threshold and Merge reliability, Score browser tampering, Check breach history, Collection (Full or EU, section 7), and Domains and allowed origins.
7. Privacy: purpose, footing and what Heedeo never does
Purpose
Heedeo processes device and account evidence for one purpose: to secure the authentication and signup your users ask you for, and to prevent fraud on your service. It does so for you alone: every identifier is hashed with your account, nothing is shared between customers or compared across them, nothing is used for advertising, and nothing is sold.
Where that stands
This is how Heedeo is built to fit each regime; it is not legal advice, and your counsel decides your own footing.
- EU. Reading the browser and setting the visitor cookie is storage and access on the user's device (the ePrivacy Directive, Article 5(3)). Heedeo's footing is the exemption for what is strictly necessary to provide the service the user asked for, here the security of the signup or login they started; fraud prevention beyond that rests on legitimate interest under the GDPR (Article 6(1)(f)), which Recital 47 names for preventing fraud. Your page calls
identify()only at an action you send to events, and the EU collector profile (below) leaves out the readings that do most to single out a machine. - UK. PECR has the same rule for storage and access, and the Data (Use and Access) Act 2025 adds to it an exception for what is strictly necessary to prevent or detect fraud; the rest rests on legitimate interests under the UK GDPR, as in the EU.
- US. Under the CCPA, as the CPRA amended it, helping to ensure security and integrity and to detect and prevent fraud is a business purpose; Heedeo processes the data for you as your service provider, and never sells or shares it (no cross-context behavioural advertising). The state privacy acts (Virginia, Colorado, Connecticut, Texas and the others) exempt processing to prevent, detect or respond to fraud and security incidents from their opt-outs.
What Heedeo never does
Heedeo never returns a verdict or blocks anyone: your code decides on every event. It sets no caps and no rate limits; counts are facts for your rule. It never flags an agent for being an agent: a declared or verified agent's automation and virtual machine are recorded, never reasons; only a claim nothing backs or a sudden crowd is. Automation with no agent declared weighs 10, which alone never reaches verify. And it never does these:
- Personal data through the browser. The script reads the browser and nothing else. Emails, phone numbers, account ids, principals and card fingerprints reach Heedeo only from your server, server to server.
- A cross-customer graph. Every hash is salted with your account, so the same email at two customers is two unrelated hashes; no link, edge, count, label or score crosses customers, and there is no shared reputation.
- Raw addresses past the request. The client IP and the email address are used while the event is answered (the IP to read its network and to ask IPinfo, the address to canonicalise and hash it, and to ask Have I Been Pwned only if you turned breach history on), then kept only as below.
- Fingerprinting in a native app. There is no mobile SDK: Apple forbids fingerprinting in apps whatever the consent. From an app, send events with the evidence your backend holds.
- Selling or sharing visitor data. Not to anyone, not for advertising, not for another customer's benefit.
- A competitor's device intelligence as an input. Fingerprint, Stytch, Castle and the like are never read. A paid email or phone service's results reach Heedeo only as evidence you send (section 3).
What Heedeo keeps, and for how long
Heedeo never stores a raw email address (only a one-way hash of its canonical form, and of the presented alias when it differs) or a raw network address (only its /24, or /48 for IPv6; a one-way hash of the exact IPv4 address or the IPv6 /64 salted with your account; and, for the IPinfo cache, a keyed hash under a server secret). Of the headers you send, only the user agent, the languages and the client hints are kept. The email's domain, never the address, is kept where a reason names it and in the mail-server and domain-age caches. The hourly tick deletes what has expired.
| What | Holds | Kept |
|---|---|---|
| Identifications | the bundle the script sent (under the EU profile, without canvas, audio and fonts), the user agent, languages and client hints, the address's /24 or /48, what identify read of the environment, how the match was made | 90 days |
| Events | the hashes of the canonical email, the alias, the account, the principal and the email's pattern; the address's /24 or /48 and its salted hash; the user agent, languages and client hints; the evidence as validated, phone and card hashed; the answer | 30 days; an event a flag points to stays with its flag |
| Visitors | the visitor cookie's id, first and last seen, the matcher's comparable readings (most as short hashes); the person's browsers, the ids merges retired, and what it did of each action (counts, first and last) | no automatic expiry |
| Actions | per event: the action, its browser and visitor, when, the band the answer suggested, your outcome on it, and the kind of network (home, business, hosting, carrier, relay, Tor) | 13 months |
| Links | per browser, the hashes of each email, account, principal, phone and card it presented, when first and last | no automatic expiry: the ledger |
| Edges | two of your browsers, how they are related and how much that proves, the hash they share | shared account, email, principal, phone or card, and probable matches: no automatic expiry; a shared address: 7 days after it was last seen |
| Placements | which visitor each browser belonged to and from when to when, and what placed it: its own cookie, a shared identity's kind and hash, or a device match's score and the browser it matched; the operator who split one; the confirmations and contradictions later facts put on a match | no automatic expiry: the history a merge or a split is read from |
| Flags, labels and outcomes | the reasons, your notes, your labels (an email as its hash, a network as its /24 or /48) and outcomes (an account as its hash) | no automatic expiry |
| Evidence hashes | the phone and card hashes | with their event (30 days) and with the links and edges they made |
| Network intelligence cache | IPinfo's answer for an address, under a keyed hash | 7 days |
| Mail-server cache | the email domain's mail hosts | 24 hours |
| Domain-age cache | the registrable domain's registration date | 30 days |
| Breach cache | Have I Been Pwned's earliest breach for an address, under a keyed hash | 30 days |
A visitor's profile (its browsers, identities and the counts of what it did) is derived from the facts above and can be rebuilt from them: from the browsers, their links and the actions. Undoing a merge (Not them) rebuilds both sides that way, so it recounts exactly what happened within the 13 months actions are kept, and nothing older.
Merges
Heedeo decides who is one person, explains it and can undo it; your code stores nothing. How: two browsers become one visitor when they present the same email or account (Merge on, under Settings, Advanced, Weights; phone and card can be turned on, though a shared card or phone is also a household's), compared as hashes salted with your account, so no merge crosses customers; or when one matches the other by its characteristics at or above your merge threshold and the other is placed by its own cookie or a shared identity, so matches never chain. A shared network never merges anyone: an address shared is a link in related, not a merge. The threshold is 0.86 by default (Settings, Advanced, Weights, Merge threshold, 0.70 to 0.95): a desktop Safari private window matches at about 0.85, so by default it joins the person through the same email or account rather than by the device alone; lower catches more private windows and merges more households. A lowered threshold reaches earlier matches within the hour, and a raised one splits nothing. The alias: the older visitor keeps its id; the other id becomes an alias that resolves to it forever, listed in visitor.aliases and answered with merged_into, so an id you stored keeps working. A merge never rewrites your events or actions: each keeps the browser and the visitor it had when it was written, and each placement keeps what made it (the identity's kind and hash, or the match score), shown on the visitor page in words ("same email", "device match 0.86") and in visitor.browsers[].evidence without the hash. The undo: a device match can be wrong (two people on one model of phone), so on the person page an operator can press Not them on a browser a match placed: it becomes a visitor of its own again, the id staying with the side that presented an email, account, phone or card (or, when both or neither did, the side seen most recently), each side's profile rebuilt from the 13 months of actions Heedeo keeps, and no later match puts the two back together. A merge by a shared email or account is not split; turning its kind off under Merge on stops future merges of that kind and splits nothing. A later shared email confirms a match, and a split, two different verified phones or two accounts you reported legit contradict it; Settings, Advanced, Weights, Merge reliability shows, per confidence, how often each happened in the last 90 days, so you can tell whether your threshold is honest.
Deleting a person
When someone asks you to delete their data, DELETE /v1/visitors/<visitor_id> with the secret key removes everything Heedeo holds of that person for you, in one transaction, while no event or merge of theirs can land half way. An id a merge retired deletes the visitor it joined. The answer is the visitor's id and how many rows each kind of record lost:
curl -s -X DELETE -H "authorization: Bearer $HEEDEO_SECRET_KEY" https://app.heedeo.com/v1/visitors/8855…
# {"id":"8855…","deleted":{"flags":1,"actions":12,"outcomes":1,"merge_outcomes":0,"events":12,"observations":3,"browser_links":4,"browser_edges":2,"browser_visitors":3,"visitor_alias":1,"browsers":2,"visitors":1}}
| Deleted | What it held of the person |
|---|---|
flags | the flags on their browsers and events, and a related flag on another browser that points at one of their events |
actions | what they did, 13 months of it |
outcomes | the outcomes you reported on their events, their visitor and the accounts their browsers last presented |
merge_outcomes | the confirmations and contradictions on the merges of their browsers and into their visitor |
events | their events, with the hashes, evidence and answers stored on them |
observations | their identifications |
browser_links | the hashes of every email, account, principal, phone and card their browsers presented |
browser_edges | every link between one of their browsers and any other, either way |
browser_visitors | where each of their browsers was placed, and by what |
visitor_alias | the ids merges retired into the visitor |
browsers | their browsers, with the matcher's readings |
visitors | the visitor and its profile |
Your own labels under lists (a visitor id, an email hash or a network you marked block or allow) are kept: they are yours, and a label on an id you block should outlast the data behind it. Another person's browser keeps its own records, even where they carry this visitor's id as what Heedeo believed when it wrote them (a browser split off this visitor keeps the events it had while in it). Daily usage counts hold no identifier and stay. A browser of theirs that comes back afterwards is identified afresh, with nothing of what was deleted to match it against. A visitor that is not yours answers 404, as does one already deleted. With the package: await heedeo.deleteVisitor(visitorId), the same { data, error } shape, not retried.
Collection: the full and EU profiles
Under Settings, Advanced, Collection you choose what the script reads. Full, the default, is every source: screen, GPU, fonts, how the browser draws canvas graphics and processes audio, language and time-zone settings, and the browser's own description of itself. EU leaves out canvas, audio (with its base latency) and fonts (the installed fonts and the default font sizes): /i.js serves your publishable key a script that never reads them, identify drops them from any bundle that still carries them (a script a browser cached before you switched, for up to an hour), and the matcher compares none of them, on new identifications or on visitors recorded before. Switching to EU also removes what Heedeo kept of them. Identification then leans on the visitor cookie (first-party, 400 days, with its storage copies) and the ledger: a returning browser is still the same visitor by its cookie, and two browsers are still related by the email, account, phone or card they presented. What weakens is recognising a browser that cleared its cookies and storage by its readings alone.
For your privacy policy
Add a line like this, naming what you send (leave out the fields you do not send): "To prevent fraud and abuse, and to secure the signup and sign-in you ask us for, we measure technical characteristics of your browser when you sign up or sign in (such as screen, fonts, language settings and how it renders canvas graphics and audio) through our provider Heedeo, which uses them for that purpose only, for us alone, and never for advertising or sale; your email address and your network address are kept only as one-way hashes, with the network address also as its approximate network. We also share with Heedeo whether your email address is verified and when; your phone number, whether it is verified, its line type and its country; your sign-in provider and what it says of your account (whether your email is verified, your organisation's domain, Apple's real-user status, whether the address is a private relay, when the account was created and whether two-factor authentication is on); when your account with us was created, its plan and since when it has been paid; your payment card's fingerprint, funding type, card verification result, wallet, 3-D Secure result and country, and our payment provider's risk levels; and the kind, result and time of identity checks we run. Your phone number and card fingerprint are kept only as one-way hashes." Under the EU profile, write "(such as screen, language settings and how the browser describes itself)" instead of the first parenthesis.
If you turn on breach history, add: "Heedeo checks your email address with Have I Been Pwned to see whether it appeared in known data breaches; the address is not stored." The email's domain (never the address) is looked up in the public domain registries to learn its age and in public DNS (Cloudflare's, then Google's) for its mail servers; where Heedeo's server holds an IPinfo key, your network address is looked up at IPinfo.
What Heedeo keeps of each evidence field, with the event, for 30 days (section 3); the two hashes also stay with the links and edges they made:
| Evidence field | What Heedeo keeps |
|---|---|
email.verified | as sent |
email.verified_at | as sent |
phone.number | only a one-way hash salted with your account, never the number |
phone.hash | your hash, hashed again with your account |
phone.verified | as sent |
phone.line_type | as sent |
phone.country | as sent |
oauth.provider | as sent |
oauth.email_verified | as sent |
oauth.hosted_domain | as sent (a domain, never an address) |
oauth.real_user_status | as sent |
oauth.private_relay | as sent |
oauth.account_created_at | as sent |
oauth.two_factor | as sent |
account.created_at | as sent |
account.plan | as sent |
account.paid_since | as sent |
payment.funding | as sent |
payment.card_fingerprint | only a one-way hash salted with your account, never the fingerprint |
payment.cvc_check | as sent |
payment.wallet | as sent |
payment.three_ds | as sent |
payment.country | as sent |
payment.radar_risk_level | as sent |
payment.free_trial_abuse_risk | as sent |
verification.kind | as sent |
verification.result | as sent |
verification.at | as sent |
8. Billing
One identification is one identify() call; page loads are free. Call identify() only at the actions you send to events.
9. Report what you learn
When you find out what a signup really was, tell Heedeo. An outcome changes no flag and no label: it is ground truth, the labelled set that tunes your weights later, and the more of it there is, the sooner your own numbers replace the defaults. A paid conversion is a cheap legit; a refund, a chargeback or a third free trial is abuse.
curl -s -H "authorization: Bearer $HEEDEO_SECRET_KEY" -H "content-type: application/json" \
-d '{"event_id":"7504b755-…","outcome":"abuse","label":"trial_abuse","note":"third trial this month"}' \
https://app.heedeo.com/v1/outcomes
Send exactly one of event_id (from the events response), visitor_id or user_id (hashed on arrival, as events hash it; an abuse by user_id is confirmed_abuse on the next event of every browser that has presented that account, or presents it then); outcome is abuse or legit; label is your own short word and note one line, both optional. One outcome per subject: a later report replaces the earlier one; a visitor_id a merge retired is reported on the visitor it joined, which the answer names with merged_into. An event or visitor that is not yours answers 404. The answer is the stored row: kind, event_id or visitor_id, outcome, label, source (api, or review when a reviewer resolved the flag as Abuse or Legit on the review page), note and created_at. Outcomes show on the visitor page and beside resolved flags. With the package: heedeo.outcome({ eventId, outcome: 'abuse' }).
10. Ask about a visitor
Between events, your server can ask what a visitor stands at now: GET /v1/visitors/<visitor_id> with the secret key; an id a merge retired answers the visitor it joined, with visitor.merged_into set. DELETE on the same address deletes the person (section 7, Deleting a person). The score is the standing identity evidence, not any one event's: the largest of what its own distinct emails and accounts (and principals no agent presented) weigh (100 × (count − 1) ÷ (n − 1)), an abuse report, and what each related browser (a shared account, email, principal, phone or card, a probable match, or an address shared within the last seven days) currently lends, less the strongest trust its evidence (section 3) and a legit outcome earn now, never below 0 and never below an abuse report on the visitor itself. All contributions use your current weights; changing weights or the identity limit takes effect on the next lookup. Related identity counts use distinct hashes across the two browsers, so shared identities count once. Related accounts made later move it, so account A reads review once accounts B and C exist on the same browser or address.
curl -s -H "authorization: Bearer $HEEDEO_SECRET_KEY" https://app.heedeo.com/v1/visitors/8855…
The answer: visitor (id, first_seen, last_seen, emails and accounts over 24h, 7d and all time, and as on an event confidence, browsers with what placed each and its evidence, aliases and actions), listed (your label on the visitor), score, thresholds (verify, review, block: your workspace's), suggested (the band, from those numbers alone), trust (each trust reason that holds, with its negative weight; the strongest counts), related (current links, as section 4 describes them: the same list the last event answered, unless links were made since), outcomes (kind, outcome, label, created_at), visitor.actions (as section 4) and recent, the visitor's newest 50 actions (action, at, browser_id, suggested, outcome), the newest first. With the package: await heedeo.visitor(visitorId), the same { data, error } shape. A visitor that is not yours answers 404. Each link keeps the weight it was made with (a shared address the most any shared address proved), so a lookup needs no event of its own.