heedeo docs Sign inOpen dashboard
Manual integration

Integrating Heedeo

Heedeo tells you who is behind an action:

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.

  • Who the person is across their browsers: the visitor, one id however many browsers they use.
  • What that person already did with you, kept 13 months, so a second trial is a repeat with no table on your side.
  • How suspicious this action is: how many emails and accounts the person has shown you, and what the email and network look like.

It returns signals; your code decides. You need three things:

  1. The script and identify served from your own domain: through Cloudflare, on a hostname of yours, or by proxying two paths yourself.
  2. One identify() call in the browser at the action.
  3. One server call at the action: POST /v1/evaluations, by heedeo.evaluate() where you want the answer or heedeo.logEvent() where you only record it.

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, it goes in the script tag.
  • 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 routes: With your agent and Step by step. Both end in the same live check: it counts the identifications and events received and shows when the last of each came. Home turns live with the first event.

With your agent. Copy the line on Home into your coding agent:

Set up Heedeo in this app: https://app.heedeo.com/SKILL.md

The line holds no key. Your agent studies your app, asks how you want to handle signups (section 5), then shows you a plan you can change. Your answer only changes the code it writes in your app. Heedeo stores nothing of it and enforces nothing.

  • For your keys, your agent sends you a link and a code. Sign in, check who is asking (the page shows the address, country and tool that started the request, how long ago, and the project name as the request gave it), type the code your agent gave you, and choose Connect.
  • The page does not show the code and does not connect without it, so a link someone else sends you cannot hand them a key in one click. Only type a code from an agent you just asked. A code works once, for 5 minutes.
  • Your agent then writes both keys to .env.local and keeps that file out of git. The secret goes to the file, not into your chat.
  • Each connection makes a new secret key. Keys your app already uses keep working, and each key a connection made is listed on API keys with its own Revoke.

Step by step. Choose Step by step on Home's setup and follow the four steps:

  1. The .env.local lines. The secret is masked until you show it, and is shown on that first visit only; Rotate on API keys makes a new one.
  2. The install: npm install @heedeo/sdk.
  3. identify() in the browser.
  4. event() on your server. The code on Home records every signup and refuses no one; to refuse signups, add a rule from section 5.

The sections below are those steps in full.

1. Serve Heedeo from your domain

The script and identify must come from your own domain, not from Heedeo's, so that Heedeo recognises returning visitors. Three ways do that:

  • Through Cloudflare, with no code, set up on the Domains page (https://app.heedeo.com/review/domains).
  • Your own hostname, with no code, set up on the same page.
  • A route in your own app, by hand. When your pages have no server of their own, that route goes on your API (section 3, A single-page app, a hosted sign-in and a separate API).

Both zero-code ways serve two paths under your workspace's path prefix and nothing else: <prefix>/i.js and <prefix>/v1/identify. The prefix looks like /k9m2x and is shown on the Domains page. A workspace made before the page has /hd, and Regenerate there gives it a random one.

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 sends no token, so your server asks for verification (section 3).

Through Cloudflare, for a site Cloudflare already serves.

  1. 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.
  2. Press List zones and pick the zone your pages are on.
  3. Press Deploy. Heedeo deploys a Worker named heedeo-k9m2x (your prefix) into your account, on the route *customer.com/k9m2x/*.
  4. The page shows the endpoint on the zone, https://customer.com/k9m2x. The Worker answers the same paths on every host under it. Use the host your pages are on, like https://www.customer.com/k9m2x, so that your page and the script are one origin.

About the token:

  • Give it those three permissions and nothing more. Heedeo checks a token with Cloudflare before it keeps or uses it, and refuses one that can do more (Cloudflare's "Edit Cloudflare Workers" template can).
  • Workers Scripts › Edit is account-wide in Cloudflare, so the token can change any Worker in that account. If that is too much, put the zone in an account of its own.
  • Heedeo keeps the token sealed and shows it nowhere. It uses the token only for this Worker, and only when you press a button on the Domains page: to list your zones, deploy it, redeploy it, apply an update and delete it.

When we ship a new version of the Worker, the Domains page says so and offers Apply the update. Heedeo never changes the Worker in your account on its own, and the one you have keeps serving until you apply it.

On Workers Free the Worker serves 100,000 requests a day. Past that, Cloudflare skips it, and your page sends no token, so your server asks for verification (section 3).

Your own hostname, for any other site.

  1. On the Domains page, type a hostname on the domain your pages are on, like id.customer.com. It must have three parts and be on a domain you own, not a shared one like vercel.app. It cannot start with a word blocking lists match, such as track or analytics. A workspace can have up to five.
  2. Add one record at your DNS provider: id.customer.com CNAME connect.heedeo.com.
  3. Cloudflare then issues the certificate, and the hostname turns from Pending to Active. Check asks at once; Heedeo also asks every hour.
  4. Your endpoint is https://id.customer.com/k9m2x.

Your page and the host are the same site but not the same origin. The script posts with credentials, and once the hostname is live identify answers CORS for your domain's origins, https://customer.com and https://*.customer.com, and refuses every other origin. Until then nothing changes for pages that already work. Other pages that identify go under Origins on the same page.

  • A hostname whose CNAME is not in within 7 days is released.
  • If another workspace added your hostname first and never set it up, the page gives you a TXT record to add (_heedeo.id.customer.com). With it in your DNS, add the hostname again and it is yours.
  • When you remove a hostname, take its CNAME out of your DNS too. For 30 days the hostname stays reserved to your workspace; after that, anyone could add it and be served what still reaches it.

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:

  • Recognition: Heedeo recognises returning visitors most reliably through Cloudflare, or through a route in your own app. Your own hostname works in every browser, with a little less certainty for some returning visitors.
  • 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, the page sends no token, and your server asks for verification (section 3).

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

With @heedeo/sdk you need none of the code in this part: the package's one route in its quickstart (https://app.heedeo.com/docs/sdk, step 3) replaces it, with its trustedClientIp(request) in place of the clientIp(h) below.

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.
  • Should forward the browser's 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. Forwarding every header, as the Next example does, is fine; Heedeo reads only its own _idd cookie.
  • Returns Heedeo's set-cookie and accept-ch.
  • Sets x-heedeo-client-ip to the end user's real IP, the same value your server later sends as client_ip, with authorization: Bearer <your secret key> beside it.
  • Never forwards the page's own authorization header.

The @heedeo/sdk proxy does all of this.

Heedeo believes x-heedeo-client-ip only from a server that sends the workspace's secret key (or from the Workers the Domains page sets up), since anyone could name any address. Without the key the header is ignored, the identification has no known network, and network_changed cannot fire for it.

Breaking (2026-11-01): a proxy that sends x-heedeo-client-ip without the secret key, including @heedeo/sdk 0.1.0-pilot.15 and earlier, no longer has its IP believed, and the Domains page shows a warning until it does. Upgrade to 0.1.0-pilot.16 or add the header.

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. The client IP comes from one function, which the server action in section 3 uses too:

// lib/client-ip.ts
// The end user's IP from the source your host documents as trusted. 127.0.0.1 is for development
// only (below).
export function clientIp(h: Headers): string | null {
  if (process.env.NODE_ENV === "development") return "127.0.0.1";
  // On Vercel, which sets x-forwarded-for itself: return h.get("x-forwarded-for")?.split(",")[0]?.trim() || null;
  return null;
}

Never fall back to 127.0.0.1 in production: every visitor would share one address. Return null until you know where your host puts the real IP. With null, the proxy answers 503, identify() rejects, and the page sends no token, so every signup is asked to verify (section 3) until it is set.

// proxy.ts
import { type NextRequest, NextResponse } from "next/server";
import { clientIp } from "./lib/client-ip";

const HEEDEO = "https://app.heedeo.com";

export function proxy(request: NextRequest) {
  const ip = clientIp(request.headers);
  if (!ip) return new NextResponse(null, { status: 503 }); // no trusted IP yet: no signals
  const headers = new Headers(request.headers);
  headers.set("x-heedeo-client-ip", ip);
  // Vouches for the IP above; replaces the page's own authorization, which never goes up.
  headers.set("authorization", `Bearer ${process.env.HEEDEO_SECRET_KEY}`);
  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:/, ""),
    authorization: `Bearer ${process.env.HEEDEO_SECRET_KEY}`, // vouches for the IP above
  };
  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, test in Chrome: Safari does not recognise a returning visitor there.

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 reads the browser so the first call is quick, and it keeps nothing. What it reads is the collection profile you choose under Settings, Advanced, Collection: every source by default, or the EU profile, which leaves out canvas, audio, fonts, speech voices, text and emoji measurements, media formats and the keyboard layout (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 }, or rejects within 3 seconds, so a handler that awaits it never holds a signup.
  • It also keeps the token in a first-party cookie for those five minutes. Send the token this call returned, not the cookie's.
  • Identify also sets Heedeo's own visitor cookie, _idd, which is HttpOnly.

Clearing cookies or opening a private window does not make a new person: Heedeo still recognises the browser, in most cases. Such a browser first gets a visitor_id of its own from identify(), because Heedeo is not certain of it yet. It is your event's answer that names the earlier visitor, in visitor.id, with both browsers in visitor.browsers. So the person's id is the event's visitor.id, not the visitor_id that identify() returns to the page.

Carry the receipt to your server in the form (a hidden field) or your JSON body. Send the one this call returned, and none when it returned none.

identify() can reject (an ad blocker, a network error) or window.heedeo can be missing (the script loads async). Never stop a signup in the browser for that: submit without a token. Your server still sends the event, and asks for verification (section 3).

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: submit anyway; the server still sends the event and asks for verification
  await signup(form); // your server action or fetch to your own endpoint
}

3. One server call: evaluate or log

On your server, at the action, send the receipt with the action's name to POST /v1/evaluations. It answers the evaluation: flag, the score and band, the reasons, decision and risks (section 4). POST /v1/events is the same call under its earlier name, and answers the same. The package has two methods for it:

PackageGives youUse it at
heedeo.evaluate()the evaluationan action you decide on now: a signup, a trial, a coupon, a payout
heedeo.logEvent()the event_id and notes (no token, an expired token, an unknown visitor, a once action sent with no identity)anything else you want in the person's history: a login, a page view, an action of your own

Both record the event and run it through the same scoring, so an event is flagged in Review, sent to your webhooks and counted toward the person the same whichever method sent it. Which one you use never changes what Heedeo flags; Heedeo's own rules do (below). Send each action once, by one of the two.

The action. Name it from this list, and Heedeo knows what it means:

actionWhat it isSend with it
signupan account is createdemail, and user_id when the account exists
logina sign-in to an existing accountuser_id
trial_starta free trial beginsuser_id
redeem:CODEa coupon or credit by its code: redeem:SPRING, redeem:welcome_creditsuser_id
payouta reward or referral is paid outuser_id

signup, trial_start, redeem:CODE and payout are things a person does once. Heedeo remembers what each person did for 13 months, across all of their browsers, and flags the same person doing one again under another email or account: a second free trial, a second welcome credit. The same action again under the same email or account is the same attempt, not a repeat: a signup refused for a taken username and sent again is one signup. Any other name, such as purchase or one of your own, is stored and counted, and judged on nothing: Heedeo flags it only when you blocked or reported the person.

Spell the five names as they are here. A near-miss of one (sign_up, SignUp, register, log_in, a redeem without its code) would be recorded as a name of your own and judged on nothing, so a workspace on the decision refuses it with a 400 naming the spelling to send, when the call sends heedeo-version: 2026-11-08 or later (the package from 0.1.0-pilot.18). An older call's names are taken as they always were.

One trial per person is then this one call, beside the uniqueness check on email or account your signup already has. You keep no table of trials and no column of visitor ids.

const { data } = await heedeo.evaluate({ request, token, action: "trial_start", email, userId });
// a second trial by the same person under another email is flagged; section 5's rules decide what happens

The rest of the call:

  • Required: action, client_ip and headers.
  • token, what identify() gave. When there is none, leave it out and still send the event (below).
  • email, and user_id once the account exists.
  • acting_for, when an agent acts for one of your users: that user's account id (the principal; actingFor in the package). It is hashed on arrival like user_id, never stored raw.
  • headers: the end user's request headers that Heedeo reads, never cookie or authorization. They are the user agent, the accepted types and languages, client hints, the fetch metadata headers, the agent-signature headers and your edge's country header; the helper below lists them, and the package sends the same. Add host, :method and :path of that request, which agent signatures (Web Bot Auth) cover, and :forwarded: the header names your code looks for, comma-separated, so Heedeo can tell a header the request lacked from one you do not send.
  • 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.
  • status, when you know how the action ended: succeeded, or failed when your server refused it or it errored. Leave it out and the event is attempted.

A retry under a new request_id, a double submit, or the form sent again after your server refused it (a taken username, a short password) is caught too when it is the same event. The same receipt, action, email, user_id, acting_for, client_ip and evidence while the receipt lives (five minutes) gets the first event's answer, event_id included, and records nothing. Anything else on a spent receipt is a replay, recorded and flagged receipt_reused. A replay is someone else on the receipt, so it is recorded as an unknown visitor's: its answer's visitor is null, and nothing of it (its email, its action, its flag, a verdict on it) is put on the person the receipt was made for. The one exception is the same person's next action, which is recorded as theirs and not flagged. Still make a new receipt for each action.

If there is no token, still send the event. Heedeo records it with no browser and no visitor, scores it on what needs none (the email and its domain, the network and your labels), and answers the note no_token. A missing token adds no weight on its own, since a blocker hides real people too; the reasons that need a browser (probable_match, the environment's, same_machine) cannot fire without one, and neither can the bursts and rates, which count new identities and visitors. From @heedeo/sdk 0.1.0-pilot.18 the package sends it; earlier packages sent nothing and answered missing_token.

Still, do not let the signup through on a missing token alone. Require a verification step your app has, such as a confirmed email or a CAPTCHA, before the account is created or the grant is made. A script that never loads your page sends no token, and so does a person whose blocker stopped the script. The same goes for any receipt that is not a usable one, since a script can send one it made up:

  • no token: no_token in reasons (and in logEvent()'s notes); a package before 0.1.0-pilot.18 answers error.code missing_token and sends nothing;
  • a receipt Heedeo refused: any 4xx but 401 and 403, such as 400 invalid_token (the package answers api_error with that status);
  • a receipt that names no visitor of yours: token_expired or unknown_visitor in reasons (a spent one answers receipt_reused, which flags).

Proceed without signals only when Heedeo itself is unavailable: a network error, a timeout, a 5xx after the retry, or a missing or wrong key (401 or 403). RULES.md's noReceipt (at https://app.heedeo.com/docs/rules) is this check in code.

The trade-off: when Heedeo is down, the script cannot load or identify() fails, so the page has no token either, and nothing the page sends can prove an outage, since a script could claim one. During a Heedeo outage your verification step runs for every signup. It is a verification step, never a refusal.

With the package (@heedeo/sdk, on Node.js 20.18 or later, the Next.js App Router and Cloudflare Workers). The package quickstart (https://app.heedeo.com/docs/sdk) sets up the client in lib/heedeo.ts, with its own clientIp option: a function that takes the request and returns the trusted IP (the quickstart's trustedClientIp; section 1's function takes headers, so pass (request) => clientIp(request.headers)). The package sends the right headers, the request_id, one retry within a 3 second budget and the API version, so do not write your own fetch where it runs. In a route handler, pass the request it received:

// app/api/signup/route.ts
const { data, error } = await heedeo.evaluate({ request, token, action: "signup", email });

In a Next.js server action there is no request. Pass the request's headers and the path of the page whose form calls the action. This needs @heedeo/sdk 0.1.0-pilot.17 or later:

// app/signup/actions.ts
"use server";
import { headers } from "next/headers";
import { heedeo } from "@/lib/heedeo"; // @/ is Next's default alias; use a relative path if your project has none

export async function signup(_prev: unknown, form: FormData) {
  const email = String(form.get("email") ?? "");
  // your own validation first
  const { data, error } = await heedeo.evaluate({
    headers: await headers(),
    path: "/signup", // the page whose form calls this action
    token: (form.get("heedeo_token") as string | null) || null,
    action: "signup",
    email,
  });
  if (error) console.warn("Heedeo", error.code); // never stops signup
  console.info("Heedeo signup", data?.event_id, data?.visitor?.id, data?.listed, JSON.stringify(data?.decision), JSON.stringify(data?.risks));
  // your plan's rule goes here (section 5), before the account is created
  // create the account
}

The method is POST unless you pass method. The package builds the request from these, so your clientIp function gets the same headers it gets in the proxy route.

Call Heedeo on every signup path

An abuser uses the path you forgot. Call Heedeo on every path that creates an account, before the account is created, and enforce the answer there:

  • your own signup form or route;
  • each OAuth and social sign-in (Google, Apple, GitHub and the like), at the call where your server first sees the new user. The receipt has to cross the trip to the provider and back, and it lives five minutes. Call identify() just before the handoff and send the token to your own server, which keeps it with the OAuth state it is about to send; the callback validates state and reads the receipt back from there. That works for every provider. Reading the _idt cookie in the callback instead (Path=/, SameSite=Lax) works only for a provider that comes back with a top-level GET. Sign in with Apple comes back with a cross-site form POST (response_mode=form_post) whenever it asks for the name or email, and a browser sends no SameSite=Lax cookie on that. On the cookie route, clear it first (document.cookie = "_idt=; Max-Age=0; Path=/"): identify() clears it itself, but a script that never loaded leaves the last spent one, which flags as receipt_reused. Either way, a person who spends more than five minutes at the provider comes back with an expired receipt (token_expired), and is asked to verify;
  • any API route, mobile backend or invite flow that creates an account;
  • a hosted sign-in, at the first call your server receives and at the grant (below).

Each one sends signup, or the grant's own name, and runs your rule on the answer before it creates or gives anything. A path with no browser page, or one where the page sent no token, still sends the event and still needs its check: require verification there, as above.

A single-page app, a hosted sign-in and a separate API

The shape: your pages are static files (S3 and CloudFront, Netlify, any CDN) where every path answers index.html; sign-in is a hosted provider called from the browser (Supabase Auth, Clerk, Auth0, Firebase); your own server is an API on another origin of the same site, such as api.example.com for pages on app.example.com. Your server never sees a signup form, and there is nowhere on the pages' origin for a proxy route. Four things change; everything else in this guide holds.

1. Serve Heedeo from the API's origin. Mount the package's proxy on the API and pass its address as endpoint (http is accepted only on localhost and *.localhost, from @heedeo/sdk 0.1.0-pilot.17). The route must sit under the API's own credentialed CORS for your pages' origin: Access-Control-Allow-Origin set to your page's origin, never *, and Access-Control-Allow-Credentials: true. The script posts identify as text/plain with credentials, so there is no preflight and no extra allowed header. The browser reads the answer only with those two headers, and the proxy does not add them itself. In Hono:

import { Heedeo } from "@heedeo/sdk";

const heedeo = new Heedeo({ secretKey: process.env.HEEDEO_SECRET_KEY, clientIp });
app.use("*", cors({ origin: APP_ORIGIN, credentials: true /* your existing settings */ }));
app.all("/hd/*", (c) => heedeo.proxy(c.req.raw)); // after the CORS middleware
// In the SPA
import { createHeedeo } from "@heedeo/sdk/browser";

