heedeo docs Sign inOpen dashboard
Start

Heedeo package quickstart

Pilot 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. Never put it in a NEXT_PUBLIC_ variable.

HEEDEO_SECRET_KEY=sk_your_secret_key
NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_key

Or let the package's CLI do it. In your project, run:

npx @heedeo/sdk init

It opens a Connect page in your browser (sign in, check the code matches the terminal, choose Connect), then writes both keys to .env.local, adds .env.local to .gitignore, and prints a prompt for your coding agent. The secret it writes is a new one; keys your app already uses keep working. --api <url> (or HEEDEO_API_URL) points it at another Heedeo, and --no-browser only prints the link.

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.

For local testing only:

// lib/trusted-client-ip.ts
export function trustedClientIp(_request: Request): string | null {
  if (process.env.NODE_ENV === 'development') return '127.0.0.1';
  return null; // Configure your host's trusted IP source before deploying.
}

For a Cloudflare-proxied app, 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. Have your coding assistant inspect the actual deployment and configure this function. A missing IP returns a setup error; it does not silently group all production visitors under localhost.

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 runtime = 'nodejs';
export const GET = heedeo.proxy;
export const POST = heedeo.proxy;

This assumes lib is at the project root, as in the runnable example. Keep /hd/i.js and /hd/v1/identify reachable before sign-in. Existing authentication middleware must allow those two paths. 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.

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 });

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 at most one path segment and no trailing slash; the script loads from ${endpoint}/i.js. Passing both path and endpoint throws. 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

In the existing signup route, after validating the submitted fields:

const { data, error } = await heedeo.event({
  request,
  token: input.heedeoToken,
  action: 'signup',
  email: input.email,
});

if (data) console.info('Heedeo event', data.event_id);
if (error) console.warn('Heedeo setup', error.code);
// Continue your existing signup. Observe only during the pilot.

request is the original Web Request received by a Next.js route handler; it preserves the actual URL, method and headers. The call does not read or consume its body. Route handlers are the supported Next.js path for this first package release. Keep an existing server-action integration on the manual guide until an adapter has been tested; do not invent a request URL or replace authentication to use the package.

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.event({
  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.suggested is allow, verify, review, or block once you set Block at score under Settings, Advanced. Trust reasons in data.reasons carry a negative weight.

An action a person should take once (a trial, a coupon by its code, a payout) takes once: true, or its Once per person switch under Settings. Heedeo remembers what each person did for 13 months across their browsers, so "one trial per visitor" is this one call, with no table on your side:

const { data } = await heedeo.event({ request, token, action: 'trial_start', email, once: true });
if (data?.suggested === 'review' || data?.suggested === 'block') return noTrial(); // a repeat_action, or anything as strong

A repeat answers repeat_action, weighing 100 times how sure Heedeo is the two browsers are one person: 100 through a shared email or account, 85 through a device match at 0.85.

data.visitor is the person: one id across their browsers, decided by Heedeo. Browsers that present the same email or account (the defaults under Settings, Advanced, Merge on) are one visitor, under the oldest one's id, and so is a browser that matches one of them by its characteristics at or above your merge threshold (0.86 by default). An id a merge retired is listed in aliases and still resolves. confidence is the least among the browsers' placements: 1 through cookies and shared identities, the match score through a device match. 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 SDK generates a request ID once and reuses the same body on its one retry. 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; it is the labelled set Heedeo uses to tune your weights later, and 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 } once 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 a visitor's standing score, after trust, in your bands. An id a merge retired answers the visitor it joined, with visitor.merged_into set:

const { data } = await heedeo.visitor(visitorId);
if (data?.suggested === 'review') holdPayout();

8. Delete a person

When someone asks you to delete their data, delete what Heedeo holds of them: their browsers, identifications, links, events, actions, outcomes and merges, in one transaction. 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.

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.

Use HTTPS for deployed testing, including Safari's secure-cookie behavior. 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 (default https://app.heedeo.com), and fetch for testing. Custom origins require HTTPS, except localhost. Browser options: publishableKey, matching path or an endpoint on your domain, and timeoutMs (default 3000 total for script loading and identification).

Configuration mistakes throw when you construct the client, so missing keys are caught at setup. 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_token, missing_client_ip, invalid_key, api_error, invalid_response, network_error, timeout_or_aborted, configuration_error.
  • Browser: browser_required, timeout, invalid_response, identification_unavailable.

Neither entry point logs credentials, request bodies or upstream error bodies.

Browser timeouts stop waiting, but cannot cancel an already-running collector call in this pilot version. Always pass the returned token explicitly; never fall back to an older cookie token after a timeout. Browser results are not security decisions. Secret keys and event signals stay on your server.

Pilot boundaries

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. React hooks, server-action adapters, 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.event() 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.