heedeo docs Sign inOpen dashboard
Manual integration

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.

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 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: <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 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:

// 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"])
    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 <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. 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. 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:

// 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:

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):

"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

{ "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

// 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.