export const heedeo = createHeedeo({
  publishableKey: import.meta.env.VITE_HEEDEO_KEY,
  endpoint: `${import.meta.env.VITE_API_URL}/hd`,
});

A rule on the pages' CDN that forwards /hd/* to the API (a CloudFront behavior with caching off, forwarding all headers, cookies and query strings) also works, with the default path in place of endpoint; the proxy's requests then reach the API through the CDN, so clientIp must read the CDN's viewer header for them (CloudFront's CloudFront-Viewer-Address) while the API's own calls come direct. A hostname from the Domains page (id.example.com) works too, with no route; the Cloudflare way works only for a host whose DNS record Cloudflare proxies, so not for pages on a DNS-only CNAME to another CDN.

Recommended: Heedeo recognises returning visitors more reliably when it is served from the same domain as your pages. The CDN rule above does that. The route on the API is the quicker start; move to the rule when you can.

2. Send the token in the call's JSON body. A custom header would need to be added to the API's allowed CORS headers; the body needs nothing.

3. Send the event at the calls your server does see. There is no signup handler, so there are two places:

  • The first call to your API after sign-in (often a session exchange). Send signup when the API sees the user for the first time and login after that, with the email, userId (the provider's user id) and evidence: { email: { verified } } as the provider reports it (false is fine). This records the person; the account already exists at the provider, so there is nothing to refuse here.
  • The call that grants what an abuser wants: a trial start, credits, a coupon. Send its own name from the list, such as trial_start, or redeem:CODE for credits or a coupon, with the same email, userId and evidence. Your plan's rule (section 5) runs here: refuse the grant, not the account, with any status your page handles (403 is fine) and one generic message.
const { data, error } = await heedeo.evaluate({
  request: c.req.raw,
  token: body.heedeo_token,
  action: "trial_start",
  email: account.email,
  userId: account.id,
  evidence: { email: { verified: account.email_verified } },
});

Each event takes its own identify() token, made just before that call.

4. Identify on both sign-in paths. Email and password: the confirmation link returns to your page with a code the provider's library exchanges; once the session exists, identify, then call the API. OAuth (Google and the like) never submits your form: identify on the page the redirect returns to, after the session exists and before the API call. The confirmation link may be opened in another browser than the one that signed up (a phone's mail app); with PKCE that browser cannot finish the sign-in and the person signs in again there, so the first event comes from whichever browser signs in. The browser that only filled in the form sends nothing.

What a refusal looks like at a trial start: under watch, start the trial and log; under score and rules, start no trial, keep the account, and show one generic message ("We could not start a free trial on this account. Contact support."), or offer a paid start or a card in place of the free trial if your product has one. A refused trial still counts as the person's trial (After a refusal, section 5).

Without the package, on a stack it does not run on, send the call yourself. This helper does what the package does: the same headers, never cookie or authorization, one request_id per attempt, one retry within 3 seconds, and the API version. It answers as the package does, { data, error } with the package's error codes, so RULES.md's noReceipt (https://app.heedeo.com/docs/rules), copied beside it, reads both the same way:

// heedeo.js: only where @heedeo/sdk does not run
const HEEDEO = "https://app.heedeo.com";
// The request headers Heedeo reads, as the package sends them.
const FORWARD = ["user-agent", "accept", "accept-language", "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest",
  "upgrade", "signature", "signature-input", "signature-agent", "content-digest",
  "sec-ch-ua", "sec-ch-ua-platform", "sec-ch-ua-mobile", "cf-ipcountry", "x-vercel-ip-country"];
const failed = (code, status) => ({ data: null, error: { code, status } });

// get(name): one of the end user's request headers; host, method and path: that request's.
export async function heedeoEvent({ token, action, email, userId, clientIp, get, host, method, path }) {
  if (!clientIp) return failed("missing_client_ip"); // no trusted IP: no signals
  const headers = { host, ":method": method, ":path": path, ":forwarded": FORWARD.join(",") };
  for (const name of FORWARD) {
    const value = get(name);
    if (value != null) headers[name] = String(value);
  }
  const body = JSON.stringify({
    token: token || undefined, // none is still sent: the answer carries the note no_token
    action, email, user_id: userId, client_ip: clientIp, headers,
    request_id: crypto.randomUUID(), // one per attempt: the retry below reuses it
  });
  const signal = AbortSignal.timeout(3000); // both attempts together, as the package
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const res = await fetch(`${HEEDEO}/v1/evaluations`, {
        method: "POST",
        headers: {
          authorization: `Bearer ${process.env.HEEDEO_SECRET_KEY}`,
          "content-type": "application/json",
          "heedeo-version": "2026-10-31",
        },
        body,
        signal,
      });
      if (res.ok) return { data: await res.json(), error: null };
      console.error("heedeo", res.status, await res.text());
      if (res.status === 401 || res.status === 403) return failed("invalid_key", res.status);
      if (res.status < 500 || attempt === 2) return failed("api_error", res.status); // a 4xx is not retried
    } catch (err) {
      console.error("heedeo", attempt, err); // a timeout or a network error
      if (signal.aborted) return failed("timeout_or_aborted");
    }
  }
  return failed("network_error"); // Heedeo unavailable: no signals
}

