Writing rules for a signup
This guide is for the coding agent setting up Heedeo, on the path where the customer sets their own rules. The customer never reads it. Read the app first, suggest rules in plain words, and write only what the customer agrees to. Every field below is in the answer to heedeo.evaluate(), which is the call a rule runs on, and 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 in data.reasons, with its weight and its base. A field is read from where it stands. weight is the workspace's own and moves when someone changes a weight under Settings; base is the same reason at Heedeo's defaults, so a rule reads base for a clear signal. 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 | residential_proxy_confidence | network | high is enough to act on. medium counts beside another sign. low is a hint. |
| High confidence only | tampered_confidence | environment | high is enough to act on. medium and low are hints. |
| 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 | 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:
datais null when Heedeo did not answer. When Heedeo is unavailable (a network error, a timeout, a 5xx, a missing key), that is no answer, never a refusal: let the person through. With no usable receipt (none, one Heedeo refused, one that names no visitor of yours), ask for the app's verification step instead (noReceiptbelow). During a Heedeo outage the page gets no token, so every signup is asked to verify; that is a verification step, never a refusal.- Read
listed !== 'allow'before anything else, so the customer can always let a real person through with Trust. - Refuse with one generic message that says how to reach support. Never show a reason, a code or a score.
- Log the event id, the visitor id, the app's own user id once there is one, the decision, and Heedeo's
decisionandrisksas JSON.
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 { data, error } = await heedeo.evaluate({ request, token, action: 'signup', email, userId });
let decision = data ? decide(data) : 'allow';
if (decision !== 'refuse' && data?.listed !== 'allow' && noReceipt(data, error)) decision = 'verify';
console.info('Heedeo signup', data?.event_id, data?.visitor?.id, userId, decision, JSON.stringify(data?.decision), JSON.stringify(data?.risks));
if (decision === 'refuse') return refuse('We could not create your account. Contact [email protected].');
if (decision === 'verify') return requireVerification(); // the app's own: a confirmed email or a CAPTCHA
// No usable receipt: none sent (no_token; missing_token from a package before 0.1.0-pilot.18, which
// sent nothing), one Heedeo refused (a 4xx such as invalid_token), or one that names
// no visitor of this workspace. A script can send a made-up one, so each asks for verification.
// Heedeo unavailable (a network error, a timeout, a 5xx, a missing key): let the person through.
function noReceipt(data, error) {
if (error) return error.code === 'missing_token' || (error.code === 'api_error' && error.status < 500);
return data.reasons.some((r) => ['no_token', 'token_expired', 'unknown_visitor'].includes(r.code));
}
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 data as EventData from @heedeo/sdk.
const ALLOWED_ACCOUNTS = 2;
function decide(data) {
if (data.listed === 'allow') return 'allow';
// base: the reason at Heedeo's defaults, so the rule holds whatever weights the workspace sets.
const weight = (code) => {
const r = data.reasons.find((r) => r.code === code);
return r ? (r.base ?? r.weight) : 0;
};
const { visitor: v, network: n, environment: env } = data;
const certain = ['alias_of_existing', 'disposable_email', 'no_mx', 'listed_block', 'confirmed_abuse', 'receipt_reused'];
if (certain.some((code) => weight(code) >= 100)) return 'refuse';
const accounts = Math.max(v?.emails.all ?? 0, v?.accounts.all ?? 0);
if (accounts > ALLOWED_ACCOUNTS) return 'refuse';
const returning = weight('repeat_action') >= 85;
const proxy = n.residential_proxy_confidence;
const hiding = [proxy === 'high' || proxy === 'medium', n.vpn, n.tor, 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 ofcertainso 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.
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(data) {
if (data.listed === 'allow') return 'allow';
const country = data.network.country;
if (country && VERIFY_COUNTRIES.includes(country)) return 'verify';
return 'allow';
}
listed === 'allow'still 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, so a rule on its weight or on the score sees nothing. Readnetwork.residential_proxy_confidence. - Reading
weightfor a clear signal. A weight lowered under Settings, or a recommendation accepted there, turns off everyweight >= 100rule on that reason, with no error. Readbase, as above. - 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, readdata.flag. - 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.