Watch-IP

Email Check API Reference

POST/v1/email/validate

Checks one email address: syntax, known disposable/temporary domains, the domain's mail server (MX), likely typos and role addresses, and returns an accept/review/reject verdict with the reasons behind it. Send a JSON body of { "email": "user@example.com" }. Designed to be called from a signup form's own JavaScript, using the same publishable, origin-locked key model as GET /v1/geo. For many addresses or a server-side signup decision, use the batch, list-job and signup-check endpoints further down this page. All checks are domain- and address-level: no mailbox is contacted, so no result establishes that a mailbox exists, who owns it, or that mail to it will arrive.

Authentication

POST /v1/email/validate takes an origin-locked publishable key via the X-Api-Key header — the same key type and allowed-origins model as GET /v1/geo. Every other endpoint on this page (batch, list jobs, signup check) accepts many addresses or an arbitrary IP, so it requires your secret key via Authorization: Bearer and must only be called from your backend; a publishable key gets a 403 there.

Response fields

email
The address you sent, echoed back exactly as submitted.
domain
The lowercased domain (internationalized domains in punycode), or null when syntaxValid is false.
syntaxValid
Whether the address is syntactically valid. Not a mailbox-existence or deliverability check.
isDisposable
Whether the domain matches a known disposable/temporary email provider — directly, through a parent domain, or through a mail server known to serve disposable domains. Always false when syntaxValid is false.
normalizedEmail
Trimmed, lowercased address with the domain in punycode, or null when syntaxValid is false.
canonicalEmail
normalizedEmail with any +tag removed (and for Gmail, dots removed and googlemail.com mapped to gmail.com) — store it to spot the same person signing up twice. Null when syntaxValid is false.
disposableSource
Why isDisposable is true: list (the domain itself is listed), parent_domain (a parent domain is listed, e.g. a.b.mailinator.com), or mx_host (the domain's mail server serves known disposable domains). Null when isDisposable is false.
isFreeProvider
Whether the domain is a consumer mailbox provider such as gmail.com or outlook.com. Informational only — it never changes the verdict.
isRoleAccount
Whether the local part is a role address such as admin@, info@ or noreply@.
didYouMean
A likely typo correction of the whole address (john@gmial.com → john@gmail.com), or null. A domain with its own working mail server is not “corrected” unless it is one edit away from a common provider.
mx.status
found (MX records exist), implicit (no MX, but an A/AAAA record), none (neither), null_mx (the domain declares it accepts no mail), no_domain (the domain does not exist), or unknown (the lookup failed or timed out). The whole mx object is null when syntaxValid is false.
mx.host
The lowest-preference MX host when status is found, otherwise null. Says nothing about whether the individual mailbox exists.
verdict
accept, review or reject — the most severe of the conditions in reasons. A recommendation for your own policy, not a final decision.
reasons
Every condition that was hit, most severe first; empty when verdict is accept. See “Verdict and reason codes” below.

Errors

400
Malformed request body. Batch: empty_batch or batch_too_large (more than 100 addresses). Signup check: invalid_ip. List jobs: invalid_body, empty_job or job_too_large (more than 100,000 addresses). An invalid email address is never a 400 — it is a result with verdict reject and reason invalid_syntax.
401
Missing or invalid API key.
403
POST /v1/email/validate: the request's Origin/Referer is not on the key's allowed-origins list (no CORS header is set, so the browser blocks the response regardless of the JSON body). Secret-key endpoints: a publishable key was used.
404
job_not_found — no list job with this id for your account.
409
job_not_completed — the job is still queued or processing, or it failed; results are only downloadable once it has completed.
410
job_expired — the job's uploaded addresses and results have been deleted (7 days after creation).
413
body_too_large — a CSV job upload larger than 10 MB.
415
A list-job body that is neither JSON nor text/csv.
429
The per-minute rate limit, or monthly_limit_exceeded when the plan's monthly request cap would be exceeded — see errors and rate limits for how to tell which. A batch or list job that would exceed the cap is rejected whole, before any work is done.
503
job_storage_unavailable — a list job could not be stored. Nothing was charged; retry.

Example

Request

curl https://api.watch-ip.com/v1/email/validate \
  -H "X-Api-Key: YOUR_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "John.Doe+news@Gmial.com"}'

Response

{
  "email": "John.Doe+news@Gmial.com",
  "domain": "gmial.com",
  "syntaxValid": true,
  "isDisposable": false,
  "normalizedEmail": "john.doe+news@gmial.com",
  "canonicalEmail": "john.doe@gmial.com",
  "disposableSource": null,
  "isFreeProvider": false,
  "isRoleAccount": false,
  "didYouMean": "john.doe+news@gmail.com",
  "mx": {
    "status": "none",
    "host": null
  },
  "verdict": "reject",
  "reasons": [
    "no_mail_server",
    "typo_suspected"
  ]
}

Verdict and reason codes

Each condition sets a minimum verdict; the most severe one wins, and reasons lists all of them, most severe first. Whether a free-provider address is acceptable is left to you — it never moves the verdict.

invalid_syntax
Reject. The address is not syntactically valid (length limits, allowed characters, dot placement, a real domain rather than an IP literal). Quoted local parts are rejected too — legal but effectively never used at signup.
domain_not_found
Reject. The domain does not exist in DNS.
no_mail_server
Reject. The domain declares that it accepts no mail (a “null MX”), or has neither MX nor A/AAAA records.
disposable_domain
Reject. The domain, a parent domain, or the domain's mail server matches known disposable/temporary email providers — see disposableSource.
typo_suspected
Review. didYouMean holds a likely correction, such as gmial.com → gmail.com.
role_account
Review. The local part is a role address such as admin@, info@, support@ or noreply@, usually shared rather than personal.
implicit_mx
Review. The domain has no MX record; mail would fall back to its A/AAAA address, which is rare for real mail domains.
dns_unavailable
Review. The DNS lookup failed or timed out, so the domain's mail setup is unknown (mx.status: unknown). Not evidence against the address.

Batch check

POST/v1/email/batch

Checks 1–100 addresses in one request with the same checks and result shape as POST /v1/email/validate, one result per address in input order. Send { "emails": [...] }. Addresses on the same domain share one DNS lookup. Counts one request per address against your monthly plan cap; a batch that would exceed the cap is rejected whole with 429 monthly_limit_exceeded. Toward the per-minute rate limit, the whole call counts as one request. Secret key only.

Request

curl https://api.watch-ip.com/v1/email/batch \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["user@gmail.com", "someone@mailinator.com"]}'

Response

{
  "results": [
    { "email": "user@gmail.com", "verdict": "accept", "reasons": [], ... },
    { "email": "someone@mailinator.com", "isDisposable": true, "disposableSource": "list", "verdict": "reject", "reasons": ["disposable_domain"], ... }
  ]
}

Email list jobs

POST/v1/email/jobs

Checks up to 100,000 addresses in the background. Send JSON { "emails": [...], "filename": "..." } or a text/csv body of at most 10 MB: the column headed email (also e-mail, email address, email_address or mail, case-insensitive) is used, or the first column when no header names one; comma, semicolon and tab separators are detected. The job is charged when it is created — one request per distinct address (exact match), so duplicates are checked but charged once — and a list that would exceed your monthly cap is rejected whole. If part of a job cannot be processed after retries, the job is marked failed and the credits for the unprocessed part are returned. Answers 202 with the job object. Secret key only; the same jobs can also be uploaded and downloaded from the dashboard's Email lists page.

Request

curl https://api.watch-ip.com/v1/email/jobs \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @newsletter.csv

Response

{
  "id": "5b0d6f5e-8a51-4f7e-9d0e-2c1f3f7f8a11",
  "status": "queued",
  "source": "api",
  "filename": null,
  "total": 12840,
  "credits": 12512,
  "processed": 0,
  "summary": null,
  "createdAt": "2026-09-28T10:00:00.000Z",
  "completedAt": null,
  "expiresAt": "2026-10-05T10:00:00.000Z"
}

Job status

GET/v1/email/jobs/{id}

Returns the same job object. Poll it until status is completed; polling is not charged.

status
queued → processing → completed. failed: part of the list could not be processed after retries; no results are available and credits for the unprocessed part were returned. expired: the uploaded addresses and results were deleted 7 days after creation.
total
Rows submitted, duplicates included.
credits
Requests charged: one per distinct address.
processed
Rows checked so far.
summary
Once completed: row counts per verdict (accept/review/reject) and per reason code that occurred. Null before that.
expiresAt
When the uploaded addresses and results are deleted.

Job results

GET/v1/email/jobs/{id}/results

Downloads a CSV with one row per submitted row, in submission order — duplicates included. Columns: email, normalized_email, verdict, reasons, syntax_valid, is_disposable, disposable_source, is_free_provider, is_role_account, did_you_mean, mx_status, mx_host. reasons is semicolon-separated, empty cells mean null, and mx_status is empty for syntactically invalid addresses (nothing was looked up). An invalid address that starts with =, +, - or @ is prefixed with an apostrophe so spreadsheet apps don't run it as a formula. 409 until the job has completed, 410 once it has expired. Downloading is not charged.

Request

curl https://api.watch-ip.com/v1/email/jobs/5b0d6f5e-8a51-4f7e-9d0e-2c1f3f7f8a11/results \
  -H "Authorization: Bearer YOUR_SECRET_KEY" -o results.csv

Signup check

POST/v1/signup/check

Scores a signup from its email address and IP in one call, from your backend when the signup form is submitted (a verdict computed in the browser can be forged). Send { "email": "...", "ip": "...", "userAgent": "..." } — userAgent is optional. The response contains the full email check result (email), the IP's country, ASN and — on plans that include security data — its VPN/Tor/datacenter/threat indicators (ip), the email domain's age in days from public registration (RDAP) data (emailDomainAgeDays, null when unknown and for well-known consumer providers), a 0–100 score (the sum of the reason weights below, capped at 100) and a verdict: reject at 60 or more, review at 25 or more, otherwise accept. On a plan without security data, ip.security is null, the score uses the email signals only, and reasons includes ip_security_unavailable. Counts as one request. Secret key only.

invalid_syntax
70
domain_not_found · no_mail_server · disposable_domain
60 each
ip_tor · ip_threat
40 each
ip_vpn
25
new_domain
25 — the email domain was registered less than 30 days ago
ip_datacenter
20 — only when the IP is not already a VPN or Tor exit
automated_user_agent
20 — the supplied userAgent looks like a bot or script
typo_suspected
15
role_account · implicit_mx
10 each
dns_unavailable
5
ip_security_unavailable
0 — informational: this plan has no security data

Request

curl https://api.watch-ip.com/v1/signup/check \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@new-startup.io", "ip": "203.0.113.1"}'

Response

{
  "email": { "email": "jane@new-startup.io", "verdict": "accept", "reasons": [], ... },
  "ip": {
    "ip": "203.0.113.1",
    "country": "US",
    "asn": 64500,
    "asOrganization": "Example Hosting",
    "security": { "isTor": false, "isVpn": false, "isDatacenter": true, "isThreat": false, "isSanctionedNetwork": false }
  },
  "emailDomainAgeDays": 12,
  "score": 45,
  "verdict": "review",
  "reasons": ["new_domain", "ip_datacenter"]
}

Limits

Every address checked counts as one request against your plan's shared monthly cap — a single check, each address in a batch, each distinct address in a list job — and a signup check is one request. Batches and list jobs are admitted or rejected whole, never partially fulfilled. The per-minute rate limit counts HTTP calls, not items: a batch of up to 100 IPs or email addresses is one request toward it, while each item still counts separately toward the monthly request cap. List-job uploads and results are deleted 7 days after the job is created. DNS lookups have a short time budget: when one fails or times out the result degrades to mx.status unknown and reason dns_unavailable rather than an error. Disposable-domain data is merged from three community-maintained open-source lists, refreshed every few hours and extended by mail servers observed to serve listed domains — see data sources. A very new disposable provider may not be flagged yet. All checks are domain- and address-level: no mailbox is contacted, so no result establishes that a mailbox exists, who owns it, or that mail to it will arrive.

Related pages