// RULES.md's, as it is there: no usable receipt asks for verification; Heedeo unavailable does not.
export function noReceipt(data, error) {
  if (error) return error.code === 'missing_token' || (error.code === 'api_error' && error.status < 500);
  return data.reasons.some((r) => ['no_token', 'token_expired', 'unknown_visitor'].includes(r.code));
}

Express, with the helper:

app.post("/signup", express.urlencoded({ extended: false }), async (req, res) => {
  const { data, error } = await heedeoEvent({
    token: req.body.heedeo_token,
    action: "signup", email: req.body.email,
    clientIp: req.ip.replace(/^::ffff:/, ""), // with app.set("trust proxy", ...) set for your host
    get: (name) => req.get(name), host: req.get("host"), method: req.method, path: req.originalUrl,
  });
  // Your plan's rule (section 5) goes here, and may refuse. Then, unless the person is Trusted:
  if (data?.listed !== "allow" && noReceipt(data, error))
    return askForVerification(req, res); // your app's own: a confirmed email or a CAPTCHA
  // create the account
});

Next.js server action, with the helper, for a project that cannot use the package. clientIp is section 1's function; adjust the import paths to your layout:

"use server";
import { headers } from "next/headers";
import { heedeoEvent, noReceipt } from "@/heedeo";
import { clientIp } from "@/lib/client-ip";

export async function signup(form: FormData) {
  const incoming = await headers();
  const { data, error } = await heedeoEvent({
    token: form.get("heedeo_token") as string | null,
    action: "signup", email: String(form.get("email") ?? ""),
    clientIp: clientIp(incoming), // null outside development until you know your host's source: no event
    get: (name: string) => incoming.get(name), host: incoming.get("host"), method: "POST",
    path: "/signup", // the path of the page whose form calls this action
  });
  console.info("Heedeo signup", data?.event_id, data?.listed, JSON.stringify(data?.decision), JSON.stringify(data?.risks));
  // Your plan's rule (section 5) goes here, and may refuse. Then, unless the person is Trusted:
  if (data?.listed !== "allow" && noReceipt(data, error)) return { ok: false, verify: true }; // to your verification step
  // create the account
  return { ok: true };
}

The helper is plain JS; in a strict TypeScript project name them .ts and type data 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.

API version. Every call to /v1/evaluations, /v1/events and /v1/visitors reads in a dated API version: the heedeo-version header (heedeo-version: 2026-10-31), else the day your workspace was made, as Stripe pins an account's version at signup. A new workspace gets the latest answers with no setup, and an older one keeps the shape its integration was written against until it sends a later date; the package sends its own from @heedeo/sdk 0.1.0-pilot.15, and the helper above sends it too. 2026-10-31 adds same_machine to related[].link (section 4); an earlier version answers the same event without those entries, its reasons unchanged.

Errors come as { "error": { "code", "message" } }:

  • 401 for a missing or wrong key.
  • 400 invalid_request or invalid_token for a malformed call.
  • 409 invalid_request for a request_id that already answered another event (another action, or another receipt). A request_id is one attempt at one action, and its stored answer is never given to a different event.
  • 503 busy when another event on the same receipt arrived at the same moment. Send the call again with the same request_id (the package's helper does).
  • 413 too_large for a body over 64 KB (/v1/evaluations, /v1/events, /v1/lists, /v1/outcomes and identify's request).
  • 429 rate_limited from identify, with a Retry-After, past 120 calls a minute from one end user's address or 6,000 a minute for one workspace. identify() then rejects, and your page sends no token, so your server asks for verification (section 3).

An expired receipt is not an error: you get a 200 with visitor: null and the note token_expired. No receipt is not one either: a 200 with visitor: null and the note no_token.

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 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: mobile, landline, fixed_voip, non_fixed_voip, toll_free, prepaid or unknown. You may send phone.hash, your own hash of the number, instead of number: 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. Signing in with a provider never lowers a score: it proves someone holds an account there, not that they are one real person. Its email_verified proves the address (below) and private_relay is relay_email; the other fields are stored and add no weight.
  • 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. 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).

Send email.verified (or oauth.email_verified, an OAuth provider's word) on every event where you know the address was verified (a confirmed link, a code, an OAuth provider's verified email), so that a person's laptop and phone are known as one person. An address that was only typed counts for less, because anyone can type a stranger's address.

What Heedeo makes of it, each a reason in reasons (section 4):

  • Suspicion, on the event that brought the evidence: voip_phone a non-fixed VoIP or toll-free number; prepaid_card a prepaid card; verification_failed a check you ran failed, its kind in the text; email_unverified once you have sent email.verified for a day, an address new to the browser that no event verified; card_mismatch the card's country is not the country of the event's address; relay_email the sign-in provider's private relay address.
  • Trust, whenever it holds: verified_id a passed id_document or liveness check; paid_history plan paid with paid_since more than 30 days ago and no abuse report; wallet_card Apple Pay, Google Pay, Link or a passed 3-D Secure; legit_outcome you reported the visitor, one of its events or its account as legit; verified_phone a mobile line verified by you or by a passed otp; card_on_file a card that passed its CVC check; tenure an account created more than 90 days ago. A prepaid card earns no card trust.
  • Shared phones and cards: the same phone or card on two of your accounts is suspicion (related[].link shared_phone or shared_card). One person with two phones or 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, and an address's age from breach history when you turn it on, and a vendor's score belongs in your own rule beside Heedeo's answer, not inside it.

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. Heedeo asks the public domain registries when the email's domain was registered. A domain registered under 30 days ago is young_domain, on the event that adds the address to the browser: throwaway domains are bought and used at once. One registered 30 to 90 days ago is recent_domain, as a domain bought for a farm is often left a few weeks first; it is recorded, and weighs nothing until you set its weight under Settings, Advanced, Weights. When the registry cannot say, the answer says email.domain_age_unknown: true and nothing fires. A free provider's domain is never looked up, nor a disposable one, which is already disposable_email.
  • 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 address names a function, not a person (admin, info, billing, support, sales, contact, hello, team and the like); on a signup it is role_address, except from a declared or verified agent.
  • Breach history, off by default. Turn it on under Settings, Advanced, Weights, Check breach history. Heedeo then sends each new address to Have I Been Pwned, a third party, once, on the event that adds the address to the browser, to see whether it appeared in a known data breach; the address is not stored. An address in a breach more than two years old has existed that long: email_tenure, a trust reason. One in no breach is email_never_seen on the event that adds it: a new address, or a careful person. It works only where Heedeo's server holds a Have I Been Pwned key. Mention it in your privacy policy if you turn it on (section 7, For your privacy policy).

Deprecated: earlier integrations send once with the call. It is accepted and ignored: the action's name says it.

4. The fields to act on

The answer to a signup that Heedeo flagged: the same person signed up a second time, under another email, from the same browser.

{ "flag": true,
  "score": 150,
  "threshold": 100,
  "thresholds": { "verify": 60, "review": 100, "block": null, "action": null },
  "suggested": "review",
  "decision": { "flag": true, "band": "review", "score": 150, "risk": "account_abuse", "by": "risk", "judged": ["account_abuse"] },
  "risks": { "account_abuse": { "score": 150, "judged": true,
                                "reasons": [{ "code": "repeat_action", "weight": 100 }, { "code": "many_identities", "weight": 50 }] } },
  "version": null,
  "reasons": [{ "code": "repeat_action", "text": "signup already done by this visitor on 7 Nov from this browser", "weight": 100, "base": 100 },
              { "code": "many_identities", "text": "This event raised the visitor's identities to 2; the threshold is 3", "weight": 50, "base": 50 }],
  "visitor": { "id": "613d…", "confidence": 1, "first_seen": "…", "last_seen": "…",
               "emails": { "24h": 2, "7d": 2, "all": 2 }, "accounts": { "24h": 0, "7d": 0, "all": 0 },
               "addresses": { "1h": 1, "24h": 1, "7d": 1 }, "countries": { "1h": 1, "24h": 1, "7d": 1 },
               "browsers": [{ "id": "613d…", "kind": "stored", "confidence": 1, "merged_by": "this browser", "evidence": {}, "first_seen": "…", "last_seen": "…" }],
               "aliases": [],
               "actions": { "signup": { "count": 2, "first": "2026-11-07T18:10:31Z", "last": "…" } } },
  "related": [],
  "account": null,
  "counts": { "24h": 2, "7d": 2 },
  "activity": { "last_hour": 2, "hourly_norm": 0.4, "burst": false },
  "listed": null,
  "email": { "disposable": false, "alias_of_existing": false, "pattern": false, "free_provider": true, "role_address": false,
             "young_domain": false, "domain_registered": null, "domain_age_unknown": false, "breach": null },
  "network": { "link_weight": 0.5, "datacenter": false, "vpn": false, "vpn_confidence": null, "vpn_service": null,
               "residential_proxy": false, "residential_proxy_confidence": null, "proxy_services": [], "proxy": false, "tor": false,
               "private_relay": false, "anonymous": false, "network_type": "isp", "asn": "AS7922", "devices": 3, "risks": [],
               "country": "US", "city": "Boston", "region": "Massachusetts", "timezone_mismatch": false },
  "agent": null,
  "environment": { "tampered": false, "tamper": [], "tampered_confidence": null, "anti_detect": null, "virtual_machine": false, "vm": [],
                   "virtual_machine_confidence": null, "private_mode": false, "rarity": 1, "connection": null,
                   "automation_framework": null, "interaction": { "pointer": 41, "key": 52, "touch": 0 } },
  "event_id": "79c7…" }

flag and decision. flag is whether to stop this action. decision says why, in the same six fields on every event: flag; band, which your thresholds under Settings make of score (allow, verify, review from the flag line, block once you set one); score; risk, the question that reached your flag line; by, what decided: risk (Heedeo's answer to that question), label (your Block label on the person, their email or their network) report (your abuse report on the person or an account they used) or retry (a once action tried again by the same person under the email or account of an earlier attempt, which keeps at least that attempt's band until you resolve it as legit), and null when nothing flagged; and judged, the questions Heedeo judges this action on: account_abuse for the once actions of section 3, none for a login or a name of your own. That is Heedeo's rule, read from the action's name; nothing in the call chooses it. A flag can also appear later on an earlier signup, when Heedeo learns that two people are one, and it goes away again (withdrawn, section 12) if that turns out wrong.

risks. What Heedeo answers to each question about the person. There is one today, account_abuse: one person making more than one account. Each has its score, judged (whether Heedeo judges this action on it) and its reasons, each with the weight it contributed. Every question is scored on every event: on a login, risks.account_abuse still says what a signup would have answered, with judged: false.

The rest of the decision:

  • score and suggested: the decision's score and band, at the top level as integrations read them.
  • threshold and thresholds: your flag line and your Verify, Flag and Block lines under Settings, Advanced (60, 100 and none by default). An action can have its own three (Settings, Advanced, Actions): then thresholds.action names it.
  • version: when your weights last changed, or null while you use the defaults (Settings, Advanced, Weights; section 9).
  • reasons: every reason that fired, each with one line of text for you, never for the end user, its weight, what it contributed to this action (0 on an action judged on nothing), and base, the same at the default weights. Every code, and the three notes that ride in reasons with weight 0 (no_token, token_expired, unknown_visitor, no_identity), is in the table under Reasons and default weights, below.
  • listed: your own label for this visitor, email or network: "block", "allow" or null.

Heedeo enforces nothing: your code reads the answer and decides (section 5).

The visitor (visitor is null without a usable receipt):

  • 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. An id you stored earlier is the visitor's id or one of its aliases (section 7, Merges, has the undo).
  • visitor.confidence: how sure Heedeo is that every browser in visitor.browsers is this person: 1 when it is certain.
  • visitor.first_seen and last_seen: when Heedeo first and last saw the visitor.
  • visitor.browsers: the person's browsers, the oldest first, each with the fields in the table after this list. 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 on its next lookup, or at once from a visitor.merged webhook (section 12).
  • 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).
  • 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. At signup, before a user id exists, emails is the one to use.
  • visitor.addresses and visitor.countries: how many different network addresses and countries this visitor's browsers came from in the last hour, day and week (1h, 24h, 7d), this event included. Mobile networks, Private Relay and Tor are not counted: many people share them. Home, work and a café are three addresses in a day; many in an hour, or several countries in a day, deserves a look.
  • The principal (acting_for): the people a declared or verified agent acts for are expected, and do not count as its accounts. An anonymous browser acting for many people is multi-accounting under another name, and they count.

