Skip to content

Signup protection

Terminal window
curl -sS https://api.emvero.tech/v1/signup-checks \
-H "Authorization: Bearer emv_your_credential" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-8f21c0" \
-d '{
"email": "ada@example.com",
"token": "<the hidden field>",
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"client_hints": {
"ua": "\"Chromium\";v=\"141\", \"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"99\"",
"platform": "\"Windows\"",
"mobile": "?0"
}
}'
{
"id": "0b8e6f2a-5d1c-4f7e-9a3b-2c6d8e1f4a07",
"action": "allow_with_email_confirmation",
"signal_codes": ["fp_fast_submission"],
"email": {
"id": "5c1d9a2e-7b3f-4e8a-b6c0-1d2e3f4a5b6c",
"email": "ada@example.com",
"status": "deliverable",
"action": "allow",
"reason_codes": ["MX_FOUND", "SMTP_RCPT_ACCEPTED"],
"…": "the rest of a POST /v1/verifications answer"
},
"signals": {
"status": "evaluated",
"fp_version": 3,
"vocabulary_version": 10,
"token": {
"outcome": "valid",
"issued_at": "2026-11-02T10:01:12Z",
"first_issued_at": "2026-11-02T10:01:12Z"
},
"findings": [
{ "code": "fp_fast_submission", "class": "binding", "default_raises": true, "detail": { "since_first_token_ms": 812 } },
{ "code": "fp_network_vpn", "class": "identity", "default_raises": false }
],
"not_evaluated": ["fp_novelty_not_evaluated"],
"not_evaluated_classes": ["novelty"],
"network": {},
"transport": {},
"timezone": {},
"client_claims": {}
}
}

The example is synthetic and its last four objects are emptied for length. Here the email result allows registration, and the check arrived 812 milliseconds after the page received its first token. That short interval calls for confirmation under the default policy; it does not prove automation.

  1. The page gets a token from the capture node once the form is shown, with a small script, and the form posts it in a hidden field. Embedding fp-token.js is that half.
  2. Your server, handling the signup, sends the token to POST /v1/signup-checks with the email address and what it saw of the request: the client’s IP address, its User-Agent and its client hints.
  3. You act on action, exactly as on a verification: Verdicts and pending has what each one means.

The check is made from your server with your emv_ key, never from the page. The page key in the script only obtains tokens, and a token is useless without the account that checks it.

Open Signup Protection in the account area to enable the connection and add a page key for your signup page’s HTTPS origins. Wait for the connection to become ready, then complete an evaluated check through your own page and server. A ready connection or copied snippet does not prove installation. Until the product is on for the account, both checking routes and the relay list answer 409 signup_protection_disabled.

Field Required Where it comes from Rule
email yes, on signup-checks The address being signed up As on POST /v1/verifications
variant no The email verification’s depth
token no The hidden field, as posted Up to 2,048 bytes of printable ASCII
ip no The client’s address, as your server saw it - behind a proxy, the address the proxy reports An IPv4 or IPv6 address, no zone
user_agent no The User-Agent request header Up to 1,024 bytes
client_hints.ua no Sec-CH-UA Up to 512 bytes each
client_hints.platform no Sec-CH-UA-Platform
client_hints.mobile no Sec-CH-UA-Mobile
client_hints.platform_version no Sec-CH-UA-Platform-Version
client_hints.full_version_list no Sec-CH-UA-Full-Version-List

Send each header’s value as it arrived, quotes included. Browsers send the first three client hints unasked and the last two only to a site that requested them with Accept-CH; a hint the request did not carry is simply left out.

Nothing but email is required, and that is deliberate: a missing value is never an error, it is something that could not be compared. An empty token comes back as fp_token_absent, no ip as fp_ip_not_supplied, no user_agent as fp_ua_not_supplied. Each one costs the check part of what it can see, so send everything you have.

action is the email verification’s action combined with the signals, and the signals only ever move it one way:

The email answered The signals action
reject anything reject
allow_with_email_confirmation - as a pending or unknown address does anything allow_with_email_confirmation
allow a code in signal_codes allow_with_email_confirmation
allow evaluated, nothing raised allow
allow unavailable allow

Signals never raise the decision to reject and never soften the email’s. At most they ask the person to prove the address is theirs - the cheapest question there is for somebody who is signing up for real.

A missing token is not evidence of automation. A script blocker, a form that never asked for a token and a capture node that cannot issue tokens all look the same: the check comes back token_unusable with fp_token_absent, free of charge, and the default action asks for email confirmation. If the node is down, that is what every signup gets until it is back. Do not act on fp_token_absent more strictly than an email confirmation.

