> ## Documentation Index
> Fetch the complete documentation index at: https://fdbad6a-heedeo-e1965c.manicule.dev/llms.txt
> Use this file to discover all available pages before exploring further.
> For every page in one file, fetch https://fdbad6a-heedeo-e1965c.manicule.dev/llms-full.txt (large).
> To search and read these docs as tools, connect the MCP server at https://fdbad6a-heedeo-e1965c.manicule.dev/api/mcp.
> Every agent surface: https://fdbad6a-heedeo-e1965c.manicule.dev/.well-known/llms-manifest.json

# 3. One server call: evaluate or log (/one-server-call)

<!-- agent-signals: reading_time_min: 35 · est_tokens: 14226 · updated: 2026-11-11 -->
Related: [Integrating Heedeo](/index.md), [Connect your app](/connect-your-app.md), [1. Quick start](/quick-start.md), [2. Serve Heedeo from your own domain (do this before you go live)](/serve-heedeo-from-your-own-domain.md), [4. The fields to act on](/the-fields-to-act-on.md), [5. Decide: score, rules or watch](/decide.md)

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.

```ts
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`. Send `client_ip` (the end user's IP address) and `headers` as well whenever your server sees the end user's request. Without `client_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_ip` wins.
* **`token`**, what `identify()` gave. When there is none, leave it out and still send the event (below).
* **`email`**, and **`user_id`** once the account exists.
* **`acting_for`**, when an agent acts for one of your users: that user's account id (the principal; `actingFor` in the package). It is hashed on arrival like `user_id`, never stored raw.
* **`headers`**: the end user's request headers that Heedeo reads, never `cookie` or `authorization`. They are the user agent, the accepted types and languages, client hints, the fetch metadata headers, the agent-signature headers and your edge's country header; the helper below lists them, and the package sends the same. Add `host`, `:method` and `:path` of that request, which agent signatures (Web Bot Auth) cover, and `:forwarded`: the header names your code looks for, comma-separated, so Heedeo can tell a header the request lacked from one you do not send.
* **Always send a `request_id`**, made once per signup attempt and reused if you retry the call. A retry with the same `request_id` returns the stored answer. A label write (`POST /v1/lists`, section 5, Let a real person through) and a report (`POST /v1/outcomes`, section 9) take a `request_id` too, compared among those two calls only and kept 72 hours; an events `request_id` is separate.
* **`status`**, when you know how the action ended: `succeeded`, or `failed` when your server refused it or it errored. Leave it out and the event is `attempted`.

A retry under a new `request_id`, a double submit, or the form sent again after your server refused it (a taken username, a short password) is caught too when it is the same event. The same 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_token` in `reasons` (and in `logEvent()`'s `notes`); a package before 0.1.0-pilot.18 answers `error.code` `missing_token` and sends nothing;
* a 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_expired` or `unknown_visitor` in `reasons` (a spent one answers `receipt_reused`, which flags).

Proceed without signals only when Heedeo itself is unavailable: a network error, a timeout, a 5xx after the retry, or a missing or wrong key (401 or 403). 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:

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

```ts
// 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):

```js
// 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/sdk@0.2.0-beta.1`, 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). Call `identify()` just before the handoff and send the token to your own server, which keeps it with the OAuth `state` it is about to send; the callback validates `state` and reads the token back from there. That works for every provider. Reading the `_idt` cookie in the callback instead (`Path=/`, `SameSite=Lax`) works only for a provider that comes back with a top-level GET. Sign in with Apple comes back with a cross-site form POST (`response_mode=form_post`) whenever it asks for the name or email, and a browser sends no `SameSite=Lax` cookie on that. On the cookie route, clear it first (`document.cookie = "_idt=; Max-Age=0; Path=/"`): `identify()` clears it itself, but a script that never loaded leaves the last spent one, which flags as `receipt_reused`. Either way, a person who spends more than five minutes at the provider comes back with an expired 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:

```ts
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
```

```ts
// 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 `signup` when the API sees the user for the first time and `login` after that, with the email, `userId` (the provider's user id) and `evidence: { email: { verified } }` as the provider reports it (`false` is fine). This records the person; the account already exists at the provider, so there is nothing to refuse here.
* The call that grants what an abuser wants: a trial start, credits, a coupon. Send its own name from the list, such as `trial_start`, or `redeem:CODE` for credits or a coupon, with the same email, `userId` and evidence. Your plan's rule (section 5) runs here: refuse the grant, not the account, with any status your page handles (403 is fine) and one generic message.

```ts
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](https://app.heedeo.com/docs/api.json): 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`:

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

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

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

```ts
"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](https://app.heedeo.com/docs/api.json), 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 no `trace_id` in the body.
* The package's `proxy()` passes back an identify answer's body, so an error's `trace_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.

```json
"evidence": {
  "email":   { "verified": true, "verified_at": "2026-10-26T09:00:00Z" },
  "phone":   { "number": "+14155550123", "verified": true, "line_type": "mobile", "country": "US" },
  "oauth":   { "provider": "google", "email_verified": true, "hosted_domain": "acme.com" },
  "account": { "created_at": "2025-06-01T00:00:00Z", "plan": "paid", "paid_since": "2026-08-01T00:00:00Z" },
  "payment": { "funding": "credit", "card_fingerprint": "Xt5EWLLDS7FJjR1c", "cvc_check": "pass", "wallet": "apple_pay",
               "three_ds": "authenticated", "country": "US", "radar_risk_level": "normal" },
  "verification": { "kind": "otp", "result": "passed", "at": "2026-10-26T09:01:00Z" }
}
```

The values:

* `phone.line_type`: `mobile`, `landline`, `fixed_voip`, `non_fixed_voip`, `toll_free`, `prepaid` or `unknown`. You may send `phone.hash`, your own hash of the number, instead of `number`: one or the other, and always the same one.
* `oauth.provider`: `google`, `apple`, `github`, `microsoft` or `other`, with `real_user_status` (Apple's 0, 1 or 2), `private_relay`, `account_created_at` and `two_factor` where the provider says them. Signing in with a provider never lowers a score: it proves someone holds an account there, not that they are one real person. Its `email_verified` proves the address (below) and `private_relay` is `relay_email`; the other fields are stored and add no weight.
* `account.plan`: `free`, `trial` or `paid`.
* `payment.funding`: `credit`, `debit` or `prepaid`. `cvc_check`: `pass` or `fail`. `wallet`: `apple_pay`, `google_pay`, `link` or `none`. `three_ds`: `authenticated`, `failed` or `not_attempted`. `radar_risk_level` and `free_trial_abuse_risk`: `normal`, `elevated`, `highest`, `not_assessed` or `unknown`.
* `verification.kind`: `otp`, `email_link`, `3ds`, `id_document`, `liveness` or `captcha`. `result`: `passed` or `failed`.
* Times are ISO 8601; countries two letters. Leave out what a source says outside these values (Stripe's `unknown` funding, Twilio's `personal` line).

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

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

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

The one-liner from each source:

### Stripe

From the PaymentMethod `pm` and, after a payment, its charge `ch`:

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

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

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

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

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

**Deprecated:** earlier integrations send `once` with the call. It is 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.
