# Integrating Heedeo Heedeo tells you who is behind a signup: which browser (the visitor), how many emails and accounts it has shown you, and 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`. Heedeo's address is `https://app.heedeo.com` (called `HEEDEO` below). Sign up at `https://app.heedeo.com/review/sign-up` for your two keys, shown on the first page after: a publishable key `pk_...` (public, goes in the script tag) and a secret key `sk_...` (server only, never in the browser; shown that once, so copy it then). Put the secret key in your server's environment as `HEEDEO_SECRET_KEY` (in Next, `.env.local`); the examples read it from there. ## 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: `/i.js` and `/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. ```html ``` Or with the package (see the package quickstart, `https://app.heedeo.com/docs/sdk`): ```ts import { createHeedeo } from "@heedeo/sdk/browser"; export const heedeo = createHeedeo({ publishableKey, endpoint: "https://id.customer.com/k9m2x" }); ``` `endpoint` comes in `@heedeo/sdk` 0.1.0-pilot.11, which is not on npm yet; until it is, use the script tag. Section 2 holds as written, with this `src` in the tag. ### 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` (forwarding every header, as the Next example does, is fine; Heedeo reads only its own `_idd` cookie), return Heedeo's `set-cookie`, 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: ```ts // 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`): ```js 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"]) 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"]) 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 `` (in the Next App Router, a plain ` ``` 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. 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. ```js 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. **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. **One helper for any Node server**, with one retry under the same `request_id`: ```js // 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 }) { 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, 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**: ```js 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, }); // your rule (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 and `refuse` and `friction` are section 5's rule; adjust the import paths to your layout): ```ts "use server"; import { cookies, headers } from "next/headers"; import { heedeoEvent } from "../heedeo"; import { clientIp } from "../proxy"; import { friction, refuse } from "../rule"; 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 (refuse(signals)) { console.log("signup refused", signals.event_id); return { ok: false, message: "We could not create your account. Contact support." }; } // create the account (with friction(signals) adding your verification step) return { ok: true }; } ``` The helper and the rule are 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`. ## 4. The fields to act on ```json { "flag": true, "score": 120, "threshold": 100, "reasons": [{ "code": "many_identities", "text": "This event raised the visitor's identities to 3; the threshold is 3", "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 } }, "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 visitor's events of this action, 24h and 7d); `email` (`disposable`, `alias_of_existing`, `pattern`); `network` (`datacenter`, `vpn`, `proxy`, `tor`, `private_relay`, `anonymous`, `network_type`, `asn`); and `agent` (`operator`, `verified`, `declared`, `operator_network`, `automation`), null when nothing says agent. A headless or automated browser shows in `agent.automation`; that is recorded, never a reason. - `flag`: true when `score` reaches your `threshold` (100 by default, under Settings). It means the event deserves a look; it is not a verdict. - `score`: the reasons' weights added up. A strong reason weighs 100 and flags alone: `listed_block`, `receipt_reused`, `many_identities`, `disposable_email`, `no_mx`, `alias_of_existing`, `declared_agent`. An abnormal pattern weighs 80, so it flags with a second pattern or a privacy network: `probable_match`, `email_pattern`, `network_burst`, `action_rate`, `operator_burst`, and `datacenter` (a cloud provider's published ranges). 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, and its `weight`. `many_identities` this event raised the visitor's distinct emails or accounts to your threshold n (3 by default); `probable_match` the visitor probably matches another one that would reach n with this event; `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` a burst of generated-looking addresses; `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 accounts on this /24 well above its own norm; `action_rate` this visitor takes the action far more than your median visitor; `declared_agent` the user agent claims an agent with no signature; `operator_burst` a sudden crowd from one agent operator; `receipt_reused` the receipt was already spent by an earlier event; `network_changed` identify and the event came from different networks. Two notes ride in `reasons` with weight 0: `token_expired` and `unknown_visitor`, which mean no visitor facts in this response. - `visitor.emails.all` and `visitor.accounts.all`: distinct canonical emails and account ids this browser has shown you, ever (also `24h`, `7d`), this event included. `many_identities` counts 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. - `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. ## 5. An example rule ```js // rule.js. N is yours: 2 refuses the second account on one browser; Heedeo's own flag uses n = 3. const N = 3; export const refuse = (s) => s && s.listed !== "allow" && (s.listed === "block" || s.visitor?.emails.all >= N); export const friction = (s) => s?.activity?.burst; // in the signup handler, after heedeoEvent(): if (refuse(signals)) { console.log("signup refused", signals.event_id); return genericRefusal(); } if (friction(signals)) requireEmailVerification(); // your phone or email step 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. An `allow` label is how you let a real person through after a false flag. For the first two weeks of the pilot, log what the rule would do and refuse nothing. ## 6. The review page Sign in at `https://app.heedeo.com/review/flags` with the account you signed up with (an email code, a password or Google); your email, password and sessions are under Settings, Manage account. The flags list shows each flagged event with its reasons; Visitors lists every browser that has called `identify()`, with its events, identities and flags, so you can see unflagged signups arriving too; open a visitor to see its emails, accounts and networks, and label it **Block**, **Allow** or **Clear** (an email can be labelled too). A label is yours: it comes back as `listed` on that visitor's next event, and Heedeo enforces nothing. Resolve a flag with a note once you have looked. Settings hold n, your keys, secret key rotation (the old key stops at once) and the link to your account. ## 7. 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: automation and verified operators are recorded, and only a claim nothing backs or a sudden crowd is a reason. It never stores a raw email (only a hash of the canonical address) and keeps identifications 90 days and events 30 days; flags, labels and visitors stay. Add a line like this to your privacy policy: "To prevent fraud and abuse, we measure technical characteristics of your browser at signup (such as screen, fonts, language settings and how it renders canvas graphics and audio) through our provider Heedeo; your email address is stored only as a one-way hash." ## 8. Billing One identification is one `identify()` call; page loads are free. Call `identify()` only at the actions you send to events.