Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

Retool

Internal tools

What exists today

Retool talks to any REST API through a resource. Nothing LayerCall-specific is needed, and the API key lives in the resource rather than in a component.

Setup

  1. 1Create a REST API resource with base URL https://www.layercall.com
  2. 2Add a default header X-Api-Key with your key so it is never in front-end code.
  3. 3Add a query POSTing to /v1/score/user with the fields from your table row.
  4. 4Bind the verdict to a Tag component so reviewers see allow / review / block at a glance.

Code

// Retool → Transformer, bound to a Tag component's Value.
// Turns the verdict into Retool's own colour vocabulary so a reviewer
// reads the queue without reading numbers.

const r = {{ scoreUser.data }};

if (!r || {{ scoreUser.error }}) {
  return { label: "unscored", colour: "neutral" };
}

return {
  allow:  { label: "Allow",  colour: "success" },
  review: { label: "Review", colour: "warning" },
  block:  { label: "Block",  colour: "danger"  },
}[r.verdict] ?? { label: r.verdict, colour: "neutral" };

What to do with each verdict

Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.

VerdictIn Retool
allowLeave the row actionable — the reviewer has nothing to do.
reviewSurface r.summary in a Callout above the row's action buttons, so the reviewer sees why before deciding.
blockDisable the approve button with a disabled expression on the same transformer, rather than hiding the row. A hidden row looks like a bug to whoever is on shift.

What comes back

A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.

Show the full response(POST /v1/score/user)
{
  "risk_score": 65,
  "verdict": "review",
  "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.",
  "components": {
    "email": {
      "email": "test@guerrillamail.com",
      "normalized_email": "test@guerrillamail.com",
      "risk_score": 100,
      "verdict": "block",
      "status": "do_not_mail",
      "sub_status": "disposable",
      "deliverability_score": 0,
      "did_you_mean": null,
      "signals": {
        "syntax_valid": true,
        "mx_found": true,
        "is_disposable": true,
        "is_homograph": false,
        "is_role_account": true,
        "is_free_provider": false,
        "is_suspicious_handle": true,
        "is_tagged": false,
        "is_risky_tld": false,
        "is_new_domain": null,
        "has_spf": true,
        "has_dmarc": true,
        "has_website": true,
        "mailbox_exists": null,
        "is_catch_all": null,
        "mailbox_status": "unavailable",
        "has_digital_footprint": null
      },
      "domain": "guerrillamail.com",
      "domain_age_days": null,
      "mx_provider": null,
      "mx_records": [
        "mail.guerrillamail.com."
      ],
      "abuse_reports": 0,
      "digital_footprint": {
        "has_gravatar": false,
        "gravatar_profile_url": null,
        "breach_count": null,
        "seen_in_breach": null
      }
    },
    "phone": {
      "parse_status": "ok",
      "phone": "+14155552671",
      "risk_score": 0,
      "signals": {
        "syntax_valid": true,
        "is_possible": true,
        "is_voip": false,
        "is_premium_rate": false,
        "is_toll_free": false,
        "assigned_area_code": true,
        "is_fictional": false
      },
      "number": {
        "e164": "+14155552671",
        "country": "US",
        "national": "(415) 555-2671",
        "international": "+1 415 555 2671",
        "line_type": "fixed_line_or_mobile"
      },
      "verdict": "allow",
      "abuse_reports": 0
    },
    "ip": {
      "ip": "185.220.101.1",
      "risk_score": 60,
      "signals": {
        "is_vpn": true,
        "is_proxy": true,
        "is_datacenter": true,
        "is_tor": true,
        "recent_abuse": false,
        "is_hijacked_netblock": false
      },
      "geo": {
        "country": "DE",
        "city": "Berlin",
        "asn": "AS60729",
        "isp": "Stiftung Erneuerbare Freiheit"
      },
      "vpn_provider": null,
      "hijacked_source": null,
      "verdict": "review",
      "abuse_reports": 0
    }
  },
  "components_checked": [
    "email",
    "phone",
    "ip"
  ],
  "linkage": {
    "device_email_count": null,
    "email_device_count": 0,
    "email_ip_count": 0,
    "subnet_rate_1h": 2,
    "domain_rate_1h": 0
  },
  "actor": {
    "type": "unknown",
    "proven": false,
    "basis": "none",
    "operator": null,
    "trigger": null,
    "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here."
  },
  "top_signals": [
    "ip: tor exit node",
    "ip: commercial vpn",
    "ip: datacenter asn",
    "email: disposable domain",
    "email: role account",
    "email: machine-generated handle"
  ]
}

Traps specific to Retool

A key set on a component instead of the resource ships to the browser

Retool resources run server-side, so headers configured there are never sent to the client. Headers added inside a query's component-level config are a different thing and are visible in devtools. Set it once, on the resource.

Queries run on page load by default

A scoring query with 'Run on page load' enabled fires for every row the table renders, which bills a lookup per row per refresh. Switch it to manual and trigger it from the row selection instead.

Questions

Will the key be exposed to users of the app?

Not if it is set on the resource. Retool resources run server-side, so headers configured there are never shipped to the browser. Putting the key in a component's headers would expose it — do not.

Can I score a whole table at once?

Yes, with /v1/batch — one query, up to 500 values of one type. That is far cheaper than a per-row query firing on load, which is the usual way a Retool bill gets surprising.