Skip to content

Email verify

The Email verify API checks whether addresses are syntactically valid, reachable, and appropriate for your use case. It applies layered checks (syntax, disposable domains, MX records, SMTP probing where allowed, and major-provider rules) and returns structured fields such as valid, status, confidence, catch_all, and smtp_blocked so clients and agents can decide how strictly to treat each result.

  • Base URL: https://api.truval.dev
  • Auth: Authorization: Bearer sk_live_... (standard API keys; sk_test_... may be used in non-prod)
  • POST /v1/email/verify — one address per request; synchronous JSON response, or 202 + background delivery via optional webhook
  • POST /v1/email/verify/batch — up to 50 addresses; JSON body with results in the same order as input
  • POST /v1/email/verify/stream — up to 50 addresses; NDJSON (application/x-ndjson), lines may arrive in completion order

Import paths, variables, and tooling are covered next. Request and response fields for the single-email endpoint follow; Async webhook, Batch verification, and Streaming verification document batch and stream behavior and limits.

https://api.truval.dev/openapi.json

Note: Import this URL into Cursor, Postman, or any OpenAPI-compatible tool.

The saved collection includes every public route and these variables:

VariablePurpose
TRUVAL_BASE_URLAPI host (default https://api.truval.dev; no trailing slash)
TRUVAL_API_KEYYour sk_live_... key (sent as the Bearer token on verify routes)
TRUVAL_MGMT_KEYYour sk_mgmt_... provisioning key (sent as the Bearer token on /v1/management/* routes)

Import via URL — in Postman or Bruno, choose ImportLink, then paste:

https://docs.truval.dev/postman/truval.json

That URL is served by this documentation site and stays in sync with API releases.

Run in Postman

The Run in Postman button forks the collection into your workspace (Postman may ask you to sign in). For Bruno, Cursor, or a raw file, use Import → Link with https://docs.truval.dev/postman/truval.json above.

POST https://api.truval.dev/v1/email/verify
HeaderValue
AuthorizationBearer sk_live_...
Content-Typeapplication/json
FieldTypeRequiredDescription
emailstringYesThe email address to verify
webhookstringNoURL to receive the async callback. See Async webhook
webhook_secretstringNoOptional shared secret for webhook signing. When provided, truval.dev includes X-Truval-Signature: sha256=... on the webhook callback (HMAC-SHA256 over the raw JSON body).
Terminal window
curl https://api.truval.dev/v1/email/verify \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
FieldTypeDescription
emailstringThe email address that was verified
validbooleantrue when SMTP result is deliverable or catch-all. false for invalid addresses, SMTP timeout (smtp_timeout), undeliverable, or unknown (including major providers with smtp_blocked — use confidence, not valid alone)
statusstringSee status values below
confidencefloatOrdinal 0–1: how decisive the check was (not a statistical probability the mailbox exists). See confidence ladder below
failed_checkstring|nullWhich layer failed first: syntax · disposable · no_mx · smtp · smtp_timeout, or null when verification finished without failing a layer (deliverable, catch-all, unknown including smtp_blocked, inconclusive SMTP). Authoritative enum: OpenAPI schema VerifyEmailResult (failed_check).
disposablebooleanMatched against 50k+ throwaway domain blocklist
rolebooleanRole address — admin@, info@, noreply@ etc
free_providerbooleanGmail, Yahoo, Hotmail etc
catch_allbooleanServer accepts all addresses; mailbox existence is not confirmable
smtp_blockedbooleanProvider blocks SMTP probing — Gmail, Outlook, Yahoo
mx_foundbooleanMX records exist for the domain
mx_hoststring|nullPrimary MX hostname
suggestionstring|nullTypo correction e.g. "gmail.com" when input was "gnail.com"
latency_msnumberEnd-to-end processing time in milliseconds

When an agent consumes this API, it should not blindly rely on the valid boolean. Use status, failed_check, mx_found, and smtp_blocked first, then use confidence as a summary band.

Decision order (recommended):

  1. If suggestion is set → offer correction.
  2. If disposable → reject throwaway addresses.
  3. If status is invalid or undeliverable → reject or ask for another address.
  4. If catch_all or status === "catch_all" → do not treat valid: true as mailbox proof; confirm or add risk controls.
  5. If smtp_blocked → domain checks passed; confidence around 0.75 is expected; do not treat valid: false as failure by itself.
  6. If status === "unknown" and mx_found and not smtp_blocked → SMTP was inconclusive (common on org/M365); typical confidence is 0.50 — ask the user to confirm or use secondary verification, not the same as “fake email.”
  7. Otherwise use confidence bands from the Agent decision guide.
  • confidence: Ordinal summary of how decisive the pipeline was — not P(mailbox exists). See the confidence ladder.
  • suggestion: If present, the agent should ask the user: “Did you mean [suggestion]?”
  • smtp_blocked: For Gmail, Outlook, and Yahoo, we cannot run SMTP probes. If smtp_blocked is true, a confidence of around 0.75 is normal and should be accepted by the agent.
  • catch_all: If catch_all is true (or status is catch_all), do not treat valid: true as mailbox proof. Catch-all is returned with lower confidence (0.65) because the domain may still hard-bounce specific recipients later.
  • disposable: If true, the agent should reject the email and ask for a non-throwaway address.
  • role: If true, the agent knows it’s talking to an inbox like admin@ rather than a person.

For full examples, see the Agent decision guide.

Typical confidence values (see OpenAPI for the full field description):

ValueWhen
0.97deliverable — SMTP accepted the mailbox
0.75unknown with smtp_blocked — domain/MX OK, major provider blocks probing
0.65catch_all — server accepts arbitrary recipients
0.50unknown, mx_found, not smtp_blocked — SMTP inconclusive (e.g. policy/greylist/Exchange behavior)
0.02undeliverable
0.0Invalid path: syntax, disposable, or no MX
ValueMeaning
deliverableSMTP probe returned 250 — mailbox exists
undeliverableSMTP probe returned 550 — mailbox does not exist
unknownCould not confirm — smtp_blocked, SMTP timeout, or ambiguous/non-interpretable SMTP on the MX (common for some org/M365 hosts); check mx_found and smtp_blocked
catch_allServer accepts all addresses — mailbox existence unconfirmable; treat as higher bounce risk (confidence around 0.65)
invalidFailed syntax, disposable, or MX check
{
"email": "[email protected]",
"valid": true,
"status": "deliverable",
"confidence": 0.97,
"failed_check": null,
"disposable": false,
"role": false,
"free_provider": false,
"catch_all": false,
"smtp_blocked": false,
"mx_found": true,
"mx_host": "mail.example.com",
"suggestion": null,
"latency_ms": 187
}

For agents or workflows that shouldn’t block waiting for the SMTP probe, you can submit the verification asynchronously.

When you include a webhook URL in the request body, the endpoint returns 202 Accepted immediately with a job_id. The verification runs in the background and POSTs the full result to your webhook when complete. For automatic retries, HMAC verification (multiple languages), and IP allow-listing guidance, see the Webhooks reference. For HMAC, SSRF-safe callbacks, and data residency in one place, see Security.

Webhook URL rules (SSRF-safe): https only (not http). The host must be a hostname, not an IP address (IPv4 or IPv6). Usernames and passwords in the URL are not allowed. localhost and *.local hosts are rejected. The callback request does not follow HTTP redirects—your endpoint must respond on the exact URL you provide.

Residual risk: Hostnames that resolve to private networks (DNS rebinding) are not blocked by this check alone.

sequenceDiagram
    participant C as Client
    participant A as truval.dev API
    participant W as Webhook Server

    Note over C,A: Synchronous (Default)
    C->>A: POST /v1/email/verify {"email": "..."}
    activate A
    Note right of A: Full pipeline runs<br/>(blocks up to 3s)
    A-->>C: 200 OK {"valid": true, ...}
    deactivate A

    Note over C,W: Asynchronous (Webhook)
    C->>A: POST /v1/email/verify {"email": "...", "webhook": "https://..."}
    A-->>C: 202 Accepted {"job_id": "...", "status": "pending"}
    Note right of A: Background execution
    A-)W: POST callback {"job_id": "...", "valid": true, ...}
    W-->>A: 2xx Success (Optional)
Terminal window
curl https://api.truval.dev/v1/email/verify \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]", "webhook":"https://your-agent.com/truval-callback", "webhook_secret":"whsec_..."}'
{
"job_id": "job_1234567890abcdef",
"status": "pending"
}

When the background job completes, it makes a POST request to your webhook URL with the full result object, plus the job_id for correlation.

{
"job_id": "job_1234567890abcdef",
"email": "[email protected]",
"valid": true,
"status": "deliverable",
"confidence": 0.97,
"...": "..."
}

If you provide webhook_secret, truval.dev signs the callback with X-Truval-Signature: sha256=<hex> (HMAC-SHA256 over the exact raw UTF-8 JSON body). For retry timing, delivery rules, and signature verification examples in Node.js, Python, Go, Ruby, and PHP, see the dedicated Webhooks reference.

Verify up to 50 addresses in one request. Each address runs the same five layers as the single-email endpoint; results are returned in the same order as emails. Verifications run in parallel on the server.

Rate limits: Usage is counted per email. A batch of 50 emails consumes 50 units of your per-minute quota. If the batch would exceed your limit, the API returns 429 before processing any address in the batch.

POST https://api.truval.dev/v1/email/verify/batch

Same as single verify: Authorization and Content-Type: application/json.

FieldTypeRequiredDescription
emailsstring[]Yes1–50 email addresses
Terminal window
curl https://api.truval.dev/v1/email/verify/batch \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
FieldTypeDescription
resultsarrayOne object per input email (same shape as single-email response), in request order
countnumberLength of results
latency_msnumberWall-clock time for the entire batch request
{
"results": [
{
"email": "[email protected]",
"valid": true,
"status": "deliverable",
"confidence": 0.97,
"failed_check": null,
"disposable": false,
"role": false,
"free_provider": false,
"catch_all": false,
"smtp_blocked": false,
"mx_found": true,
"mx_host": "mail.example.com",
"suggestion": null,
"latency_ms": 120
},
{
"email": "[email protected]",
"valid": false,
"status": "invalid",
"confidence": 0.0,
"failed_check": "syntax",
"disposable": false,
"role": false,
"free_provider": false,
"catch_all": false,
"smtp_blocked": false,
"mx_found": false,
"mx_host": null,
"suggestion": null,
"latency_ms": 0
},
{
"email": "[email protected]",
"valid": false,
"status": "invalid",
"confidence": 0.0,
"failed_check": "syntax",
"disposable": false,
"role": false,
"free_provider": false,
"catch_all": false,
"smtp_blocked": false,
"mx_found": false,
"mx_host": null,
"suggestion": null,
"latency_ms": 0
}
],
"count": 3,
"latency_ms": 312
}

Verify 1–50 addresses in one request; each result is sent as one JSON object per line (NDJSON) with Content-Type: application/x-ndjson. Verifications run in parallel; lines arrive in completion order (whichever address finishes first), not necessarily the same order as emails.

Rate limits: Same as batch — usage is counted per email. If the request would exceed your limit, the API returns 429 before streaming (no lines are written).

POST https://api.truval.dev/v1/email/verify/stream

Same as batch: Authorization and Content-Type: application/json.

FieldTypeRequiredDescription
emailsstring[]Yes1–50 email addresses

Each line is a complete JSON object with the same fields as the single-email response (see Response above). There is no wrapper object and no trailing batch count — only one result object per line, ending with a newline.

Use --no-buffer (-N) so lines print as they arrive:

Terminal window
curl -N https://api.truval.dev/v1/email/verify/stream \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \

Example (fetch — read NDJSON incrementally)

Section titled “Example (fetch — read NDJSON incrementally)”
const res = await fetch('https://api.truval.dev/v1/email/verify/stream', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ emails: ['[email protected]', '[email protected]'] }),
})
if (!res.ok) throw new Error(await res.text())
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line) continue
const result = JSON.parse(line) as Record<string, unknown>
console.log(result.email, result.valid)
}
}
if (buffer.trim()) {
const result = JSON.parse(buffer) as Record<string, unknown>
console.log(result.email, result.valid)
}

Invalid body (empty array, more than 50 emails, or malformed JSON) returns 400 with docs pointing to this section:

{
"error": "invalid_request",
"message": "Request body must be JSON with an emails array (1–50 addresses).",
"action": "Send: {\"emails\":[\"[email protected]\",\"[email protected]\"]}",
"docs": "https://docs.truval.dev/api/email-verify#streaming"
}

Invalid body (empty array, more than 50 emails, or malformed JSON) returns 400:

{
"error": "invalid_request",
"message": "Request body must be JSON with an emails array (1–50 addresses).",
"action": "Send: {\"emails\":[\"[email protected]\",\"[email protected]\"]}",
"docs": "https://docs.truval.dev/api/email-verify#batch"
}

For a searchable catalog of every error code, HTTP status, fix guidance, and how values map to dashboard usage_log.error_class, see Error reference.

StatusErrorMeaning
401missing_api_keyNo Authorization header provided
401invalid_api_keyKey not found or revoked
400invalid_requestRequest body is missing or malformed (single: email field; batch/stream: emails array 1–50)
413payload_too_largeRequest body exceeds the 2MB payload limit
429rate_limit_exceededPer-minute rate limit exceeded (batch/stream: one unit per email)
429monthly_quota_exceededFree tier only: included monthly verification units for the current UTC calendar month would be exceeded (details)
503quota_check_failedTemporary failure checking monthly quota — retry shortly (details)
402payment_requiredHard EUR spend cap for the billing period would be exceeded — adjust the cap in Billing & Limits or upgrade (details)
404not_foundEndpoint path is invalid

Payload size limit (minimum latency): The API rejects requests over 2MB when the client provides an accurate Content-Length header. This is implemented as a fast header check to avoid adding latency by pre-reading request bodies.

Missing API key (401):

{
"error": "missing_api_key",
"message": "No Authorization header provided.",
"action": "Include your API key as: Authorization: Bearer sk_live_...",
"docs": "https://docs.truval.dev/api/email-verify#authentication"
}

Invalid API key (401):

{
"error": "invalid_api_key",
"message": "API key not found or revoked.",
"action": "Generate a new key at https://dash.truval.dev.",
"docs": "https://docs.truval.dev/api/email-verify#authentication"
}

Invalid request (400):

{
"error": "invalid_request",
"message": "Request body must be JSON with an email field.",
"action": "Send: {\"email\":\"[email protected]\"}",
"docs": "https://docs.truval.dev/api/email-verify#request"
}

Per-minute caps apply to all tiers. For batch and stream endpoints, usage is counted per email (e.g. 50 addresses → 50 units toward the per-minute limit).

TierRate limit (req/min)
Free10
Builder100
Scale1,000

On the free tier, the API enforces a hard cap on verification units per UTC calendar month (aligned with public pricing: 500 by default). Enforcement uses the same rule as Supabase count_usage_for_customer: rows in usage_log for your account in the half-open window [first day of month 00:00:00 UTC, first day of next month 00:00:00 UTC), except rows whose failed_check is rate_limit_exceeded or monthly_quota_exceeded (those responses are still logged for the dashboard but do not consume quota).

What counts as one unit (each adds a quota row; all outcomes below are still visible in Concrete logs):

  • POST /v1/email/verify — one unit per request after the JSON body is accepted (including 400 responses for invalid body after auth).
  • POST /v1/email/verify/batch and POST /v1/email/verify/streamone unit per email in the emails array when the body validates (same as rate-limit cost).
  • Successful verifications log one row per email; syntax failures inside verification still log a row and count.
  • 429 rate_limit_exceeded and 429 monthly_quota_exceeded — logged, not counted toward the monthly cap.

When the cap would be exceeded, the API returns 429 with error monthly_quota_exceeded (no window field):

{
"error": "monthly_quota_exceeded",
"message": "Monthly free tier limit of 500 verification units reached for this UTC month.",
"action": "Upgrade your plan or wait until the next UTC month.",
"docs": "https://docs.truval.dev/api/email-verify#monthly-quota",
"limit": 500,
"used": 500,
"reset_at": "2026-04-01T00:00:00.000Z"
}

reset_at is the start of the next UTC month (when the quota resets). This is not the same timestamp as per-minute rate limiting. Clients should retry after reset_at (add a small cushion for clock skew/latency).

If the usage database cannot be queried temporarily, verify endpoints may return 503:

{
"error": "quota_check_failed",
"message": "Could not verify monthly usage quota. Retry shortly.",
"action": "If this persists, contact support.",
"docs": "https://docs.truval.dev/api/email-verify#monthly-quota"
}

Paid tiers (Builder / Scale): Billable verification counts toward your plan’s included volume, Stripe meter overage, and dashboard Billing & Limits counters. It means one email for which the API returned a result after layer 1 (syntax). Concretely:

  • Not billable: the request fails only at syntax (layer 1) inside verification — failed_check is syntax on a successful HTTP 200 response. Malformed addresses rejected by request validation (400) are not billable.
  • Billable (one unit per email): outcomes with failed_check of disposable, no_mx, smtp, or smtp_timeout, or failed_check null (deliverable / catch-all / unknown with full pipeline).

For POST /v1/email/verify/batch and POST /v1/email/verify/stream, billable units are summed per email in the payload (not per HTTP request). Async mode with a webhook bills when the background verification completes, using the same rules.

Free tier: Monthly enforcement uses the quota definition above (monthly quota), not the paid billable definition. Stripe meter events are not emitted for free tier accounts.

Hard spend cap (402): Before running work, the API checks your cap using the number of emails in the request (worst case if every address were billable). Actual recorded usage may be lower if many addresses exit at syntax only.

TierIncluded volume (reference)
Free500 verification units / UTC calendar month (monthly quota)
Builder5,000 billable verifications / subscription billing period
Scale100,000 billable verifications / subscription billing period

When the per-minute rate limit is exceeded, the API returns 429 with error rate_limit_exceeded:

{
"error": "rate_limit_exceeded",
"message": "Rate limit of 100 req/min exceeded.",
"action": "Wait until reset_at (plus a small cushion) before retrying.",
"limit": 100,
"window": "1m",
"reset_at": "2026-03-24T12:01:00.000Z",
"docs": "https://docs.truval.dev/api/email-verify#rate-limits"
}

The dashboard (Billing & Limits tab) lets you configure two independent EUR caps per billing period:

Soft cap: An advisory threshold. When spending reaches the soft cap, a transactional alert email is sent to the account email address (see Email alerts). API calls continue — the soft cap does not block requests.

Hard cap: A hard enforcement threshold. When the projected overage in the current billing period would exceed the configured hard cap, verify endpoints return 402 Payment Required before running any verification (applies to single, batch, and stream routes; batch/stream use the total email count as the worst-case pre-check).

{
"error": "payment_required",
"message": "Hard spend cap reached. Upgrade or raise your cap to continue.",
"action": "Adjust your hard cap in Billing & Limits, or upgrade your plan.",
"docs": "https://docs.truval.dev/api/email-verify#spend-cap",
"hard_cap_eur": 50,
"current_overage_eur": 52.5
}

hard_cap_eur is null when no numeric cap is set but the request is still blocked (rare); otherwise it is the configured cap in euros. current_overage_eur is the projected overage for the period at the time of the check.

To adjust caps: dashboard → Billing & Limits. Changes take effect on the next API request.

On 429, inspect error in the JSON body:

  • rate_limit_exceeded — per-minute cap. Wait until reset_at (usually within the same minute window).
  • monthly_quota_exceeded — free-tier monthly cap. Wait until reset_at (start of the next UTC month) or upgrade; do not spin in a tight loop. In both cases, retry after reset_at (add a small cushion for clock skew/latency).

Example in Python:

import time
from datetime import datetime, timezone
if response.status_code == 429:
error_data = response.json()
err = error_data.get("error")
reset_at = error_data.get("reset_at")
if not reset_at:
raise RuntimeError("429 without reset_at")
reset_time = datetime.fromisoformat(reset_at.replace("Z", "+00:00"))
wait_seconds = (reset_time - datetime.now(timezone.utc)).total_seconds()
wait_seconds += 0.05 # small cushion after reset_at to avoid racing the window edge
if wait_seconds > 0:
label = "Monthly quota" if err == "monthly_quota_exceeded" else "Rate limit"
print(f"{label}: waiting {wait_seconds:.2f}s...")
time.sleep(wait_seconds)

Truval sends transactional alert emails to your account email address at the following thresholds:

ThresholdTrigger
75%Approaching cap — verify integrations are handling volume as expected
90%Near limit — consider upgrading or reviewing usage
100%Cap reached — quota exhausted (free) or soft cap hit (paid)

Alerts are sent for:

  • Free tier: 75%, 90%, and 100% of the monthly 500-unit quota cap
  • Paid tiers: 75%, 90%, and 100% of the included billable volume for the billing period
  • Soft spend cap: when EUR spend reaches the configured soft cap threshold
  • Hard spend cap: when the hard cap is enforced and API calls begin returning 402

Alerts are deduplicated per threshold per period — you receive each alert at most once per billing cycle crossing. To manage alert preferences, go to dashboard → Profile → Notifications.

Concrete logs (per-call rows visible in the Usage tab) are retained for:

TierRetention
Free7 calendar days (UTC)
Builder90 days
Scale90 days

Unknown route (404):

{
"error": "not_found",
"message": "The requested endpoint does not exist.",
"action": "Use POST /v1/email/verify, POST /v1/email/verify/batch, POST /v1/email/verify/stream, or inspect https://api.truval.dev/openapi.json.",
"docs": "https://docs.truval.dev/api/email-verify#not-found"
}

Each request runs through five layers in order. The first failure exits immediately — no wasted compute.

  1. Syntax — regex + RFC 5321 checks (local part length, consecutive dots, etc). Returns in 0ms.
  2. Disposable — KV lookup against 50k+ throwaway domains. Returns in under 1ms.
  3. MX lookup — DNS-over-HTTPS query for MX records, cached 6 hours. Returns in 20–80ms.
  4. SMTP probe — TCP connection to the mail server, RCPT TO check without sending email. Overall timeout 3000ms, plus per-read timeout 1000ms for reliability.
  5. Signals — role detection, free provider flag, typo suggestion. 0ms, pure local logic. Does not introduce a failed_check by itself; it refines the response after layers 1–4.
Outcomefailed_check
Syntax invalid (layer 1)syntax
Disposable domain (layer 2)disposable
No MX records (layer 3)no_mx
SMTP RCPT undeliverable or other SMTP failure before timeout (layer 4)smtp
SMTP phase hits overall or read timeout (layer 4)smtp_timeout
Completed without any of the above (deliverable, catch-all, unknown with smtp_blocked, SMTP inconclusive / unknown, etc.)null

Gmail, Outlook, Yahoo, and iCloud block SMTP probing from all third-party services. This is not a truval.dev limitation — it affects every email verification provider.

For these domains, truval.dev verifies the MX records and domain authenticity (layers 1–3), then returns:

{
"valid": false,
"status": "unknown",
"confidence": 0.75,
"smtp_blocked": true
}

A confidence of 0.75 means: the domain is real, not disposable, has valid MX records, but we cannot confirm the specific mailbox. This is the most honest answer possible.