Each browser in visitor.browsers has:

FieldWhat it holds
idthe browser's id
kindstored (this browser itself, known for certain), declared (joined by a shared identity) or matched (joined by a device match)
confidence1 for stored and declared, the match's confidence for matched
merged_bywhat placed it, in words a support person can read: "this browser", "same email", "same account", "device match 0.95"
evidencethe same without words: {} for the browser itself, 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_seenwhen Heedeo first and last saw the browser

Around the visitor:

  • counts: this browser's events of this action, over 24h and 7d.
  • related: browsers of other visitors related to this visitor's browsers, one entry per link, the strongest first. Each has visitor_id (the visitor that browser is in now), link, weight (how much the link proves, from 0 to 1) and the other browser's own identities. The visitor's own browsers are in visitor.browsers. link is one of the values in the table after this list.
  • account, when you sent a user_id, the facts for a login rule (section 5): 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. They are facts for your rule, never a flag. null without a user_id.
  • email, what Heedeo read from the address (Email depth, in section 3), with the fields in the table after this list.
  • network, what Heedeo knows of the event's address, with the fields in the table after this list.
  • agent, null when nothing says an agent or a program sent the request: operator (the operator the agent names or proves), verified (it signed with Web Bot Auth, or your edge verified it), declared (what its user agent claims), operator_network (the address is in an agent operator's network), automation (the browser shows automation), http_client and missing_headers. A headless or automated browser shows in agent.automation, and is the automation reason only when no agent is declared or verified.
  • agent.http_client: the HTTP library, tool or crawler the request's user agent names, such as curl, python-requests or googlebot, or null. A name means a program, not a person's browser, sent the request.
  • agent.missing_headers: the headers every browser sends that this request lacked, such as ["accept-language"]; [] when none was missing. null when Heedeo cannot tell, as from @heedeo/sdk before 0.1.0-pilot.17 or a call without :forwarded (section 3). A non-empty list means the request most likely did not come from a person's browser.
  • Your own server-to-server calls, your mobile app, and a CDN or proxy in front of your server that does not pass the browser's headers through can show in either field, so check for those before a rule refuses on either. Neither field is a reason; use them in your own rule.
  • environment, what the identification's browser showed of itself, null without an identification, with the fields in the table after this list.
  • 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.

The values of related[].link:

linkWhat it means
shared_account or shared_emailthe two presented the same account id or email. An email either of the two only typed, never verified, is a weaker link.
shared_phone or shared_cardthe two presented the same phone or card in evidence.
shared_principalthe two acted for the same person: an agent's acting_for, or the person's own browser presenting that account id.
probableHeedeo found one a probable match of the other, not sure enough to make them one visitor; weight is the match's confidence.
same_machinetwo browsers on one computer, such as an agent's headless browser beside the person's own. It never makes the two one visitor.
ipthe two were seen on the same network address recently. A weak link: as often a stranger as the person.

The fields of email:

FieldWhat it holds
disposable, alias_of_existing and patterntrue when disposable_email, alias_of_existing or email_pattern fired
free_providerthe domain is a large free mail provider's
role_addressthe address names a role, such as admin@, info@ or billing@
young_domainthe reason fired
domain_registeredwhen the email's domain was registered; null for a free provider's, which is never asked, or when the registry could not say
domain_age_unknowntrue when the registry was asked and could not say; never a reason
breachHave I Been Pwned's earliest breach holding the address as { "first": "2013-10-04" }, first null in none; null while breach history is off

The fields of network. A field Heedeo could not learn for this address is null:

FieldWhat it holds
link_weightwhat sharing this address proves about two browsers, from 0 to 1. 0 on a mobile network, Private Relay, Tor, an address many devices share and a network Heedeo could not name, where sharing it says nothing
datacenterthe address belongs to a cloud or hosting provider, not to a home, office or mobile line
vpnthe address is a VPN's. It can be true where the vpn reason did not fire
vpn_confidencehow sure Heedeo is of vpn: high or medium (below); null unless vpn is true
vpn_servicethe VPN service by name, such as MULLVAD_VPN, when known
residential_proxythe address is someone's home connection rented out as a proxy, so the person using it is somewhere else
residential_proxy_confidencehow sure Heedeo is of residential_proxy: high, medium or low (below); null unless it is true
proxy_servicesthe proxy services seen on the address, such as OXYLABS_PROXY; [] when none
proxy, tor and private_relaythe address is a proxy, a Tor exit, or Apple's iCloud Private Relay. Private Relay is how many honest Safari users browse, and is never a reason
anonymoustrue when the tor, vpn, proxy or anonymizer reason fired, so false on Private Relay and on mobile networks
network_typeisp, business or hosting
asnthe network's number, such as AS16509
deviceshow many devices recently used the address. Many means a shared address: an office, a campus, a mobile network or a proxy
riskswhat the address has recently been used for that calls for care, such as TUNNEL, CALLBACK_PROXY or LOGIN_BRUTEFORCE; [] when none
countrythe country the address is in, as its ISO 3166-1 alpha-2 code such as US: IPinfo's, or your edge's country header (cf-ipcountry, x-vercel-ip-country) when IPinfo cannot place the address; null when neither says
city and regionwhere the address is, as named places; never coordinates
timezone_mismatchthe browser's time zone differs from the address's. A traveller's and a wrong clock differ too, so it is a hint. null when either is unknown

A confidence says how far to trust its flag in a rule of your own. Act on high. Treat medium as worth a verification step or a look beside other signals. Treat low as a hint, not enough to refuse anyone.

The fields of environment:

FieldWhat it holds
tamperedthe browser's own readings disagree, on the checks behind the browser_tampered reason; null from an older script. tamper lists every disagreement found
tampered_confidencehow sure Heedeo is of tampered: high, medium or low, read as network's are; null unless it is true
anti_detecttrue when the browser disguises what it is, the way anti-detect browsers and stealth tools do; tamper names what gave it away. Privacy browsers such as Brave and Tor Browser change some readings too, and read null. null when nothing showed, which does not prove an honest browser; never false. A name only this check finds moves no score yet, so tampered can be false while anti_detect is true
virtual_machinethe browser runs in a virtual machine, with vm, what showed it
virtual_machine_confidencehow sure Heedeo is of virtual_machine: high or medium; null unless it is true
private_modea private window; null when it cannot be told
rarityhow many of your visitors in the last 30 days have a device that looks like this one; a low number is a rare device
connectionhow the browser connected: tls (such as TLSv1.3), cipher, protocol (such as HTTP/2) and rtt_ms, the round trip in milliseconds, each null when not known. null unless Heedeo is served through the Domains page, and through Cloudflare until you apply the Worker's update there. Moves no score
automation_frameworktrue when an automation tool was controlling the browser, or its developer tools were open: a developer testing your site shows here too. null when nothing showed either, which does not prove a person was there. Moves no score
interactionhow often the person moved or clicked the pointer (pointer), pressed a key (key) and touched the screen (touch) on the page before you called identify(), each counted up to 1000; never which keys or where. All three 0 on a signup means nobody used the page: a script sent it, or the form filled and sent itself. An automation tool can type and click as a person does, so counts above 0 do not prove a person. null from an older script. Moves no score

Testing: test traffic flags. Check what your rule does, not flag. Section 5, Test your rule, says what each test signup answers and why local tests score higher than real ones.

Reasons and default weights

Every reason, with its default as your Settings, Advanced, Weights table shows it before you accept your own. A trust reason speaks for the person and shows negative in reasons. A reason whose default is 0 is recorded: it shows in reasons wherever it fires, so you can see what it would catch, and decides nothing. A note says something about the call itself and decides nothing. The evidence reasons and how to send their evidence are under Evidence in section 3.

ReasonDefaultWhat it means
listed_block100Strong; flags alone. You labelled this visitor, email or network block.
receipt_reused100Strong; flags alone. The receipt was already spent by an earlier, different event: a replay, answered with visitor null. The same event sent again gets the first answer back and is not this reason.
disposable_email100Strong; flags alone. The address is at a disposable email provider.
no_mx100Strong; flags alone. The email's domain cannot receive mail, so no one reads the address. The honest case is a typo in the domain.
alias_of_existing100Strong; flags alone. The email is an alias, such as a dot or plus-tag spelling, of an address another of your visitors already holds. The person's own address typed another way (johndoe@ then john.doe@) is never this reason.
many_identities100This visitor has used more than one email or account. A person's own new device brings their identities with it and adds none.
probable_match100This visitor is probably the same person as another of your visitors, who holds other emails or accounts.
related_identities100Another of your visitors, who holds other emails or accounts, is related to this one by a shared account, email, principal, machine or address. It appears only on an event that adds an identity or a new link; GET /v1/visitors shows the standing evidence.
confirmed_abuse100You reported this visitor, or an account it has presented or presents now (by user_id), as abuse, or a visitor related to this one. Your report on this person flags any action of theirs.
repeat_action100This person already did this signup, trial_start, redeem:CODE or payout under another email or account; the text says when, and from which browser. Below 100 when Heedeo is likely, not certain, it is the same person.
account_browsers80A fact for your login rule (account.visitors, section 5), not a sign of abuse: many browsers have presented this account lately, as when a login is shared or sold. It flags nothing on its own.
datacenter80Abnormal pattern. The address belongs to a cloud provider, not to a home, office or mobile line.
network_burst80Unusually many new identities are arriving from this network. An honest person on a network where a farm is signing up is in the same crowd, so it never flags alone.
email_pattern80Unusually many addresses that look generated from one pattern, such as 1001@ and 1002@ at one domain, are arriving. It never flags alone.
domain_burst80Recorded, and weighs 0 on the default weights until it is measured: set its weight under Settings, Advanced, Weights to score it. Unusually many new identities with addresses at one email domain are arriving, against that domain's own hourly norm, as when a farm buys a domain and signs up on it from fresh browsers. A free provider's domain and a disposable one are never counted, and a company's people signing up through the day stay under the norm. It never flags alone.
young_domain80Abnormal pattern. The email's domain was registered under 30 days ago (Email depth in section 3).
recent_domain50Recorded, and weighs 0 on the default weights until it is measured: set its weight under Settings, Advanced, Weights to score it. The email's domain was registered 30 to 90 days ago (Email depth in section 3).
operator_burst80Unusually many visitors from one agent operator are arriving at once. It never flags alone.
action_rate80This visitor takes the action much more often than your typical visitor.
declared_agent20An unverified agent claim: the browser says it is an agent and does not prove it.
tor25Privacy network. The network hides the person's own address.
vpn20Privacy network. The network hides the person's own address.
proxy20Privacy network. The network hides the person's own address.
anonymizer20Privacy network. The network hides the person's own address.
hosting20Privacy network. The address is in a hosting network, as most VPN exits are.
network_changed20Privacy network. The browser was identified from one network and the event came from another.
residential_proxy0Recorded, not scored yet. Privacy network. The address is a residential proxy (network.residential_proxy), so the person using it is somewhere else.
address_rate0Recorded, not scored yet. The visitor came from many different addresses in an hour or a day (visitor.addresses).
account_new_browser20A fact for your login rule (account.new_browser, section 5), not a sign of abuse: this browser is new to an account another browser already presented. It flags nothing on its own.
role_address20Weak tell, signups only. A signup with a role address (admin@, info@, billing@, support@, sales@ and the like) rather than a person's. Never for a declared or verified agent.
email_never_seen20Weak tell, breach history on. The address appears in no known data breach, as a newly made address often does.
browser_tampered80Record-only: reported at weight 0 until you turn its scoring on under Settings, Advanced, Weights (we suggest watching it for two weeks first). The browser misreports what it is; environment.tamper names what disagreed. Only the checks behind tampered count; a name only anti_detect finds moves no score.
virtual_machine25Weak tell. The browser runs in a virtual machine. Never for a declared or verified agent, whose operator runs it in a cloud.
private_mode10Weak tell, with a new identity only. A private window added a new email or account; a private window alone is ordinary, and says nothing.
automation10Weak tell. The browser is automated and does not say it is an agent; an agent that says what it is gets no reason.
voip_phone80Evidence. A non-fixed VoIP or toll-free number.
prepaid_card80Evidence. A prepaid card.
verification_failed40Evidence. A check you ran failed, its kind in the text.
email_unverified20Evidence. Once you have sent email.verified for a day, an address new to the browser that no event verified.
card_mismatch20Evidence. The card's country is not the country of the event's address.
relay_email10Evidence. The sign-in provider's private relay address.
verified_id100Trust, subtracted. A passed id_document or liveness check.
paid_history80Trust. plan paid with paid_since more than 30 days ago and no abuse report.
wallet_card60Trust. Apple Pay, Google Pay, Link or a passed 3-D Secure.
legit_outcome60Trust. You reported the visitor, one of its events or its account as legit.
verified_phone40Trust. A mobile line verified by you or by a passed otp.
card_on_file40Trust. A card that passed its CVC check.
tenure40Trust. An account created more than 90 days ago.
email_tenure20Trust, breach history on. The address was in a breach more than two years ago, so it has existed that long.
no_tokennoteThe event came with no receipt, so the answer has no visitor or browser facts. It weighs nothing: the email, network and label reasons still apply; the bursts and rates, which count visitors, do not.
token_expirednoteThe receipt had expired, so the answer has no visitor facts.
unknown_visitornoteThe receipt names no visitor of your workspace, so the answer has no visitor facts.
no_identitynoteA signup, trial_start, redeem:CODE or payout came with neither email nor user_id, so Heedeo could not count the person's accounts. Send one.

5. Decide: score, rules or watch

Heedeo enforces nothing. Your code decides what happens at signup, and you choose one of three paths. Your agent asks which before it edits anything, and you can change it at any time.

PathThe choiceWhat your code does at signupFor whom
scoreLet Heedeo decide (recommended)Refuses when Heedeo flags the signup (flag).You want Heedeo to weigh every signal, and to tune the line in Settings without a deploy.
rulesSet my own rulesRefuses on rules written for your app.You know your users, and want the final call on each rule.
watchJust watchRecords the signup, refuses no one, logs what Heedeo answered.You want to see what Heedeo flags before you act on it.

All three do the same around the rule:

  • Send the signup event as signup, and a trial, credits or a coupon under its own name (section 3). The name is all Heedeo needs to know a person does it once, so no path says so on the call.
  • Send it with evaluate() after your own validation and before the account is created, so a rule can stop the account. With a hosted sign-in the account exists before your server hears of it: the rule runs at the call that grants the trial or credits instead (section 3, A single-page app, a hosted sign-in and a separate API).
  • Log one line per signup on your server: the event_id, the visitor's id, whether you refused, listed, and decision and risks as JSON. decision and risks explain what Heedeo answered; the event_id is how we find the event together.
  • When Heedeo is unavailable (a network error, a timeout, a 5xx, a missing key), data is null. Carry on as if Heedeo were not there. That is never a refusal. With no usable receipt (none, one Heedeo refused, or one that names no visitor of yours), require verification, such as a confirmed email or a CAPTCHA, rather than letting the signup through (section 3).
  • When you refuse, create nothing and show one generic message, such as "We could not create your account. Contact support." Never show a reason code, its text or the score.

Let Heedeo decide

Refuse a signup when Heedeo flags it:

const refuse =
  data != null && data.listed !== "allow" && data.flag;

flag is true when the signup reached your flag line, or when you blocked or reported the person. The flag line is How strict under Settings: Relaxed, Balanced (the default) or Strict. Moving it moves this rule with no deploy. A few real people on unusual setups will be refused; a rule on the clear signals refuses fewer.

A login or a name of your own is judged on nothing (section 3), so its score is 0 by design. If your app already has an email or phone verification step, require it when suggested is verify; do not add one for Heedeo. Signup, login and an in-app action can each have their own three lines (Settings, Advanced, Actions), and what each reason counts for is yours too (Settings, Advanced, Weights, or a recommendation fitted on your own outcomes, section 9).

At login

Heedeo does not judge a sign-in: a person's own laptop, phone and work desktop are normal. Send login with the user_id: by logEvent() when you put no rule on it, or by evaluate() to put your own rule on the account's facts:

// A new browser on this account: ask for the second step your app already has.
const challenge = data != null && data.account?.new_browser === true;
// Many browsers on one account in a day, as when a login is shared or sold: look at it.
const shared = data != null && data.account != null && data.account.visitors["24h"] >= 5;

flag on a login is still true when you blocked or reported the person, so a rule that refuses on it refuses only them.

Set my own rules

Your agent suggests rules for your app in plain words, and writes only the ones you agree to. It works from the rules guide at https://app.heedeo.com/docs/rules: what to look for in your code, how strong each signal is, the shape of a rule and a rule to start from.

The clear signals are the ones a rule can refuse on alone, each at full weight:

// The signals this app refuses on. Each must fire at full weight (100, read from base, so it holds
// whatever weights the workspace sets): a repeat or an abuse report that rests on a weaker link,
// such as a similar device or a shared address, passes.
const REFUSE_ON = new Set(["repeat_action", "disposable_email", "no_mx", "listed_block", "confirmed_abuse", "receipt_reused"]);
const refuse =
  data != null && data.listed !== "allow" && data.reasons.some((r) => REFUSE_ON.has(r.code) && (r.base ?? r.weight) >= 100);

These are the clear signals:

SignalWhy it is hard to get wrongWhat a false positive looks like
repeat_actionAt full weight Heedeo is certain the same person did this signup, trial_start, redeem:CODE or payout before, under another email.One person who really needs two accounts, such as work and personal, from one browser.
disposable_emailThe address is at a disposable email provider.A real person who keeps a throwaway address for signups.
no_mxThe domain cannot receive mail, so the address is not one anyone reads.A typo in the domain, or a company domain whose DNS is broken at that moment. The corrected retry is then a repeat (After a refusal, below).
listed_blockYou labelled this visitor, email or network block yourself.A network label on a shared office or campus address refuses everyone behind it.
confirmed_abuseYou reported this visitor, or an account it presents, as abuse.Someone who shares a card or phone with a person you reported.
receipt_reusedThe receipt was already spent on a different event: a replay.Your own code sending a second, different event on one receipt, such as to add the user id later. Send one event per attempt.

Why base: it is what the signal weighed at Heedeo's defaults, so (r.base ?? r.weight) >= 100 asks whether it fired at full strength, and repeat_action weighs 100 times how sure Heedeo is that the two browsers are one person, so a repeat Heedeo is likely but not certain of weighs 95 at most, one from another browser on the same machine weighs 85, and both pass. confirmed_abuse lent by a related visitor weighs less the weaker the link, so one through a shared address passes. weight is your workspace's: lowering one of these under Settings, Advanced, Weights, or accepting a recommendation that does, moves the score and leaves this rule alone. To stop refusing on a signal, take it out of REFUSE_ON. A rule deployed with r.weight >= 100 keeps doing what it did, and stops refusing on a signal whose weight is lowered: change that one comparison to get this.

Just watch

Refuse no one and add no verification step. The log line is how you judge a rule before you turn one on. Leave a comment where the rule will go:

console.info("Heedeo signup", data?.event_id, data?.visitor?.id, data?.listed, JSON.stringify(data?.decision), JSON.stringify(data?.risks));
// a rule from section 5 goes here, before the account is created

For your first two weeks, read the log for the signups you know are real and the ones you know are not, then move to score or rules.

After a refusal

Heedeo does not know what your code did with its answer. A signup you refused is still in the person's history, like one you let through. So the same person trying again under another email is a repeat. The common honest case is a typo: someone mistypes their email's domain, is refused on no_mx, corrects it, and is refused again on repeat_action on score, or by a rule that refuses every repeat. The same person retrying with the same email is the same attempt, not a repeat.

Two things keep this small. Say how to reach you in your refusal message, and let the person through when they do (below). And validate what you can before the event: a signup your own checks stop never reaches Heedeo. When you trust someone later, or Review confirms a flag after signup, a webhook tells your app (section 12).

Let a real person through

A Block label flags the person on every action. An allow label is how you let a real person through after a false flag: both rules above read it first. Press Trust on the person's page under Visitors, or send POST /v1/lists with your secret key. Their next event answers listed: "allow", whatever else it holds. To take the label off, press Trust again, or send the same call with "op":"remove". It answers {"removed":true}, or false when there was no label; their next event is scored as usual.

curl -X POST https://app.heedeo.com/v1/lists -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"op":"add","kind":"visitor","value":"<visitor id>","action":"allow"}'
curl -X POST https://app.heedeo.com/v1/lists -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"op":"remove","kind":"visitor","value":"<visitor id>"}'

Trust and Delete are on the person's page for you. The API calls are for an agent or a script.

Rules on raw fields

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. 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 ignores your settings and how sure Heedeo is, so prefer a path above unless you need a hard line. The network's flags work the same way, with their confidence: refusing a trial from a residential proxy only when Heedeo is sure of it, if (signals?.network?.residential_proxy_confidence === "high") …, and asking for a verification step on a medium one. 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 the action's name replaces it.

Test your rule

Run these in a real browser through your own signup form, and read your server's log line for each. Use real-looking addresses at a mail provider, such as Gmail. Numbered addresses (test1@, test2@) from one network soon fire email_pattern and network_burst.

An automated or headless browser adds automation to every signup here, so 0 reads 10 and 150 reads 160. No path refuses on it alone.

  1. A first honest signup. In a new workspace, a first signup with a real address answers suggested: "allow", score: 0 and no reasons, with a visitor that is not null. No path refuses it.
  2. The same person again. In the same browser, sign up again under another email. It answers repeat_action at weight 100 ("signup already done by this visitor ... from this browser") and many_identities at 50: score 150, suggested: "review". score refuses it, and so does a rule on the clear signals.
  3. An address that takes no mail. Sign up with an @example.com address. example.com takes no mail, so it answers no_mx at 100: score 100, suggested: "review". score refuses it, and so does a rule on the clear signals. A disposable address, such as one at mailinator.com, answers disposable_email at 100 the same way.
  4. Be a new person again. Clearing cookies or opening a private window is not enough: Heedeo still recognises the browser, and its next signup is usually a repeat. Instead, delete the person: on their page under Visitors, open More details and press Delete this person, or from your server call await heedeo.deleteVisitor(visitorId) with the package, or DELETE /v1/visitors/<visitor_id> (section 7, Deleting a person). The same browser then signs up as a new person, with no repeat, if it is your only test browser. Deleting one person is not enough when other test browsers on the same machine are still there: the next signup is linked to them as same_machine and is a repeat Heedeo is less sure of, which a rule on the clear signals lets through and score refuses. For a clean start, delete each test visitor first. Another test browser on the same machine and network is not a new person either.
  5. Let yourself through. Press Trust on your person's page under Visitors, or send the add call from "Let a real person through". Your next signup answers listed: "allow", and every path lets it through, even when it also answers repeat_action at 100. When you are done, take the label off: press Trust again, or send the remove call.

Local testing scores higher than real traffic, so score refuses test signups sooner than it refuses real ones. Every tester on your machine reaches Heedeo from 127.0.0.1 in development, so local signups look related and, as they add up, like a burst (related_identities, network_burst). Test browsers on one computer are one machine to Heedeo, as in test 4: a signup from another of them under a new email is a repeat ("from another browser on the same machine").

To see what real people get, deploy and read your log.

What an agent checks

Home needs your sign-in, so a coding agent checks for itself with the secret key. GET /v1/setup-status answers the counts behind Home's live check:

curl -H "authorization: Bearer $HEEDEO_SECRET_KEY" https://app.heedeo.com/v1/setup-status
{
  "identifications": { "count": 3, "last": "2026-11-07T10:02:11.482+00:00", "first": "2026-11-07T09:58:40.116+00:00" },
  "events": { "count": 2, "last": "2026-11-07T10:02:12.104+00:00", "first": "2026-11-07T09:58:41.020+00:00" }
}

count is how many the workspace has received, last when the newest came and first when the first did, each null before the first. After a test signup both counts are above 0, and last falls within the test. A missing or wrong key answers 401.

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, 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 says what the flag is about (Account abuse) and 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, and reaches your app as flag.decided when it has a webhook (section 12). j and k move between flags.
  • Visitors: one row per person, searchable by id or event id. A person's page shows Account abuse with its score, and their browsers, each with how it joined (First seen, Same email, Same account, Similar device) and how confident Heedeo is that they are one person; Not them takes a 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.
  • API keys: the publishable key, the main secret key (masked) with Rotate, and your other secret keys. Create secret key (owners) makes another under a name, such as Staging or Local dev, so one environment's key changes without touching your live one; it is shown once. Each other key lists who made it and when, with its own Revoke, which stops it at once. Rotate replaces only the main secret key: the old one stops at once, and your other secret keys keep working. A workspace holds up to 20 keys made here or by an assistant; the main key and the keys made through Connect don't count, and Connect is never refused. Members see the keys but cannot create, rotate or revoke them.
  • Settings: Once per person, the actions Heedeo flags when the same person does them again (section 3); How strict; Auto-confirm, off by default: set a score from 1 to 1000 and every new flag at or above it is confirmed as abuse for you and shows as Auto-confirmed under Review, Resolved, with Undo. Only flagged events are ever confirmed, so a low score confirms every flag. Only flags made after you save it are confirmed: flags already waiting are left for you, and so is an event you already reported on. Unlike your own Confirm, an auto-confirmed flag does not add confirmed_abuse to that person's later events or their neighbours', and Recommend weights does not learn from it: otherwise one false flag would keep confirming itself. Your own report on the same event (POST /v1/outcomes) replaces it, and then it counts. Webhooks, the endpoints Heedeo tells of changes (section 12). 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 separately for 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 collection 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 made separately for your account, so the same email at two customers is two unrelated hashes; no link, 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 and Spur about it, the address to 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 one-way hashes of it (of its normalised form, and of the address as typed when that differs). It never stores a raw network address, only its approximate network (the /24, or the /48 for IPv6) and one-way hashes of it. Of the headers you send, only the user agent, the languages and the client hints are kept; the rest are read and discarded. The email's domain, never the address, is kept where a reason names it and with the domain lookups below. What has expired is deleted every hour.

WhatHoldsKept
Identificationsthe browser readings the script sent (under the EU profile, without canvas, audio, fonts, speech voices, text and emoji measurements, media formats and the keyboard layout), the user agent, languages and client hints, the approximate network, what the readings showed of the browser (such as a private window, a virtual machine, an automation tool or a disguised browser), how many times the person used the pointer, the keyboard and the touch screen before the action, and how Heedeo recognised it; when Heedeo is served through the Domains page, how the browser connected: its encryption (with the size and one-way hashes of the settings it offered), protocol and round trip, and the city of the Cloudflare data centre that answered90 days
Eventsone-way hashes of the email, the account, the principal and the email's pattern; the approximate network and a one-way hash of the address; the address's country, region and city, and what IPinfo and Spur said about its network; the user agent, languages and client hints; the evidence you sent, 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 readings Heedeo recognises each browser by (most hashed); 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
Identities a browser presentedper browser, one-way hashes of each email, account, principal, phone and card it presented, when first and lastno automatic expiry
Links between browserstwo of your browsers, what they share (as a hash) or that they are probably the same device, and how strongly that links thema shared account, email, principal, phone or card, and probable matches: no automatic expiry; a shared address: 7 days after it was last seen
Merge historywhich visitor each browser belonged to and from when to when, and what joined it (its own cookie, a shared identity as its hash, or a device match and its confidence); the operator who pressed Not them; what later confirmed or contradicted a matchno automatic expiry
Flags, labels and outcomesthe reasons, your notes, your labels (an email as its hash, a network as its /24 or /48), outcomes (an account as its hash), and each decision on a flag with the outcomes it replaced, so it can be undoneno automatic expiry
Phone and card hashesthe phone and card hasheswith their event (30 days), and as long as the identities and links they made
Network lookupsIPinfo's answer for an address, with the address hashed7 days; 1 day when it says residential proxy
Spur lookupsSpur's answer for an address, with the address hashed1 day
Mail-server lookupsthe email domain's mail servers24 hours
Domain-age lookupswhen the email's domain was registered30 days
Breach lookupsHave I Been Pwned's earliest breach for an address, with the address hashed30 days
Webhook deliverieseach message sent to your endpoints (section 12), as sent, and of your server's last answer its status code, the time it took and its first 1,000 characters30 days; deleting an endpoint deletes its deliveries

A webhook message holds ids and labels as the API answers them, and the event's answer without its reasons' text: never a raw email or network address, a person's name or an operator's email. What your server answers is kept as it answered, so leave personal data out of your webhook answers.

A visitor's profile (its browsers, identities and the counts of what it did) comes from the records above. Undoing a merge (Not them) gives each side its own counts again: Heedeo 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 account, or the same email and both verified it (evidence.email.verified on an event of each that presented it). An email that is only typed joins no one: anyone can type an address, so it is a weak link in related that lends 50 at most. Merge on, under Settings, Advanced, Weights, chooses which shared identities merge; phone and card can be turned on, though a shared card or phone is also a household's. Two browsers also become one visitor when Heedeo recognises them as the same device, with confidence at or above your merge threshold. A device match is never certain, so its confidence stays below 1, and people whose machines only look alike (one laptop model across a class, two in a household) stay separate visitors. A shared network alone never merges anyone: a shared address is a link in related, not a merge. No merge crosses customers.

The threshold is 0.86 by default (Settings, Advanced, Weights, Merge threshold, 0.70 to 0.95): how sure Heedeo must be before it counts two browsers as one visitor. Lower catches more repeat signups, and joins more people who are not the same person; the default keeps look-alike devices apart. Raising it splits nothing already merged. Merge reliability (Settings, Advanced, Weights) shows the device merges of the last 90 days by confidence, how many were later confirmed or contradicted, and the precision; a threshold is safe when the precision in its row is at or above it.

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. A visitor has one outcome of yours, the latest: when both sides of a merge hold one, the newer stands. What joined each browser is shown on the visitor page in words ("same email", "device match 0.95") and in visitor.browsers[].evidence, never as a 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 device match placed. It becomes a visitor of its own again, each side gets its own counts of the last 13 months back, and no later match puts the two back together. The id stays with the side that presented an email, account, phone or card (or, when both or neither did, the side seen most recently), and an id a merge retired goes with its own browser. Not them refuses while the person has a label, because which side it meant is your call: clear the label, press Not them, then label the right person. An outcome on the visitor stays with the side holding the browser it was reported on, under that side's id, and the other side goes clean. From then on neither side is listed in the other's related, or lends it anything. 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.

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, all at once. An id a merge retired deletes the visitor it joined. The answer is the visitor's id and how many records of each kind were deleted:

curl -s -X DELETE -H "authorization: Bearer $HEEDEO_SECRET_KEY" https://app.heedeo.com/v1/visitors/8855…
# {"id":"8855…","deleted":{"flag_decisions":1,"flags":1,"actions":12,"outcomes":1,"outcomes_folded":0,"merge_outcomes":0,"event_requests":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
flag_decisionsthe decisions on their flags, with the outcomes those decisions replaced; a decision that also covered someone else's flag stays, without anything of theirs
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 only they presented
outcomes_foldedearlier reports on them that a later one replaced when two of their visitors became one
merge_outcomeswhat later confirmed or contradicted the merges of their browsers and into their visitor
event_requeststhe request ids a repeat of their events was answered under
eventstheir events, with the hashes, evidence and answers stored on them, and events with no browser that carry an identity only they presented
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_visitorswhich visitor each of their browsers belonged to, and what joined it
visitor_aliasthe ids merges retired into the visitor
browserstheir browsers, with the readings Heedeo recognised them by
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: from a time the two were one visitor (a browser split off this visitor keeps the events it had while in it), or because an answer about that other person named this visitor as related or as a possible match. That id answers 404 from then on. 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.

A few rows name no browser: an event sent after its token expired, and a report on a user_id that came before any browser presented it. The delete reaches them through the email, account, phone or card they carry, when only this person's browsers presented it: such an event goes, with its flags, its outcomes and its delivery log messages, and so does such a report. It cannot reach:

  • rows that carry only an identity another of your visitors also presented, such as a login two people share: they could be either person's, so they stay, and a report on that account stays with the other visitor;
  • rows that carry an identity this person's browsers never presented: nothing ties them to this person;
  • someone with no visitor id, because you only ever sent events from your backend: there is no visitor to delete;
  • an event you send after the delete, on a token issued before it: it is a new event, with no browser.

Deleting a person also removes, at once, the webhook messages about them that were delivered or failed from your endpoints' delivery logs, and sends visitor.deleted (section 12) to every endpoint that listens for it, so your app can delete its side too. That message, and any message about them still being sent at that moment, stay in the log until their 30 days are up.

Collection: the full and EU profiles

Under Settings, Advanced, Collection you choose what the script reads. Full, the default, is every source: screen, GPU, the fonts installed, how the browser draws canvas graphics and processes audio, how it measures text and emoji, the speech voices it offers, the audio and video formats it says it can play and record, the keyboard layout where the browser shares it, language and time-zone settings, the browser's own description of itself, whether an automation tool or the browser's developer tools are attached to the page, how many times the person moved or clicked the pointer, pressed a key or touched the screen on the page before the action (counts only: never which keys, what was typed or where), and signs that a script disguises the browser: which graphics card a background worker uses, whether the browser's built-in functions were replaced, and whether the window size and pixel density the page reports are the browser's own. EU leaves out canvas, audio, fonts, speech voices, text and emoji measurements, media formats and the keyboard layout: the script never reads them. For up to an hour after you switch, a browser that cached the earlier script may still send them, and Heedeo discards them. Switching to EU also removes what Heedeo kept of them, and Heedeo no longer uses them for any visitor. On a large workspace the removal finishes in the background: Settings says it is removing until it is done, and you can choose Full again after that. Under EU, a returning browser is still the same visitor, and browsers are still related by the email, account, phone or card they presented, but Heedeo recognises a browser that cleared its cookies and storage less reliably than under Full. Both profiles read whether an automation tool or developer tools are attached and count the person's input: these say how the page was used and single out no one. Both also check for a disguise: that says how the browser is set up, not who uses it, and the graphics card is one both profiles already read.

Under both profiles, Heedeo keeps the visitor's id on the device: in a first-party cookie, _idd, that identify sets for 400 days, with copies the script keeps in the browser's localStorage and IndexedDB.

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, installed fonts and speech voices, language settings and keyboard layout, the media formats it supports, and how it renders text, 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 the keys, your network address is looked up at IPinfo and at Spur, under both collection profiles.

What Heedeo keeps of each evidence field, with the event, for 30 days (section 3); the two hashes also stay as long as the identities and links they made (above):

Evidence fieldWhat Heedeo keeps
email.verifiedas sent
email.verified_atas sent
phone.numberonly a one-way hash made for your account, never the number
phone.hashyour hash, hashed again for 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 made for 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 records what really happened, and Recommend weights (Settings, Advanced, Weights) learns your weights from the events you labelled, so the more you report, 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). risks is what is known about the person now, per question, with trust counted: risks.account_abuse.score and its reasons. It is not a verdict on any action: decide at the action, from its event. It uses your current settings, so a change shows on the next lookup.

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, addresses and countries over 1h, 24h and 7d, and as on an event confidence, browsers with what placed each and its evidence, aliases and actions), listed (your label on the visitor), network (the newest event's, with the fields section 4 describes, which an event older than a field lacks; null when no event is kept), environment (the newest identification's, as section 4 describes it; null without one), risks (above), score (risks.account_abuse.score), thresholds (verify, review, block: your workspace's), suggested (the band score reaches at your thresholds; kept for packages before pilot.18, which require it, and no verdict on the person), 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. A lookup needs no event of its own.

11. Use Heedeo from an AI assistant

Heedeo is an MCP server at https://app.heedeo.com/mcp (the MCP page in the sidebar shows it, with Copy). The assistant signs you in through Heedeo's login, in your browser, and then acts as you: it reaches every workspace you can open, and nothing else.

  • Claude: in Claude, open Settings → Connectors. Click Add custom connector and paste the server URL. Click Connect and sign in to Heedeo.
  • Claude Code: run this in your terminal: claude mcp add --transport http heedeo https://app.heedeo.com/mcp. In Claude Code, run /mcp, choose heedeo and sign in to Heedeo.
  • Cursor: add "heedeo": { "url": "https://app.heedeo.com/mcp" } under mcpServers in ~/.cursor/mcp.json. In Cursor Settings → MCP, sign in next to heedeo.
  • ChatGPT: in ChatGPT, open Settings → Apps & Connectors → Advanced settings and turn on Developer mode. Click Create, name it Heedeo and paste the server URL. Sign in to Heedeo.

Let a coding agent do it. The MCP page's agent prompt is one line: Use curl to read https://app.heedeo.com/docs/mcp.md and connect the Heedeo MCP server to this agent. Paste it into Claude Code, Cursor or another coding agent. /docs/mcp.md tells the agent how each client adds the server, to wait while you sign in (never to ask for a password or a key), and to check the connection with whoami, list_workspaces and get_home. Opening https://app.heedeo.com/mcp in a browser shows the URL and this prompt, each with Copy.

Workspaces. list_workspaces lists yours. Every other tool takes workspace, an id or a name; with one workspace you can leave it out, with several the tool asks for it rather than guess. Every answer names the workspace it acted in.

What it can do: read Home, setup status and usage (get_home, get_setup_status, get_usage), flags (list_flags) and visitors (list_visitors, get_visitor), settings, recommendations, reliability and keys (get_settings, get_recommendation, get_reliability, list_keys, which shows a secret key's start only), the team (list_team) and domains (get_domains); decide flags as Review does, one (resolve_flag) or all of a person's at once (decide_flags), and undo a decision (undo_decision); label visitors (label_visitor), split a browser (split_browser), delete a person (forget_visitor, which cannot be undone); change what Settings changes (rename_workspace, set_strictness, update_thresholds, set_action_thresholds, remove_action_thresholds, set_once, set_identity_step, set_switch, set_merge_declared, set_merge_threshold, set_collector_profile, set_auto_confirm, apply_recommendation); change what Domains changes (set_origins, add_hostname, check_hostname, remove_hostname, regenerate_prefix, and for a Worker already deployed redeploy_cloudflare_worker and delete_cloudflare_worker); leave a workspace (leave_workspace); and whoami, create_workspace. An owner can also change the team (invite_member, renew_invite, cancel_invite, set_member_role, remove_member) and the secret keys (create_key, revoke_key, rotate_secret_key); a member is refused with the page's own message. Each write follows the same rules, and answers with the same messages, as the page. The prompt triage_flags walks the open flags one at a time, and the resource heedeo://docs/integration is this guide.

Roles. The assistant has your role on each workspace (list_workspaces and whoami say which), never more. A member is refused the owner-only tools above (the team changes and the secret keys), in the page's own words, as on the pages. A decision is recorded as yours, and Undo in Review reverses it as it does one made there. When you leave a workspace or an owner removes you, the assistant loses it on its next call.

Links and keys in the conversation. An invite answers its link, and create_key and rotate_secret_key answer the new secret key, each once, as the page shows them. Each lands in the conversation and the assistant's history: an invite link is a credential, as a key is. Pass the link only to the person it is for and put a key straight into your server's secrets. If the conversation is shared, rotate the main key again (rotate_secret_key), revoke a named one (revoke_key), or cancel the invite (cancel_invite).

What it cannot do: approve a Connect code, deploy a Cloudflare Worker or give Heedeo a new Cloudflare token, or add or change webhooks. Connect hands a terminal a secret key, and the page asks you to type the code your own terminal shows, so a code someone else sent you is not one step from a key; through an assistant the code is only text. A Cloudflare token can edit Workers in your account, so it is typed on the Domains page, never into a conversation. Use the pages for those.

Disconnect. The MCP page lists the assistants with access, with when each connected and was last used. They act as you: disconnect any you don't recognize. Disconnect stops access at once (403, disconnected) until you press Reconnect. Also remove Heedeo in the assistant. To use it again, press Reconnect and sign in from the assistant: in Claude Code, run /mcp and choose heedeo. Signing out of Heedeo doesn't disconnect an assistant.

12. Webhooks

Heedeo sends a signed request to your server when a flag or a visitor changes after the event: an operator confirms or dismisses a flag in Review, a person is trusted, blocked, merged or deleted. Your app can then act on its own, such as suspending an account whose flag was confirmed. The decision at signup stays in the event's answer (section 5): a webhook is never in the signup path, and your signup never waits for one.

Add an endpoint

  1. Open Settings, Webhooks (https://app.heedeo.com/review/settings/webhooks) and choose Add endpoint. Only an owner of the workspace can add, edit, pause, roll or delete an endpoint; members can read the list and the delivery log.
  2. Enter the Endpoint URL: a public https address on your server, such as https://example.com/webhooks/heedeo. An http address, an address on your own network, one with a user name and password in it, or one over 2,048 characters is refused.
  3. Tick the Events to send. The first six are ticked for you. event.scored is not: it sends one request for every event your server sends.
  4. Choose Add endpoint. The page shows the endpoint's signing secret, whsec_ and 32 characters. Put it in your server's environment as HEEDEO_WEBHOOK_SECRET. An owner can show it again with Reveal.
  5. Choose Send test (Test and debug, below).

A workspace can have up to 10 endpoints, each with its own secret and its own events. A change goes to every enabled endpoint that listens for its type, with the same message id.

Verify each request

Each message is a POST with content-type: application/json, user-agent: Heedeo-Webhooks/1 and three headers:

HeaderWhat it holds
webhook-idthe message id, the same as the body's id
webhook-timestampwhen this attempt was sent, in Unix seconds
webhook-signaturev1, and the signature in base64. It can hold several, separated by spaces

Verify the signature before you act on a message, and verify it over the raw body: the bytes exactly as they arrived. A framework that parses the JSON first loses them, and the signature no longer matches. This is the most common mistake.

With the package (@heedeo/sdk 0.1.0-pilot.17 or later), in a Next.js route handler:

// app/api/webhooks/heedeo/route.ts
import { verifyWebhook } from "@heedeo/sdk";

export async function POST(request: Request) {
  const { data: message, error } = verifyWebhook({
    body: await request.text(), // the raw body: never request.json()
    headers: request.headers,
    secret: process.env.HEEDEO_WEBHOOK_SECRET,
  });
  if (error) {
    console.warn("Heedeo webhook refused", error.code);
    return new Response(null, { status: 400 });
  }
  // A test from Settings carries sample ids: answer it and change nothing.
  if (message.test) return new Response(null, { status: 204 });
  // The same message can arrive twice: skip an id you have handled (your own table of ids).
  if (await alreadyHandled(message.id)) return new Response(null, { status: 204 });

  if (message.type === "flag.decided" && message.data.flag.visitor_id) {
    const { id, status, visitor_id, decided_at } = message.data.flag;
    // Order is not promised: a retried decision can arrive after a newer one, so keep each flag's
    // newest decided_at (your own table) and act only on a newer one.
    const newest = await newestDecision(id);
    if (!newest || new Date(decided_at) > newest) {
      if (status === "confirmed") await suspendVisitor(visitor_id); // your own code
      if (status === "open") await restoreVisitor(visitor_id); // the decision was undone
      await saveNewestDecision(id, new Date(decided_at));
    }
  }
  // the other types you listen for: What to do with each, below
  await markHandled(message.id);
  return new Response(null, { status: 204 }); // within 10 seconds; queue anything slow
}

message is typed by its type, so message.data has that type's fields. Other frameworks:

  • Express: put the route before app.use(express.json()), with express.raw({ type: "application/json" }) on it, and pass body: req.body (a Buffer) and headers: req.headers.
  • Hono: body: await c.req.text(), headers: c.req.raw.headers.
  • Any other: read the body as text or bytes before any JSON parser runs, and pass the headers as the framework gives them.

verifyWebhook takes the body as a string or bytes (a parsed object throws, so the mistake shows on your first test), the headers as a Headers object or a plain object in any casing, and the secret. It answers { data, error } like the package's other calls. error.code says which check failed, and never holds the secret or a signature:

error.codeWhat failed
invalid_keythe secret is missing or malformed; nothing was checked
webhook_headers_missingone of the three headers is missing
webhook_timestamp_invalidwebhook-timestamp is more than 5 minutes old or ahead of your clock, or not a Unix time
webhook_signature_invalidno v1 signature matches: the wrong secret (a rolled one), or a body that changed on its way
webhook_body_invalidthe signature matched, and the body is not a Heedeo message

toleranceSeconds changes the 5 minutes (300), and now passes a clock, for tests.

Without the package, in any language:

  1. Read webhook-id, webhook-timestamp and webhook-signature. Refuse a request without all three.
  2. Refuse a webhook-timestamp more than 5 minutes before or after your server's clock. The time is signed, so a captured request cannot be sent again later.
  3. The signed content is {webhook-id}.{webhook-timestamp}.{body}: the two header values and the raw body, joined by full stops.
  4. The key is the secret's part after whsec_, decoded from base64 (24 bytes), not the text of the secret.
  5. Compute the HMAC-SHA256 of the signed content under the key, and encode it in base64.
  6. Compare it with each signature in webhook-signature that starts v1, (the part after the comma), in constant time: hmac.compare_digest in Python, crypto.timingSafeEqual in Node, hmac.Equal in Go, ActiveSupport::SecurityUtils.secure_compare in Ruby, hash_equals in PHP. Skip any other version. One match is enough.
  7. Only then parse the body as JSON.

This is the Standard Webhooks format (https://www.standardwebhooks.com). The open standardwebhooks libraries for Python, Go, Ruby, PHP, Java, Rust, C# and Elixir verify Heedeo's requests as they are, with the secret as Settings shows it. In Python, with Flask:

# pip install standardwebhooks
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["HEEDEO_WEBHOOK_SECRET"])

@app.post("/webhooks/heedeo")
def heedeo_webhook():
    try:
        message = webhook.verify(request.get_data(), request.headers)  # the raw body
    except WebhookVerificationError:
        return "", 400
    if message.get("test"):
        return "", 204  # a test from Settings: change nothing
    # skip a message["id"] you have handled, then act on message["type"]
    return "", 204

The message

Every message has the same envelope:

FieldWhat it holds
idthe message id, the same as webhook-id, on every retry and every endpoint. Dedupe on it
typeone of the seven types below
timestampwhen the change happened (ISO 8601, UTC), not when this attempt was sent
api_versionthe date of the message's shape, 2026-11-08
datathe change, with the fields of its type
testtrue on a message from Send test, whose ids are samples; absent on every other message

A message holds ids and labels as the API answers them, never a raw email or address or a person's name (section 7). Times are ISO 8601. Each type follows, with when it is sent, its data fields and its sample, which is also what Send test sends.

flag.created

Sent when Heedeo flags an event (flag: true), and when Heedeo opens a related flag on another visitor linked to the flagged one. Review lists both.

FieldWhat it holds
flag.idthe flag
flag.kindevent, the event's own flag, or related, a flag on a visitor related to the event's
flag.event_idthe event that opened it
flag.riskwhat the flag is about: account_abuse, one person making more than one account; null when no risk judges the event's action
flag.actionthe event's action, such as signup, which the flag keeps after the event itself is deleted
flag.visitor_idthe person the flag is on; null for an event without a visitor
flag.created_atwhen it was opened
eventthe event's evaluation, as POST /v1/evaluations answers it (section 4), in the latest API version, each reason with its code, weight and base and without its text. Only on an event flag: a related flag has none, since that answer is another person's. null when Heedeo flags an earlier action after its event was deleted, such as a signup found later to be a second account of one person

The sample shows the answer's first fields; the rest are section 4's.

{
  "id": "3b8e2f0c-6d1a-4e7b-9f2c-5a4d3c2b1e0f",
  "type": "flag.created",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "flag": {
      "id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
      "kind": "event",
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "risk": "account_abuse",
      "action": "signup",
      "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "created_at": "2026-11-06T09:30:00.418207+00:00"
    },
    "event": {
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "flag": true,
      "score": 100,
      "threshold": 100,
      "suggested": "review",
      "reasons": [{ "code": "many_identities", "weight": 100, "base": 100 }]
    }
  }
}

flag.decided

Sent when a flag is confirmed or dismissed, in Review, by an AI assistant (section 11) or by Auto-confirm, and when a decision is undone. One message per flag: deciding all of a person's flags at once sends one for each.

FieldWhat it holds
flag.id, flag.kind, flag.event_id, flag.risk, flag.actionas in flag.created
flag.visitor_idthe flag's person, by their id now
flag.statusconfirmed (abuse), dismissed (legit), open when a decision was undone, or withdrawn when Heedeo takes back a flag it raised later, before anyone decided it, because the two people it joined turned out to be two
flag.decision_idthe decision, which Undo reverses whole: every flag decided in the same step (a person's flags at once in Review, or by an AI assistant) carries the same one. On open, the decision undone. An older message may hold null
flag.decided_atwhen it was decided or undone
{
  "id": "8c7d6e5f-4a3b-4c2d-9e1f-0a9b8c7d6e5f",
  "type": "flag.decided",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "flag": {
      "id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
      "kind": "event",
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "risk": "account_abuse",
      "action": "signup",
      "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "status": "confirmed",
      "decision_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
      "decided_at": "2026-11-06T09:30:00.418207+00:00"
    }
  }
}

visitor.listed

Sent when a visitor, an email or a network is labelled allow or block, or its label is removed: Trust or Block in Review, an assistant, or POST /v1/lists (section 5, Let a real person through).

FieldWhat it holds
label.kindvisitor, email or network
label.valueas POST /v1/lists answers it: the visitor id, the email's hash, or the network (its /24, or /48 for IPv6)
label.actionblock or allow, or null when the label was removed
visitor_idthe visitor, for a visitor label; null for an email or a network label
{
  "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "type": "visitor.listed",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "label": {
      "kind": "visitor",
      "value": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "action": "block"
    },
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f"
  }
}

