Heedeo package quickstart
Heedeo tells your server who is behind a signup, a trial or a coupon, and whether to stop it. You call it once, where you are about to give something away:
// app/api/signup/route.ts
const verdict = await heedeo.evaluate({ token, action: "signup", email });
if (verdict.flagged) {
return Response.json(
{ error: "We could not create your account. Contact support." },
{ status: 403 },
);
}
// create the accountverdict.flagged is true when a signup reaches your flag line: at the default strictness, the same person back under another email, or a disposable address. It is also true for anyone you blocked. It is never true for a person you pressed Trust on. If Heedeo is down or slow, it is false and the signup goes on. Heedeo enforces nothing: your code decides.
The five steps below take a signup form from nothing to a refused repeat signup. A coding agent can do them for you: paste Set up Heedeo in this app: https://app.heedeo.com/SKILL.md into Claude Code, Cursor or Codex.
Before you begin, you need:
- a Heedeo workspace: sign up and you get both keys;
- Node.js 20.18 or later on your server, or Cloudflare Workers;
- the server route that creates your accounts, and the form that posts to it.
| If you | Start at |
|---|---|
| use Next.js, Hono or another server that runs this package | Step 1, below. |
| have no npm on your pages, or a server in another language | The integration guide: one script tag and one HTTP call. |
| sign people in with Supabase Auth, Clerk, Auth0 or Firebase | Steps 1 to 3, then Trials, coupons and logins for where step 4’s call goes, then step 5 and Before you go live. |
| want to refuse on reasons you choose | Steps 1 to 5, then Write your own rules. |
1. Install
npm install @heedeo/[email protected]It runs on Node.js 20.18 or later, the Next.js App Router and Cloudflare Workers, and has no dependencies. The verdict needs 0.2.0-beta.1 or later: on an earlier version verdict.flagged is undefined and refuses no one, so check after you install:
npm ls @heedeo/sdk
# └── @heedeo/[email protected]Name the version, as above, and never a tag such as @latest: a release is on npm before latest moves to it, so for a while @latest gives the release before. In an app that has the package already a plain npm install @heedeo/sdk keeps the range its package.json saved, and the ^0.1.0-pilot.18 range npm saved never takes a 0.2 release.
2. Add your keys
Get both keys when you sign up. The publishable key (pk_) goes to the browser. The secret key (sk_) is shown on that first visit only: Rotate under API keys makes a new one.
# .env.local
HEEDEO_SECRET_KEY=sk_your_secret_key
NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_keyKeep the secret key on your server. Never put it in a
NEXT_PUBLIC_orVITE_variable, in a client component or in git. If it leaks, press Rotate for the main key, or Revoke on a named key, under API keys: the old key stops at once.
Set both in your deployed environment too. A plain Node server reads the file with node --env-file=.env.local, which every Node the package runs on has. In a Vite app the publishable key is VITE_HEEDEO_KEY, read as import.meta.env.VITE_HEEDEO_KEY.
// lib/heedeo.ts: import only from server files
import { Heedeo } from "@heedeo/sdk";
export const heedeo = new Heedeo({ secretKey: process.env.HEEDEO_SECRET_KEY });new Heedeo() with no argument reads HEEDEO_SECRET_KEY itself.
3. Identify in the browser
// lib/heedeo-browser.ts: safe to import in a client component
import { createHeedeo } from "@heedeo/sdk/browser";
export const heedeo = createHeedeo({
publishableKey: process.env.NEXT_PUBLIC_HEEDEO_KEY,
});When the page with your sign-up or sign-in button mounts, load Heedeo ahead of the click:
useEffect(() => heedeo.preload(), []);Then, in your form’s submit handler, identify just before you post, and send the token along:
// your signup form
const { data } = await heedeo.identify();
await fetch("/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, token: data?.token }),
});identify() answers { data, error } and never throws. data is { token, visitor_id, confidence }; when it is null, error.code says why.
- Call
identify()at the action, not on page load. A token lives five minutes and is good for one event. preload()loads Heedeo’s script and identifies nobody; it never throws. Without it the firstidentify()loads the script at the click, and on a slow phone that can take longer than the wait, so the signup goes on with no token. Call it on every page that starts a sign-up or sign-in, a “Continue with Google” button included.- Loading the script reads the browser and, on the default collection profile, asks Heedeo for one identifier the browser keeps in its cache (the EU profile stores nothing on load). If your site asks for consent before it loads Heedeo, call
preload()once you have it. - If it fails (an ad blocker, a slow network),
datais null within 6 seconds, or 3 after apreload(). Post anyway, with no token. - Use the package or Heedeo’s script tag, not both.
4. Decide on your server
Call evaluate() after your own validation and before you create the account, so you can still say no.
// app/api/signup/route.ts
import { heedeo } from "@/lib/heedeo";
export async function POST(request: Request) {
const { email, token } = await request.json();
const verdict = await heedeo.evaluate({ token, action: "signup", email });
console.info("Heedeo signup", JSON.stringify(verdict.toLog()));
if (verdict.flagged) {
return Response.json(
{
error:
"We could not create your account. Contact [email protected].",
},
{ status: 403 },
);
}
// No usable token (a blocker, or a script that skipped your page) is still
// scored on the email and network. If your app has its own check, require it:
// if (verdict.noToken) return requireVerification(email);
// create the account here
return Response.json({ ok: true });
}That route refuses what Heedeo flags. Its log line, for a repeat signup:
{"event_id":"0b6f…","visitor_id":"0f8e…","flagged":true,"listed":null,"band":"review","score":150,"by":"risk","reasons":["repeat_action","many_identities"],"error":null,"trace_id":null}evaluate() never throws, retries one time on a failure and gives up after 3 seconds. Log toLog(): its event id is how we find a signup together. You move the flag line with How strict under Settings, with no deploy.
The action is a name from Heedeo’s list, spelled exactly: signup here, and trial_start, redeem:CODE, payout or login elsewhere (Trials, coupons and logins, below). Show a refused person one message with a way to reach you, never a reason or a score.
5. Test it
Run these through your own form, in a real browser, in a new workspace at the default strictness (Balanced), and read your server’s log line for each. Other reasons can show beside these: an automated browser adds automation, and many test signups from one network add network_burst.
| Do this | The log line shows | What happens |
|---|---|---|
| Sign up with a real address at a mail provider. | "flagged":false, an event_id and a visitor_id | The account is created. |
| Sign up again in the same browser, under another email. | "flagged":true, "reasons":["repeat_action","many_identities"] | Your 403. |
Sign up with an @example.com address. | "flagged":true, with no_mx among the reasons | Your 403: the domain takes no mail. |
Sign up with an @mailinator.com address. | "flagged":true, with disposable_email among the reasons | Your 403: a disposable address. |
| Press Trust on yourself under Visitors, then sign up again. | "flagged":false, "listed":"allow" | The account is created. |
| Stop your network, or unset the secret key, then sign up. | "flagged":false, "error":"network_error" or "invalid_key" | The account is created: an outage refuses no one. |
A visitor_id of null on the first row means no usable token reached Heedeo: check step 3. If every signup shows no_token, the publishable key and the secret key belong to different workspaces or environments. To be a new person again, open your page under Visitors, then More details, and press Delete this person; clearing cookies is not enough. To find yourself under Visitors, search for the visitor_id or the event_id from your log line.
Signups from your own machine score higher than real traffic: your test browsers are one machine to Heedeo, and numbered addresses (test1@, test2@) from one network soon add email_pattern and network_burst. Use real-looking addresses, and read what real people get from your deployed app’s log.
Check that both calls reached Heedeo. Home ticks off the browser call and the server call as each arrives. From a terminal, or for a coding agent, the same counts come back from one call with the secret key. Pass the event_id from your log line to ask about that signup:
curl -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
"https://app.heedeo.com/v1/setup-status?event_id=79c7b0de-4c1e-4b47-9d0f-3a1c2b6e5f10"{
"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",
"identified": { "count": 1, "last": "2026-11-07T10:02:12.104+00:00" }
},
"event": { "id": "79c7b0de-4c1e-4b47-9d0f-3a1c2b6e5f10", "identified": true, "visitor_id": "0b8f5c2e-7d41-4a9e-b3c6-5e2f1a8d4c70" },
"served": null
}After a test signup both counts are above 0 and each last falls within your test. identifications at 0 means the browser call never arrived (step 3); events at 0 means the server call never did (step 4). events.identified counts the events that came with a usable token. A count above 0 can be an older event, so the proof is your log line: a visitor_id and none of no_token, token_expired or unknown_visitor in reasons (the table above). event confirms Heedeo holds that signup under this key: identified is true and visitor_id is the one in your log line. identified: false means it reached Heedeo with no usable token (step 3); event: null means the workspace holds no event by that id, most often because your server sent it with another workspace’s key. A 401 means the key is missing or wrong.
If a test does not go as the table says, see When something is missing or wrong.
Before you go live
What you tested on your machine is the quick start. Go through this list on the deployed app:
- Both keys are set where the app runs. Give each environment its own secret key: Create secret key under API keys makes one under a name, such as Staging, so one environment’s key changes without touching your live one.
- Heedeo is served from your own domain. Ad blockers, Safari and Brave block a script from another site: see Serve Heedeo from your own domain.
servedin thesetup-statusanswer above is the first time it worked and stays set after that, so to see it still works, press Check on the Domains page. - With a route in your own app,
clientIpreturns the real visitor’s address on your host. Anullmeansmissing_client_ip, and every signup goes on unchecked. - Every path that creates an account calls Heedeo: your form, each social sign-in, an invite flow, a mobile backend. An abuser uses the one you forgot. See Call Heedeo on every signup path.
- Your allowed origins are listed on the Domains page. Until some are, any website may load Heedeo with your publishable key.
- Your privacy policy says so. The integration guide has the sentence to add.
- A refused person can reach you. Your message names a support address and gives no reason and no score, and you know where Trust is under Visitors for the real person who writes in.
- Your server logs
verdict.toLog()for every signup. Itsevent_idis how you, and we, find a signup later. - The deployed app passes the tests in step 5, in Safari as well as Chrome, the outage row included: with the secret key unset, a signup still goes through.
Write your own rules
flagged weighs every signal for you. When you want each refusal tied to a reason you chose, read the reasons yourself:
const verdict = await heedeo.evaluate({
token,
action: "trial_start",
email,
userId,
});
// Strong alone
const throwawayEmail = verdict.certain("disposable_email", "no_mx");
const aliasedEmail = verdict.certain("alias_of_existing"); // john.doe+2@
const knownAbuser = verdict.certain(
"listed_block",
"confirmed_abuse",
"receipt_reused",
); // you blocked or reported them, or a token was replayed
// certain they had a trial
const repeatTrial = verdict.certain("repeat_action");
// Weak alone, refused together
const looksFamiliar = verdict.has("probable_match", "related_identities");
const hidingNetwork = verdict.has("vpn", "proxy", "tor", "datacenter");
const refuse =
!verdict.trusted &&
(throwawayEmail ||
aliasedEmail ||
knownAbuser ||
repeatTrial ||
(looksFamiliar && hidingNetwork));
if (refuse)
return Response.json({ error: "trial_unavailable" }, { status: 403 });has() is any of these reasons, at any strength. certain() is any of them at full strength: a likely repeat passes. strength(code) is the number, for a line of your own. All three read Heedeo’s defaults, so a rule keeps working when someone changes a weight under Settings. Put verdict.trusted first in every rule, so you can always let a real person through, and never refuse on a VPN alone: many honest people use one. On a login or an action name of your own, Heedeo judges nothing, so use has() there.
The rules guide says how strong each signal is and has rules to start from. The integration guide’s section 5 covers asking a person to verify, or holding a signup for review, in place of refusing.
When something is missing or wrong
evaluate() never throws and never holds a signup for more than 3 seconds. Every read on the verdict is safe with no answer, so you write no null check.
| What happened | What the verdict says | What to do |
|---|---|---|
| Heedeo is down, slow or unreachable | flagged is false, error.code is network_error, timeout_or_aborted or api_error | Nothing. The signup goes on. |
| The secret key is missing or wrong | flagged is false and error.code is invalid_key. A missing or malformed key also warns on the console at start. | Set HEEDEO_SECRET_KEY where the server runs. Until then nobody is checked. |
| The browser sent no token, an expired one or a made-up one | noToken is true. The event is still recorded and scored on the email and the network. | Ask for the verification step your app already has. |
Every signup shows no_token | The tokens come from another workspace or environment, or the call was given something other than data.token. | Check that both keys belong to one workspace, and pass data.token. |
| The action’s name is misspelt | error.code is api_error, error.apiCode is invalid_request, and error.message names the spelling to send. The client warns with it on the console. | Fix the name. Nobody is checked until you do. |
By what you see:
| You see | Why | What to do |
|---|---|---|
verdict.flagged is undefined, and nobody is ever refused | The installed package is older than 0.2.0-beta.1. | npm install @heedeo/[email protected], then npm ls @heedeo/sdk. If it still shows an earlier version, do not ship the integration on the earlier one: write to us. |
heedeo.preload is not a function in the browser | The same: preload() came with 0.2.0-beta.1. | The same. |
require("@heedeo/sdk") fails with ERR_PACKAGE_PATH_NOT_EXPORTED | The installed package is older than 0.2.0-beta.1, which first gave require() a file to load. | The same; until that version is published, load it with import(), as the integration guide’s Express and other servers shows. |
identify() answers identification_unavailable | The page loads Heedeo twice (its script tag and the package), or the script could not load: a wrong path or endpoint, or an ad blocker or Safari blocking it from Heedeo’s own address. | Keep one of the two; check path and endpoint; serve Heedeo from your own domain. |
identify() answers timeout on a phone | The script loaded at the click, on a slow network. | Call preload() when the page mounts (step 3). |
identify() answers invalid_key | The publishable key did not reach the browser bundle. | Check the variable’s prefix (NEXT_PUBLIC_, VITE_), and rebuild. |
Your own route answers 503, or the verdict’s error is missing_client_ip | clientIp returned null for this request. The server warns on the console the first time in each process (on serverless, once per cold start). | Return the visitor’s address for your host: Serve Heedeo from your own domain. |
| Every test signup on your machine is flagged | Your test browsers are one machine, on one network. | Delete each test person under Visitors, or press Trust on yourself. |
| A real customer was refused | The reasons are in your log line, by its event_id, and the whole answer is await heedeo.evaluation(eventId) (0.2.0-beta.1 or later; GET /v1/evaluations/:id without the package), as it was given, for 30 days or as long as its flag is in Review. | Press Trust on their page under Visitors, or call heedeo.trust({ visitorId }) (0.2.0-beta.1 or later). Their next try goes through, with no deploy, unless you also blocked their email or network: a Block outweighs a Trust, so check the person’s page for one. visitorId is null when the signup had no token: trust their { email } instead. |
A script that never loads your page sends no token, so noToken is worth acting on when your app has its own check:
// your own: a confirmed email or a CAPTCHA
if (verdict.noToken) return requireVerification(email);noToken is false for a person you trust and false when Heedeo is unavailable. Do not add a verification step for Heedeo: with none, log it and carry on.
Trials, coupons and logins
Name the action from this list, spelled exactly. The name is how Heedeo knows what a person should only do one time:
action | What it is | Send with it |
|---|---|---|
signup | an account is created | email, and userId when the account exists |
login | a sign-in to an existing account | userId |
trial_start | a free trial begins | userId |
redeem:CODE | a coupon or credit by its code: redeem:SPRING, redeem:welcome_credits | userId |
payout | a reward or referral is paid out | userId |
Heedeo flags the same person doing a signup, trial_start, redeem:CODE or payout again under another email or account. Any other name is recorded and judged on nothing. A near-miss (sign_up, register, a redeem without its code) is not recorded: the verdict’s error.apiCode is invalid_request, it shows in your log line, and nobody is refused until you fix the name.
One trial per person is the same call under another name, with no table on your side:
const verdict = await heedeo.evaluate({
token,
action: "trial_start",
email,
userId,
});
if (verdict.flagged) return noTrial(); // the account stays, with no trialEach call takes its own token: call identify() again just before it.
If your sign-in is hosted (Supabase Auth, Clerk, Auth0 or Firebase, called from the browser), the provider creates the account before your server hears of it, so there is no signup to refuse. Send signup from the first call your server receives after sign-in, and run your rule where you grant the trial or the credits. The integration guide’s section 3, A single-page app, a hosted sign-in and a separate API, has the full example.
At a login, Heedeo judges nothing: a person’s own laptop and phone are normal. Record it with logEvent(), which answers only the event’s id:
await heedeo.logEvent({ token, action: "login", userId });Use evaluate() there only for a rule of your own on verdict.data?.account: whether this browser is new to the account, and how many browsers present it.
Tell Heedeo what you know
What your backend knows about the person goes in evidence, every field optional: a verified email, the phone and its line type, the sign-in provider’s claims, your account’s age and plan, the card from Stripe.
await heedeo.evaluate({
token,
action: "signup",
email,
userId,
evidence: {
email: { verified: true },
phone: { number: "+14155550123", verified: true, line_type: "mobile" },
account: {
created_at: user.createdAt,
plan: "paid",
paid_since: user.paidSince,
},
},
});The phone number and card fingerprint are hashed on arrival and never stored raw. The integration guide’s Evidence has the one-liner for Stripe, Twilio Lookup, Google, Apple and GitHub.
Report what you learn
When you find out what a signup really was, say so. An outcome changes no flag and no label; Heedeo uses it to fit your weights to your own traffic.
// account.heedeoEventId: the verdict.eventId you saved with the account at
// signup
await heedeo.outcome({
eventId: account.heedeoEventId,
outcome: "abuse",
label: "trial_abuse",
});
// or { visitorId }, or { userId } when the account exists. outcome is 'abuse'
// or 'legit'.A paid conversion is a cheap legit; a refund, a chargeback or a third free trial is abuse. A later report on the same event, visitor or account replaces the earlier one. The package’s own retry of a report does not: from 0.2.0-beta.1 it carries the first attempt’s requestId, so it never replaces a Confirm or Dismiss given in Review in between.
Server actions, Hono and retries
A Next.js server action has no request, and needs none. With your own proxy route (below), pass the request’s headers and the path of the page whose form calls the action, so Heedeo reads the browser’s headers:
// app/signup/actions.ts
"use server";
import { headers } from "next/headers";
import { heedeo } from "@/lib/heedeo";
export async function signup(_prev: { message: string }, form: FormData) {
const email = String(form.get("email") ?? "");
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,
});
if (verdict.flagged)
return { message: "We could not create your account. Contact support." };
// create the account
return { message: "" };
}Return your own values from a server action, as here, never the verdict: Heedeo’s answer holds reasons no end user should see.
In a route handler, or any framework that hands you a Web Request (in Hono, c.req.raw), pass it as request in the same way. The call reads its headers and never its body.
A form on React’s useActionState identifies in onSubmit, sets the token on the form data, then dispatches the action:
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const { data } = await heedeo.identify();
if (data) form.set("heedeo_token", data.token);
startTransition(() => action(form));
}The package retries a failed call one time, and Heedeo counts it as one event. If your own code retries the whole action, pass a stable requestId so the retry gets the first answer. Never send two events on one token, such as to add a user id later.
Serve Heedeo from your own domain
Do this before you go live. From your own domain, ad blockers and privacy browsers like Safari and Brave do not block Heedeo, and returning visitors keep their cookie. There are two ways.
A host from the Domains page (recommended). Once the Domains page serves Heedeo from your domain, pass its endpoint and change nothing on your server:
// lib/heedeo-browser.ts
export const heedeo = createHeedeo({
publishableKey: process.env.NEXT_PUBLIC_HEEDEO_KEY,
endpoint: "https://www.customer.com/k9m2x",
});endpointis an HTTPS origin with an optional path. Plain http is accepted on localhost.- It also takes a list, tried in order.
- If your own address fails to load Heedeo, the package loads it from
https://heedeo.devinside the same 3 seconds, so the signup still gets a token. The Domains page says when this happens. - Pass
pathorendpoint, not both.
A route in your app. Your app forwards Heedeo’s two paths itself, and tells Heedeo each visitor’s IP. Give the server client a clientIp:
// lib/heedeo.ts: import only from server files
import { Heedeo } from "@heedeo/sdk";
import { trustedClientIp } from "./trusted-client-ip";
export const heedeo = new Heedeo({
secretKey: process.env.HEEDEO_SECRET_KEY,
clientIp: trustedClientIp,
});trustedClientIp(request) is the one hosting-specific piece. It must return the real visitor’s IP from a source your server trusts, or null when unavailable. Use the same function for the proxy and signup. The package deliberately does not guess whether an incoming forwarded header is trustworthy.
One function covers the common hosts. Keep the branch for yours and delete the rest:
// lib/trusted-client-ip.ts
export function trustedClientIp(request: Request): string | null {
// Development: there is no proxy, so every request is you. Set
// NODE_ENV=development in the command that starts the server locally, not in
// a .env file a Vite build also reads.
if (process.env.NODE_ENV === "development") return "127.0.0.1";
// Vercel: sets VERCEL=1, and puts the client's public IP in x-forwarded-for,
// overwriting any value the client sent.
if (process.env.VERCEL)
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || null
);
// Cloudflare in front of an origin that accepts only Cloudflare (see below).
// return request.headers.get('cf-connecting-ip');
// AWS App Runner, alone in front: the last x-forwarded-for entry (below).
// const entries = request.headers.get('x-forwarded-for')?.split(',');
// return entries?.at(-1)?.trim() || null;
// Anything else: no trusted source yet.
return null;
}- Vercel. Vercel documents
x-forwarded-foras “the public IP address of the client that made the request”, overwrites it, and does not forward external IPs, to prevent spoofing (request headers).x-real-ipcarries the same value.VERCELis a system environment variable, set at build and at runtime while Enable access to System Environment Variables is on in the project’s settings (the default). With your own proxy or CDN in front of Vercel, this header holds that proxy’s address, so use the next case instead. - Cloudflare.
cf-connecting-ipis an option only when your origin rejects requests that bypass that trusted proxy. Do not assume that a public Railway origin is protected just because the custom domain uses Cloudflare. - AWS App Runner. AWS’s App Runner guide does not say what it puts in
x-forwarded-for. App Runner’s front end is Envoy, and Envoy appends the address it received the connection from, so the last entry is the one a client cannot set; the first can be anything the client sent. Before you rely on it, deploy a route that returns the request’sx-forwarded-for, call it from a phone off your network, and check that the phone’s address is the last entry. With CloudFront or Cloudflare in front of App Runner, the last entry is that CDN’s address. Use the CDN’s own header instead, and only when nothing can reach the service’s own*.awsapprunner.comaddress around the CDN. - A Node server behind your own proxy or load balancer. Return the address your proxy sets, from the header it writes, and only when the app is reachable through that proxy alone. If the header can hold a list, take the entry your proxy added, not the first, which the client can set.
- Anything else. Return
null. The proxy route then answers 503,evaluate()answersmissing_client_ip, and signup goes on without signals.
Have your coding assistant inspect the actual deployment and keep only the branch that matches it.
Then add the route, and name it in the browser with createHeedeo({ publishableKey, path: '/hd' }). Without path, the package loads from heedeo.dev and the route goes unused.
// app/hd/[...path]/route.ts
import { heedeo } from "../../../lib/heedeo";
export const GET = heedeo.proxy;
export const POST = heedeo.proxy;With clientIp set, every evaluate() and logEvent() call needs the request, or a server action’s headers and path: the function reads the visitor’s IP from it.
Pages with no server of their own (a single-page app as static files on a CDN, its API on another origin of the same site, such as api.example.com for app.example.com): mount the route on the API and pass it as endpoint in place of path. In Hono, after your CORS middleware, since the route needs the API’s credentialed CORS for your pages’ origin:
app.all("/hd/*", (c) => heedeo.proxy(c.req.raw));Keep /hd/i.js and /hd/v1/identify reachable before sign-in. Existing authentication middleware must allow those two paths. To test the route by hand, ask for /hd/i.js?key=<publishable key>, as identify() does: with no key it answers 400, A publishable key is required. Only these endpoints are forwarded; this route cannot forward event submissions or arbitrary URLs. Cookies and request headers unrelated to Heedeo are not sent upstream. The route answers its own refusals as JSON, { "error": { "code", "message" } }, with no trace id, since Heedeo never saw them: 404 for any other path, 405 for another method, 413 for a body over 64 KB, 502 when Heedeo cannot be reached, and 503 while the secret key or clientIp is missing. The script keeps Heedeo’s own cache header (an hour).
Read an answer again
When a person writes in to say they were refused, read what Heedeo answered from the event_id in your log line (0.2.0-beta.1 or later):
const { data, error } = await heedeo.evaluation(eventId);
// not_found: unknown, gone, or deleted
if (error) console.warn("Heedeo evaluation", eventId, error.apiCode);
else
console.info(
"Heedeo evaluation",
data.answeredAt,
data.action,
data.verdict.toLog(),
);It is history, not a decision now: answeredAt and action say when and for what, and verdict is the verdict evaluate() gave then. Its flagged, trusted and reasons are what they were at the event, so a Trust or Block put on since does not change them. Never refuse or let someone through on it later: send the new action with evaluate(), which reads every label of yours (the person’s, their email’s, their network’s). heedeo.visitor(visitorId) shows the person’s own label only. An answer is kept 30 days, or as long as a flag holds its event, and deleted with its visitor; any id Heedeo cannot answer for you, another workspace’s among them, is the same 404 api_error. Like visitor(), it is tried a second time on a 5xx or a network error, and it records nothing.
Look up or delete a person
Between events, read who a visitor is and what is known about them now. It is not a verdict on any action: to decide on a payout, send payout with evaluate().
const { data } = await heedeo.visitor(visitorId);
console.info("Heedeo visitor", data?.visitor.id, JSON.stringify(data?.risks));When someone asks you to delete their data, delete what Heedeo holds of them, all at one time: their browsers, identifications, events, actions and outcomes. Your list labels are kept.
const { data, error } = await heedeo.deleteVisitor(visitorId);
if (error)
// not done: send it again or look into it
throw new Error(`Heedeo did not delete ${visitorId}: ${error.message}`);
// data: { id: '0f8e…', deleted: { browsers: 2, events: 12, actions: 12, … },
// deleted_at: '2026-11-11T08:25:01.069+00:00' }An id a merge retired resolves to the visitor it joined, in both calls. A deletion is safe to send again: an id you deleted before answers that id with an empty deleted and the first deleted_at, and from 0.2.0-beta.1 the package tries a failed deletion a second time. An id Heedeo never held for you is a 404 api_error, and an event id a 400 that says to send its visitor_id, so a request with the wrong id never reads as done. A lookup of a deleted id answers a 404 api_error.
Trust or block a person from your code
When a real customer writes in after a refusal, or your own system catches an abuser, set the label the buttons under Visitors set, with trust(), block() or clearLabel() (0.2.0-beta.1 or later):
// verdict.flagged is false for them from their next event
await heedeo.trust({ visitorId });
await heedeo.block({ visitorId }); // flagged on every action
await heedeo.clearLabel({ visitorId }); // back to no labelEach takes exactly one of { visitorId }, { email } or { network } (an IP, or its /24 or /48). visitorId is null when the signup had no token: label their { email } instead; a target naming none, or more than one, sends nothing and answers configuration_error. Each is safe to send again (below); if two people change one label at the same moment, the last change wins. Heedeo still enforces nothing: a label changes what the next verdict says, and your code decides.
Each call sends a requestId, the same one on the package’s retry. If the first attempt landed and only its answer was lost, the retry gets that answer and writes nothing, so it never undoes a label a teammate or an assistant gave in between. Heedeo keeps the answer for 72 hours. If your own code retries the call, make the id once and pass it on each try; for a job queue, make it when you enqueue the job. Never make it from the person’s id: a later Trust of the same person would get the old answer and write nothing.
// made for this Trust, kept across your retries
const requestId = crypto.randomUUID();
await heedeo.trust({ visitorId, requestId });The same id with another target or action answers api_error with status 409 and apiCode invalid_request.
Receive webhooks
Heedeo can tell your server when a flag or a visitor changes later: a flag confirmed in Review, a person trusted, blocked, merged or deleted. Add an endpoint under Settings, Webhooks, copy its signing secret into HEEDEO_WEBHOOK_SECRET, and verify each request with verifyWebhook.
Verify over the raw body, the bytes exactly as they arrived. The most common mistake is a JSON parser that runs first: the parsed body is not what was signed. In Express, mount the webhook route before express.json(), with express.raw() on it:
import express from "express";
import { verifyWebhook } from "@heedeo/sdk";
const app = express();
// Before app.use(express.json()): this route needs the raw bytes, which a JSON
// parser consumes.
app.post(
"/webhooks/heedeo",
express.raw({ type: "application/json" }),
async (req, res) => {
const { data: message, error } = verifyWebhook({
body: req.body, // a Buffer, from express.raw()
headers: req.headers,
secret: process.env.HEEDEO_WEBHOOK_SECRET,
});
if (error) {
console.warn("Heedeo webhook refused", error.code);
return res.sendStatus(400);
}
// Send test: sample ids, change nothing
if (message.test) return res.sendStatus(204);
// a message can arrive twice
if (await alreadyHandled(message.id)) return res.sendStatus(204);
if (message.type === "flag.decided" && message.data.flag.visitor_id) {
const { id, status, visitor_id, decided_at } = message.data.flag;
// Two deliveries of one flag can overlap, and a retried decision can
// arrive after a newer one. So hold your own lock on the flag (a row lock
// in your table) while you read its newest decided_at, act and save.
await withFlagLock(id, async () => {
const newest = await newestDecision(id);
if (newest && new Date(decided_at) <= newest) return;
// your own code
if (status === "confirmed") await suspendVisitor(visitor_id);
// dismissed, withdrawn or undone: lift any hold
else await restoreVisitor(visitor_id);
await saveNewestDecision(id, new Date(decided_at));
});
}
await markHandled(message.id);
res.sendStatus(204); // within 10 seconds: queue anything slow
},
);
app.use(express.json()); // the rest of your appIn a Next.js route handler, pass body: await request.text() and headers: request.headers; in Hono, body: await c.req.text() and headers: c.req.raw.headers. A parsed object as body throws, so the mistake shows on your first test. message is typed by its type (WebhookEvent). Answer a failure with a 4xx, and Heedeo sends the message again later.
A message can gain fields at any time: read the ones you know and ignore the rest. api_version changes only when a field is removed, renamed, changes type or changes meaning. A flag.created for a person you trust still comes, so Review shows them: act on the flag only when message.data.flag.listed is not allow, your label on what it is about when it was raised. event.flagged is what the event was answered when it was scored: a flag Heedeo raises later on an earlier signup carries that signup’s answer, flagged: false, on a person you may not trust. The guide’s section 12 has both cases.
Then press Send test on the endpoint’s page. The guide’s section 12 has each type with its fields and sample, what to do with each, the retries, and how to verify without the package in any language.
Reference
The HTTP API the package calls is described in OpenAPI 3.1 at /docs/api.json, generated from the API’s code: for a server in another language, or a tool that reads a spec.
Which version you need
Each feature names the first version that has it. Check yours with npm ls @heedeo/sdk. Install by the version these guides name, npm install @heedeo/[email protected], not by a tag: a new version is on npm before the guides name it, and latest moves to it only once the API it needs is live, so for a short while after a release @latest can still install the version before.
| Feature | Package |
|---|---|
endpoint in createHeedeo(), Heedeo served from your own domain | 0.1.0-pilot.12 or later |
The heedeo-version header on every call | 0.1.0-pilot.15 or later |
proxy() sending your secret key with the visitor’s IP | 0.1.0-pilot.16 or later |
A server action’s headers and path, in place of request | 0.1.0-pilot.17 or later |
verifyWebhook() | 0.1.0-pilot.17 or later |
An http:// endpoint on localhost | 0.1.0-pilot.17 or later |
The request headers forwarded with each event, so agent.missing_headers is known | 0.1.0-pilot.17 or later |
An event sent with no token, where earlier packages refused it as missing_token | 0.1.0-pilot.18 or later |
The API version 2026-11-08, which refuses a misspelt action’s name | 0.1.0-pilot.18 or later |
The verdict: verdict.flagged, has(), certain(), toLog() and verdictOf() | 0.2.0-beta.1 or later |
preload() in the browser | 0.2.0-beta.1 or later |
trust(), block() and clearLabel() | 0.2.0-beta.1 or later |
A request_id on every label write and report, the same one on the package’s retry | 0.2.0-beta.1 or later |
evaluation(), an answer read again by its event id | 0.2.0-beta.1 or later |
deleteVisitor() tried a second time on a 5xx or a network error | 0.2.0-beta.1 or later |
| Heedeo’s script from its CDN by default, with no route of your own | 0.2.0-beta.1 or later |
The heedeo-sdk header on every call, naming the package’s own version | 0.2.0-beta.1 or later |
A failed call’s trace id: error.traceId and toLog().trace_id | 0.2.0-beta.1 or later |
require("@heedeo/sdk") in a CommonJS app and in Jest | 0.2.0-beta.1 or later |
The verdict
What evaluate() answers. Read flagged first. The verdict’s own members are camelCase (eventId); data and toLog() keep the API’s names (event_id), so a log line matches the dashboard and the raw answer.
| Member | What it is | When Heedeo did not answer |
|---|---|---|
flagged | Stop this action: it reached your flag line, or you blocked or reported the person, and you have not pressed Trust on them. | false |
trusted | You pressed Trust on this person. | false |
blocked | You pressed Block on this person. | false |
band | allow, verify, review or block at your thresholds, and allow for a person you trust. Use verify to ask for a step your app already has. | null |
has(...codes) | Any of these reasons fired. | false |
certain(...codes) | Any of these fired at full strength. | false |
strength(code) | How strongly a reason fired: 0 when it did not, 100 when certain, negative for a trust reason. | 0 |
noToken | The token was missing, expired, unreadable or names no visitor of yours. False for a person you trust. | false |
eventId | The event’s id, for your log and for outcome(). | null |
visitorId | The person’s id across their browsers. Null with no usable token. | null |
toLog() | One flat object for a log line: event_id, visitor_id, flagged, listed, band, score, by (what decided), reasons (their codes), error (the error’s code, null when Heedeo answered) and trace_id (@heedeo/sdk 0.2.0-beta.1 or later: the failed call’s id for Heedeo support, null when Heedeo answered). It holds no email and no address. | The same, with the error’s code |
data | The whole answer, as the integration guide’s section 4 lists it. | null |
error | Why there is no answer: code, message, and status and apiCode when the API refused the call. | The error |
A verdict is still the { data, error } every call answers, so code written before it keeps working: const { data, error } = await heedeo.evaluate(...). data.flag does not know about Trust; data.flagged, Heedeo’s own reading on every answer, and verdict.flagged do.
A copy ({ ...verdict }, JSON, a server action’s return) keeps the values, flagged among them, and loses the four functions. To read a stored answer again, pass its data to verdictOf().
Read this, not that, in data:
| To know | Read | Not |
|---|---|---|
| whether to stop the action | verdict.flagged, or flagged without the package | flag, decision.flag |
| the band | verdict.band, or suggested without the package | decision.band |
| the score | score | decision.score, risks[r].score |
| the flag line | thresholds.review | threshold |
| why | decision.by, decision.risk, decision.judged |
The fields under Not stay in every answer, for the code that already reads them. On a workspace still on the legacy verdict, decision is a preview of the decision and can disagree with flag, so it does not say why: read it as the reason only where decision.flag equals flag, the check toLog().by makes. The same holds for an answer stored while a workspace was legacy.
To read an answer you already hold, such as a webhook’s event or one in a test:
import { verdictOf } from "@heedeo/sdk";
const verdict = verdictOf({ data: message.data.event, error: null });Trust and Block, in each place
One label, named four ways. It sits on a person, an email address or a network; Review and an assistant set it on a person or an email address, and a network’s comes through the package or POST /v1/lists.
| In Review | In the package | In an assistant (MCP) | On the wire |
|---|---|---|---|
| Trust | trust(), verdict.trusted | label_visitor with action: "allow" | POST /v1/lists action: "allow"; a later evaluation’s listed: "allow" |
| Block | block(), verdict.blocked | label_visitor with action: "block" | POST /v1/lists action: "block"; a later evaluation’s listed: "block" |
| Press the pressed button again (a person), or Clear (an email) | clearLabel() | label_visitor with action: "clear" | POST /v1/lists op: "remove", which answers removed; a later evaluation’s listed: null when no other label matches |
An evaluation’s listed reads every label that matches it, the person’s, the email’s and the network’s, and a Block wins over a Trust.
A Trust also keeps Auto-confirm (Settings) off the flags it covers, on the person, the email or the network: they are still raised and sent as flag.created, and wait in Review for you, unless a Block outweighs the Trust, as in listed.
Dismiss is not Trust. Dismiss on a flag records the outcome legit, as outcome(), resolve_flag with dismiss and POST /v1/outcomes do: the person’s next events score lower, and verdict.trusted stays false. Only a Trust label makes flagged false whatever the score.
trust(), block() and clearLabel() need @heedeo/sdk 0.2.0-beta.1 or later.
Methods
Every method answers { data, error } and never throws, with two exceptions: proxy() answers a Response, and verifyWebhook() throws when it is handed a parsed body, no headers or a toleranceSeconds that is not a whole number of seconds, since each is a mistake to catch on your first test. Checking error narrows data.
| Method | What it does |
|---|---|
heedeo.evaluate(input) | Records an action and answers a verdict. Use it where you decide. |
heedeo.logEvent(input) | Records an action and answers its event_id and notes. Heedeo scores and flags the event the same. |
heedeo.outcome({ eventId, outcome }) | Reports what an event, a visitor or an account turned out to be. Its retry carries the same requestId from 0.2.0-beta.1. |
heedeo.evaluation(eventId) | Reads what Heedeo answered for an event again, as it was given: { answeredAt, action, verdict }, history and never a decision now. 0.2.0-beta.1 or later. |
heedeo.visitor(id) | Reads a person: their browsers, counts and standing risk. |
heedeo.deleteVisitor(id) | Deletes everything Heedeo holds of a person. Safe to send again. |
heedeo.trust(target), heedeo.block(target) | Sets your Trust or Block label on { visitorId }, { email } or { network }, with an optional requestId. Safe to send again. 0.2.0-beta.1 or later. |
heedeo.clearLabel(target) | Takes the label off. Answers { removed }. Safe to send again. 0.2.0-beta.1 or later. |
heedeo.proxy(request) | Serves Heedeo’s script and identification from a route in your app. |
verifyWebhook({ body, headers, secret }) | Checks a webhook’s signature and answers its typed message. |
heedeo.preload() in the browser | Loads Heedeo’s script ahead of the click. Identifies nobody, returns nothing, never throws. |
heedeo.identify() in the browser | Answers { token, visitor_id, confidence } for one action. On a signed-in page, pass who it is: identify({ userId, email }). |
evaluate() and logEvent() record one event, in one request. A failed request (a 5xx or a network error) is tried a second time under the same requestId, inside timeoutMs. From 0.2.0-beta.1, trust(), block(), clearLabel() and outcome() do the same: each makes a requestId (or takes yours) and sends it again on its retry, so a retry whose first attempt landed writes nothing.
evaluate() and logEvent() take token, action, email, userId, evidence, status (attempted, succeeded or failed), requestId, actingFor (the account an agent acts for), and either request or a server action’s headers and path. The call’s own fields are camelCase; evidence keeps the names its sources use, which are Heedeo’s wire names, so card.checks.cvc_check goes in as cvc_check.
Options
Server, new Heedeo(options):
secretKey: defaultHEEDEO_SECRET_KEYfrom the environment.clientIp(request): only with your own proxy route. Without it, each event takes the address Heedeo saw when the browser identified.timeoutMs: default 3000, for a call and its retry together.path: the routeproxy()serves, default/hd.baseUrl: defaultHEEDEO_API_URLwhen set, elsehttps://app.heedeo.com. HTTPS only, except on localhost.fetch: for tests.
Browser, createHeedeo(options):
publishableKey.endpoint, a host on your domain, orpath, your route on the page’s origin. With neither,https://heedeo.dev.timeoutMs: the longestidentify()may hold the action, default 6000, for loading the script and identifying together. Its last 3 seconds (half of it, when you set less than 6000) are kept for the identification, so a slow download cannot use them up.
Browser methods: preload(), which loads the script and returns nothing, and identify().
Types
Every type is exported, so your own helpers can name what they take and return:
import type {
EventInput,
Evidence,
HeedeoErrorCode,
HeedeoOptions,
OutcomeInput,
Verdict,
VerdictLog,
WebhookEvent,
} from "@heedeo/sdk";
import type { Identification } from "@heedeo/sdk/browser";
function refusal(verdict: Verdict): Response | null {
return verdict.flagged
? Response.json({ error: "signup_unavailable" }, { status: 403 })
: null;
}Verdict is what evaluate() answers and VerdictLog what toLog() returns. API_VERSION is the dated API version the package sends with each call, and WEBHOOK_TYPES lists every webhook type.
Errors
A failure is a value: { data: null, error: { code, message, status?, apiCode?, traceId? } } (traceId from @heedeo/sdk 0.2.0-beta.1 or later). code is a HeedeoErrorCode, so TypeScript checks a switch over it.
traceId is the id of the request Heedeo answered, from its heedeo-trace-id header (@heedeo/sdk 0.2.0-beta.1 or later). Quote it to Heedeo support when a call fails: it finds the request in Heedeo’s log. toLog() carries it as trace_id. A network_error, or a timeout before any answer, has none, since nothing came back; when a 5xx was tried again and the second attempt got no answer, it is the 5xx’s.
code | What happened | What to do |
|---|---|---|
invalid_key | The key is missing, malformed or refused (401 or 403). Every call answers { data: null, error: { code: 'invalid_key' } }, and a missing or malformed key also warns on the console when the client is made. | Set the key where the code runs. A deploy without it cannot break signup, and checks nobody. |
api_error | Heedeo refused the call or failed. status is the HTTP status and apiCode the API’s own code, such as invalid_request or rate_limited: the guide’s errors table has every one. For invalid_request, message says what to send instead, and the client warns with it on the console. | For invalid_request, read message and fix the call. A rate_limited or a 5xx is not yours to fix: carry on. |
network_error | Heedeo could not be reached, after the retry. | Carry on. |
timeout_or_aborted | No answer inside timeoutMs, or the request was aborted. | Carry on. |
invalid_response | The answer was not one. | Carry on, and tell us. |
missing_client_ip | Your clientIp function returned no address. | Fix it for your host (Serve Heedeo from your own domain, above). |
configuration_error | A call was shaped wrongly, such as a server action’s headers with no host. | Read message. |
In the browser, identify() answers invalid_key, configuration_error, browser_required, timeout, invalid_response or identification_unavailable. A page that also carries Heedeo’s script tag answers identification_unavailable: keep the tag or the package. So does a script that could not load: check path and endpoint. verifyWebhook answers invalid_key, webhook_headers_missing, webhook_timestamp_invalid, webhook_signature_invalid or webhook_body_invalid.
The browser client never throws, even on a mistake in its options, since it runs on the page it protects. The server client throws only when you construct it with a bad option, such as an http:// baseUrl off localhost. Neither logs credentials, request bodies or upstream error bodies. A browser timeout stops the wait and cannot cancel an identification under way: after one, send no token rather than an older one.
What it runs on
An ES module for Node.js 20.18 or later and Cloudflare Workers with nodejs_compat, with a CommonJS copy for require(). Load it with import, or in a CommonJS app with const { Heedeo } = require("@heedeo/sdk"), which works on the same Nodes and in Jest with no flags in 0.2.0-beta.1 or later (on 0.1.0-pilot.18 or earlier, require() fails with ERR_PACKAGE_PATH_NOT_EXPORTED: update it with npm install @heedeo/[email protected], and until that version is published, use import("@heedeo/sdk") inside an async function, as the integration guide’s “Express and other servers” shows). Where Node can require() an ES module (20.19 and later 20 releases, and 22.12 and after), require() loads the same file import does, so an app that uses both holds one copy of the package; Node 22.12, and 23.0 to 23.4, print an ExperimentalWarning when it does. Elsewhere require() loads the CommonJS copy, and an app that uses both holds two. On Cloudflare Workers, use import: a require() there makes a larger Worker. TypeScript compiled to CommonJS is given CommonJS types (.d.cts) for what require() loads, so module: commonjs, node16, nodenext and node20 all take the import. The browser entry is separate and imports no server code. Other edge runtimes have not been verified. React hooks and an Express wrapper are not included; on a stack the package does not run on, the integration guide has a helper that sends the same call, less the heedeo-sdk header that names the package’s version.
Cloudflare Workers
Enable nodejs_compat in Wrangler. Create the client inside the request handler so keys come from Worker secrets:
import { Heedeo } from "@heedeo/sdk";
export default {
async fetch(request, env) {
const heedeo = new Heedeo({
secretKey: env.HEEDEO_SECRET_KEY,
clientIp: (request) => request.headers.get("cf-connecting-ip"),
});
if (new URL(request.url).pathname.startsWith("/hd/")) {
return heedeo.proxy(request);
}
// In your signup handler, use heedeo.evaluate() as above.
return env.ASSETS.fetch(request);
},
};This is the own-route way (Serve Heedeo from your own domain, above): the browser names it with createHeedeo({ publishableKey, path: '/hd' }). The IP resolver is for a Worker receiving requests through Cloudflare, not an unprotected origin. If you use static assets, set assets.run_worker_first for /hd/* and your signup route. Store HEEDEO_SECRET_KEY with wrangler secret put; only the publishable key belongs in the browser.
Next steps
- Serve Heedeo from your own domain, then the rest of Before you go live.
- Write rules of your own: how strong each signal is, and rules to start from.
- Ask a person to verify, or hold a signup for review, in place of refusing.
- Receive webhooks when a flag is confirmed or a person is trusted, blocked or deleted.
- Report what you learn, so Heedeo fits its weights to your traffic.
- The whole answer, field by field, and every reason with its default weight.