Writing rules for a signup
This guide is for whoever writes a customer’s own rules, and first for the coding agent setting up Heedeo on that path. It assumes the quickstart is done and heedeo.evaluate() already runs at signup. Read the app first, suggest rules in plain words, and write only what the customer agrees to. A rule runs on the verdict that heedeo.evaluate() answers: verdict.has() and verdict.certain() read the reasons, and verdict.data holds every field below. The integration guide’s section 4 has the full list.
What to look for in the code
- What an abuser wants. The free trial, credits, a coupon, seats or sends, and the call that grants it. The rule runs there.
- Who the real users are. People on work VPNs, new companies on new domains, teams on one office network, agents or scripts that sign up through an API. Each one changes which signals you can trust.
- How many accounts a real person needs. Work and personal, or one per client? That number is
ALLOWED_ACCOUNTSbelow. - A verification step. An email or phone check the app already has gives a
verifysomewhere to go. Do not add one. - A way to reach support. A real person will be refused one day, and the message must tell them where to go.
- What sits in front of the server. A CDN that drops headers, a mobile app or the app’s own server-to-server calls can look like a program.
How strong each signal is
A reason is read from the verdict, in three ways:
verdict.has("vpn", "tor"): any of these fired, at any strength.verdict.certain("repeat_action"): it fired at full strength. A rule refuses alone only on this.verdict.strength("repeat_action"): how strongly it fired: 0 when it did not, 100 when certain, negative for a trust reason.
All three read the reason at Heedeo’s defaults, so a rule holds whatever weights someone sets under Settings. A field is read from where it stands in verdict.data, which is null when Heedeo did not answer. The defaults are in the integration guide.
| Strength | Signal | Read it from | What it means for a rule |
|---|---|---|---|
| Safe alone | repeat_action at 100 | reasons | Certain it is the same person doing a once action again. 95 or less is likely, and 85 is another browser on the same machine. It fires on signup, trial_start, redeem:CODE and payout, from the name alone. |
| Safe alone | disposable_email | reasons | The address is at a disposable email provider. |
| Safe alone | no_mx | reasons | The domain cannot receive mail. The honest case is a typo, so check the address in the form first. |
| Safe alone | alias_of_existing | reasons | A dot or plus spelling of an address another visitor already holds. |
| Safe alone | listed_block | reasons | The customer labelled this visitor, email or network block. |
| Safe alone | confirmed_abuse | reasons | The customer reported this person, or someone firmly linked to them. Below 100 the link is weaker, so it does not count. |
| Safe alone | receipt_reused | reasons | A token spent on another event. The app must send one event per attempt, or it refuses itself. |
| High confidence only | tampered_confidence | environment | high is enough to act on. medium and low are hints. |
| Context | residential_proxy_confidence | network | The address is on a proxy list: proxy traffic has left from it, most often from an ordinary home or mobile line, which says little about this visitor. Never act on it alone, at any confidence. Count high or medium beside another sign. |
| Context | vpn | network | Many honest people use one. Never refuse on it alone. |
| Context | tor | network | Never alone. |
| Context | datacenter | network | Rare for a person, normal for an agent or a script. Never alone. |
| Context | young_domain | email | The domain is under 30 days old. So is every new company’s. |
| Context | private_mode | environment | A private window is ordinary. Never alone. |
| Context | virtual_machine | environment | Developers and farms both run them. Never alone. |
| Context | timezone_mismatch | network | Travellers and wrong clocks show here too. A hint. |
| Rate | visitor.emails | visitor | Distinct emails, over 24h, 7d and all. The count to use at signup. |
| Rate | visitor.addresses | visitor | Distinct addresses over 1h, 24h and 7d. Three in a day is a normal person; many in an hour is not. |
| Rate | network_burst | reasons | Many new identities from one network, up to 80. Honest people can be in the same crowd. |
| Rate | domain_burst | reasons | Many new identities on one email domain, up to 80, at 0 until you weigh it. A company’s own hiring day can be one. |
| Rate | device_burst | reasons | Many new identities on one device configuration, up to 80, at 0 until you weigh it. A popular phone on a busy day can be one. |
| Rate | action_rate | reasons | This visitor takes the action far more than most. |
| Possible false alarm | automation_framework | environment | Also true when developer tools are open. Log it, and do not refuse on it. |
| Possible false alarm | http_client | agent | The app’s own calls and API users show here. Check for those first. |
| Possible false alarm | missing_headers | agent | A CDN or the mobile app can drop headers. Check for those first. |
| Possible false alarm | zero interaction | environment | All three counts at 0 means nobody used the page. A form that fills and sends itself reads the same. |
The shape of a rule
Every rule does these things, in this order:
- Read the verdict, never
dataalone. When Heedeo is unavailable (a network error, a timeout, a 5xx, a missing key) the verdict flags nothing and every check is false: that is no answer, never a refusal, so the person is let through. - Read
verdict.trustedbefore anything else, so the customer can always let a real person through with Trust.verdict.flaggedalready does. - With no usable token (
verdict.noToken: no token, an expired one, one Heedeo cannot read, or one that names no visitor of yours), ask for the app’s verification step. A script can leave the token out. It is false when Heedeo did not answer, so a Heedeo outage asks no one to verify and refuses no one. - Refuse with one generic message that says how to reach support. Never show a reason, a code or a score.
- Log
verdict.toLog()with the app’s own user id once there is one, and the decision. It 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). When a refused person writes in, the line’sevent_idreads the whole answer again, as it was given:heedeo.evaluation(eventId)(0.2.0-beta.1 or later). It is history: never refuse or let through a later action on it, since only a newevaluate()reads every label.
Name the action from Heedeo’s list, spelled exactly (signup, login, trial_start, redeem:CODE, payout): the name is how Heedeo knows which actions a person does once and which risks judge it. Never send once.
const verdict = await heedeo.evaluate({
token,
action: "signup",
email,
userId,
});
let decision = decide(verdict);
if (decision !== "refuse" && verdict.noToken) decision = "verify";
console.info(
"Heedeo signup",
JSON.stringify({ userId, decision, ...verdict.toLog() }),
);
if (decision === "refuse")
return refuse("We could not create your account. Contact support@acme.com.");
// the app's own: a confirmed email or a CAPTCHA
if (decision === "verify") return requireVerification();Same person, and hiding
A good rule to start from, for an app with something free to give away. A person may hold a few accounts. A person who comes back while hiding may not. Change ALLOWED_ACCOUNTS to what the app’s real users need, and type verdict as Verdict from @heedeo/sdk.
const ALLOWED_ACCOUNTS = 2;
function decide(verdict: Verdict) {
if (verdict.trusted) return "allow";
// data is null when Heedeo did not answer: every read below is then empty,
// and the rule allows.
const { visitor: v, network: n, environment: env } = verdict.data ?? {};
if (
verdict.certain(
"alias_of_existing",
"disposable_email",
"no_mx",
"listed_block",
"confirmed_abuse",
"receipt_reused",
)
)
return "refuse";
const accounts = Math.max(v?.emails.all ?? 0, v?.accounts.all ?? 0);
if (accounts > ALLOWED_ACCOUNTS) return "refuse";
const returning = verdict.strength("repeat_action") >= 85;
const proxy = n?.residential_proxy_confidence;
const hiding = [
proxy === "high" || proxy === "medium",
n?.vpn,
n?.tor,
n?.proxy,
n?.datacenter,
env?.anti_detect,
env?.tampered_confidence === "high",
(v?.addresses?.["1h"] ?? 0) >= 3,
(v?.countries?.["24h"] ?? 0) >= 2,
].filter(Boolean).length;
if (returning && hiding >= 1) return "refuse";
if (hiding >= 2) return "verify";
return "allow";
}repeat_actionfires because the action is namedsignup. Under a name of the app’s own it never fires, andreturningis always false.repeat_actionis left out of thecertain()check so the allowance can work. A second account from a person hiding nothing passes.returningstarts at 85, so a repeat from another browser on the same machine counts. The hiding is what decides.verifyasks for the app’s own verification step.
Rules by what you want to catch
A customer often names what they want caught rather than a signal. Each of these is one check, and they stack in one function: write the ones the customer named, and leave out the rest. catches is true for a signup to act on; the code around it reads verdict.trusted first, as in the shape above.
- 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 passes. - Email aliases and one person holding several accounts. A certain
alias_of_existing, andvisitor.emails.allorvisitor.accounts.allabove the allowance. A second account passes while the allowance is 2. - Proxies, VPNs and risky networks.
vpn,tor,proxy,datacenter, ornetwork.residential_proxy_confidenceathighormedium, on a person who comes back or moves between addresses. A new person on a VPN or a listed address, who shows nothing else, passes. - Disposable and dead email addresses. A certain
disposable_emailorno_mx.
// What this app catches at signup. Delete a check to stop catching on it.
function catches(verdict: Verdict) {
const visitor = verdict.data?.visitor;
const network = verdict.data?.network;
if (verdict.certain("listed_block", "confirmed_abuse")) return true;
if (verdict.certain("receipt_reused")) return true;
const addressesThisHour = visitor?.addresses?.["1h"] ?? 0;
const countriesToday = visitor?.countries?.["24h"] ?? 0;
const changingAddress = addressesThisHour >= 3 || countriesToday >= 2;
const likelyRepeat = verdict.strength("repeat_action") >= 85;
if (verdict.certain("repeat_action")) return true;
if (likelyRepeat && changingAddress) return true;
const ALLOWED_ACCOUNTS = 2; // work and personal
const emails = visitor?.emails.all ?? 0;
const accounts = visitor?.accounts.all ?? 0;
if (verdict.certain("alias_of_existing")) return true;
if (Math.max(emails, accounts) > ALLOWED_ACCOUNTS) return true;
// Never caught alone: many honest people use a VPN.
const proxyRisk = network?.residential_proxy_confidence;
const onProxyList = proxyRisk === "high" || proxyRisk === "medium";
const hidingNetwork = [
network?.vpn,
network?.tor,
network?.proxy,
network?.datacenter,
onProxyList,
].some(Boolean);
if (hidingNetwork && (changingAddress || likelyRepeat)) return true;
if (verdict.certain("disposable_email", "no_mx")) return true;
return false;
}- The first two checks are always there: the customer’s own Block labels and abuse reports, and a replayed token.
changingAddressis three addresses in an hour or two countries in a day, more than home, work and a café.likelyRepeatis a strength of 85 or more: another browser on the same machine.- A VPN alone never catches anyone. If the customer wants every VPN caught, say what that costs (many honest people use one) and write it only once they agree.
ALLOWED_ACCOUNTSis the app’s own number. With Repeat signups also on, a certain repeat is caught at the second account, whatever the allowance.
What a caught signup meets
Settle this with the customer before you write the rule. There are three answers, and one app can use more than one: refuse on a certain signal and hold the rest.
- Refuse it. No account, and one message for everyone that says how to reach support.
// The same message for everyone: never a reason or a score.
const REFUSAL = {
error: "We could not create your account. Contact support@yourcompany.com.",
};
return Response.json(REFUSAL, { status: 403 });- Ask them to verify. Only when the app already has a step: a confirmed email or a CAPTCHA. Never add one for Heedeo.
return requireVerification(email);- Hold it for review. No account yet, and a short form to ask for a review. The customer’s team decides in Heedeo.
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.REFUSALis the one refusal message from Refuse it above, declared at the top of the route.
const { eventId, visitorId } = verdict;
// With no visitor there is no one to Trust, so the hold could never end.
if (!eventId || !visitorId) return Response.json(REFUSAL, { status: 403 });
await holdSignup({ email, eventId, visitorId });
return Response.json({ review: 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();
const held = await saveReviewRequest({ eventId, message });
// Anyone can post an event id: answer one you never held as not found.
if (!held) return Response.json({ error: "Not found" }, { status: 404 });
const personInHeedeo =
"https://app.heedeo.com/review/visitors?q=" + encodeURIComponent(eventId);
await notifyTeam(held.email + " asked for a review: " + personInHeedeo);
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, which the verdict reads astrusted:flaggedis then false and every rule readstrustedfirst, so they pass whatever else the signup shows. - Tell the person. With a webhook (the integration guide’s 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";
import { heedeo } from "@/lib/heedeo";
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 });
// A message can arrive late, after a newer merge, Trust or Block: act on
// who the person is now and the label they have now, not on the message.
if (
(message.type === "visitor.listed" || message.type === "visitor.merged") &&
message.data.visitor_id
) {
// An id a merge retired answers the visitor it joined.
const { data: found } = await heedeo.visitor(message.data.visitor_id);
if (!found) return new Response(null, { status: 503 }); // sent again later
const id = found.visitor.id;
// Your own lock on the person (a row lock in your table), with the label
// read again inside it, so an older message never acts after a newer one.
const done = await withVisitorLock(id, async () => {
const { data: now } = await heedeo.visitor(id);
// merged again since the first read: the retry locks the id that stays
if (!now || now.visitor.id !== id) return false;
// What you held under the retired ids, to the id Trust names.
if (message.type === "visitor.merged")
await moveHeld(message.data.merged, id);
if (now.listed === "allow") await letIn(id);
if (now.listed === "block") await closeReview(id);
return true;
});
if (!done) return new Response(null, { status: 503 });
}
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 (the integration guide’s After a refusal).
Country
network.country is the country of the visitor’s address, as its two-letter ISO code (US, NG), or null when Heedeo could not place it. Read it from the answer rather than from the app’s own edge header: it is IPinfo’s, and the edge header’s only when IPinfo cannot place the address. A VPN or a proxy puts the person somewhere else, so pair a country rule with the hiding signals above rather than trusting the country alone.
const VERIFY_COUNTRIES = ["XA", "XB"]; // the app's own list
function decide(verdict: Verdict) {
if (verdict.trusted) return "allow";
const country = verdict.data?.network.country;
if (country && VERIFY_COUNTRIES.includes(country)) return "verify";
return "allow";
}verdict.trustedstill comes first, so Trust lets a real person through from anywhere.- An unknown country (
null, or absent on an answer stored before the field) is not a reason: the rule lets it through.
Common mistakes
- Reading the
residential_proxyreason. It weighs 0, socertain()and the score see nothing. Readnetwork.residential_proxy_confidence. - Refusing on a proxy listing alone. A listed address is most often a household’s own line that one device lent to a proxy network. Even at
highconfidence, few listed visitors turn out abusive. Let it count only beside another sign. - Reading a reason’s
weightyourself. A weight lowered under Settings, or a recommendation accepted there, turns off everyweight >= 100rule on that reason, with no error. Useverdict.certain(), which reads the reason at Heedeo’s defaults. - Reading
verdict.data.flagin place ofverdict.flagged. The rawflagdoes not know about Trust, so it refuses people the customer let through.flaggeddoes, and so does the answer’s owndata.flagged, the same reading made by Heedeo, for a caller without the package. - Sending
once, or a name of the app’s own. A near-miss such assign_uporregisteris refused with a 400, and a name likefree_trialis judged on nothing. Use the five names. - Refusing on
young_domain. It hurts any product that new businesses sign up to. - Returning
verifywith no verification step. There is nothing to send the person to, so it means let them through and log it. - Refusing on
suggested === 'block'. That band does not exist until someone sets Block at score, so the rule refuses no one. To refuse what Heedeo flags, readverdict.flagged. - Judging a rule on localhost. Local signups share one address and score high. The integration guide’s “Test your rule” says what each test answers.