signal_codes is the list that did the asking: the code of every finding with default_raises: true, in the order the findings came, without repeats. It is filled in even when the email already decided the action, so a stored check explains itself. Signal codes has every code and what default_raises means.

The email verification’s own reason_codes stay inside email. The two vocabularies never mix: every signal code starts with fp_.

signals.status is one of three:

status When Charge
evaluated The token was read and the signals service answered 5 credits
token_unusable There was no token, or it had expired, been altered, or was issued for another account’s page free
unavailable The signals service did not answer in time, answered with an error, or did not evaluate free

An unavailable check is not an error: it is a 200 whose signals is only {"status": "unavailable"}, and the action is the email’s alone. A token_unusable one carries the code that says why, and the code raises the action.

An evaluated response can include fp_version, vocabulary_version, token (outcome, issued_at, first_issued_at), findings, not_evaluated and not_evaluated_classes. The connection reports network, transport, timezone, client_claims and, since v13, probe depend on the available evidence and service version; connection reports lists their fields. Every check in them is also a finding or a not_evaluated code, so a policy built on the findings misses nothing. Skip fields you do not know. No field carries the token’s identity or the visitor’s address.

Every finding has code, class and default_raises; some also carry severity (hard or soft, where the rule that found it defines one) and detail, whose shape depends on the code. Findings and notes are passed on exactly as the signals service sent them, so a field added to them later arrives without a change on our side. A stricter policy of your own is best built on class and default_raises rather than on a list of codes: it then covers codes added later. Current opaque tokens use format v14; that token format is distinct from response version fields.

Since v13, probe contains the browser-probe status, build and checks. fp_probe_forged and fp_probe_automation require confirmation under the default policy; fp_probe_tampering is informational. A missing probe can be reported as fp_probe_not_evaluated while the token is usable and the operation remains evaluated at five credits.

notes_version and notes carry informational facts separately from findings. The current note vocabulary is 2: rtt_extra_leg, webtransport, ua_brand, bot_claim and bot_operator. Notes have no default_raises, never enter signal_codes and do not change the default action.

bot_claim distinguishes a declared crawler inside its operator’s network (crawler_verified), outside that network (crawler_unverified), a crawler with no supported operator check (crawler_unchecked), a declared tool (tool) and no such claim (none). bot_operator names the declared crawler’s operator. Even crawler_unverified is informational. none does not establish a human visitor. A note value of unavailable means it could not be evaluated; it is not the signals.status: unavailable outcome. Under a newer note vocabulary, an unrecognised value can be unknown.

Repeat-visit counts, farm-cluster detection and a raising signal for declared tools are not currently provided. The signal-code reference lists the note values and groups.

Without the email: POST /v1/signup-signals

Section titled “Without the email: POST /v1/signup-signals”

The same request without email and variant, for when the address is verified somewhere else or later. The answer is id, action, signal_codes and signals, and action is what the signals would make of an email that answered allow: allow, or allow_with_email_confirmation when a code raises it. Never reject.

A verification that cannot finish inside the request answers pending, and so does the email part of a signup check. The signals are final by then; action is allow_with_email_confirmation until the email has an answer. Retrieve the check after email.retry_after_ms:

Terminal window
curl -sS https://api.emvero.tech/v1/signup-checks/0b8e6f2a-5d1c-4f7e-9a3b-2c6d8e1f4a07 \
-H "Authorization: Bearer emv_your_credential"

It is free, and it answers the stored signals with the email as it stands now and action worked out again from the email’s current action. A stored check stays readable whatever happens to your signup protection afterwards. Somebody else’s check answers 404, the same as one that never existed.

The signals cost 5 credits when evaluated and nothing otherwise. On POST /v1/signup-checks the email part is priced as a verification is, and a pending email is charged where it becomes an answer. A repeat under the same Idempotency-Key is free.

Header On a signup check
X-Credits-Charged What this attempt charged, both parts together; 0 for a replay
X-Credits-Operation-ID The signals operation, then the email’s, comma-separated - each readable with GET /v1/credit-operations/{id}
X-Credits-Remaining The balance after the attempt

On POST /v1/signup-signals the operation header names one operation.

The API requires enough available credits for the requested reserves, even when the eventual signals outcome costs nothing. A signals-only request that cannot reserve its credits returns 402 insufficient_credits before evaluation. In a combined request, signal evaluation can begin before the email reserve fails; the signals part is then closed free of charge. A 402 response therefore does not guarantee that no request reached the signals service.

A token presented to your account a second time within 70 minutes of being issued - through either route - comes back with fp_token_reused added to the findings, and that raises the action. So retry with the same Idempotency-Key, not with a fresh request: a repeat under the key answers the stored check for 30 minutes and costs nothing, where a new request with the same token is a second presentation. Idempotency has the rest. The key is optional and a request without one is checked afresh.