visitor.merged

Sent when two visitors become one person (section 7, Merges): one id stays, and the others retire into it.

FieldWhat it holds
visitor_idthe id that stays
mergedthe ids that retired into it in this merge. Each still resolves to visitor_id (section 10)
{
  "id": "9f8e7d6c-5b4a-4f3e-8d2c-1b0a9f8e7d6c",
  "type": "visitor.merged",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "merged": ["5d1e7a9c-3b4f-4e2a-8c6d-7f8e9a0b1c2d"]
  }
}

visitor.split

Sent when a merge is undone: Not them on a person's browser in Review, or an assistant's split_browser (section 7, Merges).

FieldWhat it holds
visitor_idthe visitor that was split; its id stands
split_intothe other visitor, now beside it
browser_idthe browser taken out
browser_visitor_idwhich of the two holds that browser now: visitor_id or split_into
{
  "id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
  "type": "visitor.split",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "split_into": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9",
    "browser_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "browser_visitor_id": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9"
  }
}

visitor.deleted

Sent when a person is deleted: Delete this person in Review, DELETE /v1/visitors/<visitor_id> or heedeo.deleteVisitor (section 7, Deleting a person).

FieldWhat it holds
visitor_idthe person deleted
aliasesevery id that had retired into them, the oldest merge first
{
  "id": "7a6b5c4d-3e2f-4a1b-8c0d-9e8f7a6b5c4d",
  "type": "visitor.deleted",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "aliases": ["5d1e7a9c-3b4f-4e2a-8c6d-7f8e9a0b1c2d"]
  }
}

