heedeo docs Sign inOpen dashboard
Manual integration

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.

Give this to your agent Open in Cursor

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.local to .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-browser only prints the link; --api <url> (or HEEDEO_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.local lines (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, and event() 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 _idt cookie: a same-origin form post or fetch carries 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 sent email.verified for 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's cf-ipcountry or x-vercel-ip-country header when you forward it); relay_email (10) the sign-in provider's private relay address.
  • Trust, whenever it holds: verified_id (100) a passed id_document or liveness check; paid_history (80) plan paid with paid_since more 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_domain with email_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 passed otp; 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 the shared_phone and shared_card links.
  • Phones and cards join the ledger: two browsers that presented one phone or card are related (related[].link shared_phone or shared_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 };

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.com is acme.com; acme.co.uk stays 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 is young_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 says email.domain_age_unknown: true and 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 weighs disposable_email, and its age would add nothing.
  • Free providers and roles. email.free_provider says 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_address says the local part names a function, not a person (admin, info, billing, support, sales, contact, hello, team and the like, before any + tag); on a signup it is role_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 is email_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 when score reaches your threshold, the review threshold that applied (100 by default, under Settings, Advanced). It means the event deserves a look; it is not a verdict.
  • thresholds and suggested: your numbers under Settings, Advanced: Verify at score (60 by default), Flag at score (100, the same as threshold) and Block at score (null until you set one, always above the flag), and the band score falls in: allow below verify, verify from there, review from the flag threshold, block from the block threshold. An action can have its own three (Settings, Advanced, Actions): then thresholds.action names it, and it is null when 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, or null while 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 a listed_block or 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, and probable_match fires 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_pattern and operator_burst weigh 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_rate weighs 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: tor 25; vpn, proxy, anonymizer, hosting and network_changed 20. 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, its weight and base, what it would weigh at the defaults (the two differ only once you own weights). many_identities this 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_match the 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_identities another 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/visitors keeps 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_browsers many 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_abuse you reported this visitor, or an account it has presented or presents now (by user_id), as abuse (100), or a browser related to this one (100 times the link); disposable_email; no_mx the domain has no mail server; alias_of_existing the email is a dot or plus-tag alias of one already linked; email_pattern generated-looking addresses arriving above the pattern's own norm; listed_block you labelled this visitor, email or network block; datacenter a cloud provider's published ranges; hosting IPinfo calls the network hosting, as it does most VPN exits; vpn, proxy, tor, anonymizer the network hides the person's own address; network_burst new identities on this /24 above its own norm, weighed by how far; action_rate this visitor takes the action more than your median visitor, weighed by how much; declared_agent the user agent claims an agent with no signature; operator_burst a crowd from one agent operator above its own norm; receipt_reused the 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, its weight and base negative) breach history is on and the address was in a breach more than two years ago, so it has existed that long; network_changed identify and the event came from different networks; browser_tampered (80) the browser's own readings disagree (environment.tamper names 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 codes verified_id, paid_history, wallet_card, real_user, legit_outcome, verified_phone, card_on_file, tenure, whose weight and base are negative) are listed under Evidence in section 3. Two notes ride in reasons with weight 0: token_expired and unknown_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 its aliases.
  • visitor.confidence: how sure Heedeo is that every browser in visitor.browsers is 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 with id, kind (stored its own cookie, declared a shared identity, matched a 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's kind for a shared one, the match's score for a device match, never a hash or a raw value), first_seen and last_seen. A browser of the person is here, not in related.
  • visitor.aliases: the ids a merge retired into this one, the newest first (up to 100). Each still resolves: GET /v1/visitors/<id> and POST /v1/outcomes with a retired id answer the visitor it joined, with merged_into set 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 the first and last time. Kept 13 months (section 7).
  • repeat_action (100): the action is once per visitor (Once per person under Settings, or once on 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_identities counts 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. score counts it beside the identity reasons, not with them.
  • visitor.emails.all and visitor.accounts.all: distinct canonical emails and account ids this visitor has shown you across its browsers, ever (also 24h, 7d), this event included. many_identities weighs the larger of the two; at signup, before a user id exists, emails is the one to use. visitor is 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 toward many_identities like 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" or null.
  • activity: your traffic for this action in the last hour against its weekly hourly norm, and burst when 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.

ReasonDefaultKind
listed_block100strong; flags alone
receipt_reused100strong
disposable_email100strong
no_mx100strong
alias_of_existing100strong
declared_agent20an unverified agent claim
many_identities100the identity curve: at your identity limit n (3), each identity beyond the first adding the identity step
probable_match100the identity curve, times the match score
related_identities100the identity curve, times the link
confirmed_abuse100your abuse report; a related browser's times the link
repeat_action100a once action the visitor already did, times the confidence of the link to the browser that did it
account_browsers80at five browsers on one account in a day (velocity's ramp)
datacenter80abnormal pattern
network_burst80at its mark (velocity's ramp)
email_pattern80at its mark (velocity's ramp)
young_domain80abnormal pattern
operator_burst80at its mark (velocity's ramp)
action_rate80at its mark (velocity's ramp)
tor25privacy network, counted once with the five below
vpn20privacy network
proxy20privacy network
anonymizer20privacy network
hosting20privacy network
network_changed20privacy network
account_new_browser20weak tell
role_address20weak tell, signups only
email_never_seen20weak tell, breach history on
browser_tampered80record-only (0) until you turn its scoring on
virtual_machine25weak tell
private_mode10weak tell, with a new identity only
automation10weak tell, never for a declared or verified agent
voip_phone80evidence
prepaid_card80evidence
verification_failed40evidence
email_unverified20evidence
card_mismatch20evidence
relay_email10evidence
verified_id100trust: subtracted
paid_history80trust
wallet_card60trust
real_user60trust
legit_outcome60trust
verified_phone40trust
card_on_file40trust
tenure40trust
email_tenure20trust, 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 (suggested at 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 (key c) records the outcome abuse and Dismiss (key d) records legit, the same outcomes as POST /v1/outcomes (section 9); either resolves the flag. j and k move 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 listed on 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.

WhatHoldsKept
Identificationsthe 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 made90 days
Eventsthe 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 answer30 days; an event a flag points to stays with its flag
Visitorsthe 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
Actionsper 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
Linksper browser, the hashes of each email, account, principal, phone and card it presented, when first and lastno automatic expiry: the ledger
Edgestwo of your browsers, how they are related and how much that proves, the hash they shareshared account, email, principal, phone or card, and probable matches: no automatic expiry; a shared address: 7 days after it was last seen
Placementswhich 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 matchno automatic expiry: the history a merge or a split is read from
Flags, labels and outcomesthe 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 hashesthe phone and card hasheswith their event (30 days) and with the links and edges they made
Network intelligence cacheIPinfo's answer for an address, under a keyed hash7 days
Mail-server cachethe email domain's mail hosts24 hours
Domain-age cachethe registrable domain's registration date30 days
Breach cacheHave I Been Pwned's earliest breach for an address, under a keyed hash30 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}}
DeletedWhat it held of the person
flagsthe flags on their browsers and events, and a related flag on another browser that points at one of their events
actionswhat they did, 13 months of it
outcomesthe outcomes you reported on their events, their visitor and the accounts their browsers last presented
merge_outcomesthe confirmations and contradictions on the merges of their browsers and into their visitor
eventstheir events, with the hashes, evidence and answers stored on them
observationstheir identifications
browser_linksthe hashes of every email, account, principal, phone and card their browsers presented
browser_edgesevery link between one of their browsers and any other, either way
browser_visitorswhere each of their browsers was placed, and by what
visitor_aliasthe ids merges retired into the visitor
browserstheir browsers, with the matcher's readings
visitorsthe 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 fieldWhat Heedeo keeps
email.verifiedas sent
email.verified_atas sent
phone.numberonly a one-way hash salted with your account, never the number
phone.hashyour hash, hashed again with your account
phone.verifiedas sent
phone.line_typeas sent
phone.countryas sent
oauth.provideras sent
oauth.email_verifiedas sent
oauth.hosted_domainas sent (a domain, never an address)
oauth.real_user_statusas sent
oauth.private_relayas sent
oauth.account_created_atas sent
oauth.two_factoras sent
account.created_atas sent
account.planas sent
account.paid_sinceas sent
payment.fundingas sent
payment.card_fingerprintonly a one-way hash salted with your account, never the fingerprint
payment.cvc_checkas sent
payment.walletas sent
payment.three_dsas sent
payment.countryas sent
payment.radar_risk_levelas sent
payment.free_trial_abuse_riskas sent
verification.kindas sent
verification.resultas sent
verification.atas 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.