Status error When
400 invalid_token, invalid_ip, field_too_long A field breaks its rule above
400 unknown_field, malformed_request, missing_email, unknown_variant, invalid_idempotency_key As on POST /v1/verifications
402 insufficient_credits The available balance cannot cover a required reserve; see the combined-call qualification above
409 signup_protection_disabled Signup protection is off for the account, or its data is being deleted
409 idempotency_conflict The same key with a different body
409 request_in_progress The same key while the first attempt runs; honour Retry-After
413 request_too_large The body is over the size limit
429 The rate limits every route has
503 product_unavailable Signup protection is switched off on our side, for every account
503 accounting_paused, accounting_unavailable, accounting_uncertain As on POST /v1/verifications - see Errors

GET /v1/signup-checks/{id} answers 404 not_found and 503 product_unavailable, and never 409.

GET /v1/relay-list answers the addresses and networks that have behaved as relays on your own pages: connections that ended at an intermediary before reaching the node. It is for addresses the node never measured itself - a server-side call, a mobile app, a form posted without the script - which you can look up in your copy. A connection the node did measure already carries its verdict in the findings.

Terminal window
curl -sS https://api.emvero.tech/v1/relay-list \
-H "Authorization: Bearer emv_your_credential" \
-H 'If-None-Match: "<the ETag you received last>"'
  • Free, and limited per account per minute: 429 relay_list_rate_limited with Retry-After beyond that.
  • Conditional. Send the ETag you last received as If-None-Match and an unchanged list answers 304. The list is rebuilt about once an hour, so asking every few minutes is plenty.
  • Not for HTTP caches: every answer carries Cache-Control: no-store. Keep the list in your application, and stop using it after the document’s valid_until.
  • Informational, like the network codes in the findings: an entry says connections from an address ended at an intermediary, not what kind - a VPN, a corporate gateway and a privacy relay look the same.

The body is the list document exactly as the signals service built it:

{
"version": 1,
"kind": "relay_observed",
"client": "q8m2v7c4a1z9k3x0",
"build": 1791792000,
"built_at": "2026-11-02T08:00:00Z",
"valid_until": "2026-11-02T14:00:00Z",
"window_start": "2026-10-19T08:00:00Z",
"rules_version": 1,
"net_rules_version": 3,
"thresholds": { "min_evaluated": 3, "min_share": 0.5, "prefix_min_relays": 3 },
"truncated": false,
"units": 52300,
"families": [4, 6],
"entries": [
{ "prefix": "203.0.113.7/32", "basis": "observed", "evaluated": 12, "hits": 12, "asn": 64500,
"first_hit": "2026-10-21T10:00:00Z", "last_hit": "2026-11-01T19:02:11Z" },
{ "prefix": "203.0.113.0/24", "basis": "inferred", "relays": 5, "evaluated": 61, "hits": 60, "asn": 64500,
"first_hit": "2026-10-20T08:00:00Z", "last_hit": "2026-11-02T07:40:03Z" },
{ "prefix": "2001:db8:1:2::/64", "basis": "observed", "evaluated": 7, "hits": 7, "asn": 64501,
"first_hit": "2026-10-22T00:00:00Z", "last_hit": "2026-10-30T11:15:54Z" }
]
}
Field Meaning
client Your client code, as in the account area
build, built_at The list’s build; build is also the ETag
valid_until After it, treat your copy as invalid
window_start The oldest connection the build looked at
thresholds The rules it was built under: an address is listed after at least min_evaluated measured connections, min_share of them through an intermediary; a /24 after prefix_min_relays listed addresses and no direct one
truncated The list hit its size limit
entries[].prefix /32 or /24 for IPv4, /64 for IPv6
entries[].basis observed: the address or /64 itself; inferred: its /24 neighbours were, which is weaker
entries[].evaluated, hits Measured connections, and how many of them ended at an intermediary; on an inferred entry both are summed over its listed addresses, and relays counts them
entries[].asn, first_hit, last_hit The network and the first and last such connection

To look an address up, take the longest match: the /32 (or the /64 for IPv6), then the /24.

Result When
listed, observed The address’s own entry, or its /64, matched
listed, inferred Only a /24 entry matched
not listed Nothing matched, and the copy is valid and not truncated
not looked up You hold no copy, it is past valid_until, or it is truncated and nothing matched

“Not listed” says only that the address is absent from a fresh list. An address measured fewer than min_evaluated times, or one whose connections were only partly through an intermediary, is never listed, whatever it is.

404 relay_list_unknown_client means there is no list for your account yet, and 503 relay_list_unavailable that none could be fetched; treat your addresses as not looked up in either case.