event.scored

Sent for every event, with its answer: one request for each event your server sends, by POST /v1/evaluations or POST /v1/events. Off unless you tick it. It is for keeping your own copy of every answer, including one your server stopped waiting for.

FieldWhat it holds
eventthe event's answer, as in flag.created
{
  "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
  "type": "event.scored",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "event": {
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "flag": true,
      "score": 100,
      "threshold": 100,
      "suggested": "review",
      "reasons": [{ "code": "many_identities", "weight": 100, "base": 100 }]
    }
  }
}

What to do with each

Your app holds the account; the message names the person by visitor_id, the visitor.id you stored from the event's answer. A rule for each:

  • flag.created: under watch, hold what the account was given, or queue it for your own look; under score or rules your rule already ran at the event, so log it.
  • flag.decided: on confirmed, suspend the accounts of flag.visitor_id or end their trial; on dismissed, lift any hold; on open, undo what you did on confirmed.
  • visitor.listed: for a visitor label, block suspends the person's accounts, allow restores them, and null returns them to your usual rule; an email or network label names no visitor, and the next event's listed carries it.
  • visitor.merged: replace each stored id in merged with visitor_id, so the person's accounts are one person's.
  • visitor.split: stop treating visitor_id and split_into as one person; the next event of each account says which of the two it is.
  • visitor.deleted: delete what you store under visitor_id and each of its aliases, as your own deletion process says.
  • event.scored: store or log the answer by its event_id; never wait for it at signup.

