VeriFlowMail

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

  1. Create an account and confirm your email.
  2. On the dashboard, create a Test key.
  3. 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
  }
}
  • status is the decision; reason says why.
  • mailbox_checked is false for every fast check: only the address and its domain were checked. So a fast deliverable means "the domain accepts mail", not "this mailbox exists".
  • suggestion is set when the domain looks like a typo of a common provider. Show it as "Did you mean…?"; don't change the address silently.
  • score is a 0–100 confidence. In fast mode it never reaches 100, because the mailbox itself isn't checked.

Statuses & reasons

ReasonStatusModeMeaning
invalid_syntaxundeliverablefastThe address is not a valid email address.
reserved_domainundeliverablefastThe domain is reserved for testing or documentation and can't receive mail.
domain_not_foundundeliverablefastThe domain does not exist.
null_mxundeliverablefastThe domain explicitly states that it does not accept email.
no_mx_recordsundeliverablefastThe domain exists but has no mail server.
disposable_domainriskyfastThe domain belongs to a disposable (temporary) email service.
parked_domainriskyfastThe domain is parked or for sale with a parking service, so mail is unlikely to reach anyone.
website_only_domainriskyfastThe domain has a website but no mail server record. Mail would go to the web server and often bounces.
possible_typoriskyfastThe domain looks like a misspelling of a common provider. See `suggestion`.
no_reply_addressriskyfastThe address is a no-reply address and is unlikely to be read.
role_accountriskyfastThe address belongs to a role or team (e.g. info@), not a person.
temporary_failureunknownfastA DNS lookup failed temporarily. Try again later.
domain_accepts_maildeliverablefastThe domain can receive email. The mailbox itself was not checked (fast mode).
mailbox_existsdeliverabledeep (coming)The mail server confirmed that the mailbox exists.
mailbox_not_foundundeliverabledeep (coming)The mail server said the mailbox does not exist.
mailbox_fullriskydeep (coming)The mailbox exists but is full.
catch_allriskydeep (coming)The domain accepts mail for any address, so the mailbox can't be confirmed.
greylistedunknowndeep (coming)The mail server asked us to try again later.
smtp_timeoutunknowndeep (coming)The mail server did not answer in time.
smtp_blockedunknowndeep (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}`);
CodeHTTPWhat to do
invalid_api_key401Missing, malformed or revoked key. Check the Authorization header.
invalid_json400The body isn't valid JSON.
invalid_request400The body is valid JSON but a field is missing or wrong, or a header is malformed.
deep_mode_unavailable400Deep checks aren't available yet. Use mode fast.
test_mode_requires_test_address422Test keys only accept addresses at test.veriflowmail.com.
idempotency_key_reused422That Idempotency-Key was used with a different body. Use a new key.
idempotency_key_in_progress409A request with that key is still running. Retry shortly.
quota_exceeded429This month's fast-check allowance is used up. Upgrade or wait for next month.
insufficient_credits402Not enough deep credits for the request (deep checks, coming later).
rate_limited429Too many requests. Wait for the number of seconds in Retry-After.
internal_error500Something 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.

PlanFast checks / monthRequests / minuteAPI keys (live + test)
Free5,000601 + 1
Starter50,0003003 + 3
Growth250,0001,20010 + 10
Business1,000,0003,00050 + 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 undeliverable and disposable_domain. For possible_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 unknown on the last run.
  • Store status, reason, suggestion and checked_at on each contact, and an action:
AnswerActionOutreach
deliverablesendYes
undeliverable, or disposable_domainremoveNever
risky (role, no-reply, possible typo) or unknownreviewYour 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-Key per address per day, so retries are charged once.
  • Wait Retry-After seconds on rate_limited. Stop the run and alert someone on invalid_api_key or quota_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. deliverable in fast mode means "the domain accepts mail".
  • A temporary DNS failure is reported as unknown / temporary_failure, never as undeliverable, 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 unknown or catch_all for some addresses.
  • We don't publish an accuracy percentage until we have measured one.