Skip to content
Heedeo
Heedeo

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_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 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.

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 onlytampered_confidenceenvironmenthigh is enough to act on. medium and low are hints.
Contextresidential_proxy_confidencenetworkThe 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.
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.
Ratedevice_burstreasonsMany 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.
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. Read the verdict, never data alone. 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.
  2. Read verdict.trusted before anything else, so the customer can always let a real person through with Trust. verdict.flagged already does.
  3. 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.
  4. Refuse with one generic message that says how to reach support. Never show a reason, a code or a score.
  5. Log verdict.toLog() with the app’s own user id once there is one, and the decision. It holds event_id, visitor_id, flagged, listed, band, score, by (what decided), reasons (their codes), error (the error’s code, null when Heedeo answered) and trace_id (@heedeo/sdk 0.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’s event_id reads 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 new evaluate() 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 [email protected].");
// 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_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 the certain() check 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.

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, and visitor.emails.all or visitor.accounts.all above the allowance. A second account passes 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, passes.
  • Disposable and dead email addresses. A certain disposable_email or no_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.
  • changingAddress is three addresses in an hour or two countries in a day, more than home, work and a café. likelyRepeat is 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_ACCOUNTS is 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 [email protected].",
};

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:

  1. Hold the signup. Create no account. Keep who was held in your own table (the email, the event_id and 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. REFUSAL is 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 });
  1. 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 });
}
  1. 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 as trusted: flagged is then false and every rule reads trusted first, so they pass whatever else the signup shows.
  2. 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 follow visitor.merged too:
// 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.trusted 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 certain() and the score see nothing. Read network.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 high confidence, few listed visitors turn out abusive. Let it count only beside another sign.
  • Reading a reason’s weight yourself. A weight lowered under Settings, or a recommendation accepted there, turns off every weight >= 100 rule on that reason, with no error. Use verdict.certain(), which reads the reason at Heedeo’s defaults.
  • Reading verdict.data.flag in place of verdict.flagged. The raw flag does not know about Trust, so it refuses people the customer let through. flagged does, and so does the answer’s own data.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 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 verdict.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.