Delivery and retries

  • Success is any 2xx answer within 10 seconds. Anything else is a failure: another status, no answer within 10 seconds, no connection, or a redirect, which is not followed.
  • Answer first, then work. Check the signature, store the message or queue a job, and answer 2xx. A slow handler times out and the message is sent again.
  • Eight tries over about a day. The first at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 8 hours. After the eighth failure the message is Failed: it stays in the log, and goes again only if you press Send again.
  • At least once. A message can arrive more than once, such as when your server did its work but answered too late. Every try has the same id and the same body; only webhook-timestamp and the signature are new. Dedupe on id.
  • Order is not promised. A later change can arrive before an earlier one, as when the first is being retried. Each message carries its own timestamp, and a flag its decided_at: keep the newest.
  • A message can arrive before your own code has stored the id it names, such as a flag.created for an event your signup handler has not saved yet. Answer 2xx and find the id later, or answer with a 5xx to have the message sent again, a minute later and then on the schedule above.
  • Only what happens while an endpoint listens is sent. A change is sent to the endpoints that are enabled and listen for its type when it happens. Nothing earlier is sent to a new endpoint or a newly ticked type.
  • No fixed addresses. Requests come from no published list of addresses: verify the signature instead of allowlisting.

Test and debug

  • Send test, on the endpoint's page: choose one of its types, and Heedeo sends that type's sample (above) with "test": true once, with no retries, and shows your server's answer in place, such as Delivered. Your server answered 200 in 143 ms. or Not delivered. Your server answered 500. A test never makes an endpoint Failing. Skip side effects when test is true: its ids are samples.
  • Deliveries, on the same page: every message sent to the endpoint, newest first, with All and Failed. Each row has when the change happened, the type (with a Test tag), the status (Sending, Delivered, Retrying, Failed), your server's last answer (its status code, or Timed out, No connection, Domain not found, TLS failed, Not public) and the attempts. Open one for its message id, when it was last tried and will be next, the response (status, time taken and the first 1,000 characters your server answered) and the payload exactly as sent.
  • Send again, on an opened delivery: sends that message now with the same id, signed afresh, whatever its status, unless it is being sent already. A real message that fails again goes back on its schedule while it has tries left. On a paused or disabled endpoint only a test can be sent again: Resume or Enable it first.
  • The log keeps each message for 30 days. Deleting an endpoint deletes its log.
  • Send test and Send again are an owner's; members can read the log.

