Error Handling
The VerifNow API uses standard HTTP status codes and returns consistent response objects so you can handle every scenario gracefully.
Important: When a validation is successfully processed, the API always returns 200 OK — regardless of whether the email is valid or not. Check the valid, message, and deliverability fields in the response body to determine the validation outcome.
Successful validation responses (200 OK)
When the API processes your validation request, it returns 200 OK with a response body containing the result.
For email, valid is false only when the address cannot receive mail. The message says why:
| Reason | message | valid |
|---|---|---|
| Empty input | "Email cannot be empty" | false |
| Invalid syntax | "Invalid email format" | false |
| Domain has neither MX nor A records | "Domain has no valid MX or A records" | false |
| Domain is on the blocklist | "Domain reputation is blocked" | false |
Otherwise valid is true on every plan, and the message reflects the deliverability assessment:
| Deliverability | message | valid |
|---|---|---|
DELIVERABLE | "Valid email address" | true |
RISKY | "Email address is valid but has risk factors" | true |
UNDELIVERABLE | "Email address is valid but unlikely to be deliverable" | true |
Risk signals such as a likely typo or a disposable provider lower deliverability, never valid.
Decide what to accept from deliverability, risk_score and the signals, as shown in
Acceptance strategy.
Example: deliverable email
{
"valid": true,
"message": "Valid email address",
"normalizedValue": "john@gmail.com",
"originalValue": "john@gmail.com",
"validationLevel": "PREMIUM",
"emailDetails": {
"signals": { ... },
"risk_score": 5,
"risk_level": "LOW",
"deliverability": "DELIVERABLE",
"applied_level": "PREMIUM"
}
}Example: domain that cannot receive mail
{
"valid": false,
"message": "Domain has no valid MX or A records",
"normalizedValue": null,
"originalValue": "user@gmal.com",
"validationLevel": "PREMIUM",
"emailDetails": {
"signals": {
"syntax_valid": true,
"mx_valid": false,
"typo_detected": true,
"suggested_domain": "gmail.com",
...
},
"risk_score": 100,
"risk_level": "HIGH",
"deliverability": "UNDELIVERABLE",
"applied_level": "PREMIUM"
}
}A 200 response does not mean the email is valid. Always check the valid and deliverability fields in the response body.
When the request itself fails
Anything other than 200 means the API could not process the request. There are two error
shapes, because errors are raised in two places:
| Raised by | Shape | Example |
|---|---|---|
| The edge (authentication, the FREE quota, unknown paths) | {"error": "..."} | {"error": "Invalid API key"} |
| The API itself | {"status": 400, "message": "..."} | {"status": 429, "message": "Too many requests in flight for this account. Retry in a moment."} |
Branch on the HTTP status code, not on the text. There are no machine-readable error codes: the
message is written for a person and may be reworded. If you log something, log the status and the
X-Request-Id header — every response carries one, and it is what support will ask for.
HTTP status codes
| Status | When | What to do |
|---|---|---|
200 OK | The validation ran | Read valid — see above. Not an error, even when valid is false. |
400 Bad Request | The body is not JSON | Fix the request. Retrying sends the same broken body. |
401 Unauthorized | No X-API-KEY header | Fix the configuration. Do not retry. |
403 Forbidden | The key is invalid or revoked | Fix the configuration. Do not retry. |
404 Not Found | Unknown path | Check the endpoint — validation paths are /api/v1/validate/{rule}. |
405 Method Not Allowed | Not a POST | Send a POST. |
415 Unsupported Media Type | Content-Type is not application/json | Set the header. |
429 Too Many Requests | FREE quota spent, or too many requests in flight | Depends which — read Retry-After. See Rate Limits. |
5xx | A server or upstream failure | Retry with backoff. |
A missing or empty value is not an error. {} or {"value": ""} is processed and returns
200 with valid: false and a message such as "Email cannot be empty". Only a body that is not
JSON at all returns 400.
What the common errors look like
HTTP/2 401
X-Request-Id: 4f2c…
{"error": "Missing API key"}HTTP/2 403
X-Request-Id: 9a71…
{"error": "Invalid API key"}HTTP/2 400
X-Request-Id: c03e…
{"status": 400, "message": "Request body must be JSON, for example {\"value\": \"user@example.com\"}"}Handling errors in code
With the SDK
The Node.js SDK, @verifnow/sdk, turns these into typed errors, retries what is worth retrying —
connection failures, 5xx, and a 429 whose Retry-After is short — and never retries a 400, 401
or 403. The Java SDK does not retry: see its README for how it reports failures.
import {
VerifNow,
VerifNowAuthError,
VerifNowConnectionError,
VerifNowRateLimitError,
VerifNowRequestError,
} from '@verifnow/sdk'
const client = new VerifNow({ apiKey: process.env.VERIFNOW_API_KEY! })
export async function validate(email: string) {
try {
return await client.validateEmail(email)
} catch (error) {
if (error instanceof VerifNowAuthError) {
// 401 / 403 — the key is missing, invalid or revoked.
throw new Error('Check the VERIFNOW_API_KEY environment variable.', { cause: error })
}
if (error instanceof VerifNowRateLimitError) {
// 429 that did not clear within the SDK's retries — usually a spent FREE quota.
console.warn('VerifNow quota or concurrency limit', {
retryAfterSeconds: error.retryAfterSeconds,
requestId: error.requestId,
})
throw error
}
if (error instanceof VerifNowRequestError) {
throw error // 400 — the request needs fixing, not retrying
}
if (error instanceof VerifNowConnectionError) {
throw error // unreachable or timed out, after retries — your call whether to accept unverified
}
throw error
}
}See the Node.js examples for the full pattern.
Without the SDK
Check the status before reading valid. An error body has no valid field, so code that reads
it on a 401 or a 429 will tell your user their email is invalid when the problem is your key or
your quota.
export async function validateEmail(email: string) {
const response = await fetch('https://api.verifnow.io/api/v1/validate/email', {
method: 'POST',
headers: {
'X-API-KEY': process.env.VERIFNOW_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: email }),
signal: AbortSignal.timeout(5000),
})
if (!response.ok) {
// Either shape: {"error": "..."} from the edge, {"status", "message"} from the API.
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string }
const text = body.message ?? body.error ?? response.statusText
const requestId = response.headers.get('X-Request-Id')
if (response.status === 401 || response.status === 403) {
throw new Error(`VerifNow rejected the API key: ${text} (request ${requestId})`)
}
if (response.status === 429) {
const retryAfter = Number(response.headers.get('Retry-After') ?? 'NaN')
throw new Error(`VerifNow limit: ${text}` + (Number.isFinite(retryAfter) ? ` — retry in ${retryAfter}s` : ''))
}
throw new Error(`VerifNow ${response.status}: ${text} (request ${requestId})`)
}
return (await response.json()) as { valid: boolean; message?: string }
}Retrying
Retry 5xx and connection failures with exponential backoff. Retry a 429 only when Retry-After
is short: a concurrency limit clears in a second, a spent monthly quota does not, and sleeping on it
helps nobody. Never retry 400, 401 or 403 — they fail the same way every time.
Best practices
- A
200does not mean the value is valid — readvalid. - Branch on the status code, not the message — messages are for people and may change.
- Log the
X-Request-Idwith every failure. - Retry
5xxand short429s only. - Surface
suggested_domainfromemailDetails.signalswhen a typo is detected. - Watch your quota with the
X-RateLimit-*headers — see Rate Limits.