heedeo docs Sign inOpen dashboard
Start

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.

Give this to your agent Open in Cursor

1. Install

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.

HEEDEO_SECRET_KEY=sk_your_secret_key
NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_key

2. Configure the server once

// lib/heedeo.ts: import only from server files
import { Heedeo } from '@heedeo/sdk';
import { trustedClientIp } from './trusted-client-ip';

export const heedeo = new Heedeo({
  secretKey: process.env.HEEDEO_SECRET_KEY,
  clientIp: trustedClientIp,
});

trustedClientIp(request) is the one hosting-specific piece. It must return the real visitor's IP from a source your server trusts, or null when unavailable. Use the same function for the proxy and signup. The package deliberately does not guess whether an incoming forwarded header is trustworthy.

One function covers the common hosts. Keep the branch for yours and delete the rest:

// lib/trusted-client-ip.ts
export function trustedClientIp(request: Request): string | null {
  // Development: there is no proxy, so every request is you. Set NODE_ENV=development in the
  // command that starts the server locally, not in a .env file a Vite build also reads.
  if (process.env.NODE_ENV === 'development') return '127.0.0.1';
  // Vercel: sets VERCEL=1, and puts the client's public IP in x-forwarded-for, overwriting any
  // value the client sent.
  if (process.env.VERCEL) return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || null;
  // Cloudflare in front of an origin that accepts only Cloudflare (see below).
  // return request.headers.get('cf-connecting-ip');
  // AWS App Runner, 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). 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).

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

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=<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" } }: 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

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

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:

'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<HTMLFormElement>) {
    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 (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <button type="submit" disabled={identifying || pending}>Create account</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

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:

actionWhat it isSend with it
signupan account is createdemail, and userId when the account exists
logina sign-in to an existing accountuserId
trial_starta free trial beginsuserId
redeem:CODEa coupon or credit by its code: redeem:SPRING, redeem:welcome_creditsuserId
payouta reward or referral is paid outuserId

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:

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

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

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:

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

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:

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.

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:

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:

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.