API documentation
One endpoint checks an email address and explains the result. Fast checks (syntax, DNS/MX, disposable and role addresses, typos) are available now. Deep checks, which ask the mail server whether the mailbox exists, are not available yet.
Quick start
- Create an account and confirm your email.
- On the dashboard, create a Test key.
- Run this, replacing the key:
Verify an address
curl
curl -X POST https://www.veriflowmail.com/api/v1/verifications \
-H "Authorization: Bearer vfm_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"deliverable@test.veriflowmail.com"}'TypeScript
const response = await fetch("https://www.veriflowmail.com/api/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"email":"deliverable@test.veriflowmail.com"}),
});
const data = await response.json();
if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);Authentication & test mode
Send your key in the Authorization header: Bearer vfm_live_… or Bearer vfm_test_…. Keys are shown once when created; store them as secrets on your server, never in browser code.
Test keys only accept addresses at test.veriflowmail.com. The part before the @ chooses the result: any reason code below (e.g. disposable_domain@test.veriflowmail.com) or a status (deliverable, undeliverable, risky, unknown). Test calls never contact mail servers and never use your allowance.
Verify an address
POST /api/v1/verifications with {"email": "…", "mode": "fast"}. mode is optional and defaults to fast.
A typo with a suggestion
curl
curl -X POST https://www.veriflowmail.com/api/v1/verifications \
-H "Authorization: Bearer vfm_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"possible_typo@test.veriflowmail.com"}'TypeScript
const response = await fetch("https://www.veriflowmail.com/api/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"email":"possible_typo@test.veriflowmail.com"}),
});
const data = await response.json();
if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);Response:
{
"id": "test_possible_typo",
"email": "possible_typo@test.veriflowmail.com",
"normalized": "possible_typo@test.veriflowmail.com",
"status": "risky",
"reason": "possible_typo",
"flags": {
"disposable": false,
"role": false,
"free_provider": false,
"catch_all": null
},
"suggestion": "possible_typo@gmail.com",
"score": 30,
"mode": "fast",
"mailbox_checked": false,
"checked_at": "2026-10-17T12:00:00.000Z",
"diagnostics": {
"mx_records": [],
"implicit_mx": false,
"dns_error": null,
"duration_ms": 0
}
}statusis the decision;reasonsays why.mailbox_checkedisfalsefor every fast check: only the address and its domain were checked. So a fastdeliverablemeans "the domain accepts mail", not "this mailbox exists".suggestionis set when the domain looks like a typo of a common provider. Show it as "Did you mean…?"; don't change the address silently.scoreis a 0–100 confidence. In fast mode it never reaches 100, because the mailbox itself isn't checked.
Statuses & reasons
| Reason | Status | Mode | Meaning |
|---|---|---|---|
invalid_syntax | undeliverable | fast | The address is not a valid email address. |
reserved_domain | undeliverable | fast | The domain is reserved for testing or documentation and can't receive mail. |
domain_not_found | undeliverable | fast | The domain does not exist. |
null_mx | undeliverable | fast | The domain explicitly states that it does not accept email. |
no_mx_records | undeliverable | fast | The domain exists but has no mail server. |
disposable_domain | risky | fast | The domain belongs to a disposable (temporary) email service. |
parked_domain | risky | fast | The domain is parked or for sale with a parking service, so mail is unlikely to reach anyone. |
website_only_domain | risky | fast | The domain has a website but no mail server record. Mail would go to the web server and often bounces. |
possible_typo | risky | fast | The domain looks like a misspelling of a common provider. See `suggestion`. |
no_reply_address | risky | fast | The address is a no-reply address and is unlikely to be read. |
role_account | risky | fast | The address belongs to a role or team (e.g. info@), not a person. |
temporary_failure | unknown | fast | A DNS lookup failed temporarily. Try again later. |
domain_accepts_mail | deliverable | fast | The domain can receive email. The mailbox itself was not checked (fast mode). |
mailbox_exists | deliverable | deep (coming) | The mail server confirmed that the mailbox exists. |
mailbox_not_found | undeliverable | deep (coming) | The mail server said the mailbox does not exist. |
mailbox_full | risky | deep (coming) | The mailbox exists but is full. |
catch_all | risky | deep (coming) | The domain accepts mail for any address, so the mailbox can't be confirmed. |
greylisted | unknown | deep (coming) | The mail server asked us to try again later. |
smtp_timeout | unknown | deep (coming) | The mail server did not answer in time. |
smtp_blocked | unknown | deep (coming) | The mail server refused to answer the check. |
Errors
Errors return {"error": {"code", "message", "request_id"}}. Every response has an X-Request-Id header; include it when contacting support.
Error: a test key with a real address
curl
curl -X POST https://www.veriflowmail.com/api/v1/verifications \
-H "Authorization: Bearer vfm_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"someone@company.com"}'TypeScript
const response = await fetch("https://www.veriflowmail.com/api/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"email":"someone@company.com"}),
});
const data = await response.json();
if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);| Code | HTTP | What to do |
|---|---|---|
invalid_api_key | 401 | Missing, malformed or revoked key. Check the Authorization header. |
invalid_json | 400 | The body isn't valid JSON. |
invalid_request | 400 | The body is valid JSON but a field is missing or wrong, or a header is malformed. |
deep_mode_unavailable | 400 | Deep checks aren't available yet. Use mode fast. |
test_mode_requires_test_address | 422 | Test keys only accept addresses at test.veriflowmail.com. |
idempotency_key_reused | 422 | That Idempotency-Key was used with a different body. Use a new key. |
idempotency_key_in_progress | 409 | A request with that key is still running. Retry shortly. |
quota_exceeded | 429 | This month's fast-check allowance is used up. Upgrade or wait for next month. |
insufficient_credits | 402 | Not enough deep credits for the request (deep checks, coming later). |
rate_limited | 429 | Too many requests. Wait for the number of seconds in Retry-After. |
internal_error | 500 | Something failed on our side. Retry; quote the request_id if it continues. |
Limits & plans
Limits are enforced on our servers. Responses include X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes Retry-After in seconds. Results with status unknown don't count towards your allowance. Each IP address is also limited to 600 requests a minute.
| Plan | Fast checks / month | Requests / minute | API keys (live + test) |
|---|---|---|---|
| Free | 5,000 | 60 | 1 + 1 |
| Starter | 50,000 | 300 | 3 + 3 |
| Growth | 250,000 | 1,200 | 10 + 10 |
| Business | 1,000,000 | 3,000 | 50 + 50 |
Safe retries
If a request times out, retry it with the same Idempotency-Key header. You get the first response back (marked Idempotent-Replayed: true) and are charged once. Keys are kept for 24 hours; reusing one with a different body returns 422.
Safe retries with Idempotency-Key
curl
curl -X POST https://www.veriflowmail.com/api/v1/verifications \
-H "Authorization: Bearer vfm_test_YOUR_TEST_KEY" \
-H "Idempotency-Key: signup-8f2c41" \
-H "Content-Type: application/json" \
-d '{"email":"disposable_domain@test.veriflowmail.com"}'TypeScript
const response = await fetch("https://www.veriflowmail.com/api/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
"Idempotency-Key": "signup-8f2c41",
"Content-Type": "application/json",
},
body: JSON.stringify({"email":"disposable_domain@test.veriflowmail.com"}),
});
const data = await response.json();
if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);Usage
GET /api/v1/usage returns this month's fast checks (used, limit, remaining), your deep-credit balance and your rate limit.
Check your usage
curl
curl https://www.veriflowmail.com/api/v1/usage \ -H "Authorization: Bearer vfm_test_YOUR_TEST_KEY"
TypeScript
const response = await fetch("https://www.veriflowmail.com/api/v1/usage", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
},
});
const data = await response.json();
if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);Integration guides
Two common ways to use the API. Both call it from your server; the key never goes to a browser. The code below is checked against the API on every change.
Check addresses at sign-up
- Call it when the form is submitted, with a short timeout (3 seconds).
- Refuse
undeliverableanddisposable_domain. Forpossible_typo, show "Did you mean …?" and let the person fix it. - Let everything else through, including role addresses and
unknown. - Fail open: if the API is slow, rate-limited, out of allowance or down, accept the sign-up. Real people must never be blocked by our outage.
// Server-side only: the key must never reach the browser.
export type SignupVerdict = { ok: true } | { ok: false; message: string };
export async function checkSignupEmail(email: string): Promise<SignupVerdict> {
const api = process.env.VERIFLOWMAIL_API_URL ?? "https://www.veriflowmail.com";
try {
const res = await fetch(`${api}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERIFLOWMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, mode: "fast" }),
signal: AbortSignal.timeout(3_000),
});
// Rate limit, used-up allowance or an outage: let the sign-up through.
if (!res.ok) return { ok: true };
const result = (await res.json()) as { status: string; reason: string; suggestion: string | null };
if (result.reason === "possible_typo" && result.suggestion) {
return { ok: false, message: `Did you mean ${result.suggestion}?` };
}
if (result.status === "undeliverable" || result.reason === "disposable_domain") {
return { ok: false, message: "Please use a real, permanent email address." };
}
return { ok: true }; // deliverable, role addresses, unknown
} catch {
return { ok: true }; // timeout or network error: fail open
}
}
Clean a list before outreach
- Run a scheduled job a few hours before sending. It checks the addresses that are due: never checked, last checked over 30 days ago, or
unknownon the last run. - Store
status,reason,suggestionandchecked_aton each contact, and an action:
| Answer | Action | Outreach |
|---|---|---|
deliverable | send | Yes |
undeliverable, or disposable_domain | remove | Never |
risky (role, no-reply, possible typo) or unknown | review | Your choice; re-check unknown on the next run |
- Fail closed: send only addresses with a recent result. If the API is down, already-checked contacts still go out and the rest wait for the next run.
- Use one
Idempotency-Keyper address per day, so retries are charged once. - Wait
Retry-Afterseconds onrate_limited. Stop the run and alert someone oninvalid_api_keyorquota_exceeded. - Stay under your plan's requests per minute: two requests at a time is plenty for a few hundred addresses a day.
// Server-side only: run it from a scheduled job, never in a user's request.
import { createHash } from "node:crypto";
export type Action = "send" | "review" | "remove";
export interface Verification {
id: string;
status: "deliverable" | "risky" | "undeliverable" | "unknown";
reason: string;
suggestion: string | null;
score: number;
checked_at: string;
}
/** Stop the whole run: missing or revoked key, or the month's allowance is used up. */
export class StopRun extends Error {}
export function actionFor(result: Verification): Action {
if (result.status === "deliverable") return "send";
if (result.status === "undeliverable" || result.reason === "disposable_domain") return "remove";
return "review"; // role, no-reply, possible typo, unknown
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** One fast check. Returns null when the address should simply be tried on the next run. */
export async function verifyForOutreach(email: string, today = new Date()): Promise<Verification | null> {
const api = process.env.VERIFLOWMAIL_API_URL ?? "https://www.veriflowmail.com";
const key = process.env.VERIFLOWMAIL_API_KEY;
if (!key) throw new StopRun("VERIFLOWMAIL_API_KEY is not set");
// Same address on the same day = same key: a retry is answered from the first call, charged once.
const hash = createHash("sha256").update(email.trim().toLowerCase()).digest("hex").slice(0, 32);
const idempotencyKey = `outreach-${hash}-${today.toISOString().slice(0, 10)}`;
const backoff = [2_000, 5_000, 15_000];
for (let attempt = 0; attempt <= backoff.length; attempt++) {
let res: Response;
try {
res = await fetch(`${api}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ email, mode: "fast" }),
signal: AbortSignal.timeout(15_000),
});
} catch {
if (attempt < backoff.length) await sleep(backoff[attempt]!);
continue; // timeout or network error: retry with the same key
}
if (res.ok) return (await res.json()) as Verification;
const body = (await res.json().catch(() => null)) as { error?: { code?: string; request_id?: string } } | null;
const code = body?.error?.code;
if (code === "invalid_api_key" || code === "quota_exceeded") {
throw new StopRun(`${code} (request ${body?.error?.request_id})`);
}
if (code === "rate_limited") {
await sleep((Number(res.headers.get("retry-after")) || 5) * 1_000);
attempt--; // waiting out the rate limit isn't a failed attempt
continue;
}
if (code === "idempotency_key_in_progress" || res.status >= 500) {
if (attempt < backoff.length) await sleep(backoff[attempt]!);
continue;
}
return null; // other 4xx: log the request id (never the address) and move on
}
return null;
}
Accuracy & limitations
- A fast check confirms the domain can receive email; it does not confirm that the mailbox exists.
deliverablein fast mode means "the domain accepts mail". - A temporary DNS failure is reported as
unknown/temporary_failure, never asundeliverable, and isn't charged. - Disposable domains come from a public list, refreshed regularly. New disposable services can appear before they are listed.
- Typo suggestions cover common email providers and mistyped endings like .con.
- Some mail providers deliberately hide whether a mailbox exists, so even deep checks (coming) will return
unknownorcatch_allfor some addresses. - We don't publish an accuracy percentage until we have measured one.
