EmailVerifyerAPI developer documentation
One REST surface, one frozen response shape, and one error vocabulary. Everything on this page is the contract the service is built and tested against, with runnable examples in fourteen languages.
Getting started
Overview
The API answers one question well: can this address receive mail, and how confident are we? You send an address or a list, and you get back a verdict, the reason behind it, the individual checks that produced it, and a score you can threshold on.
Two surfaces exist. The public API is authenticated with an API key and is what your servers call. The session API backs the dashboard, uses cookies and CSRF, and is not intended for machine clients. This page documents the public API and describes the session API only so you know why you should not call it.
One host, one prefix
https://www.emailverifyerapi.com/api/public/v1
Bearer API key
Authorization: Bearer mp_live_…, scoped per key.
JSON in, JSON out
UTF-8 only. Success is {"data":…}, failure is {"error":{…}}.
What the API does
| Capability | Endpoint | Notes |
|---|---|---|
| Verify one address | POST /verify | Syntax, MX, SMTP probe, disposable, role and free-provider checks in one call. |
| Verify a list | POST /batches | Asynchronous. Submit up to 500 addresses per request, poll, then page the results. |
| Read batch progress | GET /batches/{id} | Returns the job status from the locked job_status set. |
| Read batch results | GET /batches/{id}/results | Cursor-paginated, 25 per page by default and 100 at most. |
What the API deliberately does not do
- It does not promise a mailbox exists. Catch-all domains accept every address, so the honest answer there is
accept_all, notvalid. - It does not send email. Verification stops at the SMTP
RCPTstage and no message is ever delivered. - It does not score reputation or predict inbox placement. Those depend on your sending history, not on the address.
- It does not store the addresses you verify longer than your retention setting allows. See security and data handling.
Free alternatives while you wait for a keyThe browser verifier runs entirely client-side, and the MX, SPF, DKIM and DMARC checkers need no account. None of them require this API.
Getting started
Quickstart
Three steps from nothing to a verdict. Pick your language once and every example on this page follows it.
- Create a key. In the dashboard, open API keys, choose the scopes you need, and copy the key. The full value is shown once, at creation.
- Put it in the environment, not in the source. The examples read
EVA_API_KEY. - Call
POST /verifyand branch ondata.status.
curl -sS -X POST https://www.emailverifyerapi.com/api/public/v1/verify \
-H "Authorization: Bearer $EVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'The response
{
"data": {
"email": "[email protected]",
"status": "valid",
"reason": "deliverable",
"checks": {
"syntax": true,
"mx": true,
"smtp": true,
"disposable": false,
"role": false,
"free": false
},
"suggestion": null,
"score": 0.98
}
}Read the code, not the proseBranch on status, reason and error.code. Human-readable message strings are free to change at any time and are never part of the contract.
Getting started
Authentication and API keys
Every public-API request carries an API key as a bearer token. There is no other authentication path: no query-string keys, no basic auth, no session cookies.
Authorization: Bearer mp_live_9f2c1d7a4b8e0c3f5a6d2b9e1c4f7a0d3b6e9c2f5a8d1b4e
Content-Type: application/json
Accept: application/jsonKey anatomy
| Property | Value | Why it matters |
|---|---|---|
| Format | mp_live_<48 hex> or mp_test_<48 hex> | The environment is visible in the key itself, so a test key can never reach production data by accident. |
| Display prefix | First 11 characters, for example mp_live_9f2 | The dashboard and audit log show only the prefix. That is enough to identify a key and useless to an attacker. |
| Storage | SHA-256 hash, matched in constant time | We cannot show you a key again after creation, and a database disclosure does not leak usable credentials. |
| Status | active, revoked, expired | Revocation is immediate. The next request with that key returns 401 invalid_api_key. |
Scopes
Scopes are attached at creation and cannot be widened afterwards. Create one key per integration and give it the narrowest set that works.
| Scope | Grants | Typical holder |
|---|---|---|
verify:read | POST /verify | The signup form and any single-address path. |
verify:write | POST /batches | The nightly list-cleaning job. |
jobs:read | GET /batches/{id} and its results | Dashboards and reporting jobs that read but never submit. |
A key missing the required scope gets 403 insufficient_scope, never a silent empty result.
Operating rules
- One key per environment and per service. Shared keys make the audit log useless and turn one rotation into an outage.
- Rotate on a schedule and on every departure. Create the new key, deploy it, then revoke the old one. Revoking first causes downtime.
- Revocation requires a fresh MFA challenge in the dashboard, and notifies every owner and admin on the tenant with the key prefix, the actor and the IP.
- Never ship a key to a browser or a mobile binary. Anything a user can install, a user can read. Proxy the call through your own backend.
If a key leaksRevoke it in the dashboard first, then look at the audit log filtered by that key prefix to see what it touched. Rotating after the investigation leaves the window open longer than it needs to be.
Getting started
Test mode and going live
Keys are environment-tagged. A key beginning mp_test_ exercises the same routes, the same validation and the same response shape as a live key, and does not consume the plan's verification quota.
- Write your integration tests against the contract, not against a live domain. Real mailboxes change; the response shape does not.
- Assert on
statusandreason, and on the presence of the sixcheckskeys. Do not assert onscoreto two decimal places: scoring inputs improve inside v1 without a version bump. - Test the failure paths. A missing scope, an expired key, a 429 and a 402 are all things production will hand you eventually.
Pre-launch checklist
| Check | Why |
|---|---|
| Retry policy covers 429 and 5xx only | Retrying a 422 repeats a request the server will reject identically. |
| Client timeout above 5 seconds per address | The SMTP probe alone has a 5-second budget. A 2-second client timeout guarantees false unknown results. |
Unknown reason values do not crash the client | reason is an open enum: new values ship without a major version. |
| Keys read from configuration, never from source control | A key in a repository is a key in every fork of that repository. |
| Quota alerts wired to a channel a human reads | 402 quota_exceeded is not a retryable condition and stops verification dead. |
Core concepts
Requests, responses and the envelope
Two response shapes exist across the whole API. Nothing else is ever returned, which means one deserialiser and one error handler cover every endpoint.
// Success, single resource
{ "data": { ... } }
// Success, list endpoint
{ "data": [ ... ], "next_cursor": "eyJpZCI6…" } // next_cursor is null on the last page
// Failure, every status code
{ "error": { "code": "rate_limited", "message": "Too many requests." } }
// Failure with field detail, only for 422 validation_failed
{ "error": { "code": "validation_failed", "message": "…",
"fields": { "email": ["must be a valid address"] } } }Request conventions
| Convention | Detail |
|---|---|
| Encoding | UTF-8 JSON bodies. Internationalised addresses are accepted in Unicode form and normalised server-side. |
| Content type | application/json on every request with a body. A different type returns 400 bad_request. |
| Methods | GET reads and never changes state. POST creates. There are no PUT or DELETE routes on the public API. |
| Transport | HTTPS only. Plain HTTP is redirected, and credentials sent over it should be treated as compromised. |
| Time | Every timestamp is UTC, ISO 8601, with an explicit offset. Nothing is returned in local time. |
| Identifiers | Resource ids are UUIDs, time-ordered where the service generates them. Treat them as opaque strings. |
| Money | Billing figures are integer minor units with an explicit currency. Currencies are never converted into one another. |
Forward compatibility, in one lineIgnore fields you do not know, tolerate new reason values, and never switch exhaustively on reason without a default branch. Added fields and added open-enum values ship continuously inside v1 and are not breaking changes.
Core concepts
The verification result object
One object shape is returned by POST /verify and by every row of a batch result page. It is frozen by contract tests: renaming or removing any key below requires a new major version.
| Field | Type | Meaning |
|---|---|---|
email | string | The address as evaluated, normalised and lower-cased in the domain part. |
status | enum, closed | valid, invalid, risky or unknown. A new value here would be a breaking change, so you can switch on it exhaustively. |
reason | enum, open | The specific finding behind the status. New values may appear inside v1: always keep a default branch. |
checks.syntax | boolean | The address parses per RFC 5322 and has a plausible domain. |
checks.mx | boolean | The domain publishes at least one usable MX record. See the MX checker for the detail behind this flag. |
checks.smtp | boolean | The SMTP conversation completed. False means the probe was blocked, greylisted or timed out, not that the mailbox is missing. |
checks.disposable | boolean | The domain belongs to a throwaway-mail provider. |
checks.role | boolean | The local part is a shared function such as info, sales or abuse rather than a person. |
checks.free | boolean | A consumer mailbox provider. Reported for segmentation only; it never changes the status. |
suggestion | string or null | A likely correction for an obvious typo, for example gmial.com to gmail.com. Offer it, never apply it silently. |
score | float 0.0 to 1.0 | Confidence, derived from the same table the status comes from. Stored as an integer 0 to 100 and divided by 100 on the way out. |
Where the seven-value story wentInternally the service records a wider result set that separates disposable, role and accept_all. The API collapses all three into risky and keeps the detail in reason, so a client never has to handle two overlapping vocabularies.
Core concepts
Status, reason and score
Verdicts are deterministic. The table is evaluated top down and the first matching row wins, so the same inputs always produce the same output. This is the whole decision function, published rather than described.
| # | Condition | status | reason | score |
|---|---|---|---|---|
| 1 | Syntax fails | invalid | syntax | 0.00 |
| 2 | No usable MX record | invalid | no_mx | 0.00 |
| 3 | Disposable domain | risky | disposable | 0.30 |
| 4 | SMTP accepted, not catch-all, not a role account | valid | deliverable | 0.98 |
| 5 | SMTP accepted, role account | risky | role | 0.60 |
| 6 | SMTP accepted every address, catch-all | risky | accept_all | 0.50 |
| 7 | SMTP completed and rejected the recipient | invalid | mailbox_not_found | 0.05 |
| 8 | SMTP probe did not complete | unknown | timeout | 0.40 |
Every reason value
| reason | What happened | Do this |
|---|---|---|
deliverable | The receiving server accepted the recipient. | Send. |
mailbox_not_found | The server rejected that specific address. | Remove it. This is a hard bounce waiting to happen. |
syntax | The address is not a valid address. | Reject at the form, and show suggestion if present. |
no_mx | The domain cannot receive mail at all. | Remove it, and check whether the domain is parked or misspelled. |
disposable | A throwaway provider, alive today and gone next week. | Block at signup for paid products, allow for free trials if churn is acceptable. |
role | A shared function mailbox, not a person. | Keep for transactional mail, exclude from anything personalised, expect complaints if you market to it. |
accept_all | The domain accepts every recipient, so nothing can be proven. | Send to engaged contacts only, and watch the bounce rate for that domain. |
greylisted | The server deliberately deferred the probe. | Re-verify later. The address is not bad. |
timeout | The probe ran out of time. | Re-verify later, or accept the address with a lower confidence. |
full_inbox | The mailbox exists and cannot take more mail. | Treat as temporary. It often clears. |
blocked | The receiving server refused the probe itself. | Inconclusive. Judge the address on engagement history instead. |
Thresholds worth copying
- Block at signup:
status === "invalid". Nothing else is safe to block on without losing real customers. - Warn at signup:
suggestion !== null. A typo correction recovers a signup that a hard block would have lost. - Exclude from a cold campaign:
status !== "valid". Cold sending is where risky addresses cost you a domain reputation. - Keep in a warm campaign:
status !== "invalid". An engaged contact on a catch-all domain is still a customer.
Core concepts
Errors
Error codes are stable identifiers. Messages are prose and may be reworded at any time. Branch on error.code, log error.message, and never parse a message string.
| HTTP | code | Cause | Retry? |
|---|---|---|---|
| 400 | bad_request | Malformed body, wrong content type, or unparseable JSON. | No, fix the request. |
| 400 | bad_cursor | A pagination cursor that was edited, truncated or reused from another resource. | No, restart the walk. |
| 401 | invalid_api_key | Unknown, revoked or expired key, or a missing Bearer prefix. | No. |
| 403 | insufficient_scope | The key is valid but lacks the scope this route requires. | No, mint a key with the right scope. |
| 403 | forbidden | Authenticated, and not allowed to touch this resource. | No. |
| 404 | not_found | No such resource, or one belonging to another tenant. The two are indistinguishable on purpose. | No. |
| 402 | quota_exceeded | The plan's included verifications are spent. | No. Upgrade or wait for the period to roll over. |
| 410 | endpoint_sunset | A deprecated surface reached its announced sunset date. | No. The response carries the migration-guide URL. |
| 422 | validation_failed | Input failed validation. Carries a fields map. | No, fix the input. |
| 429 | rate_limited | Too many requests for this key. Sets Retry-After. | Yes, after the stated delay. |
| 500 | internal_error | Something failed on our side. Logged with a correlation id; the message stays generic on purpose. | Yes, with backoff. |
{
"error": {
"code": "validation_failed",
"message": "The request could not be processed.",
"fields": {
"emails": ["must contain between 1 and 500 addresses"]
}
}
}One rule that prevents most incidentsTreat 402, 401, 403 and 422 as permanent, and 429 plus 5xx as transient. A client that retries a 422 in a tight loop will burn a rate-limit budget it needs for real work.
Core concepts
Rate limits and quotas
Two independent ceilings apply. A rate limit caps how fast you may call. A quota caps how many verifications your plan includes. Hitting the first is a pacing problem; hitting the second is a billing decision.
| Ceiling | Applies to | Value | On exhaustion |
|---|---|---|---|
| Default request rate | Every public-API route, per key | 120 requests per 60 seconds | 429 rate_limited with Retry-After |
| Verification rate | POST /verify, per key | Your plan's requests-per-second allowance | 429 rate_limited with Retry-After |
| Batch size | POST /batches | 500 addresses per request | 422 validation_failed |
| Page size | Result pages | 25 by default, 100 maximum | Values above 100 are clamped, not rejected |
| Included verifications | Your tenant, per billing period | Set by the plan; unused credits roll over on paid tiers up to one month's allowance | 402 quota_exceeded |
Pacing that works
- Rate-limit yourself first. A client-side token bucket set just below your allowance costs one small class and removes most 429s.
- Prefer one batch to five hundred single calls. A batch is one request against the rate limit, and the work happens off your critical path.
- Honour
Retry-Afterexactly. It is the only authoritative wait. Fall back to exponential backoff with jitter when the header is absent. - Never run retries in an unbounded loop. Cap attempts, then surface the failure. Silent infinite retry is how one slow dependency becomes an outage.
- Alert on
402separately. It means work stopped for a commercial reason, and no amount of engineering fixes it.
Core concepts
Pagination
List endpoints are cursor-paginated. Cursors are opaque, stable under inserts, and cheap for the database, which is why there are no page or offset parameters to reach for.
GET /api/public/v1/batches/{id}/results?limit=100
-> { "data": [ … 100 rows … ], "next_cursor": "eyJpZCI6IjAxOTIt…" }
GET /api/public/v1/batches/{id}/results?limit=100&cursor=eyJpZCI6IjAxOTIt…
-> { "data": [ … 43 rows … ], "next_cursor": null } # done- Stop when
next_cursorisnull. Never infer the end from a short page: a full page can still be the last one. - Pass the cursor back verbatim. It is a signed, opaque token, not a row id. Editing it returns
400 bad_cursor. - Do not cache a cursor across runs. Cursors belong to the resource they came from and are not addresses you can bookmark.
limitis a maximum, not a promise. Handle a page shorter than you asked for.
API reference
Verify a single address
Runs the full check chain against one address and answers synchronously. This is the endpoint a signup form calls.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Required | One address. Whitespace is trimmed; the domain is lower-cased. Anything that is not a single address returns 422 validation_failed. |
Response
A verification result object inside data. The call blocks for the length of the check chain, and the SMTP probe alone has a five-second budget, so allow at least ten seconds of client timeout.
{
"data": {
"email": "[email protected]",
"status": "invalid",
"reason": "no_mx",
"checks": { "syntax": true, "mx": false, "smtp": false,
"disposable": false, "role": false, "free": false },
"suggestion": "[email protected]",
"score": 0.0
}
}Errors specific to this route
422 validation_failedwithfields.emailwhen the input is not one address.402 quota_exceededwhen the plan's verifications are spent. The address is not consumed.403 insufficient_scopewhen the key lacksverify:read.
API reference
Create a batch
Queues a list for asynchronous verification and returns immediately with an id and a status. Work happens on the queue, so a slow receiving server never blocks your request.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
emails | array of string | One of | 1 to 500 addresses. Duplicates are verified once and reported once. |
csv | string | One of | Raw CSV text with an address column, for callers that already hold a file. |
// Request
{ "emails": ["[email protected]", "[email protected]", "[email protected]"] }
// Response
{ "data": { "id": "0192a7f4-9c31-7c2e-8b55-2f6ad1c40e77", "status": "queued" } }ChunkingSplit anything larger than 500 addresses client-side and submit several batches. Chunking is deliberate: it bounds a single request, keeps failures small, and lets you resume a large job without resubmitting the parts that already finished.
API reference
Read batch progress
Returns the job's current status. Poll it until the status leaves queued and running, then read the results.
| status | Meaning | Terminal? |
|---|---|---|
queued | Accepted and waiting for a worker. | No, keep polling. |
running | Addresses are being verified now. | No, keep polling. |
paused | Held, usually because the tenant hit a quota mid-job. | No, but polling will not move it. Resolve the cause first. |
completed | Every address has a result. | Yes. |
failed | The job stopped after exhausting its retries. | Yes. Partial results may still be readable. |
cancelled | Stopped on request. | Yes. |
Poll like a good citizenFive seconds between polls is plenty, and every poll spends one request from the 120-per-minute budget. A one-second poll on ten concurrent jobs is 600 requests a minute, which rate-limits your own verification calls.
API reference
Read batch results
Returns one result object per address, cursor-paginated.
| Parameter | Type | Required | Notes |
|---|---|---|---|
limit | integer | Optional | Default 25, maximum 100. Higher values are clamped. |
cursor | string | Optional | The next_cursor from the previous page, passed back unchanged. |
{
"data": [
{ "email": "[email protected]", "status": "valid", "reason": "deliverable",
"checks": { "syntax": true, "mx": true, "smtp": true,
"disposable": false, "role": false, "free": false },
"suggestion": null, "score": 0.98 },
{ "email": "[email protected]", "status": "risky", "reason": "role",
"checks": { "syntax": true, "mx": true, "smtp": true,
"disposable": false, "role": true, "free": false },
"suggestion": null, "score": 0.6 }
],
"next_cursor": "eyJpZCI6IjAxOTJhN2Y0LTljMzEtN2MyZS04YjU1In0"
}Results become readable as they are produced, so a long job can be consumed incrementally rather than waiting for completed. Rows are stable once written: re-reading a page returns the same verdicts.
Guides
Cleaning a list in bulk
The full flow: chunk, submit, poll, page, write. The example below is the same program in every language, and it is the one most integrations actually need.
- Chunk at 500. One request per chunk, and a failure costs you one chunk rather than the whole file.
- Submit and keep the id. Persist it before you start polling, so a crashed job can be resumed rather than resubmitted.
- Poll every five seconds until the status leaves
queuedandrunning. - Page the results with
limit=100and follownext_cursoruntil it is null. - Write the verdicts next to your own ids, not over the top of the addresses. You will want to audit the decision later.
# Create the batch, poll it, then page the results.
BATCH=$(curl -sS -X POST https://www.emailverifyerapi.com/api/public/v1/batches \
-H "Authorization: Bearer $EVA_API_KEY" -H "Content-Type: application/json" \
-d '{"emails":["[email protected]"]}' | jq -r '.data.id')What to do with the verdicts
| status | Cold outreach | Existing customers |
|---|---|---|
valid | Send. | Send. |
invalid | Suppress permanently. | Suppress, and ask the customer for a new address in-product. |
risky | Exclude. This is where reputation damage comes from. | Keep if they have opened mail in the last 90 days. |
unknown | Re-verify once, then exclude if still unknown. | Keep. An inconclusive probe says nothing about a paying customer. |
Do not delete on a single verdictSuppress rather than delete, and record which run produced the decision. A domain that was greylisting the probe on Tuesday can be answering normally on Wednesday, and deleted rows cannot be reviewed.
Guides
Validating a signup form
Real-time verification at signup is the highest-value integration and the easiest one to get wrong. The failure mode is not a bad address getting through: it is a real customer being blocked by a slow or inconclusive probe.
Rules that hold up in production
- Verify on blur, not on every keystroke. One call per address, after the field loses focus.
- Never block the submit button on the network. Give the call a short deadline of about two seconds; if it has not answered, accept the signup and verify asynchronously.
- Block only
invalid. Blockingriskyorunknownrejects paying customers on catch-all corporate domains, which is most enterprise buyers. - Offer
suggestionas a one-tap fix. Show "did you mean [email protected]?" with a button that fills the field. Never rewrite what a user typed. - Call from your backend. A key in front-end JavaScript is a public key.
- Say why. "That address does not exist" is actionable; "invalid email" is not.
| Result | Form behaviour | Copy to show |
|---|---|---|
valid | Accept silently. | Nothing. |
invalid, reason syntax | Block, keep focus in the field. | "That does not look like an email address." |
invalid, reason no_mx | Block, and show the suggestion if there is one. | "That domain cannot receive email. Did you mean …?" |
invalid, reason mailbox_not_found | Block. | "That mailbox does not exist at that domain." |
risky, reason disposable | Your policy. Block for paid plans, allow for trials. | "Please use a permanent email address." |
risky, reason role | Accept, and flag for sales. | Nothing. |
risky, reason accept_all | Accept. | Nothing. |
unknown | Accept, re-verify in the background. | Nothing. |
Guides
Retries, timeouts and repeating safely
Every integration needs a retry policy, and most have one that makes outages worse. This is the policy the service is designed for.
# Retry 429 and 5xx only. Honour Retry-After when the server sends it.Policy
| Response | Action | Wait |
|---|---|---|
| 2xx | Done. | — |
| 429 | Retry, up to about five attempts. | Retry-After, or exponential backoff with full jitter capped at 30 seconds. |
| 500, 502, 503, 504 | Retry. | Exponential backoff with jitter. |
| 400, 401, 403, 404, 422 | Do not retry. Log and surface. | — |
| 402 | Do not retry. Alert a human. | — |
| Network timeout | Retry once for a GET. For a POST /batches, reconcile first. | Backoff. |
Timeout budgets
POST /verify: at least 10 seconds. The SMTP probe holds a 5-second budget of its own, and a tighter client timeout manufactures falseunknownresults.POST /batches: 10 seconds. The call only enqueues; it does not wait for verification.- Reads: 5 seconds is generous.
Repeating a write safely
The public API has no idempotency-key header, so a timed-out POST /batches is genuinely ambiguous: the batch may exist. Two habits remove the ambiguity.
- Persist your own correlation id with the chunk before you call, and store the returned batch id against it. On a timeout, look up the correlation id rather than resubmitting blindly.
- Make the consumer tolerant of duplicates. Keying results by address means a resubmitted chunk costs quota, not correctness.
RoadmapAn idempotency key on batch creation is a non-breaking addition and will appear in the changelog when it ships. Until then, treat batch creation as at-least-once.
Guides
The dashboard session API
The dashboard runs on a second, larger API at /api/v1. It is authenticated with a session cookie, requires a CSRF token on every write, and enforces roles and step-up MFA. It exists for the browser application, and it is documented here so that you do not build against it by mistake.
Not a machine interfaceSession routes are not part of the public contract. They change without a version bump, they require a CSRF token your server cannot obtain cleanly, and sensitive routes demand an MFA challenge that is minutes old. Automating them means an integration that breaks on a Tuesday for no visible reason.
| Area | Session route | Use this instead |
|---|---|---|
| Single verification | POST /api/v1/verify | POST /api/public/v1/verify |
| Bulk jobs | /api/v1/verification-jobs | /api/public/v1/batches |
| Key management | /api/v1/api-keys | The dashboard. Key creation and revocation are deliberately human actions. |
| Team, roles, billing, security | /api/v1/members, /roles, /billing, /security/* | The dashboard, or SSO and SCIM for provisioning at scale. |
Guides
Webhooks
v1 has no outbound webhooks. Batch completion is discovered by polling GET /batches/{id}, which the bulk guide shows in every language. Saying so plainly is more useful than a page describing an endpoint that does not answer yet.
What to build in the meantime
- One poller per job, five seconds apart, with a ceiling. Stop when the status is terminal or when your own deadline passes.
- Consume results incrementally. Rows are readable before the job reports
completed, so a long job can feed a pipeline continuously. - Do not poll from a request handler. Enqueue the poll in a background worker; a user-facing request should never wait on a batch.
When webhooks arrive
Outbound webhooks are a gated feature in development. Adding a route and a signed callback is a non-breaking change under the versioning policy, so it will land inside v1, appear in the changelog, and require no migration from anyone who is polling. The one inbound webhook the platform already operates is the Stripe billing callback, which is signature-verified and not part of the public API.
Guides
MCP and agent access
Tenants can register Model Context Protocol servers in the dashboard, over stdio or HTTP, so an agent can reach verification through a tool call rather than a hand-written HTTP client.
- Credentials are sealed. Bearer tokens and secrets attached to an MCP server are field-encrypted at rest and never returned by a read. You can replace one; you cannot retrieve it.
- Give an agent its own API key, scoped to
verify:readonly. Agents are exactly the class of caller that should not holdverify:write. - Every call stays attributable. Usage is recorded against the key id, so an agent's spend is visible next to everything else.
- Point the model at this page. The copy buttons above emit Markdown built from the live DOM, so what a model reads is what the page says, not a stale mirror of it.
Platform
Versioning and deprecation
For an API-first product this is a customer commitment, not an operational detail. It is published so you can plan against it.
Scheme
- The major version lives in the path:
/api/public/v1. There are no date-based versions, no header negotiation, and nov1.1. - A major version increments only for a breaking change. Everything else ships continuously inside v1 with no version signal.
- At most two majors run at once, the current one and its predecessor.
What counts as breaking
| Breaking, new major required | Non-breaking, ships inside v1 |
|---|---|
| Removing or renaming a field, route or enum value | Adding a field to a response |
| Changing a field's type or meaning | Adding a value to an open enum such as reason |
| Tightening validation on existing input | Relaxing validation |
Changing an error code string | Adding routes, optional parameters or headers |
| Changing authentication semantics | Changing a limit's value, with notice |
Changing the meaning of a status or reason value, or adding a status | Fixing a bug so behaviour matches the documentation |
status is a closed enum and reason is open. That single distinction is what lets you switch exhaustively on one and never on the other.
Deprecation timeline
| When | What happens |
|---|---|
| T+0 | Announcement by email to every tenant that called the surface in the previous 90 days, plus a changelog entry and a banner on this page. |
| T+0 | Responses start carrying Deprecation: true, Sunset: <HTTP-date> (RFC 8594) and Link: <guide>; rel="deprecation". |
| T+12 months | Earliest permitted sunset for a whole major version. A single field or route inside a live version: no earlier than 6 months. |
| 90, 30 and 7 days before | Reminders, sent only to tenants still calling the surface. |
| Sunset | The surface returns 410 with code endpoint_sunset and the migration-guide URL. Never a silent removal, and never a 404. |
An announced sunset date can be extended and never shortened. Enterprise agreements may negotiate longer windows, never shorter ones.
Platform
Security and data handling
Verification means handling other people's contact data, so the handling rules matter as much as the endpoint list.
| Property | Position |
|---|---|
| Transport | HTTPS only, modern TLS, with a post-quantum migration path set out in the platform architecture. |
| Credentials | API keys are stored as SHA-256 hashes and matched in constant time. Passwords use Argon2id. Secrets attached to integrations are field-encrypted. |
| Tenant isolation | Row-level security in the database, enforced per request, and covered by an isolation test suite. A resource belonging to another tenant answers 404, not 403. |
| Card data | Never touches our servers. Payment details live with Stripe. |
| Verified addresses | Kept only as long as your retention setting allows, then erased. Crypto-shredding backs the erasure guarantee. |
| Audit | Key use, key creation and revocation, and privileged actions are recorded with actor, IP and key prefix. |
| Regulatory | Built against GDPR, Québec's Law 25, PIPEDA and CCPA. See compliance and privacy. |
| Accessibility | Every surface, this page included, targets WCAG 2.2 AAA contrast and full keyboard operation. See accessibility. |
Your side of the line
- Verify only addresses you have a lawful reason to hold. A verification service is not a discovery service, and bought lists stay bought lists.
- Do not log full addresses at debug level. Log the verdict and a hash if you need to correlate.
- Keep keys out of client applications and out of source control.
- Report anything you find to the contact in security.txt. See the security page for scope.
Platform
Changelog
Every change to the public contract lands here first. Non-breaking additions ship inside v1 continuously; anything breaking follows the deprecation timeline.
- 2026-08-20 · Documentation
- This reference published: full v1 surface, the decision table, the error registry, and runnable examples in fourteen languages. Section and whole-page Markdown export added for model consumption.
- 2026-08-20 · Policy
- Versioning and deprecation policy published, including the
Deprecation,SunsetandLinkresponse headers and theendpoint_sunseterror code. - 2026-08-13 · Tools
- MX, SPF, DKIM and DMARC checkers extended with transport-security records. Free, no key required.
- Q3 2026 · API
- v1 public API in early access. Request a key.
Platform
Glossary
- Accept-all (catch-all) domain
- A domain configured to accept mail for every local part. An SMTP probe cannot distinguish a real mailbox from a nonexistent one there, which is why the honest verdict is
riskywith reasonaccept_all. - Batch
- An asynchronous verification job created by
POST /batches, holding up to 500 addresses and identified by a UUID. - Cursor
- An opaque token identifying a position in a result set. Passed back unchanged as
cursor;nullinnext_cursormeans the walk is finished. - Greylisting
- A receiving server deliberately deferring a first delivery attempt. It produces
unknown, notinvalid. - Idempotent
- Safe to repeat with the same effect. Every
GEThere is idempotent;POST /batchesis not, so it needs a correlation id on your side. - Open enum
- A field that may gain values without a major version bump.
reasonis open;statusis closed. - Quota
- The verifications your plan includes per billing period. Exhaustion returns
402 quota_exceeded. - Rate limit
- A ceiling on request frequency per key. Exhaustion returns
429 rate_limitedwithRetry-After. - Role account
- A shared function mailbox such as
info@,sales@orabuse@. Deliverable, and rarely a person. - Scope
- A capability attached to an API key at creation:
verify:read,verify:writeorjobs:read. A missing scope returns403 insufficient_scope. - SMTP probe
- A conversation with the receiving server that stops at the
RCPTstage. No message is ever sent. - Step-up authentication
- A fresh MFA challenge required for sensitive dashboard actions, including revoking a key. It has a five-minute window.
Platform
Frequently asked questions
Which endpoint should a signup form call?
POST /verify, from your backend, on blur, with a short deadline and a fallback that accepts the signup if the call is slow. See validating a signup form.
Why is an address I know is real coming back unknown?
Because the SMTP probe did not complete. Greylisting, aggressive filtering and connection blocking all produce this, and none of them say anything bad about the address. Re-verify later, and treat unknown as inconclusive rather than negative.
Can I get a definitive answer on a catch-all domain?
No, and neither can anyone else. The domain accepts every recipient by design, so the protocol itself has no way to distinguish a real mailbox. Use engagement history for those contacts instead.
Do batch results expire?
They are retained for as long as your tenant's retention setting allows, then erased. Export what you need into your own system rather than treating the API as long-term storage.
Is there a client library for my language?
Not yet. The examples on this page are deliberately dependency-free so that they work today in any stack, and each one is short enough to paste into a service and own. Official SDKs are on the roadmap.
What happens to the addresses I send you?
They are verified, stored under your tenant with row-level isolation for the length of your retention window, and then erased. They are never sold, shared, or used to build a list. See privacy.
How do I test without spending quota?
Use a key that begins mp_test_. Same routes, same shape, no quota consumption.
Why is there no page parameter?
Offset pagination gets slower as the offset grows and skips or repeats rows when data changes underneath it. Cursors have neither problem. Follow next_cursor until it is null.
Ready for a key?
The v1 API is in early access. Tell us what you are building and we will send keys with your onboarding.