Signup protection
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.
How it fits together
Section titled “How it fits together”- 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.
- Your server, handling the signup, sends the token to
POST /v1/signup-checkswith the email address and what it saw of the request: the client’s IP address, itsUser-Agentand its client hints. - 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.
What to send
Section titled “What to send”| 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.
The decision
Section titled “The decision”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
Section titled “signals”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.
When the email is pending
Section titled “When the email is pending”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:
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.
What it costs
Section titled “What it costs”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 is spent once
Section titled “A token is spent once”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.
Errors
Section titled “Errors”| 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.
The relay list
Section titled “The relay list”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.
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_limitedwithRetry-Afterbeyond that. - Conditional. Send the
ETagyou last received asIf-None-Matchand an unchanged list answers304. 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’svalid_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.