Verify email addresses from your own code with the REST API, or from AI assistants such as Claude Code, Claude.ai, Claude Desktop and Codex through our MCP server.
For each address the service runs syntax checks, DNS/MX lookups, a careful SMTP conversation with the receiving server (no email is ever sent), catch-all detection, and checks for disposable, role (sales@, info@) and free-mail addresses and likely typos (gmial.com → gmail.com). You get back one verdict (status), a precise reason (sub_status), a 0-100 score and a plain safe_to_send recommendation of yes, caution or no.
POST /v1/verify) usually answer within the request.POST /v1/jobs) take a JSON list or a CSV/TXT upload and run in the background. Follow a job by long-polling, by email when it finishes, by a signed webhook, or through the events feed.| REST base URL | https://verify-api.dnsai.com |
|---|---|
| MCP endpoint | https://verify-api.dnsai.com/mcp (Streamable HTTP, stateless) |
| Auth | Authorization: Bearer <key> on every request, REST and MCP alike |
| OpenAPI | verify-api.dnsai.com/v1/openapi.json |
Pro, Business or Enterprise. See pricing; existing accounts can upgrade from the dashboard.
Log in and click API in the header, or go to verify.dnsai.com/secure/api-access/.
Name it after the device or integration (laptop, crm-sync, claude-code) and choose Live or Sandbox. Copy the key from the one-time dialog; we store only a hash and cannot show it again.
| Prefix | Mode | What it does |
|---|---|---|
dv_live_xxxxxxxx_... | live | Real verification. Costs credits. |
dv_test_xxxxxxxx_... | sandbox | Instant, deterministic fake results. No probing, no credits. For development (§9). |
Treat a key like a password. The first part, dv_live_xxxxxxxx, is a non-secret prefix that appears in logs and in GET /v1/account. Up to 10 active keys per account; revoke any of them from the same page. Revoking a key also disconnects any Claude.ai or Claude Desktop connector built on it.
export DNSAI_VERIFY_KEY=dv_live_xxxxxxxx_...
API=https://verify-api.dnsai.com
AUTH="Authorization: Bearer $DNSAI_VERIFY_KEY"
curl -s "$API/v1/account" -H "$AUTH" # plan, credits, limits, key info
curl -s -X POST "$API/v1/verify" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"email": "[email protected]", "ref": "crm-123"}'
Returns 200 with {"result": {...}} when the check finishes within wait seconds (default 25, max 55), otherwise 202 with a job_id to poll.
curl -s -X POST "$API/v1/jobs" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"items": [{"email": "[email protected]", "ref": "1"}, {"email": "[email protected]", "ref": "2"}],
"label": "october-leads", "webhook_url": "https://example.com/hooks/dnsai"}'
# → 202 {"job_id": "job_...", "accepted": 2, "duplicates_removed": 0, "invalid": [], "credits_charged": 2, ...}
curl -s -X POST "$API/v1/jobs" -H "$AUTH" \
-F "[email protected]" -F "email_column=Email" -F "label=leads"
# other CSV columns ride along as "meta" and come back with each result
curl -s "$API/v1/jobs/$JOB?wait=55" -H "$AUTH" # long-poll until done
curl -s "$API/v1/jobs/$JOB/results?format=json&limit=1000" -H "$AUTH" # pages; follow next_cursor
curl -s "$API/v1/jobs/$JOB/results?format=csv" -H "$AUTH" -o out.csv # everything at once
curl -s "$API/v1/jobs/$JOB/results?status=deliverable" -H "$AUTH" # filter by status
curl -s "$API/v1/jobs?status=running" -H "$AUTH" # list
curl -s -X DELETE "$API/v1/jobs/$JOB" -H "$AUTH" # cancel; unprocessed items refunded
curl -s "$API/v1/[email protected]" -H "$AUTH" # your latest result, free
curl -s "$API/v1/events?after=0" -H "$AUTH" # pull feed of job.completed events
{
"email": "[email protected]", "ref": "crm-123",
"status": "deliverable", "sub_status": "mailbox_confirmed",
"score": 95, "safe_to_send": "yes",
"flags": {"catch_all": false, "role": false, "free": false, "disposable": false, "gibberish": false},
"did_you_mean": null,
"domain": {"name": "acme.com", "mx_provider": "google", "health_score": 90},
"reason": "Mailbox verified (SMTP 250)", "attempts": 1, "cached": false,
"checked_at": "2026-10-13T16:01:13Z"
}
status | sub_status | Meaning |
|---|---|---|
deliverable | mailbox_confirmed | The server confirmed the mailbox. Safe to send. |
undeliverable | mailbox_absent, domain_absent, syntax | Will bounce. Remove it. |
risky | catch_all, accept_all_provider, mailbox_full, disposable | Accepted but unprovable, or likely to bounce later. Use caution. |
unknown | provider_blocked, temp_failure, timeout, no_signal, deadline | No verdict could be reached. Retry later or treat as risky. |
score is a deliverability band (deliverable 90-99, catch-all 50-69, unknown 30, mailbox absent 5, other undeliverable 0). safe_to_send is yes, caution or no.
unknown and cached results. Duplicates within one request are merged and charged once; invalid syntax is free.cached: true. Pass max_age_days to change the window or force_fresh: true to always probe again.credits_charged is the cost. Cancelling refunds unprocessed items. System errors and deadline leftovers are refunded automatically.GET /v1/lookup returns your own most recent result without probing.GET /v1/account.Verification talks SMTP to the receiving mail server, and probing is paced per provider to keep the service trusted. So a fresh check takes seconds, not milliseconds, and when many customers submit at once, requests queue. Every response tells you where you stand:
| Signal | Where | Meaning |
|---|---|---|
result.cached: true | any result | Served from a stored result (≤ max_age_days old): instant. |
202 + estimated_seconds, queue.ahead, queue.load, Retry-After | POST /v1/verify | Not finished within wait; here is how long it will probably take. |
eta_seconds, load | GET /v1/jobs/{id} while running | Rough time to completion for that job. |
GET /v1/status (MCP get_service_status) | any time, free | Current load, queue depth, seconds to expect per single check and per 1,000 list items, and advice. |
429 + Retry-After | any | A plan limit was hit; back off for that long. |
load is normal (single checks usually finish inside the request), busy (expect 202s; poll or use webhooks) or saturated (send lists as jobs with webhook_url or notify_email, and don't poll tightly).
"decision": {"action": "send", "why": "mailbox confirmed"}
"decision": {"action": "review", "why": "domain accepts every address; mailbox unprovable"}
"decision": {"action": "review", "why": "mail server asked us to retry later", "retry_after_seconds": 1800}
"decision": {"action": "drop", "why": "mailbox does not exist"}
send = deliverable. drop = undeliverable, disposable or mailbox full. review = catch-all or accept-all (unprovable) or unknown (no verdict yet; retry_after_seconds says when a fresh check is worth it). Shape the output to your needs: fields=status,decision,score trims any result; decision=send or decision=drop,review filters a job's results; format=csv|ndjson streams whole jobs, and the CSV has decision and decision_why columns.
| You are… | Do this |
|---|---|
| A form or CRM hook checking one address as the user types or saves | POST /v1/verify with wait 10-25. Treat 202 as "pending": show the user a check is in progress, then poll status_url?wait=25 (the result is in that response), or pass webhook_url with wait=0 and never poll. Up to 200 single checks may be in flight per account; they never count against your concurrent-jobs limit. |
| A lead-verification app sending a steady stream | Batch what arrives over a few seconds into one POST /v1/jobs, give it webhook_url, and read rows from the webhook's results_url with format=ndjson or decision=send. Use Idempotency-Key so retries never charge twice. |
| A server syncing a database | Drain GET /v1/events?after=<cursor>: every finished job appears once; single verifies carry data.result inline, lists carry counts and results_url. |
| A person cleaning a list | POST /v1/jobs with notify_email: a summary and CSV link arrive by email when it finishes. Or ask Claude through MCP and it polls for you. |
Tell your users the truth: cached results are instant, most fresh checks take 3-15 seconds, greylisted or slow providers can take minutes, and a few addresses in any large list are retried for up to a couple of hours or come back unknown. A max_wall_seconds deadline on a job turns leftovers into unknown/deadline (refunded) so a nightly process always finishes.
| Plan | Requests / min | Emails / day | Emails per job | Concurrent jobs |
|---|---|---|---|---|
| Pro | 60 | 5,000 | 5,000 | 2 |
| Business | 120 | 10,000 | 10,000 | 3 |
| Enterprise | 240 | 20,000 | 25,000 | 5 |
Requests per minute apply per account (all keys together). Daily caps count live emails submitted; sandbox usage is free and uncounted. Your exact limits are in GET /v1/account → limits.
| Other limits | Value |
|---|---|
Server-side wait on POST /v1/verify | 0-55 s (default 25) |
Job long-poll ?wait= | at most 55 s |
| Results page size | at most 1000 (REST); MCP get_job_results at most 200 |
| MCP inline list | 100 addresses per call (use CSV upload or verify_list_from_url for more) |
| Upload size | 50 MB per file |
| Idempotency window | 24 h |
| Result and job retention | about 30 days. Download what you want to keep |
Throughput. Probing is paced per receiving provider to protect deliverability and reputation. Most of a batch finishes within minutes; addresses that hit temporary failures are retried over a few hours. Pass max_wall_seconds to put a deadline on a job (leftovers come back as unknown/deadline and are refunded). Use a webhook, the completion email, or ?wait= rather than tight polling.
Errors use application/problem+json with a machine-readable code and a human detail.
| HTTP | Code | What to do |
|---|---|---|
| 400 / 422 | invalid_request, … | Fix the request; don't retry. |
| 401 | missing_api_key, invalid_api_key | Check the key; it may be revoked. |
| 402 | insufficient_credits | Upgrade or wait for the period reset. |
| 403 | plan_not_entitled (body carries upgrade_url), account_inactive | Upgrade the plan. |
| 413 | job_too_large, file_too_large | Split the list. |
| 429 | rate_limited, daily_cap_reached, too_many_active_jobs | Wait Retry-After seconds, then retry. |
| 502 / 503 / 504 | Temporary; retry with backoff. |
Idempotency. Send an Idempotency-Key header (a fresh UUID per logical request, reused unchanged on retries) on every POST. A replay returns the original response and never charges twice. Keys are remembered for 24 hours.
Retries. Retry 429, 502, 503, 504 and network errors (a dropped connection, "server disconnected", timeouts) with exponential backoff, honouring Retry-After. The API sits behind Cloudflare, which closes idle keep-alive connections from time to time; a client that reuses a pooled connection can see one dropped request. With the idempotency key the retry is free and safe. The Python client library does all of this for you.
When you create a job you can pass notify_email (a summary and CSV link, sent to your account's own address) and/or webhook_url (an HTTPS endpoint that receives a signed job.completed POST). Webhooks are signed with your account's webhook secret, shown on the API & MCP page:
X-DNSai-Signature: t=<unix time>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>
Deliveries are retried at 1 min, 5 min, 30 min, 2 h and 6 h. The job's status shows delivery details. Servers without a public endpoint can drain GET /v1/events instead.
Sandbox keys (dv_test_) exercise the whole API, including jobs, cursors, CSV, MCP and events, with deterministic fake results. Nothing is probed and no credits are used. The result depends on the local part:
| Local part contains | status / sub_status |
|---|---|
invalid | undeliverable / mailbox_absent |
catchall | risky / catch_all |
unknown | unknown |
full | risky / mailbox_full |
disposable | risky / disposable |
| anything else | deliverable |
The MCP server at https://verify-api.dnsai.com/mcp offers tools such as verify_email, verify_emails, verify_list_from_url, get_job, get_job_results, list_jobs, lookup_email, cancel_job and get_account, plus prompts for cleaning a prospect list. Every tool returns within about 25 seconds; longer work returns a job_id the assistant polls.
claude mcp add --transport http dnsai-verify https://verify-api.dnsai.com/mcp \
--header "Authorization: Bearer $DNSAI_VERIFY_KEY"
# add --scope user to make it available in every project
# then: "verify these emails: [email protected], [email protected]" or "clean leads.csv before the campaign"
claude-ai).https://verify-api.dnsai.com/mcp, leave client ID and secret empty.# ~/.codex/config.toml
[mcp_servers.dnsai_verify]
url = "https://verify-api.dnsai.com/mcp"
bearer_token_env_var = "DNSAI_VERIFY_KEY"
tool_timeout_sec = 60
client.beta.messages.create(
model="claude-sonnet-5-5", max_tokens=2000,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{"type": "url", "url": "https://verify-api.dnsai.com/mcp",
"name": "dnsai-verify", "authorization_token": DNSAI_VERIFY_KEY}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "dnsai-verify"}],
messages=[{"role": "user", "content": "Verify [email protected] and tell me if it is safe to send."}])
Any other client that supports Streamable HTTP with custom headers works the same way: the URL plus the Authorization: Bearer header. The server is stateless, so no session setup is needed.
import os, requests, uuid
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
API = "https://verify-api.dnsai.com"
H = {"Authorization": "Bearer " + os.environ["DNSAI_VERIFY_KEY"]}
s = requests.Session() # retry dropped connections and 429/5xx, honouring Retry-After
s.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=1, status_forcelist=[429, 502, 503, 504],
allowed_methods=["GET", "POST", "DELETE"], respect_retry_after_header=True)))
r = s.post(f"{API}/v1/verify", headers={**H, "Idempotency-Key": str(uuid.uuid4())},
json={"email": "[email protected]"}, timeout=60).json()
print(r["result"]["status"], r["result"]["decision"]["action"])
job = s.post(f"{API}/v1/jobs", headers={**H, "Idempotency-Key": str(uuid.uuid4())},
json={"emails": ["[email protected]", "[email protected]"], "label": "demo"}).json()
while True:
st = s.get(f"{API}/v1/jobs/{job['job_id']}", headers=H, params={"wait": 55}, timeout=90).json()
if st["status"] in ("completed", "failed", "canceled"):
break
print("still running:", st["done"], "of", st["total"], "eta", st.get("eta_seconds"), "s")
rows = s.get(f"{API}/v1/jobs/{job['job_id']}/results", headers=H,
params={"format": "json", "limit": 1000}).json()["results"]
| Method & path | Purpose |
|---|---|
POST /v1/verify | One address. {email, ref?, max_age_days?, force_fresh?, wait?} |
POST /v1/jobs | Batch. JSON {emails[] | items[{email, ref, meta}], label?, max_age_days?, force_fresh?, notify_email?, webhook_url?, max_wall_seconds?} or multipart file (+ email_column) |
GET /v1/jobs | List jobs (status, limit, cursor) |
GET /v1/jobs/{id} | Status and counts; ?wait= long-polls (≤55 s) |
GET /v1/jobs/{id}/results | format=json|ndjson|csv, status=, cursor, limit (≤1000) |
DELETE /v1/jobs/{id} | Cancel; unprocessed items refunded |
GET /v1/lookup?email= | Your latest result for an address (free) |
GET /v1/events?after= | Pull feed of job.completed events |
GET /v1/account | Plan, credits, limits, key info, webhook secret |
GET /v1/status | Current load, queue depth and expected waits |
GET /v1/health | Liveness (no auth) |
GET /v1/openapi.json | OpenAPI description (no auth) |
Create your first key on the API & MCP page of your dashboard. Questions: [email protected].
Open API & MCP