5. Decide: score, rules or watch
Heedeo enforces nothing. Your code decides what happens at signup, and you choose one of three paths. Your agent asks which before it edits anything, and you can change it at any time.
| Path | The choice | What your code does at signup | For whom |
|---|---|---|---|
score | Let Heedeo decide (recommended) | Refuses when Heedeo flags the signup (verdict.flagged). | You want Heedeo to weigh every signal, and to tune the line in Settings without a deploy. |
rules | Set my own rules | Refuses on rules written for your app. | You know your users, and want the final call on each rule. |
watch | Just watch | Records the signup, refuses no one, logs what Heedeo answered. | You want to see what Heedeo flags before you act on it. |
All three do the same around the rule:
- Send the signup event as
signup, and a trial, credits or a coupon under its own name (section 3). The name is all Heedeo needs to know a person does it once, so no path says so on the call. - Send it with
evaluate()after your own validation and before the account is created, so a rule can stop the account. With a hosted sign-in the account exists before your server hears of it: the rule runs at the call that grants the trial or credits instead (section 3, A single-page app, a hosted sign-in and a separate API). - Log one line per signup on your server:
verdict.toLog(), which holdsevent_id,visitor_id,flagged,listed,band,score,by(what decided),reasons(their codes),error(the error’s code, null when Heedeo answered) andtrace_id(@heedeo/sdk0.2.0-beta.1 or later: the failed call’s id for Heedeo support, null when Heedeo answered). It explains what Heedeo answered; theevent_idis how we find the event together. - When Heedeo is unavailable (a network error, a timeout, a 5xx, a missing key), the verdict flags nothing and
datais null. Carry on as if Heedeo were not there. That is never a refusal. With no usable token (verdict.noToken: none, one Heedeo refused, or one that names no visitor of yours), onscoreandrulesrequire the verification step your app already has, such as a confirmed email or a CAPTCHA, rather than letting the signup through (section 3). With none, or underwatch, log it and carry on. - When you refuse, create nothing and show one generic message, such as “We could not create your account. Contact support.” Never show a reason code, its text or the score. Refusing is one of three things a caught signup can meet (What a caught signup meets, below).
The code below is the package’s. Without the package, read the same answer from its fields: flagged is data?.flagged ?? (data != null && data.listed !== "allow" && data.flag), the API’s own flagged and, on an answer stored before the API sent it, the same reading of flag and listed; and certain(code) is a reason whose base ?? weight is 100 or more.
Let Heedeo decide
A signup is caught when Heedeo flags it, and by default refused (What a caught signup meets, below):
const caught = verdict.flagged;flagged is true when the signup reached your flag line, or when you blocked or reported the person. It is never true for a person you pressed Trust on, and never when Heedeo did not answer. The flag line is How strict under Settings: Relaxed, Balanced (the default) or Strict. Moving it moves this rule with no deploy. A few real people on unusual setups will be refused; a rule on the clear signals refuses fewer.
A login or a name of your own is judged on nothing (section 3), so its score is 0 by design. If your app already has an email or phone verification step, require it when verdict.band is verify; do not add one for Heedeo. Signup, login and an in-app action can each have their own three lines (Settings, Advanced, Actions), and what each reason counts for is yours too (Settings, Advanced, Weights, or a recommendation fitted on your own outcomes, section 9).
At login
Heedeo does not judge a sign-in: a person’s own laptop, phone and work desktop are normal. Send login with the user_id: by logEvent() when you put no rule on it, or by evaluate() to put your own rule on the account’s facts:
// A new browser on this account: ask for the second step your app already has.
const account = verdict.data?.account;
const challenge = account?.new_browser === true;
// Many browsers on one account in a day, as when a login is shared or sold: look at it.
const shared = (account?.visitors["24h"] ?? 0) >= 5;flagged on a login is still true when you blocked or reported the person, so a rule that refuses on it refuses only them.
Set my own rules
Your agent suggests rules for your app in plain words, and writes only the ones you agree to. It works from the rules guide at https://app.heedeo.com/docs/rules: what to look for in your code, how strong each signal is, the shape of a rule and a rule to start from.
The clear signals are the ones a rule can refuse on alone, each at full strength:
// The signals this app refuses on alone. certain() asks for full strength: a likely repeat passes.
const clear = verdict.certain(
"repeat_action", "disposable_email", "no_mx", "alias_of_existing",
"listed_block", "confirmed_abuse", "receipt_reused",
);
// A weaker signal counts beside another, never alone.
const looksFamiliar = verdict.has("probable_match", "related_identities");
const hidingNetwork = verdict.has("vpn", "proxy", "tor", "datacenter");
const caught = !verdict.trusted && (clear || (looksFamiliar && hidingNetwork));These are the clear signals:
| Signal | Why it is hard to get wrong | What a false positive looks like |
|---|---|---|
repeat_action | At full weight Heedeo is certain the same person did this signup, trial_start, redeem:CODE or payout before, under another email. | One person who really needs two accounts, such as work and personal, from one browser. |
disposable_email | The address is at a disposable email provider. | A real person who keeps a throwaway address for signups. |
no_mx | The domain cannot receive mail, so the address is not one anyone reads. | A typo in the domain, or a company domain whose DNS is broken at that moment. The corrected retry is then a repeat (After a refusal, below). |
alias_of_existing | The email is another spelling (dots, a plus tag) of an address another of your visitors already holds. | A team that signs up several people on one mailbox with plus tags. |
listed_block | You labelled this visitor, email or network block yourself. | A network label on a shared office or campus address refuses everyone behind it. |
confirmed_abuse | You reported this visitor, or an account it presents, as abuse. | Someone who shares a card or phone with a person you reported. |
receipt_reused | The token was already spent on a different event: a replay. | Your own code sending a second, different event on one token, such as to add the user id later. Send one event per attempt. |
Why certain(): repeat_action weighs 100 times how sure Heedeo is that the two browsers are one person, so a repeat Heedeo is likely but not certain of is 95 at most, one from another browser on the same machine is 85, and both pass. confirmed_abuse lent by a related visitor is weaker the weaker the link, so one through a shared address passes. certain() reads each reason at Heedeo’s defaults (its base), not at your workspace’s weight: lowering one of these under Settings, Advanced, Weights, or accepting a recommendation that does, moves the score and leaves this rule alone. To stop refusing on a signal, take it out of the call. For a line of your own, verdict.strength("repeat_action") >= 85 reads the same number.
A rule written before the verdict keeps working as it is, since data and error have not changed. Earlier setups wrote this one, which is certain() by hand:
const REFUSE_ON = new Set(["repeat_action", "disposable_email", "no_mx", "listed_block", "confirmed_abuse", "receipt_reused"]);
const refuse =
data != null && data.listed !== "allow" && data.reasons.some((r) => REFUSE_ON.has(r.code) && (r.base ?? r.weight) >= 100);A rule deployed with r.weight >= 100 keeps doing what it did, and stops refusing on a signal whose weight is lowered: change that one comparison, or move to certain(), to get this.
Just watch
Refuse no one and add no verification step. The log line is how you judge a rule before you turn one on. Leave a comment where the rule will go:
console.info("Heedeo signup", JSON.stringify(verdict.toLog()));
// a rule from section 5 goes here, before the account is createdFor your first two weeks, read the log for the signups you know are real and the ones you know are not, then move to score or rules.
Rules by what you want to catch
If you know what you want caught, start there. Home’s Manual setup shows this function under Set my own rules, and your agent writes the checks you pick. Each check stands alone, so delete the ones you don’t need:
| What you want to catch | What the check reads | What passes |
|---|---|---|
| Repeat signups, even from a new IP address or a cleared browser | A certain repeat_action, or one from a strength of 85 on a person who moves between addresses (visitor.addresses, visitor.countries) | A likely repeat from a person who stays on one address. |
| Email aliases and one person holding several accounts | A certain alias_of_existing, and visitor.emails.all or visitor.accounts.all above your allowance | A second account, while the allowance is 2. |
| Proxies, VPNs and risky networks | vpn, tor, proxy, datacenter, or network.residential_proxy_confidence at high or medium, on a person who comes back or moves between addresses | A new person on a VPN or a listed address, who shows nothing else. |
| Disposable and dead email addresses | A certain disposable_email or no_mx | Every address at a provider that takes mail. |
// What this app catches at signup. Each check stands alone: delete one to stop
// catching on it. certain() is a reason at full strength, whatever weights you
// set in Settings.
function catches(verdict: Verdict) {
const v = verdict.data?.visitor;
const n = verdict.data?.network;
// Your own Block labels and abuse reports, and a token someone replayed.
if (verdict.certain("listed_block", "confirmed_abuse", "receipt_reused")) return true;
// Three addresses in an hour, or two countries in a day: more than home, work
// and a café.
const moving = (v?.addresses?.["1h"] ?? 0) >= 3 || (v?.countries?.["24h"] ?? 0) >= 2;
// A likely repeat: the same person, from a strength of 85 (another browser on
// the same machine) up to 100, where Heedeo is certain.
const likely = verdict.strength("repeat_action") >= 85;
// Repeat signups. Heedeo knows the browser whatever address it comes from.
// A certain repeat is caught alone. A likely one counts when the person also
// moves between addresses.
if (verdict.certain("repeat_action") || (likely && moving)) return true;
// Aliases and extra accounts: a dot or plus spelling of an address another
// person already holds, or more accounts than one real person needs. Two
// allows work and personal.
const ALLOWED_ACCOUNTS = 2;
if (verdict.certain("alias_of_existing")) return true;
if (Math.max(v?.emails.all ?? 0, v?.accounts.all ?? 0) > ALLOWED_ACCOUNTS) return true;
// Proxies, VPNs and risky networks. None is caught alone: many honest people
// use a VPN, and an address on a residential proxy list is most often an
// ordinary home line. Each is caught on a person who comes back, or who keeps
// changing address.
const proxy = n?.residential_proxy_confidence;
const listed = proxy === "high" || proxy === "medium";
const masked = Boolean(n?.vpn || n?.tor || n?.proxy || n?.datacenter || listed);
if (masked && (moving || likely)) return true;
// Disposable and dead email addresses: a disposable provider's, or a domain
// that takes no mail.
if (verdict.certain("disposable_email", "no_mx")) return true;
return false;
}It goes where the rule above goes, after Trust:
const caught = !verdict.trusted && catches(verdict);A VPN alone never catches anyone: many honest people use one. ALLOWED_ACCOUNTS is yours to set.
What a caught signup meets
On score and rules, a caught signup meets one of three things, and you choose which:
| Outcome | What the person sees | When to use it |
|---|---|---|
| Refuse it | One message that says how to reach support. No account. | The default. |
| Ask them to verify | The check your app already has: a confirmed email or a CAPTCHA. | Your app has one. Do not add one for Heedeo. |
| Hold it for review | A short form to ask for a review. No account yet. | You would rather look at a person than turn them away. |
Each route also acts on a signup with no usable token (verdict.noToken), since leaving the token out would otherwise pass every check that reads the browser (section 3). On Ask them to verify it meets your app’s check too. On the other two the route carries on, since its log line already shows no_token, with the line that requires your check ready to switch on: do that if your app has one, and never add one for Heedeo. When Heedeo does not answer, the person is still let through.
Hold for review
Refusing is not the only answer. To hold a caught signup and let the person ask for a review:
- Hold the signup. Create no account. Keep who was held in your own table (the email, the
event_idand the visitor’s id), and answer your form so it can say “We need a quick look before we create your account” over a short form.
// A signup with no visitor (no token, or an expired one) has no one to Trust
// in Heedeo, so a hold could never end: refuse it instead.
if (!verdict.eventId || !verdict.visitorId) {
return Response.json(
{ error: "We could not create your account. Contact [email protected]." },
{ status: 403 },
);
}
// No account yet. Keep who was held in your own table, then let your form say
// "We need a quick look before we create your account" and ask for a review.
await holdSignup({ email, eventId: verdict.eventId, visitorId: verdict.visitorId });
return Response.json({ review: verdict.eventId }, { status: 403 });- Take the request. The form posts what the person wrote. Save it with the held signup and tell whoever reviews signups, with a link that opens the person in Heedeo by the event’s id.
// app/api/signup/review/route.ts: the form a held signup posts
export async function POST(request: Request) {
const { eventId, message } = await request.json();
// Your own table: the held signup this is about, and what the person wrote.
const held = await saveReviewRequest({ eventId, message });
// An id that is none of yours: the form takes it from the request, so anyone can send one.
if (!held) return Response.json({ error: "Not found" }, { status: 404 });
// Your own alert to whoever reviews signups: an email or a Slack message.
await notifyTeam(held.email + " asked for a review: https://app.heedeo.com/review/visitors?q=" + encodeURIComponent(eventId));
return Response.json({ ok: true });
}- Decide in Heedeo. The link opens the person: their browsers, their other emails and what flagged. Press Trust to let them in, or Block to keep them out. Trust answers
listed: "allow"on their next signup, and every rule reads that first, so they pass whatever else the signup shows. - Tell the person. With a webhook (section 12) your app hears the decision the moment it is made, as
visitor.listed. A merge can retire the visitor id you kept before the team decides, so followvisitor.mergedtoo:
// app/api/webhooks/heedeo/route.ts
import { verifyWebhook } from "@heedeo/sdk";
export async function POST(request: Request) {
const { data: message, error } = verifyWebhook({
body: await request.text(), // the raw body: never request.json()
headers: request.headers,
secret: process.env.HEEDEO_WEBHOOK_SECRET,
});
if (error) return new Response(null, { status: 400 });
if (message.test) return new Response(null, { status: 204 });
if (message.type === "visitor.listed" && message.data.visitor_id) {
// Your own table of held signups, by the visitor id you kept with each.
const action = message.data.label.action;
if (action === "allow") await letIn(message.data.visitor_id); // tell them to sign up again
if (action === "block") await closeReview(message.data.visitor_id);
}
if (message.type === "visitor.merged") {
// Two visitors became one: move signups held under the retired ids to the
// id that stays, the one Trust and Block will name.
await moveHeld(message.data.merged, message.data.visitor_id);
}
return new Response(null, { status: 204 });
}Without a webhook, tell the person yourself, or let them try again later: after Trust, their next signup passes.
- The form is yours, and so is the table. Heedeo holds no request and sends no email to your users.
- Show the form to everyone who is held, with the same words. Never a reason or a score.
- A held signup is still in the person’s history, like one you refused (After a refusal).
The whole signup route, on score:
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()));
// Flagged: over your flag line (How strict, in Settings), or someone you
// blocked or reported. Never someone you pressed Trust on, and never when
// Heedeo did not answer.
if (verdict.flagged) {
// A signup with no visitor (no token, or an expired one) has no one to Trust
// in Heedeo, so a hold could never end: refuse it instead.
if (!verdict.eventId || !verdict.visitorId) {
return Response.json(
{ error: "We could not create your account. Contact [email protected]." },
{ status: 403 },
);
}
// No account yet. Keep who was held in your own table, then let your form say
// "We need a quick look before we create your account" and ask for a review.
await holdSignup({ email, eventId: verdict.eventId, visitorId: verdict.visitorId });
return Response.json({ review: verdict.eventId }, { 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 });
}After a refusal
Heedeo does not know what your code did with its answer. A signup you refused is still in the person’s history, like one you let through. So the same person trying again under another email is a repeat. The common honest case is a typo: someone mistypes their email’s domain, is refused on no_mx, corrects it, and is refused again on repeat_action on score, or by a rule that refuses every repeat. The same person retrying with the same email is the same attempt, not a repeat.
Two things keep this small. Say how to reach you in your refusal message, and let the person through when they do (below). To see why they were refused, read the answer again from the event_id your log line holds: GET /v1/evaluations/<event_id> (section 10, What Heedeo answered for an event). And validate what you can before the event: a signup your own checks stop never reaches Heedeo. When you trust someone later, or Review confirms a flag after signup, a webhook tells your app (section 12).
Let a real person through
A Block label flags the person on every action. An allow label is how you let a real person through after a false flag: verdict.flagged is false for them, and a rule of your own reads verdict.trusted first. Press Trust on the person’s page under Visitors, or send POST /v1/lists with your secret key. Their next event answers listed: "allow" (verdict.trusted), whatever else it holds. To take the label off, press Trust again, or send the same call with "op":"remove". It answers {"removed":true}, or false when there was no label; their next event is scored as usual.
curl -X POST https://app.heedeo.com/v1/lists -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"op":"add","kind":"visitor","value":"<visitor id>","action":"allow"}'
curl -X POST https://app.heedeo.com/v1/lists -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"op":"remove","kind":"visitor","value":"<visitor id>"}'With the package, the same three calls: trust(), block() and clearLabel() (0.2.0-beta.1 or later):
await heedeo.trust({ visitorId }); // let them through
await heedeo.block({ visitorId }); // flag them on every action
await heedeo.clearLabel({ visitorId }); // take the label offEach also takes { email } or { network } in place of the visitor’s id. A signup with no token has no visitor id: label their { email }. Each is safe to send again: the package sends a request_id, below. If two people change one label at the same moment, the last change wins.
Send a request_id with each label write. A retry of a write whose first attempt landed, and only lost its answer, would otherwise write again, over whatever was given in between: a teammate’s Block in Review, or an assistant’s, undone by your Trust’s retry, with nothing to show it happened. Make one request_id (1 to 128 characters, a new UUID is best) for the write you mean, and send the same one on every attempt at it. Never make it from the target: the same id on a later Trust of that person gets the old answer and writes nothing. The first answer is kept under it for 72 hours: a repeat gets that answer, {"removed":false} included, with the header heedeo-replayed: true, and writes nothing. The answer is what the first attempt did, not the label now: read GET /v1/visitors/<visitor_id> for that. A repeat with another op, kind, value or action answers 409 invalid_request, as the events call does; one label in two spellings (an email’s case, a network as an IP or its /24) is the same write. Without a request_id a write is applied each time it arrives. The package sends one on every label call from 0.2.0-beta.1, the same one on its own retry; for a retry of your own, pass it: heedeo.trust({ visitorId, requestId }). If a job queue retries the call, make the id when you enqueue the job and pass it on each run.
curl -X POST https://app.heedeo.com/v1/lists -H "authorization: Bearer $HEEDEO_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"op":"add","kind":"visitor","value":"<visitor id>","action":"allow","request_id":"<a new UUID for this write>"}'Trust and Delete are on the person’s page for you. The API calls are for an agent or a script.
Rules on raw fields
Rules on raw fields stay available, as the optional path: every count and fact in section 4 is in the answer for your own code. For example, refusing the second account on one person whatever the score: if ((verdict.data?.visitor?.emails.all ?? 0) >= 2) …, using visitor.accounts.all once a user id exists, or a hard line on a repeat whatever its link: if ((verdict.data?.visitor?.actions?.trial_start?.count ?? 0) >= 2) …. A rule like that ignores your settings and how sure Heedeo is, so prefer a path above unless you need a hard line. The network’s flags work the same way, read with their confidence, but none is enough alone: a VPN or a residential proxy listing counts only beside another sign, for example asking for a verification step when residential_proxy_confidence is "high" or "medium" and the person is already on a second account. If you kept your own table of visitor ids to allow one trial each, it keeps working (a merged id resolves through visitor.aliases and merged_into), and the action’s name replaces it.
Test your rule
Run these in a real browser through your own signup form, and read your server’s log line for each. Use real-looking addresses at a mail provider, such as Gmail. Numbered addresses (test1@, test2@) from one network soon fire email_pattern and network_burst.
An automated or headless browser adds automation to every signup here, so 0 reads 10 and 150 reads 160. No path refuses on it alone.
- A first honest signup. In a new workspace, a first signup with a real address answers
suggested: "allow",score: 0and no reasons, with avisitorthat is not null. No path refuses it. - The same person again. In the same browser, sign up again under another email. It answers
repeat_actionat weight 100 (“signup already done by this visitor ... from this browser”) andmany_identitiesat 50: score 150,suggested: "review".scorerefuses it, and so does a rule on the clear signals. - An address that takes no mail. Sign up with an
@example.comaddress.example.comtakes no mail, so it answersno_mxat 100: score 100,suggested: "review".scorerefuses it, and so does a rule on the clear signals. A disposable address, such as one atmailinator.com, answersdisposable_emailat 100 the same way. - Be a new person again. Clearing cookies or opening a private window is not enough: Heedeo still recognises the browser, and its next signup is usually a repeat. Instead, delete the person: on their page under Visitors, open More details and press Delete this person, or from your server call
await heedeo.deleteVisitor(visitorId)with the package, orDELETE /v1/visitors/<visitor_id>(section 7, Deleting a person). The same browser then signs up as a new person, with no repeat, if it is your only test browser. Deleting one person is not enough when other test browsers on the same machine are still there: the next signup is linked to them assame_machineand is a repeat Heedeo is less sure of, which a rule on the clear signals lets through andscorerefuses. For a clean start, delete each test visitor first. Another test browser on the same machine and network is not a new person either. - Let yourself through. Press Trust on your person’s page under Visitors, or send the
addcall from “Let a real person through”. Your next signup answerslisted: "allow", soverdict.flaggedis false and every path lets it through, even when it also answersrepeat_actionat 100. When you are done, take the label off: press Trust again, or send theremovecall.
Local testing scores higher than real traffic, so score refuses test signups sooner than it refuses real ones. Every tester on your machine reaches Heedeo from 127.0.0.1 in development, so local signups look related and, as they add up, like a burst (related_identities, network_burst). Test browsers on one computer are one machine to Heedeo, as in test 4: a signup from another of them under a new email is a repeat (“from another browser on the same machine”).
To see what real people get, deploy and read your log.
What an agent checks
Home needs your sign-in, so a coding agent checks for itself with the secret key. GET /v1/setup-status answers the counts behind Home’s live check, and with ?event_id= the one event that id names. Take the event_id from your test signup’s log line:
curl -H "authorization: Bearer $HEEDEO_SECRET_KEY" "https://app.heedeo.com/v1/setup-status?event_id=79c7b0de-4c1e-4b47-9d0f-3a1c2b6e5f10"{
"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
}count is how many the workspace has received and Heedeo still keeps, last when the newest came and first when the first did, each null before the first. An event is kept 30 days, but its last is remembered 13 months, events.identified.last too, so last can be set while count is 0. events.identified counts the events that came with a live token of this workspace, so Heedeo tied them to a browser: Home’s identified signups, for every action. An event with no token, an expired one or another workspace’s counts in events and not there.
The counts are progress, not proof. An identified event from yesterday keeps them above 0 while today’s test event arrives with no token. The proof is the test signup’s own log line: a visitor_id, and none of no_token, token_expired or unknown_visitor among its reasons (verdict.noToken is false), which also says which of them went wrong. event adds what the log cannot: that Heedeo holds that event under the key you called with, identified and with the same visitor_id. event is null when the workspace holds no event by that id: your server sent it with another workspace’s key, or it is past 30 days, or was deleted with its person, and absent when you send no event_id. An event_id that is not one answers 400 invalid_request.
served is when the first identification came through your own domain (section 2), null until one has: the setup is not finished before it is set. Heedeo’s own address does not count, and neither does a route on localhost. A missing or wrong key answers 401.