heedeo docs Sign inOpen dashboard
For agents

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_ACCOUNTS below.
  • A verification step. An email or phone check the app already has gives a verify somewhere 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.

StrengthSignalRead it fromWhat it means for a rule
Safe alonerepeat_action at 100reasonsCertain 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 alonedisposable_emailreasonsThe address is at a disposable email provider.
Safe aloneno_mxreasonsThe domain cannot receive mail. The honest case is a typo, so check the address in the form first.
Safe alonealias_of_existingreasonsA dot or plus spelling of an address another visitor already holds.
Safe alonelisted_blockreasonsThe customer labelled this visitor, email or network block.
Safe aloneconfirmed_abusereasonsThe customer reported this person, or someone firmly linked to them. Below 100 the link is weaker, so it does not count.
Safe alonereceipt_reusedreasonsA token spent on another event. The app must send one event per attempt, or it refuses itself.
High confidence onlyresidential_proxy_confidencenetworkhigh is enough to act on. medium counts beside another sign. low is a hint.
High confidence onlytampered_confidenceenvironmenthigh is enough to act on. medium and low are hints.
ContextvpnnetworkMany honest people use one. Never refuse on it alone.
ContexttornetworkNever alone.
ContextdatacenternetworkRare for a person, normal for an agent or a script. Never alone.
Contextyoung_domainemailThe domain is under 30 days old. So is every new company's.
Contextprivate_modeenvironmentA private window is ordinary. Never alone.
Contextvirtual_machineenvironmentDevelopers and farms both run them. Never alone.
Contexttimezone_mismatchnetworkTravellers and wrong clocks show here too. A hint.
Ratevisitor.emailsvisitorDistinct emails, over 24h, 7d and all. The count to use at signup.
Ratevisitor.addressesvisitorDistinct addresses over 1h, 24h and 7d. Three in a day is a normal person; many in an hour is not.
Ratenetwork_burstreasonsMany new identities from one network, up to 80. Honest people can be in the same crowd.
Ratedomain_burstreasonsMany new identities on one email domain, up to 80, at 0 until you weigh it. A company's own hiring day can be one.
Rateaction_ratereasonsThis visitor takes the action far more than most.
Possible false alarmautomation_frameworkenvironmentAlso true when developer tools are open. Log it, and do not refuse on it.
Possible false alarmhttp_clientagentThe app's own calls and API users show here. Check for those first.
Possible false alarmmissing_headersagentA CDN or the mobile app can drop headers. Check for those first.
Possible false alarmzero interactionenvironmentAll 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:

  1. data is 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 (noReceipt below). During a Heedeo outage the page gets no token, so every signup is asked to verify; that is a verification step, never a refusal.
  2. Read listed !== 'allow' before anything else, so the customer can always let a real person through with Trust.
  3. Refuse with one generic message that says how to reach support. Never show a reason, a code or a score.
  4. Log the event id, the visitor id, the app's own user id once there is one, the decision, and Heedeo's decision and risks as 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_action fires because the action is named signup. Under a name of the app's own it never fires, and returning is always false.
  • repeat_action is left out of certain so the allowance can work. A second account from a person hiding nothing passes.
  • returning starts at 85, so a repeat from another browser on the same machine counts. The hiding is what decides.
  • verify asks 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_proxy reason. It weighs 0, so a rule on its weight or on the score sees nothing. Read network.residential_proxy_confidence.
  • Reading weight for a clear signal. A weight lowered under Settings, or a recommendation accepted there, turns off every weight >= 100 rule on that reason, with no error. Read base, as above.
  • Sending once, or a name of the app's own. A near-miss such as sign_up or register is refused with a 400, and a name like free_trial is judged on nothing. Use the five names.
  • Refusing on young_domain. It hurts any product that new businesses sign up to.
  • Returning verify with 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, read data.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.