# Heedeo package quickstart The Heedeo package for Node.js 20.18+, Next.js App Router, and Cloudflare Workers with `nodejs_compat`. It handles browser loading, same-domain forwarding, event requests, timeouts and one retry. Heedeo returns signals; your app still owns signup and authentication. ## 1. Install ```sh npm install @heedeo/sdk ``` The package is published on npm as `@heedeo/sdk`. It has no runtime dependencies. Get your keys at https://app.heedeo.com/review/sign-up. Save the secret in your server's environment settings, including your deployed environment. A plain Node server reads `.env.local` with `node --env-file-if-exists=.env.local` (Node 22.9 or later). Never put it in a `NEXT_PUBLIC_` variable. ```sh HEEDEO_SECRET_KEY=sk_your_secret_key NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_key ``` ## 2. Configure the server once ```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, with nothing else in front: the last x-forwarded-for entry (see below). // return request.headers.get('x-forwarded-for')?.split(',').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, `event()` 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. ## 3. Add one route, or use your endpoint Your app forwards Heedeo's two paths itself. If the Domains page (https://app.heedeo.com/review/domains) already serves Heedeo from your domain, skip this step and pass its endpoint instead (step 4). ```ts // app/hd/[...path]/route.ts import { heedeo } from '../../../lib/heedeo'; export const GET = heedeo.proxy; export const POST = heedeo.proxy; ``` This assumes `lib` is at the project root, as in the runnable example. **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 step 4. In Hono, after your CORS middleware, since the route needs the API's credentialed CORS for your pages' origin (the script posts with credentials; the proxy passes back only the CORS answer Heedeo gives for your workspace's allowed origins): ```ts app.all('/hd/*', (c) => heedeo.proxy(c.req.raw)); ``` The integration guide's section 3, "A single-page app, a hosted sign-in and a separate API", has the rest: where the events go when sign-in is hosted, the token in the JSON body, and both sign-in paths. 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=`, 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" } }`: 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). **Breaking in 0.1.0-pilot.16:** the proxy route sends your secret key (`authorization: Bearer`) with the visitor's IP (`x-heedeo-client-ip`), and Heedeo uses that IP only from a proxy that sends the key. On 0.1.0-pilot.15 and earlier the signals that need the visitor's IP (`network_changed`, shared addresses, same machine) are off, and the Domains page says so. Upgrade to 0.1.0-pilot.16 or later. ## 4. Identify just before signup ```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, }); ``` In your existing submit handler: ```ts const { data } = await heedeo.identify(); await signup({ email, heedeoToken: data?.token }); ``` A form that calls a server action through React's `useActionState` (the default shape in Next.js 15 and 16) keeps the hook. An `onSubmit` handler identifies first, then dispatches the action with the token in the form: ```tsx 'use client'; import { type FormEvent, startTransition, useActionState, useState } from 'react'; import { heedeo } from '@/lib/heedeo-browser'; // @/ is Next's default alias; use a relative path if your project has none import { signup } from './actions'; export function SignupForm() { const [state, action, pending] = useActionState(signup, { message: '' }); const [identifying, setIdentifying] = useState(false); async function onSubmit(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); setIdentifying(true); const { data, error } = await heedeo.identify(); setIdentifying(false); if (data) form.set('heedeo_token', data.token); if (error) console.warn('Heedeo', error.code); // why there is no token; signup goes on startTransition(() => action(form)); } return (
{state.message &&

{state.message}

}
); } ``` `pending` covers only the action, so `identifying` keeps the button disabled during the identify wait too. The action reads the token from the form (step 5). Keep your existing `signup` function and its validation, loading state and error handling. The package loads the script once, waits for it, and returns a new receipt for each call. No script tag or global type declaration is needed. Identification happens at the action, not on every page load. If identification fails, `data` is `null` and `error` explains the problem; continue signup without signals. Do not mix the package with an existing Heedeo script tag. With an endpoint from the Domains page, pass it as `endpoint` in place of `path` and add no route, for example `createHeedeo({ publishableKey, endpoint: 'https://www.customer.com/k9m2x' })`. - `endpoint` needs 0.1.0-pilot.12 or later (`npm install @heedeo/sdk@latest`). - It is an HTTPS origin with an optional path and no trailing slash. The script loads from `${endpoint}/i.js`. - The same option takes your API's route (`createHeedeo({ publishableKey: import.meta.env.VITE_HEEDEO_KEY, endpoint: 'https://api.example.com/hd' })`, step 3). - Plain http is accepted on localhost, 127.0.0.1 and `*.localhost` from 0.1.0-pilot.17, for local development. - Pass `path` or `endpoint`, not both. Installed with the route before the domain was live? Change only this call. The server's `event` call stays the same, with its `clientIp`, and the route can go once the endpoint answers. ## 5. Record the event on your server Call it in the code that handles signup, after validating the submitted fields and before the account is created. That is where your rule can refuse a signup. Name the action from Heedeo's list, and Heedeo knows what it means: | `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` | `signup`, `trial_start`, `redeem:CODE` and `payout` are done once: Heedeo flags the same person doing one again under another email or account. Any other name is stored and judged on nothing, so only your Block label or abuse report flags it. Spell the five as they are here: from this version of the package, on a workspace on the decision, a near-miss (`sign_up`, `register`, a `redeem` without its code) is refused. **Two calls, one pipeline.** `heedeo.evaluate()` records the action and answers its evaluation: use it where you decide now (a signup, a trial, a coupon, a payout). `heedeo.logEvent()` records it and answers only the `event_id` and `notes`: use it for anything else you want in the person's history (a login, a page view, an action of your own). Heedeo scores and flags an event the same whichever call sent it, so the choice is only whether you want the answer. `heedeo.event()` is the earlier name of `evaluate()`, and still works. Your editor completes the names, each with its meaning; the guide's section 3 has the rest. **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 your server has no signup to refuse. Send two events instead: - `signup`, from the first call your server receives after sign-in. - `trial_start`, or `redeem:CODE` for credits or a coupon, from the call that gives it. Run your rule here: a refused account exists and gets nothing. The guide's section 3, "A single-page app, a hosted sign-in and a separate API", has the full example. In a Next.js route handler, or any framework that hands you a Web `Request` (in Hono, `c.req.raw`), pass the `request` it received: ```ts const { data, error } = await heedeo.evaluate({ request, token: input.heedeoToken, action: 'signup', email: input.email, }); if (error) console.warn('Heedeo', error.code); // never stops signup console.info('Heedeo signup', data?.event_id, data?.visitor?.id, data?.listed, JSON.stringify(data?.decision), JSON.stringify(data?.risks)); // Your plan's rule goes here, then create the account. ``` That Web Request carries the actual URL, method and headers. The call does not read or consume its body. With no token (a blocker stopped the script, a path without `identify()`), make the call anyway: from 0.1.0-pilot.18 the event is still sent and scored on the email, the network and your labels, and its answer carries the note `no_token`, which weighs nothing on its own. Earlier versions sent nothing and answered `missing_token`. A Next.js server action has no `request`. Pass the request's headers and the path of the page whose form calls the action (0.1.0-pilot.17 or later): ```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 { data, error } = await heedeo.evaluate({ headers: await headers(), path: '/signup', // the page whose form calls this action token: (form.get('heedeo_token') as string | null) || null, action: 'signup', email, }); // log, then your plan's rule, as above return { message: '' }; } ``` The method is `POST` unless you pass `method`. Your `clientIp` function gets a request built from these headers, as it does in the proxy route. What happens next is the path you chose, which your coding agent asks you for. The integration guide's section 5 (https://app.heedeo.com/docs#5-decide-score-rules-or-watch) has each in full and how to test it: - **Let Heedeo decide.** Refuse when `data.flag` is true. It weighs every signal, and you move the line under Settings with no deploy. - **Set my own rules.** Your agent suggests rules for your app from the rules guide (https://app.heedeo.com/docs/rules), starting from the clear signals: `repeat_action`, `disposable_email`, `no_mx`, `listed_block`, `confirmed_abuse` or `receipt_reused` at full weight, a `base` of 100. - **Just watch.** Refuse no one. Log the `event_id`, the visitor's id, `listed`, `decision` and `risks`, and read the log before you turn a rule on. On either path that refuses, let `data.listed === 'allow'` through, treat `data === null` as no answer (never a refusal), and show one generic message, never a reason. 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, a check you ran. The phone number and card fingerprint are hashed on arrival and never stored raw. The integration guide has the one-liner for Stripe, Twilio Lookup, Google, Apple and GitHub. ```ts await heedeo.evaluate({ request, token, action: 'signup', email, userId, evidence: { phone: { number: '+14155550123', verified: true, line_type: 'mobile' }, account: { created_at: user.createdAt, plan: 'paid', paid_since: user.paidSince }, }, }); ``` `data.flag` is whether to stop this action. `data.decision` says why: its `band` (`data.suggested` holds the same), its `score`, the `risk` that reached your flag line, and `by`, what decided: `risk`, your Block label (`label`) or your abuse report (`report`). The guide's section 4 shows the whole answer. `data.risks` is what Heedeo answers about the person, whether or not Heedeo judges this action on it: today `account_abuse`, one person making more than one account, with its `score`, `judged` and `reasons`. "One trial per person" is then this one call, with no table on your side: ```ts const { data } = await heedeo.evaluate({ request, token, action: 'trial_start', email, userId }); if (data?.listed !== 'allow' && data?.flag) return noTrial(); // a repeat_action, or anything as strong ``` At a sign-in, send `login` with the `userId`. Heedeo judges no sign-in: your own rule reads `data.account`, as the guide's section 5, At login, shows. `data.visitor` is the person: one id across their browsers, decided by Heedeo. - An id you stored earlier is the visitor's id or one of its `aliases`, and still resolves. - `confidence` is how sure Heedeo is that these browsers are one person: 1 when certain, less when likely. - `actions` is what the person did, 13 months of it. ```ts data.visitor = { id: '0f8e…', confidence: 1, first_seen: '2026-10-23T09:12:00Z', last_seen: '2026-10-30T18:40:00Z', emails: { '24h': 1, '7d': 1, all: 1 }, accounts: { '24h': 0, '7d': 0, all: 0 }, browsers: [ { id: '0f8e…', kind: 'stored', confidence: 1, merged_by: 'this browser', evidence: {}, first_seen: '…', last_seen: '…' }, { id: '7c21…', kind: 'declared', confidence: 1, merged_by: 'same email', evidence: { kind: 'email' }, first_seen: '…', last_seen: '…' }, ], aliases: ['7c21…'], actions: { trial_start: { count: 2, first: '2026-10-23T09:12:00Z', last: '2026-10-30T18:40:00Z' } }, }; ``` `browsers`, their `evidence`, `aliases` and `actions` are optional in the types, as are `confidence`, `merged_into` and `recent` on a visitor lookup: an answer you stored before they existed still validates. `data.related` lists other visitors' browsers only; the person's own are in `data.visitor.browsers`. The package retries a failed event once, and Heedeo counts it once. If your application retries the entire action, provide its stable ID as `requestId`. A new user action needs a new identification and ID. Do not send two events with the same receipt to attach a user ID later. ## 6. 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 your outcomes to tune your weights later: the more you report, the sooner your own numbers replace the defaults. ```ts await heedeo.outcome({ eventId: data.event_id, 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`. - `label` is your own short word, `note` one line for your reviewers; both are optional. - One outcome per event, visitor or account: a later report replaces the earlier one. The same `{ data, error }` shape comes back, with `kind`, `event_id` or `visitor_id`, `outcome`, `label`, `source`, `note` and `created_at`. Reviewers can report the same thing on the flags page by resolving a flag as Abuse or Legit. ## 7. Ask about a visitor Between events, read who a visitor is and what is known about them now, with trust counted: `data.risks.account_abuse.score` and its reasons. It is not a verdict on any action: to decide on a payout, send `payout` as an event and read its `flag`. An id a merge retired answers the visitor it joined, with `visitor.merged_into` set: ```ts const { data } = await heedeo.visitor(visitorId); console.info('Heedeo visitor', data?.visitor.id, JSON.stringify(data?.risks)); ``` ## 8. Delete a person When someone asks you to delete their data, delete what Heedeo holds of them, all at once: their browsers, identifications, events, actions and outcomes, and what joins their browsers into one person. An id a merge retired deletes the visitor it joined. Your list labels are kept. ```ts const { data, error } = await heedeo.deleteVisitor(visitorId); // data: { id: '0f8e…', deleted: { browsers: 2, events: 12, actions: 12, … } } ``` The call is not retried: a retry after a deletion that landed would answer 404. A visitor that is not yours, or is already deleted, is a 404 `api_error`. ## 9. 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` (0.1.0-pilot.17 or later). 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); } if (message.test) return res.sendStatus(204); // Send test: sample ids, change nothing if (await alreadyHandled(message.id)) return res.sendStatus(204); // a message can arrive twice if (message.type === 'flag.decided' && message.data.flag.visitor_id) { const { id, status, visitor_id, decided_at } = message.data.flag; // Order is not promised: a retried decision can arrive after a newer one, so keep each flag's // newest decided_at (your own table) and act only on a newer one. const newest = await newestDecision(id); if (!newest || new Date(decided_at) > newest) { if (status === 'confirmed') await suspendVisitor(visitor_id); // your own code if (status === 'open') await restoreVisitor(visitor_id); // the decision was undone await saveNewestDecision(id, new Date(decided_at)); } } 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. Then press Send test on the endpoint's page. The guide's section 12 (https://app.heedeo.com/docs#12-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. ## Check that it works Run a test signup and check the server result: an `event_id` and a non-null `visitor` confirm a usable identification. `flag: false` is normal; an empty flags screen is not evidence of failure. Check that signup still completes with the script blocked or Heedeo unavailable. Never expose the full signals response or internal reasons to an end user. Test the deployed app over HTTPS, in Safari as well as in Chrome. Review the privacy wording in https://app.heedeo.com/docs#for-your-privacy-policy before going live. ## Options and errors Server options: - `secretKey`. - Required `clientIp(request)`. - Optional `path` (default `/hd`), `timeoutMs` (default 3000 total across event retries), `baseUrl`, and `fetch` for testing. Without `baseUrl`, the client uses `HEEDEO_API_URL` when it is set and not empty, else `https://app.heedeo.com`. For a Heedeo other than app.heedeo.com (staging, a local stack), set that variable and pass no `baseUrl`. Where there is no `process`, the default stands. Custom origins require HTTPS, except localhost, whether they come from `baseUrl` or the variable. Browser options: `publishableKey`, matching `path` or an `endpoint` on your domain (https, or http on localhost), and `timeoutMs` (default 3000 total for script loading and identification). Raising it gives the script longer to load; the identification inside keeps its own 3 seconds. A missing or malformed key no longer throws (from 0.1.0-pilot.17): every call answers `{ data: null, error: { code: 'invalid_key' } }` and the client warns once on the console, so a deploy without the variable cannot break signup. The browser client never throws on a mistake in its options (after 0.1.0-pilot.17): a bad `endpoint`, both `path` and `endpoint`, or a bad `timeoutMs` warns once on the console and every call answers `configuration_error`, since `createHeedeo` runs at module scope on the page it protects. The server client's mistakes, such as an `http://` `baseUrl` off localhost, still throw when you construct it. Operational failures return `{ data: null, error: { code, message, status? } }`; checking `error` narrows `data` to the success value. `code` is a `HeedeoErrorCode`, so a `switch` over it is checked by TypeScript: - Server: `missing_client_ip`, `invalid_key`, `api_error`, `invalid_response`, `network_error`, `timeout_or_aborted`, `configuration_error`. `missing_token` stays in the type for code written against a version before 0.1.0-pilot.18, and is no longer answered. - Browser: `invalid_key`, `configuration_error`, `browser_required`, `timeout`, `invalid_response`, `identification_unavailable`. A page that also carries Heedeo's script tag answers `identification_unavailable` too, with a message saying so: keep the tag or this package, not both. - `verifyWebhook`: `invalid_key` (a missing or malformed secret), `webhook_headers_missing`, `webhook_timestamp_invalid` (more than 5 minutes from your clock; `toleranceSeconds` changes it), `webhook_signature_invalid`, `webhook_body_invalid`. Neither entry point logs credentials, request bodies or upstream error bodies. A browser timeout stops the wait, but cannot cancel an identification already under way in this version. Always pass the token `identify()` returned; after a timeout, send no token rather than an older one. Browser results are not security decisions. Secret keys and event signals stay on your server. ## What this release covers This package is an ESM package (use `import`) for Node.js servers. The browser entry is separate and imports no server code. The server entry supports Node.js and Cloudflare Workers with `nodejs_compat`. Other edge runtimes have not been verified. No change to your hosting, database or sign-in provider is required. Next.js server actions are supported through `headers` and `path` (0.1.0-pilot.17 or later). Hono on Node.js passes `c.req.raw` to `event()` and `proxy()`. React hooks, Express-specific wrappers and an automatic installer are not included in this release. ## 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 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.