Failing, disabled and paused endpoints

StateWhenWhat is sentHow it ends
Failingdeliveries have failed since a date, and none has succeeded sinceeach message is still tried for a day; the endpoint's page says when it will be turned off if deliveries are still failing, and Settings' Webhooks row counts itone successful delivery, a Send test that passes, or a new URL. A failure after a day with nothing sent starts a new count
Disabledafter 3 days of failed deliveries, Heedeo turns the endpoint off. An endpoint with nothing to send stays on: only a failure in the last day turns it offnothing: the messages waiting for a try are Failed, and changes made while it is disabled are never sent, not even after you turn it back onfix your server, check it with Send test, then press Enable, which starts the endpoint afresh
Pausedyou pressed Pause in the endpoint's menunothing: changes made while it is paused are not sent, and a message waiting for a retry is FailedResume sends new changes again and starts the endpoint afresh
  • Send again sends any Failed message from the log you still need, once the endpoint is on.
  • The emails: a day into a failing spell, when its first message has failed every try, the workspace's owners get one email, Webhook deliveries to example.com are failing, with the last answer, the date the endpoint will be turned off and a link to its delivery log. If it is turned off they get one more, Heedeo turned off webhooks to example.com, with what to do. Each goes once, and not to an owner who has turned off alerts on their Email page.
  • Roll secret, in the endpoint's menu, makes a new signing secret, and the current one stops working at once. Until your server has the new one it refuses Heedeo's requests, and each is retried on its schedule (Delivery and retries, above), so deploy the new secret soon after you roll it. Send again sends any message that ran out of tries meanwhile.