API docs
One POST request checks an address and returns a verdict, a 0–100 score and the reasons behind it. A free account gives you 100 checks a day: get your API key.
Authentication
Every request needs an API key. Sign in with your email to create one; it's shown once, so store it somewhere safe. Send it in the Authorization header:
Authorization: Bearer ls_your_key_here
An x-api-key: ls_your_key_here header works too. Keep keys on your server: anyone with a key can spend its quota. If a key leaks, revoke it on your account page; that takes effect within a minute.
Request
POST https://api.legitsignup.com/v1/check with a JSON body:
POST /v1/check
Authorization: Bearer ls_your_key_here
Content-Type: application/json
{ "email": "Jane.Doe+news@gmail.con" }
Send content-type: application/json. The body may be at most 1 KB and the address at most 320 characters. Addresses are only accepted in the body: a GET with the address in the URL is refused, because URLs end up in logs.
Response
A 200 response describes the address. This is a real response for the request above:
{
"email": "Jane.Doe+news@gmail.con",
"verdict": "invalid",
"score": 0,
"reasons": [
{ "code": "typo_suspected", "message": "Did you mean gmail.com?" },
{ "code": "no_mail_server", "message": "The domain does not exist." }
],
"suggestion": "Jane.Doe+news@gmail.com",
"canonical": "jane.doe+news@gmail.con",
"flags": {
"syntaxValid": true,
"role": false,
"freeProvider": false,
"disposable": false,
"typo": true,
"dnsChecked": true,
"mailServer": false,
"nullMx": false,
"burnerMx": false,
"parked": false,
"newDomain": false,
"randomLocalPart": false
}
}
| Field | Type | Meaning |
|---|---|---|
email | string | The address as checked: trimmed, with the domain lowercased and international domains in punycode. |
verdict | string | ok, risky or invalid. See Verdicts. |
score | number | 0–100. Starts at 100 and loses points per reason. |
reasons | array | Every problem found, each with a stable code and a human-readable message. Empty for a clean address. |
suggestion | string | null | The corrected address when the domain looks mistyped. Show it as “Did you mean …?”, never apply it silently. |
canonical | string | null | The address with provider aliases removed (Gmail dots and +tags, Yahoo -keywords), lowercased. Compare it across your users to catch one person creating many accounts. null when the syntax is invalid. |
flags | object | Every individual signal as a boolean. See Flags. |
Verdicts
| Verdict | When | Suggested handling |
|---|---|---|
| ok | Score 70 or more, no hard failure. | Accept. |
| risky | Score below 70, no hard failure. | Show the suggestion if there is one; otherwise accept but require email confirmation, or add friction such as a captcha. |
| invalid | Any hard failure: bad syntax, a domain that doesn't exist or can't receive mail. Score is 0. | Ask the user to fix the address. |
No verdict means the mailbox exists. We never contact the recipient's mail server, so ok means the address is well-formed, its domain accepts email, and nothing about it looks fake.
Reason codes
Codes are stable; messages may change. Each code counts once.
| Code | Effect | Meaning |
|---|---|---|
syntax_invalid | invalid | The address is malformed: bad characters, misplaced dots, too long, quoted local part, or an IP address as the domain. |
no_mail_server | invalid | The domain doesn't exist, or has no MX record and no address to fall back to. |
null_mx | invalid | The domain publishes a “null MX” record (RFC 7505): it declares that it accepts no email. |
mx_invalid_host | invalid | Every mail server points to localhost or a private address. |
disposable | −70 | The domain is a known disposable or throwaway email service. |
burner_domain | −60 | Our domain intelligence has seen this domain serving disposable inboxes. |
burner_mx | −60 | The domain's mail is handled by a known disposable email service. |
parked_domain | −40 | The domain is parked or for sale, or its mail goes to a parking service. |
typo_suspected | −40 | The domain looks like a misspelling of a popular provider. See suggestion. |
new_domain | −30 | The domain was registered less than 30 days ago. |
role_address | −15 | A role or team address such as info@, admin@ or noreply@. |
no_mx_record | −10 | No MX record; mail would go to the domain's web server address. |
dns_unavailable | −10 | The domain's DNS couldn't be looked up right now. The result is incomplete; retrying later may help. |
random_local_part | −10 | The part before @ looks randomly typed or keyboard-mashed. A weak signal on its own. |
Flags
| Flag | Meaning |
|---|---|
syntaxValid | The address passed syntax checks. When false, no other check ran. |
role | A role or team address. |
freeProvider | A consumer provider such as Gmail or Outlook. Not a penalty; useful if you only want work addresses. |
disposable | On our disposable domain list. |
typo | A typo was suspected and suggestion is set. |
dnsChecked | DNS was looked up for this check. |
mailServer | true if the domain can receive mail, false if it can't, null if unknown. |
nullMx | The domain declares it accepts no mail. |
burnerMx | The mail servers belong to a disposable email service. |
parked | The domain is parked or for sale. |
newDomain | Registered less than 30 days ago. |
randomLocalPart | The part before @ looks random. |
Errors
Errors return { "error": { "code": "…", "message": "…" } }.
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_json | The body isn't valid JSON. |
| 400 | missing_email | The body has no non-empty email string. |
| 400 | email_too_long | The address is longer than 320 characters. |
| 401 | api_key_required | No API key was sent. See Authentication. |
| 401 | invalid_api_key | The key doesn't exist or was revoked. |
| 404 | not_found | Unknown path. The only endpoint is /v1/check. |
| 405 | method_not_allowed | Anything but POST (or OPTIONS). |
| 413 | body_too_large | The body is larger than 1 KB. |
| 415 | unsupported_media_type | The content type isn't application/json. |
| 429 | quota_exceeded | The account has used today's checks. retry-after says how many seconds until the quota resets at midnight UTC. |
| 429 | rate_limited | More than 30 requests in a minute. Wait for the number of seconds in retry-after. |
| 500 | internal_error | Something failed on our side. Retry, and fail open: let the signup through. |
Limits
Free accounts get 100 checks a day, shared by all of the account's keys, and at most 30 requests a minute. The daily quota resets at midnight UTC. Requests rejected before checking (bad JSON, a missing email) don't count.
Every response to a request with a key reports the quota:
| Header | Meaning |
|---|---|
x-ratelimit-limit | Checks allowed per day. |
x-ratelimit-remaining | Checks left today. |
x-ratelimit-reset | Seconds until the quota resets. |
If the API is slow, unreachable or over quota, let the signup through. A missed check is cheaper than a lost user.
Examples
curl
curl -X POST https://api.legitsignup.com/v1/check \
-H "authorization: Bearer $LEGITSIGNUP_KEY" \
-H "content-type: application/json" \
-d '{"email": "jane@gmial.com"}'
JavaScript (Node 18+)
async function checkEmail(email) {
try {
const res = await fetch("https://api.legitsignup.com/v1/check", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LEGITSIGNUP_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null; // fail open
return await res.json();
} catch {
return null; // fail open
}
}
const result = await checkEmail("jane@gmial.com");
if (result?.verdict === "invalid") {
// ask the user to fix the address
} else if (result?.suggestion) {
// show: Did you mean result.suggestion?
}
Python
import os
import requests
def check_email(email: str) -> dict | None:
try:
res = requests.post(
"https://api.legitsignup.com/v1/check",
json={"email": email},
headers={"authorization": f"Bearer {os.environ['LEGITSIGNUP_KEY']}"},
timeout=3,
)
res.raise_for_status()
return res.json()
except requests.RequestException:
return None # fail open
result = check_email("jane@gmial.com")
if result and result["verdict"] == "invalid":
... # ask the user to fix the address
PHP
function check_email(string $email): ?array {
$ch = curl_init('https://api.legitsignup.com/v1/check');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'authorization: Bearer ' . getenv('LEGITSIGNUP_KEY'),
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ($body !== false && $status === 200) ? json_decode($body, true) : null; // null = fail open
}
Privacy
We never store the addresses you check. DNS results are cached per domain for at most 24 hours, logs record the domain and the verdict, never the address, and checked domains are kept for 30 days to research their age. Responses are sent with cache-control: no-store.
Your own account email is stored so you can sign in, along with daily check counts. Deleting your account removes both.