3. One server call: evaluate or log
On your server, at the action, send the token 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). The package reads that answer for you as a verdict (section 4, The verdict). POST /v1/events is the same call under its earlier name, and answers the same. The package has two methods for it:
| Package | Gives you | Use it at |
|---|---|---|
heedeo.evaluate() | the verdict: flagged, the reasons by has() and certain(), and the whole evaluation in data | an 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:
action | What it is | Send with it |
|---|---|---|
signup | an account is created | email, and user_id when the account exists |
login | a sign-in to an existing account | user_id |
trial_start | a free trial begins | user_id |
redeem:CODE | a coupon or credit by its code: redeem:SPRING, redeem:welcome_credits | user_id |
payout | a reward or referral is paid out | user_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 verdict = await heedeo.evaluate({ token, action: "trial_start", email, userId });
// A second trial by the same person under another email is flagged.
if (verdict.flagged) return Response.json({ error: "trial_unavailable" }, { status: 403 });The rest of the call:
- Required:
action. Sendclient_ip(the end user’s IP address) andheadersas well whenever your server sees the end user’s request. Withoutclient_ip, the event takes the address identify saw when the browser called Heedeo itself, from a live token; with neither, the event has no address and no network reason fires. When both are there,client_ipwins. token, whatidentify()gave. When there is none, leave it out and still send the event (below).email, anduser_idonce the account exists.acting_for, when an agent acts for one of your users: that user’s account id (the principal;actingForin the package). It is hashed on arrival likeuser_id, never stored raw.headers: the end user’s request headers that Heedeo reads, nevercookieorauthorization. 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. Addhost,:methodand:pathof 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 samerequest_idreturns the stored answer. A label write (POST /v1/lists, section 5, Let a real person through) and a report (POST /v1/outcomes, section 9) take arequest_idtoo, compared among those two calls only and kept 72 hours; an eventsrequest_idis separate. status, when you know how the action ended:succeeded, orfailedwhen your server refused it or it errored. Leave it out and the event isattempted.
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 token, action, email, user_id, acting_for, client_ip and evidence while the token lives (five minutes) gets the first event’s answer, event_id included, and records nothing. Anything else on a spent token is a replay, recorded and flagged receipt_reused. A replay is someone else on the token, 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 token 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 token 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, where your app has a verification step, do not let the signup through on a missing token alone. Require that step, such as a confirmed email or a CAPTCHA, before the account is created or the grant is made. With none, or under watch, log it and carry on: never add a step for Heedeo. 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 token that is not a usable one, since a script can send one it made up:
- no token:
no_tokeninreasons(and inlogEvent()’snotes); a package before 0.1.0-pilot.18 answerserror.codemissing_tokenand sends nothing; - a token Heedeo cannot read (a made-up one, one from another environment): it is scored as no token and answers
no_token, so a made-up token is no way past the checks. A 4xx is a mistake in the call or a rate limit, never a missing token; - a token that names no visitor of yours:
token_expiredorunknown_visitorinreasons(a spent one answersreceipt_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). With the package this whole check is verdict.noToken: true for the three cases above, false when Heedeo is unavailable, and false for a person you trust. The helper below has the same check as noToken().
When Heedeo is down altogether, nobody is asked to verify and nobody is refused: the signup goes on as if Heedeo were not there. When only the page’s script is unreachable, the page has no token while your server’s call still answers, so those signups meet your verification step. 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 proxy route it takes a clientIp option: a function that takes the request and returns the trusted IP (the quickstart’s trustedClientIp; the server action’s clientIp below takes headers, so pass (request) => clientIp(request.headers)). The package sends the right headers, the request_id, one retry within a 3 second budget, the API version and its own version (heedeo-sdk, from 0.2.0-beta.1), 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 verdict = 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 verdict = 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,
});
console.info("Heedeo signup", JSON.stringify(verdict.toLog())); // an error shows here, and never stops signup
// 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.
Express and other servers
The same call goes in any handler that can answer a 403. With the browser loading Heedeo from heedeo.dev, a Domains page endpoint or your own proxy route (section 2), it needs no request, and no client IP unless the client was given a clientIp. In Express, for a form that posts JSON with its content-type (from section 1’s FormData handler, post JSON.stringify({ email: form.get("email"), token: form.get("heedeo_token") }) with that header):
// server.js
import express from "express";
import { Heedeo } from "@heedeo/sdk";
const heedeo = new Heedeo({ secretKey: process.env.HEEDEO_SECRET_KEY });
const app = express();
app.post("/signup", express.json(), async (req, res) => {
const { email, token } = req.body;
const verdict = await heedeo.evaluate({ token, action: "signup", email });
console.info("Heedeo signup", JSON.stringify(verdict.toLog()));
// Your path's rule goes here (section 5). On Let Heedeo decide, it is this one line.
// A short code here: section 5 has the message, with a way to reach you.
if (verdict.flagged) return res.status(403).json({ error: "signup_unavailable" });
// No usable token is still scored on the email and network. If your app has its own
// check, require it:
// if (verdict.noToken) return requireVerification(req, res);
// create the account here
res.json({ ok: true });
});Fastify, Hono, Koa and a plain Node server are the same three lines: evaluate(), the log line and the 403. In a CommonJS app (one that uses require), write the two imports as const express = require("express"); and const { Heedeo } = require("@heedeo/sdk"); and keep the rest: in 0.2.0-beta.1 or later, require() loads the package on every Node it runs on, and in Jest with no flags. Where Node can require() an ES module (20.19 and later 20 releases, and 22.12 and after), it loads the same file import does; Node 22.12, and 23.0 to 23.4, print an ExperimentalWarning when it does. On 0.1.0-pilot.18 or earlier, require("@heedeo/sdk") fails with ERR_PACKAGE_PATH_NOT_EXPORTED: update it with npm install @heedeo/[email protected], and until that version is published, make the client once with import(), since await is not allowed at the top of a CommonJS file, and await it in the handler: const heedeo = import("@heedeo/sdk").then(({ Heedeo }) => new Heedeo({ secretKey: process.env.HEEDEO_SECRET_KEY }));, then const verdict = await (await heedeo).evaluate({ token, action: "signup", email });. Section 5 has the route for each path, with what a caught signup can meet. Where the package does not run at all (another language, an older Node), use the helper under “Without the package”, below.
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 token has to cross the trip to the provider and back, and it lives five minutes. With the package, call
heedeo.preload()when the page holding the provider’s button mounts, so the click does not wait for Heedeo to load (section 1). Callidentify()just before the handoff and send the token to your own server, which keeps it with the OAuthstateit is about to send; the callback validatesstateand reads the token back from there. That works for every provider. Reading the_idtcookie 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 noSameSite=Laxcookie 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 asreceipt_reused. Either way, a person who spends more than five minutes at the provider comes back with an expired token (token_expired), and is asked to verify where your app has a step; - 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 your app’s verification step there, where it has one, 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. Load Heedeo with no route. The tag from https://heedeo.dev, or createHeedeo({ publishableKey: import.meta.env.VITE_HEEDEO_KEY }), works on static pages as it is (section 1). For your own domain, a custom subdomain from the Domains page (id.example.com) needs no route either; the Cloudflare proxy integration works only for a host whose DNS record Cloudflare proxies, so not for pages on a DNS-only CNAME to another CDN.
To serve it from your own server instead, 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 path: "/hd" 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. Of the two, the rule serves Heedeo from the same domain as your pages.
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
signupwhen the API sees the user for the first time andloginafter that, with the email,userId(the provider’s user id) andevidence: { email: { verified } }as the provider reports it (falseis 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, orredeem:CODEfor credits or a coupon, with the same email,userIdand 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 verdict = 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 the package does not run on, send the call yourself. Check a field’s name, type and limit, and what each call can answer, in the API description: OpenAPI 3.1, generated from the API’s code, which a client generator or an HTTP tool also reads. This helper sends the call the package sends: the same headers, never cookie or authorization, one request_id per attempt, one retry within 3 seconds, and the API version. It does not send heedeo-sdk, the header that names the package’s own version (from 0.2.0-beta.1), since it is not the package: a call without it is counted as one with no package. It answers { data, error } with the package’s error codes, and noToken() beside it is the package’s verdict.noToken:
// 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-11-08",
},
body,
signal,
});
if (res.ok) return { data: await res.json(), error: null };
// The trace id is what Heedeo support finds the request by: quote it with the status.
console.error("heedeo", res.status, res.headers.get("heedeo-trace-id"), 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
}
// The package's verdict.noToken, in code: no usable token asks for verification, never for a
// person you trust. The API answers it as data.no_token; an answer stored before that is read the
// same way. Heedeo not answering (an outage, a mistake in the call, a rate limit) does not.
export function noToken(data) {
return data?.no_token ?? (data != null && data.listed !== "allow" &&
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 } = 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, with no usable token:
if (noToken(data)) return requireVerification(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. The visitor’s IP comes from one function; adjust the import paths to your layout:
// 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.
"use server";
import { headers } from "next/headers";
import { heedeoEvent, noToken } from "@/heedeo";
import { clientIp } from "@/lib/client-ip";
export async function signup(form: FormData) {
const incoming = await headers();
const { data } = 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, with no usable token:
if (noToken(data)) 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 without recording anything: curl -s -H "authorization: Bearer $HEEDEO_SECRET_KEY" -H "content-type: application/json" -d '{}' https://app.heedeo.com/v1/evaluations answers 400 invalid_request (no action), a call the key authenticated and the API then refused; a wrong key answers 401. A call with an action is recorded as a real event, even with a made-up token, which is scored as no token.
API version. Every call to /v1/evaluations (an event, and an answer read again with GET /v1/evaluations/:id, section 10) and /v1/visitors reads in a dated API version: the heedeo-version header (heedeo-version: 2026-11-08), 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. 2026-11-08 refuses a near-miss of an action’s name with a 400 invalid_request that names the spelling to send.
Errors from the API
The API’s routes (/v1/* and identify) answer an error as { "error": { "code", "message", "trace_id" } }, one row below for each code and status they answer. None of them is a reason to refuse a signup: fix the call, or carry on. The same rows are in the API description, under each operation that can answer them.
Trace id. Every answer Heedeo gives carries a heedeo-trace-id header, and an error’s body repeats it as trace_id. It names that one request: quote it to Heedeo support when a call fails, which always has its line in Heedeo’s log. A call that worked may have none (Heedeo keeps one success in ten of its busiest calls: evaluations, identify and the script), so for an answer that came back send its event_id. Quote this one: another id on the answer belongs to the hosting in front of Heedeo. It is not request_id, which is your own key for trying one attempt again. In the package a failed call’s id is error.traceId, and toLog() holds it as trace_id (@heedeo/sdk 0.2.0-beta.1 or later). Where there is less:
- An answer a cache may keep (
cache-control: public: the script and the docs) has no header, so no copy repeats an older request’s id. - A path that does not exist (404
not_found) has the header and notrace_idin the body. - The package’s
proxy()passes back an identify answer’s body, so an error’strace_id, and not the header. - An answer that did not come from Heedeo has neither: no answer at all,
proxy()refusing before it asks Heedeo (no secret key, no client IP), or a proxy of your own answering for it.
| Status | code | What happened | What to do |
|---|---|---|---|
| 401 | missing_key | No key was sent. | Set the secret key where the server runs. Until then nobody is checked. |
| 401 | invalid_key | The key is not one of yours. | Set the secret key where the server runs. Until then nobody is checked. |
| 403 | publishable_key | A publishable key was sent where the secret key belongs. | Send the secret key (sk_). |
| 403 | secret_key | A secret key was sent to identify, where the publishable key belongs. | Load the script with the publishable key (pk_), and keep the secret key on your server. |
| 403 | key_mismatch | Identify on a host that serves one workspace (your own domain, or its prefix on Heedeo’s) was sent another workspace’s publishable key. | Use the publishable key of the workspace that host belongs to. |
| 403 | origin_not_allowed | The page’s origin is not one the workspace allows to identify (the Domains page). | Add the page’s origin on the Domains page. |
| 400 | invalid_bundle | Identify’s request is not one the script sends. | Load the script from Heedeo or your own route to it; do not call identify by hand. |
| 400 | invalid_request | The call is malformed. A token Heedeo cannot read is not an error: the event is scored as no token. | Read message and fix the call. |
| 409 | invalid_request | The request_id already answered another call: another event (another action, or another token), or on /v1/lists and /v1/outcomes another write (another target, action or outcome). A request_id is one attempt at one write, and its stored answer is never given to a different one. A report sent again under its request_id after you deleted the person it was on is refused too: the report went with them and is not written again. A report on an account another of your visitors holds too, or on a browser a split left with another visitor, is not one of these: it stays with that visitor, and its repeat answers as before, naming them. | Make one request_id for each action, and for each label write and report you mean; send the same one on its retries. |
| 404 | not_found | No such path, or no visitor or event of yours has that id (GET and DELETE /v1/visitors/:id, POST /v1/outcomes), or no evaluation of yours has it (GET /v1/evaluations/:id: an answer is kept 30 days, or as long as a flag holds its event, and deleted with its visitor). An id deleted before is not this: a second deletion answers 200. | Check the path and the id you sent: an event id for an evaluation, a visitor id for a visitor. |
| 413 | too_large | The body is over 64 KB (/v1/evaluations, /v1/lists, /v1/outcomes and identify’s request). | Send a smaller body. |
| 429 | rate_limited | Too many calls, with a Retry-After. From identify, past 120 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. The dashboard’s own forms answer it too. | Your server asks for verification where your app has a step (section 3). |
| 500 | internal | Heedeo failed while answering. | Carry on as if Heedeo were not there (section 5). That is never a refusal. |
| 503 | busy | Another event on the same token arrived at the same moment. | Send the call again with the same request_id (the package’s helper does). |
| Another 5xx, or no answer | Heedeo failed or could not be reached. | Carry on as if Heedeo were not there (section 5). That is never a refusal. |
From the route on your own domain
On your own domain, the route in front of identify (the package’s proxy(), or Heedeo’s Worker, which runs the same code) answers these itself, in the same shape, before Heedeo sees the request:
| Status | code | What happened | What to do |
|---|---|---|---|
| 404 | not_found | The path is not one the route serves: only /i.js and /v1/identify under it. | Check the route’s prefix in the script tag or endpoint. |
| 405 | method_not_allowed | /i.js takes GET and /v1/identify POST (and OPTIONS); the answer’s allow says which. | Load the script with a script tag or the package, which send the right method. |
| 413 | too_large | Identify’s request is over 64 KB. | Load the script from Heedeo or your own route to it; do not call identify by hand. |
| 502 | upstream_unreachable | The route could not reach Heedeo. | Nothing on the page: identify() rejects and the page sends no token, and your server asks for verification where your app has a step (section 3). |
| 503 | not_configured | proxy() has no secret key, or its clientIp returned no address for this request. | Set HEEDEO_SECRET_KEY where the server runs, and make clientIp return the visitor’s address on your host. |
An expired token is not an error: you get a 200 with visitor: null and the note token_expired. No token 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 evaluate()): what your backend knows about the person, sent server to server, never through the browser. In the package the call’s own fields are camelCase (userId, requestId); over HTTP they are the wire’s names (user_id, request_id, and client_ip, which the package fills from its clientIp option), and a camelCase name there is refused with a 400 and records nothing. evidence keeps the names its sources use, which are Heedeo’s wire names, in both, so card.checks.cvc_check goes in as cvc_check. 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,prepaidorunknown. You may sendphone.hash, your own hash of the number, instead ofnumber: one or the other, and always the same one.oauth.provider:google,apple,github,microsoftorother, withreal_user_status(Apple’s 0, 1 or 2),private_relay,account_created_atandtwo_factorwhere 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. Itsemail_verifiedproves the address (below) andprivate_relayisrelay_email; the other fields are stored and add no weight.account.plan:free,trialorpaid.payment.funding:credit,debitorprepaid.cvc_check:passorfail.wallet:apple_pay,google_pay,linkornone.three_ds:authenticated,failedornot_attempted.radar_risk_levelandfree_trial_abuse_risk:normal,elevated,highest,not_assessedorunknown.verification.kind:otp,email_link,3ds,id_document,livenessorcaptcha.result:passedorfailed.- Times are ISO 8601; countries two letters. Leave out what a source says outside these values (Stripe’s
unknownfunding, Twilio’spersonalline).
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_phonea non-fixed VoIP or toll-free number;prepaid_carda prepaid card;verification_faileda check you ran failed, its kind in the text;email_unverifiedonce you have sentemail.verifiedfor a day, an address new to the browser that no event verified;card_mismatchthe card’s country is not the country of the event’s address;relay_emailthe sign-in provider’s private relay address. - Trust, whenever it holds:
verified_ida passedid_documentorlivenesscheck;paid_historyplanpaidwithpaid_sincemore than 30 days ago and no abuse report;wallet_cardApple Pay, Google Pay, Link or a passed 3-D Secure;legit_outcomeyou reported the visitor, one of its events or its account as legit;verified_phonea mobile line verified by you or by a passedotp;card_on_filea card that passed its CVC check;tenurean 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[].linkshared_phoneorshared_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 };Paid email intelligence (SEON, Emailage)
Heedeo does not call SEON or Emailage (LexisNexis Risk); if you have an account, fill the evidence from it like any other source. Their phone line or carrier type goes in phone.line_type, mapped to the values above as Twilio’s is (mobile, landline, fixed or non-fixed VoIP, toll-free, prepaid; anything else unknown), with phone.country; a phone or email verification you ran through them goes in verification (otp or email_link, passed or failed). Their email age, first-seen dates, domain age, social-profile counts and risk scores have no evidence field: Heedeo reads domain age itself, 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 isrecent_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 saysemail.domain_age_unknown: trueand nothing fires. A free provider’s domain is never looked up, nor a disposable one, which is alreadydisposable_email. - Free providers and roles.
email.free_providersays the address is at a large free mail provider (Gmail, Outlook, Yahoo, iCloud, Proton, GMX and the like), a fact for your rule.email.role_addresssays the address names a function, not a person (admin,info,billing,support,sales,contact,hello,teamand the like); on a signup it isrole_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 isemail_never_seenon 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 still accepted: the decision every workspace lives by reads the action’s name instead, and only a workspace still on the legacy verdict reads it.