> ## Documentation Index
> Fetch the complete documentation index at: https://1da7f22-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://1da7f22-heedeo-e1965c.manicule.dev/llms-full.txt (large).
> To search and read these docs as tools, connect the MCP server at https://1da7f22-heedeo-e1965c.manicule.dev/api/mcp.
> Every agent surface: https://1da7f22-heedeo-e1965c.manicule.dev/.well-known/llms-manifest.json

# Heedeo package quickstart (/sdk)

<!-- agent-signals: reading_time_min: 45 · est_tokens: 18954 · updated: 2026-11-12 -->
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), [3. One server call: evaluate or log](/one-server-call.md), [4. The fields to act on](/the-fields-to-act-on.md)

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:

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

`verdict.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](https://app.heedeo.com/review/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](https://docs.heedeo.com): 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](#trials-coupons-and-logins) for where step 4's call goes, then step 5 and [Before you go live](#before-you-go-live). |
| want to refuse on reasons you choose                        | Steps 1 to 5, then [Write your own rules](#write-your-own-rules).                                                                                                    |

## 1. Install

```sh
npm install @heedeo/sdk
```

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:

```sh
npm ls @heedeo/sdk
# └── @heedeo/sdk@0.2.0-beta.1
```

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: update it with `npm install @heedeo/sdk@latest`.

## 2. Add your keys

Get both keys when you [sign up](https://app.heedeo.com/review/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.

```sh
# .env.local
HEEDEO_SECRET_KEY=sk_your_secret_key
NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_key
```

> **Keep the secret key on your server.** Never put it in a `NEXT_PUBLIC_` or `VITE_` 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`.

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

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

```tsx
useEffect(() => heedeo.preload(), []);
```

Then, in your form's submit handler, identify just before you post, and send the token along:

```ts
// 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 first `identify()` 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), `data` is null within 6 seconds, or 3 after a `preload()`. 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.

```ts
// 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 support@yourcompany.com.",
      },
      { 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:

```json
{"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](https://app.heedeo.com/review) 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:

```sh
curl -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
  "https://app.heedeo.com/v1/setup-status?event_id=79c7b0de-4c1e-4b47-9d0f-3a1c2b6e5f10"
```

```json
{
  "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](#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:

1. **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.
2. **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](#serve-heedeo-from-your-own-domain). `served` in the `setup-status` answer above is the first time it worked and stays set after that, so to see it still works, press **Check** on the Domains page.
3. **With a route in your own app, `clientIp` returns the real visitor's address on your host.** A `null` means `missing_client_ip`, and every signup goes on unchecked.
4. **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](https://docs.heedeo.com/one-server-call#call-heedeo-on-every-signup-path).
5. **Your allowed origins are listed on the Domains page.** Until some are, any website may load Heedeo with your publishable key.
6. **Your privacy policy says so.** The integration guide has [the sentence to add](https://docs.heedeo.com/privacy#for-your-privacy-policy).
7. **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.
8. **Your server logs `verdict.toLog()` for every signup.** Its `event_id` is how you, and we, find a signup later.
9. **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:

```ts
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](https://docs.heedeo.com/rules) says how strong each signal is and has rules to start from. The integration guide's [section 5](https://docs.heedeo.com/decide) 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/sdk@latest`, 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.                                                                                                                                                                                                                                                                                                                                                   |
| `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](#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](#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:

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

```ts
const verdict = await heedeo.evaluate({
  token,
  action: "trial_start",
  email,
  userId,
});
if (verdict.flagged) return noTrial(); // the account stays, with no trial
```

Each 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](https://docs.heedeo.com/one-server-call#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:

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

```ts
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](https://docs.heedeo.com/one-server-call#evidence-what-your-platform-knows-about-the-person) 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.

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

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

```tsx
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](https://app.heedeo.com/review/domains) serves Heedeo from your domain, pass its endpoint and change nothing on your server:

```ts
// lib/heedeo-browser.ts
export const heedeo = createHeedeo({
  publishableKey: process.env.NEXT_PUBLIC_HEEDEO_KEY,
  endpoint: "https://www.customer.com/k9m2x",
});
```

* `endpoint` is 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.dev` inside the same 3 seconds, so the signup still gets a token. The Domains page says when this happens.
* Pass `path` or `endpoint`, 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`:

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

```ts
// 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-for` as "the public IP address of the client that made the request", overwrites it, and does not forward external IPs, to prevent spoofing ([request headers](https://vercel.com/docs/headers/request-headers)). `x-real-ip` carries the same value. `VERCEL` is 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-ip` is 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's `x-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.com` address 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()` answers `missing_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.

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

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

```ts
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()`.

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

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

```ts
// verdict.flagged is false for them from their next event
const { error } = await heedeo.trust({ visitorId });
// or block({ visitorId }): flagged on every action
// or clearLabel({ visitorId }): back to no label
// No label changed: try again, or use the buttons under Visitors.
if (error) throw new Error(`Heedeo label not set: ${error.code}`);
```

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

```ts
// made for this Trust, kept across your retries
const requestId = crypto.randomUUID();
const { error } = await heedeo.trust({ visitorId, requestId });
if (error) throw new Error(`Heedeo label not set: ${error.code}`);
```

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:

```js
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;
      // Deliveries can overlap, a retried decision can arrive after a
      // newer one, and one person can have several flags. So hold your own
      // lock on the person (a row lock in your table) while you read, save
      // and act.
      await withVisitorLock(visitor_id, async () => {
        const newest = await newestDecision(id);
        // the same decision again acts again, in case it failed before, so
        // suspending and restoring must be safe to run twice
        if (newest && new Date(decided_at) < newest) return;
        await saveNewestDecision(id, new Date(decided_at), status);
        // your own code: hold the person while any of their flags is confirmed
        if (await anyConfirmed(visitor_id)) await suspendVisitor(visitor_id);
        // dismissed, withdrawn or undone, none left confirmed: lift it
        else await restoreVisitor(visitor_id);
      });
    }

    await markHandled(message.id);
    res.sendStatus(204); // within 10 seconds: queue anything slow
  },
);

app.use(express.json()); // the rest of your app
```

In 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](https://docs.heedeo.com/webhooks) 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](https://app.heedeo.com/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`, and update with `npm install @heedeo/sdk@latest`.

| 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](https://docs.heedeo.com/the-fields-to-act-on) 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:

```ts
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`: default `HEEDEO_SECRET_KEY` from 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 route `proxy()` serves, default `/hd`.
* `baseUrl`: default `HEEDEO_API_URL` when set, else `https://app.heedeo.com`. HTTPS only, except on localhost.
* `fetch`: for tests.

Browser, `createHeedeo(options)`:

* `publishableKey`.
* `endpoint`, a host on your domain, or `path`, your route on the page's origin. With neither, `https://heedeo.dev`.
* `timeoutMs`: the longest `identify()` 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:

```ts
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](https://docs.heedeo.com/one-server-call#errors-from-the-api) 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/sdk@latest`). 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:

```ts
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](#serve-heedeo-from-your-own-domain), then the rest of [Before you go live](#before-you-go-live).
* [Write rules of your own](https://docs.heedeo.com/rules): how strong each signal is, and rules to start from.
* [Ask a person to verify, or hold a signup for review](https://docs.heedeo.com/decide), in place of refusing.
* [Receive webhooks](https://docs.heedeo.com/webhooks) when a flag is confirmed or a person is trusted, blocked or deleted.
* [Report what you learn](#report-what-you-learn), so Heedeo fits its weights to your traffic.
* [The whole answer, field by field](https://docs.heedeo.com/the-fields-to-act-on), and [every reason with its default weight](https://docs.heedeo.com/the-fields-to-act-on).
