This is the full developer documentation for VerifyEmail # VerifyEmail Docs > Everything you need to verify email, cut bounces, and protect your sender reputation — for people and for machines. ## Browse by category [Section titled “Browse by category”](#browse-by-category) ## Get started [Getting startedCreate your account and run your first verification.](/docs/help/getting-started/)[API quickstartMake your first API call in under five minutes.](/docs/api/quickstart/) ## Email verification [Verify a single emailCheck one address from the dashboard and read the verdict.](/docs/help/verifying-a-single-email/)[Bulk / list verificationClean an entire list by upload or via the API.](/docs/help/bulk-verification/)[Understanding resultsStatuses, sub-statuses, and the quality score explained.](/docs/help/understanding-results/) ## Using VerifyEmail [IntegrationsConnect your ESP, CRM, e-commerce, and no-code tools.](/docs/help/integrations/)[Account & billingCredits, plans, invoices, and team access.](/docs/help/account-and-billing/)[Security & complianceHow we protect the data you verify.](/docs/help/security/) ## API & developers [AuthenticationAPI keys and how to authenticate every request.](/docs/api/authentication/)[Verify endpointReal-time verification of a single address.](/docs/api/verify-email/)[Bulk jobsSubmit, poll, and download large lists.](/docs/api/bulk-jobs/)[Rate limitsLimits, headers, and handling 429s.](/docs/api/rate-limits/)[ErrorsStatus codes and error response shapes.](/docs/api/errors/)[SDKs & librariesOfficial clients for Node, Python, PHP, and Ruby.](/docs/api/sdks/) # API overview > The VerifyEmail REST API — verify single addresses and run bulk jobs from your own application. The VerifyEmail REST API lets you verify email addresses from your own application — a single address in real time, or millions through bulk jobs. ## Base URL [Section titled “Base URL”](#base-url) ```plaintext https://api.tryverifyemail.com/v1 ``` All requests are made over HTTPS. Requests and responses use JSON. ## Authentication [Section titled “Authentication”](#authentication) Authenticate every request with your secret API key. See [Authentication](/docs/api/authentication/) for details. ```bash curl -X POST "https://api.tryverifyemail.com/v1/email-verifications?timeout=25" \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com","source":"signup-form"}' ``` `GET /v1/me` tells you whose key you hold, its plan and the credits it can spend. See [Account](/docs/api/me/). ## How a single verification answers [Section titled “How a single verification answers”](#how-a-single-verification-answers) Each `POST` runs a fresh live check and holds the request open for up to `timeout` seconds (default `5`, maximum `90`). If the verdict lands in time you get a `200` with the result. If the mail server is still answering, you get a `202` with `"status": "pending"` and a `Location` header: wait `poll_after_ms`, then [`GET /v1/email-verifications/{id}`](/docs/api/verify-email/poll/), which takes its own `timeout`. A `202` means “not finished yet”, never “unknown”. A check the mail server never answers inside the server’s 180-second budget closes as `verdict: "unknown"`, `sub_status: "timeout"`. That outcome is terminal and **free**: the credit held for it is released. The optional `source` field on the request body (`"zapier"`, `"signup-form"`, …) is stored with the result for your own attribution; it defaults to `"api"`. ## What calls cost [Section titled “What calls cost”](#what-calls-cost) | Endpoint | Credits per address | | ----------------------------------------------------------------------- | ------------------- | | [`POST /v1/email-verifications`](/docs/api/verify-email/) — real-time | 1 | | [`POST /v1/bulk-verifications`](/docs/api/bulk-jobs/) — queued bulk job | 1 | Every address verified costs one credit, on every plan and every channel. Duplicates, failures on our side and `unknown/timeout` outcomes are never charged. See [Account & billing](/docs/help/account-and-billing/) for how credits work. ## Machine-readable spec [Section titled “Machine-readable spec”](#machine-readable-spec) The single-verification and account endpoints are described in an OpenAPI 3.0 document, [`docs/api/openapi.yaml`](https://github.com/ArulGnani/verifymaill/blob/main/docs/api/openapi.yaml), which every marketplace integration is built from. ## Start here [Section titled “Start here”](#start-here) Quickstart Your first verification call in five minutes. [Quickstart →](/docs/api/quickstart/) Authentication API keys, headers, and environments. [Authentication →](/docs/api/authentication/) Verify a single email Real-time verification of one address, with `timeout` and a `202` to poll. [Verify endpoint →](/docs/api/verify-email/) Account `GET /v1/me` — the key’s owner, plan and spendable credits. [Account endpoint →](/docs/api/me/) DMARC monitoring Enroll domains for DMARC monitoring from your own code — one `POST` returns the DNS record to publish. [DMARC endpoints →](/docs/api/dmarc-monitoring/) Blacklist monitoring Watch your sending domains and IPs across the major DNS blocklists, and re-check on demand after a delisting request. [Blacklist endpoints →](/docs/api/blacklist-monitoring/) Bulk jobs Create a job, add addresses, commit, poll, and export. [Bulk jobs →](/docs/api/bulk-jobs/) Rate limits Concurrency, throughput, and headers. [Rate limits →](/docs/api/rate-limits/) Errors Status codes and error shapes. [Errors →](/docs/api/errors/) ## SDKs [Section titled “SDKs”](#sdks) Prefer a client library? See [SDKs & libraries](/docs/api/sdks/). # Authentication > Authenticate VerifyEmail API requests with your secret API key, and keep keys secure across environments. The VerifyEmail API uses **API keys** to authenticate requests. Pass your secret key as a Bearer token in the `Authorization` header. ```http Authorization: Bearer sk_your_api_key ``` ## Test a key [Section titled “Test a key”](#test-a-key) Call [`GET /v1/me`](/docs/api/me/) to check a key before you use it. It costs no credits and doesn’t count against the rate limit. A `200` returns the account email, plan, credit balance and the key’s own name. A `401` means the key is wrong or has been revoked. * cURL ```bash curl https://api.tryverifyemail.com/v1/me \ -H "Authorization: Bearer sk_your_api_key" ``` * Node.js ```js const res = await fetch("https://api.tryverifyemail.com/v1/me", { headers: { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}` }, }); ``` * Python ```python import os, requests res = requests.get( "https://api.tryverifyemail.com/v1/me", headers={"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"}, ) ``` ```json { "email": "jane@acme.com", "plan": "growth", "credits_available": 1240, "api_key": { "id": "3f2a9c1e-7b4d-4e8a-9f60-2c1d8e5b7a90", "label": "Zapier", "mode": "live" } } ``` ## Creating a key [Section titled “Creating a key”](#creating-a-key) Keys are managed from **[API Keys](https://app.tryverifyemail.com/dashboard/developers/api-keys)** in the dashboard. Give the key a name you’ll recognise later — one key per service means you can rotate that service’s key without touching anything else. The key is shown once We store only a hash of your key, so we cannot show it to you again. Copy it when it’s created. If you lose it, revoke it and create another. Revoking takes effect immediately — the next request made with that key gets a `401`. ## Key types [Section titled “Key types”](#key-types) | Key | Prefix | Where it’s used | Exposable? | | -------------- | ------ | ------------------------ | ----------------------- | | Secret API key | `sk_` | Server-side API requests | **No** — keep it secret | Never put a key in browser code Every key is a secret key. Call the API from your own backend and keep the key out of client-side JavaScript, mobile apps, and public repositories. ## What a key can do [Section titled “What a key can do”](#what-a-key-can-do) A secret key acts for your account across the whole API — single verification, bulk jobs, and DMARC monitoring all accept the same key. One thing it cannot do is **manage keys**. Creating and revoking keys requires signing in to the dashboard, so an attacker holding a leaked key cannot issue themselves a replacement before you turn the original off. ## Keeping keys safe [Section titled “Keeping keys safe”](#keeping-keys-safe) * **Never** ship a secret key in client-side code, a mobile app, or a public repo. * Store keys in environment variables or a secrets manager. * Use **separate keys per environment** (e.g. test vs. production) and per service, so you can rotate or revoke independently. * Rotate immediately if a key is exposed — create a new key, deploy it, then revoke the old one. ## Errors [Section titled “Errors”](#errors) | Status | Meaning | | ------ | --------------------------------------------------------------- | | `401` | The key is missing, malformed, revoked, or not a key we issued. | A request that sends `Authorization: Bearer sk_…` is always authenticated as that key. If the key is invalid we return `401` rather than falling back to any session cookie that happened to ride along, so a broken key never silently acts as somebody else. # Blacklist monitoring > Watch your sending domains and IPs across the major DNS blocklists from your own code — enroll targets, trigger an on-demand check, and read the check history. Keep an eye on the addresses and domains you send from. Enroll a target once and we re-check it on a schedule; call the check endpoint when you want an answer right now — after requesting a delisting, say. | Method | Path | Purpose | | -------- | ----------------------------------------------------------------------------- | -------------------------------- | | `POST` | [`/v1/blacklist/targets`](/docs/api/blacklist-monitoring/add/) | Add a domain or IP to watch. | | `GET` | [`/v1/blacklist/targets`](/docs/api/blacklist-monitoring/list/) | List your targets. | | `GET` | [`/v1/blacklist/targets/{id}`](/docs/api/blacklist-monitoring/retrieve/) | Retrieve one target. | | `POST` | [`/v1/blacklist/targets/{id}/check`](/docs/api/blacklist-monitoring/check/) | Run a check right now. | | `GET` | [`/v1/blacklist/targets/{id}/checks`](/docs/api/blacklist-monitoring/checks/) | Page through the check history. | | `DELETE` | [`/v1/blacklist/targets/{id}`](/docs/api/blacklist-monitoring/delete/) | Remove a target and its history. | Every endpoint takes a secret key in the `Authorization` header — see [Authentication](/docs/api/authentication/). ## The flow [Section titled “The flow”](#the-flow) 1. **Enroll** a domain or IP with [`POST /v1/blacklist/targets`](/docs/api/blacklist-monitoring/add/). The first check runs on the next scheduled pass. 2. **Poll** [`GET /v1/blacklist/targets`](/docs/api/blacklist-monitoring/list/) for anything whose `status` is `listed`. 3. **Re-check on demand** with [`POST /v1/blacklist/targets/{id}/check`](/docs/api/blacklist-monitoring/check/) once you’ve asked a list to remove you, instead of waiting for the next pass. Step 2 has an alternative worth taking: set a webhook on the target and we POST each listing and delisting to you as it happens, signed, instead of you polling for it. See [Blacklist webhooks](/docs/api/blacklist-webhooks/). ## The target object [Section titled “The target object”](#the-target-object) | Field | Description | | -------------------- | ------------------------------------------------------------------------------------------ | | `id` | Use this in every other call on this page. | | `kind` | `domain`, `ipv4` or `ipv6`, decided when the target was added. | | `value` | The normalized target, in the form the lists are queried with. | | `status` | See the table below. | | `monitoring_enabled` | Whether scheduled checks run. Pausing keeps the history. | | `listed_on` | Stable blocklist ids currently listing the target, e.g. `spamhaus_zen`. | | `last_checked_at` | When we last completed a check, or `null` if never. | | `next_check_at` | When the next scheduled check is due. | | `webhook_configured` | Whether [transition webhooks](/docs/api/blacklist-webhooks/) are wired up for this target. | | `status` | Meaning | | --------- | ----------------------------------------------------------------------------- | | `pending` | Added, not yet checked. | | `clean` | The last check found no listing. | | `listed` | On at least one blocklist. See `listed_on`. | | `unknown` | The last check established nothing — every applicable list refused or failed. | `unknown` is not `clean` A list that refuses to answer has told you nothing about your standing, so we never fold that into `clean`. Treat `unknown` as “check again”, not as “you’re fine” — a transient fault reported as clean is how you find out your mail is being rejected from your customers instead of from us. Checks are scheduled by state, not on a fixed clock: a `listed` target is re-checked roughly hourly because somebody is usually waiting on a delisting, and everything else roughly every six hours. ## See also [Section titled “See also”](#see-also) * [Blacklist webhooks](/docs/api/blacklist-webhooks/) * [Authentication](/docs/api/authentication/) * [Errors](/docs/api/errors/) # Add a target > Enroll a domain or IP address for scheduled blocklist checks; re-adding an existing target is safe. ```http POST /v1/blacklist/targets ``` ## Request [Section titled “Request”](#request) | Field | Type | Required | Description | | -------------------- | ------- | -------- | -------------------------------------------------------------------- | | `value` | string | **Yes** | A domain (`example.com`) or an IP address, v4 or v6. | | `label` | string | No | Your own name for it, e.g. `Primary sending IP`. Max 120 characters. | | `monitoring_enabled` | boolean | No | Defaults to `true`. Set `false` to enroll without scheduling checks. | Whether `value` is a domain or an address is decided for you, and the value is normalized before it’s stored: `Example.COM.` and `example.com` are one target. * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/blacklist/targets \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"value": "203.0.113.24", "label": "Primary sending IP"}' ``` * Node.js ```js const res = await fetch("https://api.tryverifyemail.com/v1/blacklist/targets", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ value: "203.0.113.24", label: "Primary sending IP" }), }); const { created, target } = await res.json(); ``` * Python ```python import os, requests res = requests.post( "https://api.tryverifyemail.com/v1/blacklist/targets", headers={"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"}, json={"value": "203.0.113.24", "label": "Primary sending IP"}, ) payload = res.json() ``` ## Response [Section titled “Response”](#response) `201 Created` on first enrollment. The `target` is described field by field on [the target object](/docs/api/blacklist-monitoring/#the-target-object). ```json { "created": true, "target": { "id": "665f1c2a9b4e7d0012ab34cd", "kind": "ipv4", "value": "203.0.113.24", "label": "Primary sending IP", "status": "pending", "monitoring_enabled": true, "listed_on": [], "last_checked_at": null, "next_check_at": "2026-08-21T10:04:11.000Z", "webhook_configured": false, "created_at": "2026-08-21T10:04:11.000Z", "updated_at": "2026-08-21T10:04:11.000Z" } } ``` Re-adding is safe Posting a target you already watch is **not** an error. It answers `200 OK` with `"created": false` and returns the existing target unchanged, so config management tools that run the same request on every deploy need no special handling. A `value` that is neither an IP address nor a plausible domain answers `400`. ## See also [Section titled “See also”](#see-also) * [List your targets](/docs/api/blacklist-monitoring/list/) — the next step * [The target object](/docs/api/blacklist-monitoring/#the-target-object) * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # Check now > Run a blocklist check on a target immediately and get the target and the check back in one response. ```http POST /v1/blacklist/targets/{id}/check ``` Runs the check immediately rather than waiting for `next_check_at`, and returns the target as the check left it alongside the check itself — so confirming a delisting is one request, not two. ```bash curl -X POST https://api.tryverifyemail.com/v1/blacklist/targets/{id}/check \ -H "Authorization: Bearer sk_your_api_key" ``` ## Response [Section titled “Response”](#response) ```json { "target": { "id": "665f1c2a9b4e7d0012ab34cd", "status": "clean", "listed_on": [], "last_checked_at": "2026-08-21T11:30:02.000Z", "next_check_at": "2026-08-21T17:30:02.000Z" }, "check": { "id": "665f1c2a9b4e7d0012abcdef", "target_id": "665f1c2a9b4e7d0012ab34cd", "checked_at": "2026-08-21T11:30:02.000Z", "listed": false, "listed_on": [], "failed_on": ["spamhaus_zen"] } } ``` `failed_on` lists the providers that supplied no evidence in either direction on this check. They are reported rather than hidden: a list that keeps appearing there is an operational problem, not a clean result. ## See also [Section titled “See also”](#see-also) * [Check history](/docs/api/blacklist-monitoring/checks/) — every check on the target, newest first * [List your targets](/docs/api/blacklist-monitoring/list/) * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # Check history > Page through a target's past checks, newest first, retained for 30 days. ```http GET /v1/blacklist/targets/{id}/checks ``` ## Request [Section titled “Request”](#request) | Parameter | Type | Description | | --------- | ------- | ----------------------------------------- | | `limit` | integer | Checks per page, 1–200. | | `cursor` | string | The `next_cursor` from the previous page. | ## Response [Section titled “Response”](#response) ```json { "checks": [ { "id": "665f1c2a9b4e7d0012abcdef", "target_id": "665f1c2a9b4e7d0012ab34cd", "checked_at": "2026-08-21T11:30:02.000Z", "listed": false, "listed_on": [], "failed_on": [] } ], "next_cursor": null } ``` Newest first. `next_cursor` is `null` on the last page; treat it as opaque and pass it back verbatim. History is retained for **30 days**. ## See also [Section titled “See also”](#see-also) * [Check now](/docs/api/blacklist-monitoring/check/) — add a check to the history on demand * [Remove a target](/docs/api/blacklist-monitoring/delete/) * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # Remove a target > Delete a monitored target and its check history. ```http DELETE /v1/blacklist/targets/{id} ``` ## Response [Section titled “Response”](#response) ```json { "deleted": true } ``` Removes the target and its check history. Deleting something you don’t have enrolled answers `{"deleted": false}` rather than a `404` — the end state you asked for already holds. ## See also [Section titled “See also”](#see-also) * [Check history](/docs/api/blacklist-monitoring/checks/) * [Add a target](/docs/api/blacklist-monitoring/add/) * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # List your targets > List every domain and IP you have enrolled for blocklist monitoring, with its current status. ```http GET /v1/blacklist/targets ``` ## Response [Section titled “Response”](#response) ```json { "targets": [ { "id": "665f1c2a9b4e7d0012ab34cd", "kind": "ipv4", "value": "203.0.113.24", "status": "listed", "listed_on": ["spamhaus_zen"] } ] } ``` ## See also [Section titled “See also”](#see-also) * [Add a target](/docs/api/blacklist-monitoring/add/) — the previous step * [Check now](/docs/api/blacklist-monitoring/check/) — re-check a `listed` target on demand * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # Retrieve one target > Fetch a single monitored target by id. ```http GET /v1/blacklist/targets/{id} ``` Returns a single target object in the shape shown under [add a target](/docs/api/blacklist-monitoring/add/#response). Unknown or not yours → `404`. ## See also [Section titled “See also”](#see-also) * [List your targets](/docs/api/blacklist-monitoring/list/) * [The target object](/docs/api/blacklist-monitoring/#the-target-object) * [Blacklist monitoring](/docs/api/blacklist-monitoring/) — overview # Blacklist webhooks > Get a signed HTTP POST the moment one of your monitored domains or IPs is listed on a blocklist, or comes off one — payload, headers, and how to verify the HMAC signature. When a target you [monitor](/docs/api/blacklist-monitoring/) is listed on a blocklist — or comes off one — we POST that transition to a URL you choose, as it happens. One request per event, signed, no polling and no batching. ## Turning it on [Section titled “Turning it on”](#turning-it-on) Open the target in the dashboard and set two things: * **Destination URL** — an `https://` endpoint you control. * **Signing secret** — any string up to 256 characters. Generate it the way you would any shared secret, e.g. `openssl rand -base64 32`. Set the secret, not just the URL Until you set a signing secret of your own, deliveries are signed with an internal platform key that you do not hold — which means you cannot verify them. The `X-Key-Id` header tells you which case you are in: `custom` is your secret, `v1` / `v2` is ours. Treat anything other than `custom` as “not verifiable yet”. The secret is never returned by the API and never appears in a log line, so store your copy somewhere you can read it back. Clearing the destination clears the secret with it. ## What fires [Section titled “What fires”](#what-fires) One delivery per **transition**, per blocklist: | Event | When | | -------------------- | ---------------------------------------------------- | | `blacklist.listed` | A blocklist that was not listing this target now is. | | `blacklist.delisted` | A blocklist that was listing it no longer is. | A target listed by three blocklists in one check produces three deliveries — one per list — so a receiver can act on each list independently. A list that **refuses or fails** a query produces no event in either direction. It has told us nothing about your standing, and reporting that as a delisting is how an automation concludes you are clean while your mail is being rejected. Scheduled checks only, for now Transitions are recorded by the scheduled checks. A check you trigger yourself with `POST /v1/blacklist/targets/{id}/check` refreshes the target but does not write transition events yet, so it does not fire a webhook. A listing that a manual check finds first is delivered on the next scheduled pass that confirms it. ## The payload [Section titled “The payload”](#the-payload) ```json { "event": "blacklist.listed", "event_id": "665f1c2a9b4e7d0012abcdef", "occurred_at": "2026-08-21T11:30:02.000Z", "critical": true, "provider": "spamhaus_zen", "target": { "id": "665f1c2a9b4e7d0012ab34cd", "kind": "ipv4", "value": "203.0.113.24", "label": "Primary sending IP", "status": "listed", "listed_on": ["spamhaus_zen"] }, "detail": { "zone": "zen.spamhaus.org", "codes": ["127.0.0.4"], "txt": ["https://check.spamhaus.org/query/ip/203.0.113.24"] } } ``` | Field | Description | | -------------- | ----------------------------------------------------------------------------------------------- | | `event` | `blacklist.listed` or `blacklist.delisted`. | | `event_id` | Stable id for this transition. Also sent as `X-Event-Id`. Dedupe on it. | | `occurred_at` | ISO-8601 instant of the check that established the transition. | | `critical` | A new listing on a blocklist that stops mail outright. Page someone. | | `provider` | The blocklist that moved, as a stable id, e.g. `spamhaus_zen`. | | `target` | The target as the check left it — `listed_on` is the full current set, not just this provider. | | `detail.zone` | The DNS zone that answered. | | `detail.codes` | The return codes the list answered with, e.g. `127.0.0.4`. | | `detail.txt` | The list’s own reason strings — usually including its removal URL. This is the actionable part. | `detail.codes` and `detail.txt` are empty on a `blacklist.delisted` event: there is no listing left to explain. ## Headers [Section titled “Headers”](#headers) | Header | Value | | -------------- | -------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-Timestamp` | Unix seconds at the moment this attempt was signed. | | `X-Event-Id` | The event id. Always equal to `event_id` in the body. | | `X-Key-Id` | Which key signed: `custom` for your per-target secret, `v1` / `v2` for a platform key. | | `X-Signature` | Lowercase-hex HMAC-SHA256. | The signature covers a string built from the timestamp, the event id and a hash of the exact bytes we posted: ```plaintext signing_string = "{X-Timestamp}.{X-Event-Id}." + sha256_hex(raw_request_body) X-Signature = hmac_sha256(secret, signing_string) // lowercase hex ``` Hash the raw bytes Hash the request body **exactly as it arrived**. Parsing the JSON and re-serializing it produces different bytes — different key order, different whitespace — and the signature will not match. In Express that means `express.raw()`, not `express.json()`; in FastAPI, `await request.body()`. ## Verifying a delivery [Section titled “Verifying a delivery”](#verifying-a-delivery) * Node.js ```js import crypto from "node:crypto"; const REPLAY_WINDOW_SECONDS = 60; export function verifyBlacklistWebhook(rawBody, headers, secret) { const timestamp = headers["x-timestamp"]; const eventId = headers["x-event-id"]; const signature = headers["x-signature"]; if (!timestamp || !eventId || !signature) return false; // Reject anything outside the replay window before spending a hash on it. const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > REPLAY_WINDOW_SECONDS) return false; const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex"); const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${eventId}.${bodyHash}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(signature.trim().toLowerCase(), "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ```js import express from "express"; const app = express(); app.post("/hooks/blacklist", express.raw({ type: "application/json" }), (req, res) => { if (!verifyBlacklistWebhook(req.body, req.headers, process.env.BLACKLIST_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString("utf8")); // Acknowledge first, work afterwards — see "Answering" below. res.sendStatus(204); handle(event); }); ``` * Python ```python import hashlib import hmac import time REPLAY_WINDOW_SECONDS = 60 def verify_blacklist_webhook(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("X-Timestamp") event_id = headers.get("X-Event-Id") signature = headers.get("X-Signature") if not (timestamp and event_id and signature): return False try: age = abs(int(time.time()) - int(timestamp)) except ValueError: return False if age > REPLAY_WINDOW_SECONDS: return False body_hash = hashlib.sha256(raw_body).hexdigest() signing_string = f"{timestamp}.{event_id}.{body_hash}" expected = hmac.new( secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature.strip().lower()) ``` ```python import json import os from fastapi import FastAPI, Request, Response app = FastAPI() SECRET = os.environ["BLACKLIST_WEBHOOK_SECRET"] @app.post("/hooks/blacklist") async def blacklist_hook(request: Request): raw = await request.body() if not verify_blacklist_webhook(raw, request.headers, SECRET): return Response(status_code=401) handle(json.loads(raw)) return Response(status_code=204) ``` Compare in constant time (`crypto.timingSafeEqual`, `hmac.compare_digest`) rather than with `==`. A byte-by-byte comparison that returns early leaks where the first mismatch was, which is enough to recover a signature one byte at a time. ### The replay window [Section titled “The replay window”](#the-replay-window) Every attempt carries a fresh `X-Timestamp` and a fresh signature, so a retry never arrives already expired. Reject deliveries whose timestamp is more than **60 seconds** from your clock — without that check, a captured request stays replayable forever. ### Key rotation [Section titled “Key rotation”](#key-rotation) `X-Key-Id` names the key that signed, so a rotation is never a flag day: accept both keys for as long as you need, then drop the old one. * `custom` — your per-target secret. Rotating is: add the new secret in the dashboard, keep verifying against both for a few minutes, retire the old one. * `v1`, `v2`, … — a platform key. The number goes up when we rotate ours; a receiver that keys its lookup on `X-Key-Id` needs no change when it does. ## Answering a delivery [Section titled “Answering a delivery”](#answering-a-delivery) Answer **any 2xx** as soon as you have the bytes safely stored. Do the real work afterwards: we give a request 10 seconds, and a receiver that verifies, writes to a database and calls three APIs before answering is a receiver that times out. | Your response | What we do | | -------------------------------- | ------------------------------------ | | `2xx` | Done. The event is marked delivered. | | `429` | Retried. | | `5xx`, connection error, timeout | Retried. | | Any other `4xx` | **Not** retried. | A `401` or a `404` means the destination is misconfigured — a wrong secret, a route that no longer exists — and retrying a misconfiguration just sends the same broken request twice more. Fix the endpoint; the next transition will arrive. Retries: up to three attempts, roughly one second and then three seconds apart. After that the delivery is abandoned. The transition is still recorded, still visible on the target, and still included in your weekly summary. Deliveries are at-least-once If your endpoint processes a request but answers too slowly, we retry and you see the same event twice. Both copies carry the same `event_id` — dedupe on it and make your handler idempotent. # Bulk jobs > Verify large lists asynchronously with the VerifyEmail API — create a job, add addresses in chunks, commit it, poll progress, and page through the results. Bulk jobs verify many addresses asynchronously. The flow is: **create a job → add addresses → commit → poll progress → export results.** | Method | Path | Purpose | | ------ | ---------------------------------------------------------------------------- | ---------------------------------------------- | | `POST` | [`/v1/bulk-verifications`](/docs/api/bulk-jobs/create/) | Create a job. | | `POST` | [`/v1/bulk-verifications/{jobId}/items`](/docs/api/bulk-jobs/add-addresses/) | Add addresses in chunks of up to 500. | | `POST` | [`/v1/bulk-verifications/{jobId}/commit`](/docs/api/bulk-jobs/commit/) | Seal the input and start verifying. | | `GET` | [`/v1/bulk-verifications/{jobId}`](/docs/api/bulk-jobs/progress/) | Poll progress and the running verdict tally. | | `GET` | [`/v1/bulk-verifications/{jobId}/items`](/docs/api/bulk-jobs/export/) | Page through the results in `row_index` order. | Costs 1 credit per address Bulk jobs bill **1 credit** per address, the same rate as a [real-time verification](/docs/api/verify-email/). Duplicates within a job are verified once and charged once. If you are checking a list rather than gating a live signup form, this is the endpoint built for it. A job holds up to **100,000** rows. Every request takes `Authorization: Bearer sk_your_api_key`; a `jobId` that is not yours returns `404 {"error":"Job not found"}`. ## The flow [Section titled “The flow”](#the-flow) 1. [**Create a job**](/docs/api/bulk-jobs/create/) — returns the `jobId` every later call uses. 2. [**Add addresses**](/docs/api/bulk-jobs/add-addresses/) — upload the list in chunks of 1–500 rows, each with its `row_index`. 3. [**Commit the job**](/docs/api/bulk-jobs/commit/) — seals the input and starts verification. 4. [**Poll progress**](/docs/api/bulk-jobs/progress/) — until `status` is `completed`. 5. [**Export the results**](/docs/api/bulk-jobs/export/) — page through the rows and their [result objects](/docs/api/verify-email/result/). ## See also [Section titled “See also”](#see-also) * [Verify a single email](/docs/api/verify-email/) — the result object, verdicts, and signals * [Understanding results](/docs/help/understanding-results/) * [Errors](/docs/api/errors/) # Add addresses > Upload the list to a bulk job in chunks of up to 500 rows, each carrying a row_index that makes retries safe. ```http POST /v1/bulk-verifications/{jobId}/items ``` Send the list in chunks of **1–500 rows** per call. Each row carries the address and a `row_index` — its position in your source list, starting at 0. The index is what makes uploads safe to retry: re-sending a `(row_index, email)` pair you already sent is ignored, so a chunk that timed out can simply be sent again. * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/bulk-verifications/$JOB_ID/items \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"items":[ {"row_index":0,"email":"a@example.com"}, {"row_index":1,"email":"b@example.com"} ]}' ``` * Node.js ```js const emails = ["a@example.com", "b@example.com" /* … */]; for (let start = 0; start < emails.length; start += 500) { const items = emails .slice(start, start + 500) .map((email, i) => ({ row_index: start + i, email })); await fetch(`${BASE}/${jobId}/items`, { method: "POST", headers, body: JSON.stringify({ items }), }); } ``` * Python ```python emails = ["a@example.com", "b@example.com"] # … for start in range(0, len(emails), 500): items = [ {"row_index": start + i, "email": email} for i, email in enumerate(emails[start : start + 500]) ] requests.post(f"{BASE}/{job_id}/items", headers=HEADERS, json={"items": items}) ``` ## Response [Section titled “Response”](#response) Returns `200` with what happened to the chunk: ```json { "received": 2, "inserted": 2, "duplicates_ignored": 0 } ``` `duplicates_ignored` counts rows that matched a pair already stored. Two things make the call fail instead, both with a `409`: * **A `row_index` reused with a different address.** Nothing from the chunk is written; the body lists the offending rows. Fix them and resend. ```json { "error": "row_conflict", "conflicts": [{ "row_index": 0, "email": "other@example.com" }] } ``` * **The job is already committed.** `{ "error": "input_sealed" }` — create a new job for the extra rows. ## See also [Section titled “See also”](#see-also) * [Create a job](/docs/api/bulk-jobs/create/) — the previous step * [Commit the job](/docs/api/bulk-jobs/commit/) — the next step * [Bulk jobs](/docs/api/bulk-jobs/) — overview # Commit a job > Seal a bulk job's input and start verification; committing twice is harmless. ```http POST /v1/bulk-verifications/{jobId}/commit ``` Committing seals the input and starts verification. No body is needed. * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/bulk-verifications/$JOB_ID/commit \ -H "Authorization: Bearer sk_your_api_key" ``` * Node.js ```js await fetch(`${BASE}/${jobId}/commit`, { method: "POST", headers }); ``` * Python ```python requests.post(f"{BASE}/{job_id}/commit", headers=HEADERS) ``` ## Response [Section titled “Response”](#response) Returns `202` with the job now `preparing`: ```json { "id": "137eb443-2c6e-4a0f-9b7d-5e1f8a3c2d41", "name": "june-newsletter", "status": "preparing", "created_at": "2026-09-05T06:35:00.267Z" } ``` Committing a job that is already committed is harmless: it returns `200` with the job’s current state and does not restart anything. ## See also [Section titled “See also”](#see-also) * [Add addresses](/docs/api/bulk-jobs/add-addresses/) — the previous step * [Poll progress](/docs/api/bulk-jobs/progress/) — the next step * [Bulk jobs](/docs/api/bulk-jobs/) — overview # Create a job > Open a new bulk verification job and get back the jobId every later call uses. ```http POST /v1/bulk-verifications ``` The body is optional; `name` is a label for your own reference. * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/bulk-verifications \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"name":"june-newsletter"}' ``` * Node.js ```js const BASE = "https://api.tryverifyemail.com/v1/bulk-verifications"; const headers = { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}`, "Content-Type": "application/json", }; const res = await fetch(BASE, { method: "POST", headers, body: JSON.stringify({ name: "june-newsletter" }), }); const { id: jobId } = await res.json(); ``` * Python ```python import os, requests BASE = "https://api.tryverifyemail.com/v1/bulk-verifications" HEADERS = {"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"} res = requests.post(BASE, headers=HEADERS, json={"name": "june-newsletter"}) job_id = res.json()["id"] ``` ## Response [Section titled “Response”](#response) Returns `201`: ```json { "id": "137eb443-2c6e-4a0f-9b7d-5e1f8a3c2d41", "name": "june-newsletter", "status": "accepting_input", "created_at": "2026-09-05T06:35:00.267Z" } ``` ## See also [Section titled “See also”](#see-also) * [Add addresses](/docs/api/bulk-jobs/add-addresses/) — the next step * [Bulk jobs](/docs/api/bulk-jobs/) — overview # Export the results > Page through a bulk job's rows in row_index order, each with its full result object, using cursor pagination. ```http GET /v1/bulk-verifications/{jobId}/items?limit=100 ``` Rows come back in `row_index` order, one page at a time. `limit` defaults to 100 and can be raised to 1000. ## Response [Section titled “Response”](#response) ```json { "emails": [ { "row_index": 0, "submitted_email": "a@example.com", "normalized_email": "a@example.com", "result": { "email": "a@example.com", "normalized_email": "a@example.com", "verdict": "deliverable", "sub_status": "mailbox_exists", "quality_score": 95, "confidence": 92, "signals": { "syntax": "pass", "domain": "pass", "mx": "pass", "smtp": "pass", "catch_all": "pass", "role_account": "pass", "disposable": "pass", "dnsbl": "pass", "toxic": "pass", "greylisting": "pass" }, "verified_at": "2026-09-05T06:35:01.000Z" } }, { "row_index": 1, "submitted_email": "B@Example.com", "normalized_email": "b@example.com", "result": null } ], "next_cursor": "1" } ``` `result` is the same object a [single verification](/docs/api/verify-email/result/) returns, or `null` for a row that has not finished yet. ## Pagination [Section titled “Pagination”](#pagination) When `next_cursor` is a string, there are more rows: pass it back as `?cursor=` on the next call. It is `null` on the last page. * cURL ```bash curl "https://api.tryverifyemail.com/v1/bulk-verifications/$JOB_ID/items?limit=1000&cursor=$CURSOR" \ -H "Authorization: Bearer sk_your_api_key" ``` * Node.js ```js const rows = []; let cursor = null; do { const url = new URL(`${BASE}/${jobId}/items`); url.searchParams.set("limit", "1000"); if (cursor) url.searchParams.set("cursor", cursor); const page = await (await fetch(url, { headers })).json(); rows.push(...page.emails); cursor = page.next_cursor; } while (cursor); ``` * Python ```python rows, cursor = [], None while True: params = {"limit": 1000, **({"cursor": cursor} if cursor else {})} page = requests.get(f"{BASE}/{job_id}/items", headers=HEADERS, params=params).json() rows.extend(page["emails"]) cursor = page["next_cursor"] if cursor is None: break ``` ## See also [Section titled “See also”](#see-also) * [Poll progress](/docs/api/bulk-jobs/progress/) — the previous step * [The result object](/docs/api/verify-email/result/) — every field under `result` * [Bulk jobs](/docs/api/bulk-jobs/) — overview # Poll progress > Read a bulk job's status, the per-row counts, and the running verdict tally while it verifies. ```http GET /v1/bulk-verifications/{jobId} ``` ## Response [Section titled “Response”](#response) ```json { "id": "137eb443-2c6e-4a0f-9b7d-5e1f8a3c2d41", "name": "june-newsletter", "status": "verifying", "received": 2, "unique": 2, "duplicate": 0, "queued": 1, "processing": 0, "completed": 1, "failed": 0, "verdicts": { "deliverable": 1, "undeliverable": 0, "risky": 0, "unknown": 0 } } ``` | Field | Description | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `accepting_input` → `preparing` → `verifying` → `completed`. `failed` and `cancelled` are the other terminal states. | | `received` | Rows you uploaded. | | `unique` / `duplicate` | How many of those were distinct addresses after normalization. A duplicate is verified once and shares its result. | | `queued` / `processing` / `completed` / `failed` | Where each row is right now. A `failed` row could not be verified at all — it was not parseable as an address, or a fault on our side stopped the check. | | `verdicts` | A running tally of finished rows by verdict. | Poll every few seconds until `status` is `completed`. Rows finish individually, so you can start [exporting](/docs/api/bulk-jobs/export/) before the whole job is done. ## See also [Section titled “See also”](#see-also) * [Commit the job](/docs/api/bulk-jobs/commit/) — the previous step * [Export the results](/docs/api/bulk-jobs/export/) — the next step * [Bulk jobs](/docs/api/bulk-jobs/) — overview # Changelog > Notable changes to the VerifyEmail API and developer docs. Notable changes to the API and these docs. Newest first. ## 2026-06-28 [Section titled “2026-06-28”](#2026-06-28) * 📚 Initial public developer documentation: Quickstart, Authentication, Rate limits, Errors, the Verify and Bulk jobs endpoints, and SDKs. Keep this updated Add an entry here whenever an endpoint, parameter, response field, or auth behavior changes. Consider linking each entry to the relevant endpoint page. # DMARC monitoring > Enroll domains for DMARC monitoring programmatically — create, list, update and delete monitored domains, get the exact DNS records to publish, and check verification status. Put a domain under DMARC monitoring from your own code: enroll it, publish the DNS record we hand back, and check when reports start flowing. | Method | Path | Purpose | | -------- | --------------------------------------------------------------------- | -------------------------------------------------------- | | `POST` | [`/v1/dmarc/domains`](/docs/api/dmarc-monitoring/enroll/) | Enroll a domain and get the DNS records to publish. | | `POST` | [`/v1/dmarc/domains/{id}/verify`](/docs/api/dmarc-monitoring/verify/) | Re-read live DNS and move the domain’s status. | | `GET` | [`/v1/dmarc/domains/{id}/dns`](/docs/api/dmarc-monitoring/dns/) | Preview the DNS records again without creating anything. | | `GET` | [`/v1/dmarc/domains`](/docs/api/dmarc-monitoring/list/) | List monitored domains. | | `GET` | [`/v1/dmarc/domains/{id}`](/docs/api/dmarc-monitoring/retrieve/) | Retrieve one domain. | | `PATCH` | [`/v1/dmarc/domains/{id}`](/docs/api/dmarc-monitoring/update/) | Update a domain’s settings. | | `DELETE` | [`/v1/dmarc/domains/{id}`](/docs/api/dmarc-monitoring/delete/) | Delete a domain and revoke its reporting addresses. | Every endpoint takes a secret key in the `Authorization` header — see [Authentication](/docs/api/authentication/). ## The flow [Section titled “The flow”](#the-flow) Three steps, and the first one gives you everything you need for the second: 1. **Enroll** the domain with [`POST /v1/dmarc/domains`](/docs/api/dmarc-monitoring/enroll/). The response includes the exact TXT records to publish. 2. **Publish** those records in the domain’s DNS. 3. **Verify** with [`POST /v1/dmarc/domains/{id}/verify`](/docs/api/dmarc-monitoring/verify/) to confirm we can see them. Reports begin arriving at the next reporting interval — mailbox providers send aggregate reports roughly daily, so allow up to 24–48 hours before the first one lands. ## See also [Section titled “See also”](#see-also) * [DMARC monitoring](/docs/help/dmarc-monitoring/) in the dashboard * [Authentication](/docs/api/authentication/) * [Errors](/docs/api/errors/) # Delete a domain > Remove a DMARC enrollment, revoke its reporting addresses, and purge its reports. ```http DELETE /v1/dmarc/domains/{id} ``` ## Response [Section titled “Response”](#response) ```json { "deleted": true } ``` Removes the enrollment and revokes its reporting addresses; reports for the domain are purged. Deleting a domain you do not have enrolled answers `{"deleted": false}` rather than a `404` — the end state you asked for already holds. Remove the DNS record too Deleting the enrollment stops us accepting reports, but mailbox providers keep sending to whatever address is published. Remove our `mailto:` from your `_dmarc` record as well. ## See also [Section titled “See also”](#see-also) * [Update a domain](/docs/api/dmarc-monitoring/update/) * [Enroll a domain](/docs/api/dmarc-monitoring/enroll/) * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Preview the DNS records again > Re-read the DNS records a domain still needs published, against current DNS, without creating anything. ```http GET /v1/dmarc/domains/{id}/dns ``` Returns the same `records` array as [enrollment](/docs/api/dmarc-monitoring/enroll/#the-dns-records), re-read against current DNS. Use it to re-check what still needs publishing without creating anything. ## See also [Section titled “See also”](#see-also) * [Enroll a domain](/docs/api/dmarc-monitoring/enroll/) — where the records are first returned * [Check verification](/docs/api/dmarc-monitoring/verify/) — once they are published * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Enroll a domain > Put a domain under DMARC monitoring and get back the exact TXT records to publish — generated fresh or merged into the record you already have. ```http POST /v1/dmarc/domains ``` ## Request [Section titled “Request”](#request) | Field | Type | Required | Description | | ---------------- | ------- | -------- | -------------------------------------------- | | `domain` | string | **Yes** | The domain to monitor, e.g. `example.com`. | | `org_id` | string | No | Your own grouping key, echoed back on reads. | | `is_subdomain` | boolean | No | Mark this as a subdomain enrollment. | | `parent_domain` | string | No | The parent, when `is_subdomain` is true. | | `ruf_enabled` | boolean | No | Also provision a forensic (RUF) address. | | `tlsrpt_enabled` | boolean | No | Also provision a TLS-RPT address. | | `retention_days` | integer | No | How long to keep reports (1–3650). | * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/dmarc/domains \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"domain": "example.com"}' ``` * Node.js ```js const res = await fetch("https://api.tryverifyemail.com/v1/dmarc/domains", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ domain: "example.com" }), }); const { domain, dns_records } = await res.json(); ``` * Python ```python import os, requests res = requests.post( "https://api.tryverifyemail.com/v1/dmarc/domains", headers={"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"}, json={"domain": "example.com"}, ) payload = res.json() ``` ## Response [Section titled “Response”](#response) `201 Created` on first enrollment. ```json { "created": true, "domain": { "id": "665f1c2a9b4e7d0012ab34cd", "domain": "example.com", "status": "pending", "is_subdomain": false, "parent_domain": null, "org_id": null, "ruf_enabled": false, "tlsrpt_enabled": false, "retention_days": 90, "published_policy": null, "reporting": { "rua": "a1b2c3d4e5f6...@dmarc.tryverifyemail.com", "ruf": null, "tlsrpt": null }, "created_at": "2026-08-19T10:04:11.000Z", "updated_at": "2026-08-19T10:04:11.000Z" }, "dns_records": [ { "record": "dmarc", "host": "_dmarc.example.com", "type": "TXT", "action": "create", "value": "v=DMARC1; p=none; rua=mailto:a1b2c3d4e5f6...@dmarc.tryverifyemail.com", "current": null } ] } ``` Re-enrolling is safe Posting a domain you already monitor is **not** an error. It answers `200 OK` with `"created": false` and returns the existing enrollment unchanged. Config management tools that run the same request on every deploy need no special handling. ## The DNS records [Section titled “The DNS records”](#the-dns-records) `dns_records` is generate-or-merge, not a blind template. We read whatever is currently published at `_dmarc.` first: * **`"action": "create"`** — nothing is published yet. `value` is a safe monitoring-only starter (`p=none`), so enrolling cannot affect delivery. * **`"action": "update"`** — a DMARC record already exists. `value` is that record with our reporting address spliced into its `rua=` list and **every one of your existing policy tags preserved** (`p`, `pct`, `sp`, `adkim`, `aspf`). `current` shows what is published today so you can diff before applying. Publish `value` verbatim at `host`. ## See also [Section titled “See also”](#see-also) * [Check verification](/docs/api/dmarc-monitoring/verify/) — the next step once the records are published * [Preview the DNS records again](/docs/api/dmarc-monitoring/dns/) * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # List monitored domains > List every domain enrolled for DMARC monitoring under your key. ```http GET /v1/dmarc/domains ``` ## Response [Section titled “Response”](#response) ```json { "domains": [ { "id": "665f...", "domain": "example.com", "status": "receiving" } ] } ``` ## See also [Section titled “See also”](#see-also) * [Retrieve one domain](/docs/api/dmarc-monitoring/retrieve/) * [Enroll a domain](/docs/api/dmarc-monitoring/enroll/) * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Retrieve one domain > Fetch a single monitored domain by id. ```http GET /v1/dmarc/domains/{id} ``` Returns a single domain object in the shape shown under [enroll](/docs/api/dmarc-monitoring/enroll/#response). Unknown or not yours → `404`. ## See also [Section titled “See also”](#see-also) * [List monitored domains](/docs/api/dmarc-monitoring/list/) * [Update a domain](/docs/api/dmarc-monitoring/update/) * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Update a domain > Change a monitored domain's settings — grouping, subdomain flags, retention, and RUF or TLS-RPT provisioning. ```http PATCH /v1/dmarc/domains/{id} ``` Accepts `org_id`, `is_subdomain`, `parent_domain`, `retention_days`, `ruf_enabled` and `tlsrpt_enabled`. At least one field is required. Setting `ruf_enabled` or `tlsrpt_enabled` to `true` provisions that reporting address if it does not exist yet — call [`GET /{id}/dns`](/docs/api/dmarc-monitoring/dns/) afterwards to pick up the record you now need to publish. `status` and `published_policy` are read-only Both are derived by us from your live DNS and from real report traffic, so neither can be set through the API. Use [`POST /{id}/verify`](/docs/api/dmarc-monitoring/verify/) to re-derive them. ## See also [Section titled “See also”](#see-also) * [Retrieve one domain](/docs/api/dmarc-monitoring/retrieve/) * [Delete a domain](/docs/api/dmarc-monitoring/delete/) * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Check verification > Re-read a domain's live DNS and move its status — pending, waiting, or receiving. ```http POST /v1/dmarc/domains/{id}/verify ``` Re-reads the domain’s live DNS and moves its status. ```bash curl -X POST https://api.tryverifyemail.com/v1/dmarc/domains/{id}/verify \ -H "Authorization: Bearer sk_your_api_key" ``` ## Response [Section titled “Response”](#response) ```json { "domain": "example.com", "state": "waiting", "status": "verified", "token_found": true, "published_policy": { "p": "none", "rua": "mailto:a1b2c3d4e5f6...@dmarc.tryverifyemail.com", "raw_record": "v=DMARC1; p=none; rua=mailto:a1b2c3d4e5f6...@dmarc.tryverifyemail.com" }, "last_report_received_at": null } ``` | `state` | Meaning | | ----------- | --------------------------------------------------------------------------------------- | | `pending` | Our reporting address is not in your published `_dmarc` record yet. | | `waiting` | DNS is correct. No reports have arrived yet — this is normal for the first 24–48 hours. | | `receiving` | Reports are flowing. Monitoring is live. | A `"dns_error": true` field means the DNS lookup itself failed transiently. The domain’s status is deliberately **left unchanged** in that case — a lookup that did not answer is not evidence that your record is gone. Retry. ## See also [Section titled “See also”](#see-also) * [Enroll a domain](/docs/api/dmarc-monitoring/enroll/) — the previous step * [Preview the DNS records again](/docs/api/dmarc-monitoring/dns/) — if `state` is still `pending` * [DMARC monitoring](/docs/api/dmarc-monitoring/) — overview # Errors > VerifyEmail API status codes and error response shapes, with guidance on how to handle each. The API uses conventional HTTP status codes and returns a consistent JSON error body. ## Error shape [Section titled “Error shape”](#error-shape) Every error body is a flat object with a single `error` string: ```json { "error": "Invalid request body" } ``` Some errors carry one extra field alongside it — a `409 row_conflict` on a bulk upload lists the offending rows under `conflicts`, for example — but `error` is always present and always a string, so `body.error` is the one thing to branch on. ## Status codes [Section titled “Status codes”](#status-codes) | Code | Meaning | What to do | | ----- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success | — | | `201` | Created — a new bulk job | — | | `202` | Accepted — the work is still running | Poll: a pending [single verification](/docs/api/verify-email/verify/#202--pending) or a just-committed [bulk job](/docs/api/bulk-jobs/). | | `400` | Bad request — the body is not JSON, or a field is missing, empty, or unknown | Fix the request. | | `401` | Unauthorized — missing, malformed, or revoked API key | Check your [API key](/docs/api/authentication/). | | `402` | Payment required — the account cannot spend a credit | Read `error`; top up or resolve billing, then retry. | | `404` | Not found — the resource does not exist or is not yours | Check the id. | | `409` | Conflict — the request contradicts the resource’s current state | Read `error`; see below. | | `429` | Too many requests | Back off; see [Rate limits](/docs/api/rate-limits/). | | `5xx` | Server error | Retry with backoff; contact support if it persists. | ## Error strings [Section titled “Error strings”](#error-strings) | `error` | Status | When | | --------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Request body must be valid JSON` | `400` | The body could not be parsed as JSON. | | `Invalid request body` | `400` | The JSON is missing a required field, has an empty or over-long value, or carries a field the endpoint does not accept. | | `Idempotency-Key is too long` | `400` | The header exceeds 200 characters. | | `Invalid timeout` | `400` | The `timeout` query parameter on a single verification is not a positive whole number. | | `Unauthorized` | `401` | The key is missing, malformed, revoked, or not one we issued. | | `insufficient_credits` | `402` | The wallet is empty. Nothing was started and nothing was charged. The body also carries `balance`, `required` and `shortfall`. | | `account_frozen` | `402` | The account is frozen and cannot spend credits. The body also carries `reason`: `past_due`, `cancelled`, or `null` when no reason was recorded. | | `Job not found` | `404` | The bulk job id does not exist or belongs to another account. | | `row_conflict` | `409` | A bulk upload reused a `row_index` with a different address. The body also carries `conflicts: [{ row_index, email }]`; nothing from the chunk was written. | | `input_sealed` | `409` | Rows were sent to a bulk job that has already been committed. | Don’t confuse errors with verification verdicts A `200` whose `result.verdict` is `"undeliverable"` is a **successful** API call — the address was checked and found undeliverable. Errors above are about the request itself, not the deliverability result. See [Understanding results](/docs/help/understanding-results/). # Retrieve your account > Check that an API key works and read the account it belongs to, its plan, its credit balance and the key's own name and mode. The recommended connection test. ```http GET /v1/me ``` Returns the account the key belongs to. Use it as your connection test. A `200` means the key is valid and active, and the response gives you a label to show for the connection. The call costs no credits and does not count against the [verify rate limit](/docs/api/rate-limits/). * cURL ```bash curl https://api.tryverifyemail.com/v1/me \ -H "Authorization: Bearer sk_your_api_key" ``` * Node.js ```js const res = await fetch("https://api.tryverifyemail.com/v1/me", { headers: { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}` }, }); if (res.status === 401) throw new Error("Invalid or revoked API key"); const account = await res.json(); ``` * Python ```python import os, requests res = requests.get( "https://api.tryverifyemail.com/v1/me", headers={"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"}, ) res.raise_for_status() account = res.json() ``` ## Response [Section titled “Response”](#response) ```json { "email": "jane@acme.com", "plan": "growth", "credits_available": 1240, "api_key": { "id": "3f2a9c1e-7b4d-4e8a-9f60-2c1d8e5b7a90", "label": "Zapier", "mode": "live" } } ``` | Field | Description | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | `email` | The email address of the account that owns the key. | | `plan` | The account’s plan: `free`, `starter`, `growth` or `scale`. | | `credits_available` | Credits you can spend right now, plan credits and top-up credits combined. The dashboard shows the same number. | | `api_key.id` | The id of the key that made the call. It matches the key’s row on the API Keys page. | | `api_key.label` | The name you gave the key when you created it. | | `api_key.mode` | `live` or `test`, the type you picked when you created the key. | The dashboard calls this endpoint with your signed-in session instead of a key. Those responses carry `api_key: null`. ## Errors [Section titled “Errors”](#errors) | Status | Body | When | | ------ | ----------------------------- | ------------------------------------------------------------- | | `401` | `{ "error": "Unauthorized" }` | The key is missing, malformed, revoked, or not one we issued. | ## See also [Section titled “See also”](#see-also) * [Authentication](/docs/api/authentication/) covers creating, rotating and revoking keys * [Rate limits](/docs/api/rate-limits/) explains why only the verify call is limited * [Account & billing](/docs/help/account-and-billing/) explains how credits work # Quickstart > Make your first VerifyEmail API call in under five minutes — get a key, verify an address, and read the response. Verify your first email address in a few minutes. 1. **Get an API key.** In the dashboard, open **API keys** and create a key. Copy the secret key (it starts with `sk_`) — you won’t be able to see it again. 2. **Make a verification request.** Replace `sk_your_api_key` with your key. `timeout` is how many seconds the request waits for the verdict (default `5`, up to `90`); `source` is an optional label stored with the result so you can tell your integrations apart. * cURL ```bash curl -X POST "https://api.tryverifyemail.com/v1/email-verifications?timeout=25" \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com","source":"quickstart"}' ``` * Node.js ```js const res = await fetch( "https://api.tryverifyemail.com/v1/email-verifications?timeout=25", { method: "POST", headers: { Authorization: "Bearer sk_your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ email: "jane@example.com", source: "quickstart" }), }, ); const body = await res.json(); if (res.status === 200) { console.log(body.result.verdict, body.result.quality_score); } else { console.log("still running, poll", res.headers.get("Location")); } ``` * Python ```python import requests res = requests.post( "https://api.tryverifyemail.com/v1/email-verifications", params={"timeout": 25}, headers={"Authorization": "Bearer sk_your_api_key"}, json={"email": "jane@example.com", "source": "quickstart"}, ) body = res.json() if res.status_code == 200: print(body["result"]["verdict"], body["result"]["quality_score"]) else: print("still running, poll", res.headers["Location"]) ``` 3. **Read the response.** A `200` means the check finished; the verdict is nested under `result`. ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "completed", "result": { "email": "jane@example.com", "normalized_email": "jane@example.com", "verdict": "deliverable", "sub_status": "mailbox_exists", "quality_score": 95, "confidence": 92, "provider": "google", "primary_mx": "aspmx.l.google.com", "signals": { "syntax": "pass", "domain": "pass", "mx": "pass", "smtp": "pass", "catch_all": "pass", "role_account": "pass", "disposable": "pass", "dnsbl": "pass", "toxic": "pass", "greylisting": "pass" }, "verified_at": "2026-09-05T06:35:01.000Z" } } ``` If the mail server has not answered when your `timeout` runs out you get a `202` with `"status": "pending"` instead, and no `result` yet: ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "pending", "poll_after_ms": 2000, "next_retry_at": "2026-09-05T06:35:03.000Z" } ``` The check keeps running. Wait `poll_after_ms` milliseconds, then `GET` the URL in the response’s `Location` header (`/v1/email-verifications/{id}`), optionally with its own `?timeout=`, until it answers `200`. The [poll reference](/docs/api/verify-email/poll/) has a complete loop. A mail server that never answers inside our 180-second budget closes the check as `"verdict": "unknown"`, `"sub_status": "timeout"`. That result is terminal and costs nothing. See [Understanding results](/docs/help/understanding-results/) for what each field means. 4. **Check your balance.** `GET /v1/me` returns the account behind the key, its plan and the credits it can spend: ```bash curl https://api.tryverifyemail.com/v1/me \ -H "Authorization: Bearer sk_your_api_key" ``` ```json { "email": "you@example.com", "plan": "growth", "credits_available": 48210, "api_key": { "id": "key_01J8YV3Q2M7X9K4R6T1W5N8B0D", "label": "Quickstart", "mode": "live" } } ``` That call cost 1 credit Every address that reaches a verdict costs **1 credit**, real-time or bulk; an `unknown/timeout` outcome is not charged. Checking a list instead of a live form? Use [bulk jobs](/docs/api/bulk-jobs/), which take up to 100,000 rows per job at the same rate. ## Next steps [Section titled “Next steps”](#next-steps) * [Authentication](/docs/api/authentication/) — keys, headers, environments * [Verify a single email](/docs/api/verify-email/) — full endpoint reference * [Poll a pending verification](/docs/api/verify-email/poll/) — what to do with a `202` * [Account](/docs/api/me/) — `GET /v1/me` * [Bulk jobs](/docs/api/bulk-jobs/) — verify lists at scale * [SDKs & libraries](/docs/api/sdks/) — official clients # Rate limits > The per-plan rate limit on real-time verification, the headers it returns, and how to handle a 429. One endpoint is rate limited: [`POST /v1/email-verifications`](/docs/api/verify-email/verify/), the real-time verify call. Nothing else in the API is — not bulk jobs, not polling a result, not the monitoring endpoints. The limit is counted **per API key**. Two keys on the same account have separate budgets, so a batch job running on one key cannot throttle your production traffic on another. Requests from the dashboard (which are authenticated by your session, not a key) are counted per account. ## Limits by plan [Section titled “Limits by plan”](#limits-by-plan) | Plan | Sustained | Burst | | ------- | -------------------- | ----- | | Free | 1 request / second | 2 | | Starter | 3 requests / second | 6 | | Growth | 10 requests / second | 20 | | Scale | 25 requests / second | 50 | The sustained rate is what you can hold indefinitely. On top of it you get short bursts of up to **2× your sustained rate** — an idle key banks up to two seconds’ worth of requests and can spend them at once, then settles back to the sustained rate as the allowance refills. Need more? Contact . ## Rate-limit headers [Section titled “Rate-limit headers”](#rate-limit-headers) Every response from the verify endpoint carries your current standing — successes and `429`s alike. From a Starter key: ```http RateLimit-Limit: 3 RateLimit-Remaining: 5 RateLimit-Reset: 1 ``` | Header | Meaning | | --------------------- | ---------------------------------------------------------------------------------------- | | `RateLimit-Limit` | Your plan’s sustained rate, in requests per second. | | `RateLimit-Remaining` | Requests you can make right now, this instant. | | `RateLimit-Reset` | Seconds until your full burst allowance is available again. A duration, not a timestamp. | ## Handling 429 [Section titled “Handling 429”](#handling-429) Over the limit, the API returns **`429 Too Many Requests`** with a `Retry-After` header and a matching `retry_after` field, both in whole seconds: ```http HTTP/1.1 429 Too Many Requests Retry-After: 1 RateLimit-Limit: 3 RateLimit-Remaining: 0 RateLimit-Reset: 2 ``` ```json { "error": "rate_limited", "retry_after": 1 } ``` **A `429` never costs a credit.** The limit is applied before the address reaches the verification engine, so a throttled request is not billed and does not appear in your history — retrying it costs you nothing but the wait. * Sleep for `Retry-After` seconds, then retry. * Use **exponential backoff with jitter** when retries keep failing, so a fleet of workers does not resynchronise into the same spike. * For large volumes, prefer [bulk jobs](/docs/api/bulk-jobs/) over many single requests — they are queued and run at the fleet’s own pace, with no rate limit to manage. ```text attempt 1 → wait 1s → attempt 2 → wait 2s → attempt 3 → wait 4s → … ``` See [Errors](/docs/api/errors/) for the full list of status codes. # SDKs & libraries > Official VerifyEmail client libraries for Node.js, Python, PHP, Ruby, and more. Official client libraries wrap authentication, retries, and the endpoints so you can verify with a couple of lines. Confirm availability The package names below are the intended naming. Confirm which SDKs are actually published (and their install names) before linking them publicly. ## Install [Section titled “Install”](#install) * Node.js ```bash npm install verifyemail ``` ```js import VerifyEmail from "verifyemail"; const vm = new VerifyEmail(process.env.VERIFYEMAIL_API_KEY); const result = await vm.verify("jane@example.com"); console.log(result.status, result.score); ``` * Python ```bash pip install verifyemail ``` ```python from verifyemail import VerifyEmail vm = VerifyEmail(api_key="sk_your_api_key") result = vm.verify("jane@example.com") print(result.status, result.score) ``` * PHP ```bash composer require verifyemail/verifyemail ``` ```php $vm = new \VerifyEmail\Client('sk_your_api_key'); $result = $vm->verify('jane@example.com'); echo $result->status; ``` * Ruby ```bash gem install verifyemail ``` ```ruby vm = VerifyEmail::Client.new("sk_your_api_key") result = vm.verify("jane@example.com") puts result.status ``` ## No SDK for your language? [Section titled “No SDK for your language?”](#no-sdk-for-your-language) Every feature is available over plain HTTPS — see the [Quickstart](/docs/api/quickstart/). If you’d like an SDK we don’t offer yet, let us know at . # Verify a single email > Real-time verification of one email address via the VerifyEmail API — the request body, the completed and pending responses, polling, and every field in the result. Verify a single email address in real time. | Method | Path | Purpose | | ------ | -------------------------------------------------------------- | ------------------------------------------------ | | `POST` | [`/v1/email-verifications`](/docs/api/verify-email/verify/) | Verify an email address with a fresh live check. | | `GET` | [`/v1/email-verifications/{id}`](/docs/api/verify-email/poll/) | Poll a verification that came back `pending`. | | `GET` | [`/v1/email-verifications`](/docs/api/verify-email/list/) | List the recent checks made with your key. | Every finished check returns the same [result object](/docs/api/verify-email/result/) — the verdict, the sub-status, the scores, and the ten signals behind them. Costs 1 credit per address Every call to the verify endpoint runs a **fresh live check** against the recipient’s mail server — it never resolves from a previously cached verdict — so the verdict reflects the mailbox at the moment you ask. It costs **1 credit** per address, the same as a dashboard or bulk check. Verifying a large list? [Bulk jobs](/docs/api/bulk-jobs/) are queued rather than real-time and take up to 100,000 rows per job. See [Account & billing](/docs/help/account-and-billing/) for the full credit table. ## The flow [Section titled “The flow”](#the-flow) 1. **Verify** the address with [`POST /v1/email-verifications`](/docs/api/verify-email/verify/). Most checks finish inside the request and answer `200` with the result. 2. **Poll** with [`GET /v1/email-verifications/{id}`](/docs/api/verify-email/poll/) if the create call answered `202` — the mail server was slow to respond and the check is still running. Wait `poll_after_ms` between calls. 3. **Read the result.** Both endpoints hand back the same [result object](/docs/api/verify-email/result/) once the check completes. ## See also [Section titled “See also”](#see-also) * [Understanding results](/docs/help/understanding-results/) * [Bulk jobs](/docs/api/bulk-jobs/) for verifying many addresses * [Errors](/docs/api/errors/) # List recent verifications > Page through the finished checks made with your API key, newest first. ```http GET /v1/email-verifications?page=1&page_size=10 ``` Returns the finished checks made with your key, newest first. `page_size` defaults to 10 and is capped at 50. ```json { "rows": [ { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "checked_at": "2026-09-05T06:35:00.000Z", "result": { "email": "jane@example.com", "verdict": "deliverable", "...": "..." } } ], "page": 1, "page_size": 10, "total": 1 } ``` Each row’s `result` is the full [result object](/docs/api/verify-email/result/). `checked_at` is when you made the request; `result.verified_at` is when the verdict was produced. ## See also [Section titled “See also”](#see-also) * [The result object](/docs/api/verify-email/result/) — every field under `result` * [Verify an email](/docs/api/verify-email/verify/) — the call that produces these rows * [Verify a single email](/docs/api/verify-email/) — overview # Poll a pending verification > Fetch a verification by id until it completes, optionally holding the request open with timeout. Same two response shapes as the create call, with polling loops in cURL, Node.js and Python. ```http GET /v1/email-verifications/{id}?timeout=30 ``` Returns the same two shapes as the [create call](/docs/api/verify-email/verify/): `200` with `status: "completed"` and the `result` once the check has finished, or `202` with `status: "pending"` and a fresh `poll_after_ms` while it is still in flight. ## Query parameters [Section titled “Query parameters”](#query-parameters) | Parameter | Type | Default | Description | | --------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `timeout` | integer | none | Seconds to hold the request open waiting for the result, from `1` to `90`. Values above `90` are treated as `90`. A value that is not a positive whole number returns `400 {"error":"Invalid timeout"}`. | Without `timeout` the call answers immediately with the current state, so wait `poll_after_ms` between calls. With `timeout` it returns `200` as soon as the result lands, or `202` (with a `Location` header) if the check is still running when the timeout runs out. One call with `timeout=30` replaces a polling loop for most addresses. * cURL ```bash curl "https://api.tryverifyemail.com/v1/email-verifications/5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08?timeout=30" \ -H "Authorization: Bearer sk_your_api_key" ``` * Node.js ```js async function verify(email) { const headers = { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}`, "Content-Type": "application/json", }; let res = await fetch("https://api.tryverifyemail.com/v1/email-verifications?timeout=25", { method: "POST", headers, body: JSON.stringify({ email }), }); let body = await res.json(); while (body.status === "pending") { await new Promise((r) => setTimeout(r, body.poll_after_ms)); res = await fetch( `https://api.tryverifyemail.com/v1/email-verifications/${body.id}?timeout=25`, { headers }, ); body = await res.json(); } return body.result; } ``` * Python ```python import os, time, requests BASE = "https://api.tryverifyemail.com/v1/email-verifications" HEADERS = {"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"} def verify(email): params = {"timeout": 25} body = requests.post(BASE, headers=HEADERS, params=params, json={"email": email}).json() while body["status"] == "pending": time.sleep(body["poll_after_ms"] / 1000) body = requests.get(f"{BASE}/{body['id']}", headers=HEADERS, params=params).json() return body["result"] ``` An `id` that is not yours, or that never existed, reads as `pending` rather than `404`. Ids are unguessable, so only poll ids you received from a create call. Polling is one of three ways to collect a verdict after a `202`. You can also send the same `POST` again, which returns the same `id` and costs no second credit only while the check is still pending (once it has finished, a repeat `POST` starts a new check and costs a new credit), or read your [history](/docs/api/verify-email/list/). ## See also [Section titled “See also”](#see-also) * [Verify an email](/docs/api/verify-email/verify/) — the create call that hands you the `id` * [The result object](/docs/api/verify-email/result/) — what a completed check returns * [Verify a single email](/docs/api/verify-email/) — overview # The result object > Every field a finished verification returns — the verdict, sub-status, quality and confidence scores, and the ten signals behind them. Every finished verification — the [single endpoints](/docs/api/verify-email/), the [recent list](/docs/api/verify-email/list/), and [bulk exports](/docs/api/bulk-jobs/export/) — returns the same `result` object. | Field | Type | Description | | ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | string | The address exactly as you submitted it. | | `normalized_email` | string | The canonical form the verdict was computed against (lower-cased domain, whitespace trimmed, and so on). | | `verdict` | string | The headline outcome — see [verdicts](#verdicts). | | `sub_status` | string \| null | A machine-readable refinement of the verdict, such as `mailbox_exists`, `mailbox_not_found`, `invalid_syntax`, `catch_all`, `disposable`, or `greylisted`; `null` when none applies. See [sub-statuses](/docs/help/understanding-results/#sub-statuses). | | `quality_score` | integer | Deliverability quality, 0–100. Higher is safer to send to. | | `confidence` | integer | How sure we are of the verdict itself, 0–100. A `risky` verdict at confidence 40 deserves a re-check; at 95 it does not. | | `provider` | string \| null | The mail provider hosting the domain’s mailboxes — one of `google`, `microsoft`, `yahoo`, `proofpoint`, `mimecast`, `zoho`, `amazon_ses`, `protonmail`, `apple`, `fastmail`, `self_hosted`, `other`; `null` when it could not be determined. New tokens may be added, so match defensively. | | `primary_mx` | string \| null | The domain’s highest-priority mail exchanger hostname, such as `aspmx.l.google.com`; `null` when the domain publishes none. | | `signals` | object | The outcome of each individual check — see [signals](#signals). | | `verified_at` | string | ISO 8601 timestamp of when the verdict was produced. | ## Verdicts [Section titled “Verdicts”](#verdicts) | `verdict` | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `deliverable` | The mailbox accepts mail. | | `undeliverable` | The mailbox rejects mail. Never send to these. | | `risky` | Mail is accepted but carries a material risk: catch-all domain, role address, suspected disposable provider, or greylisting that never cleared. | | `unknown` | The recipient’s side made it impossible to conclude — for example the server refuses verification probes. Still a genuine, billed outcome. | ## Signals [Section titled “Signals”](#signals) `signals` has exactly ten keys. Each value is one status string. | Signal | What it checks | | -------------- | ----------------------------------------------------------------------------------------- | | `syntax` | The address is well-formed. | | `domain` | The domain exists in DNS. | | `mx` | The domain publishes mail exchangers. | | `smtp` | The mail server accepts the mailbox. | | `catch_all` | Whether the domain accepts every address (a `warn` here is what makes a verdict `risky`). | | `role_account` | Whether the local part is a shared role such as `info@` or `support@`. | | `disposable` | Whether the domain is a throwaway provider. | | `dnsbl` | Whether the domain or its mail servers appear on DNS blocklists. | | `toxic` | Whether the address is a known spam trap. | | `greylisting` | Whether the server greylisted the probe and whether the retry cleared it. | | Status | Meaning | | --------- | ------------------------------------------------------------------------------------------------- | | `pass` | The check succeeded cleanly. | | `fail` | The check produced a definitive negative reading. | | `warn` | The check found a risk that is not fatal on its own. | | `unknown` | The check ran but could not reach a conclusion. | | `skipped` | The check was intentionally not run — usually because an earlier one already settled the verdict. | | `error` | The check could not run because of a fault on our side. | ## See also [Section titled “See also”](#see-also) * [Understanding results](/docs/help/understanding-results/) * [Verify an email](/docs/api/verify-email/verify/) — the call that returns this object * [Verify a single email](/docs/api/verify-email/) — overview # Verify an email > Run a real-time check on one email address, choose how long the request waits for the verdict with timeout, and get a 202 to poll if it is still running. Verify a single email address in real time. ```http POST /v1/email-verifications?timeout=30 ``` Costs 1 credit per address Every call to this endpoint runs a **fresh live check** against the recipient’s mail server — it never resolves from a finished verdict, even one from a minute ago — so the verdict reflects the mailbox at the moment you ask. It costs **1 credit** per address, the same as a dashboard or bulk check. The one exception is a repeat of a check that is [still pending](#repeating-a-request), which joins it instead. Verifying a large list? [Bulk jobs](/docs/api/bulk-jobs/) are queued rather than real-time and take up to 100,000 rows per job. See [Account & billing](/docs/help/account-and-billing/) for the full credit table. ## Request [Section titled “Request”](#request) Send a JSON body with the address to check. | Field | Type | Required | Description | | -------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | string | **Yes** | The address to verify, 1–320 characters. A malformed address is not a request error — it comes back as a `200` with an `undeliverable` verdict and `sub_status: "invalid_syntax"`. | | `source` | string | No | A free-form label for your own attribution (for example which form the address came from). | You may also send an `Idempotency-Key` header (up to 200 characters). Repeating a request with the same key returns the same verification instead of starting a second one. ### Query parameters [Section titled “Query parameters”](#query-parameters) | Parameter | Type | Default | Description | | --------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `timeout` | integer | `5` | How many seconds to hold the request open while waiting for the verdict, from `1` to `90`. Values above `90` are treated as `90`. A value that is not a positive whole number returns `400 {"error":"Invalid timeout"}`. | `timeout` only controls how long **this HTTP request** waits. It does not shorten or stop the verification: if the mail server is still answering when the timeout runs out, you get a `202` and the check keeps running. Set it to match how long your client can wait. Automation platforms with a 30-second step limit should send `timeout=25`; a script that would rather block than poll can send `timeout=90`. ### Repeating a request [Section titled “Repeating a request”](#repeating-a-request) If you `POST` the same address again while your earlier check of it is still pending, you get the **same `id`** back rather than a second check. No second probe runs and no second credit is held. The repeat waits up to its own `timeout` and answers `200` if the check finishes by then, `202` if not. This only applies while the earlier check is pending. Once it has finished, a `POST` for the same address starts a new check and costs a new credit; to read the finished verdict for free, [poll by id](/docs/api/verify-email/poll/) instead. * cURL ```bash curl -X POST https://api.tryverifyemail.com/v1/email-verifications \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com"}' ``` * Node.js ```js const res = await fetch("https://api.tryverifyemail.com/v1/email-verifications", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERIFYEMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ email: "jane@example.com" }), }); const body = await res.json(); ``` * Python ```python import os, requests res = requests.post( "https://api.tryverifyemail.com/v1/email-verifications", headers={"Authorization": f"Bearer {os.environ['VERIFYEMAIL_API_KEY']}"}, json={"email": "jane@example.com"}, ) body = res.json() ``` A body that is not valid JSON returns `400 {"error":"Request body must be valid JSON"}`; a JSON body with a missing, empty, or unknown field returns `400 {"error":"Invalid request body"}`. ## Response [Section titled “Response”](#response) The call answers in one of two ways, distinguished by `status`. ### 200 — completed [Section titled “200 — completed”](#200--completed) The live check finished inside the request. The verdict sits under `result` — every field is described on [the result object](/docs/api/verify-email/result/). ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "completed", "result": { "email": "jane@example.com", "normalized_email": "jane@example.com", "verdict": "deliverable", "sub_status": "mailbox_exists", "quality_score": 95, "confidence": 92, "signals": { "syntax": "pass", "domain": "pass", "mx": "pass", "smtp": "pass", "catch_all": "pass", "role_account": "pass", "disposable": "pass", "dnsbl": "pass", "toxic": "pass", "greylisting": "pass" }, "verified_at": "2026-09-05T06:35:01.000Z" } } ``` ### 202 — pending [Section titled “202 — pending”](#202--pending) The check had not finished when your `timeout` ran out. A slow or greylisting mail server is the usual cause. A `202` means “not finished yet”, not “unknown”. The verification keeps running, and the body has no `result`. The API never invents a verdict because your timeout expired. ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "pending", "poll_after_ms": 2000, "next_retry_at": "2026-09-05T06:35:03.000Z" } ``` | Field | Description | | --------------- | -------------------------------------------------------- | | `poll_after_ms` | The minimum wait before you poll again, in milliseconds. | | `next_retry_at` | The same wait as an absolute timestamp. | The response also carries a `Location` header pointing at the poll URL, `/v1/email-verifications/{id}`. ### Getting the verdict after a 202 [Section titled “Getting the verdict after a 202”](#getting-the-verdict-after-a-202) There are three ways to get the verdict later: 1. **Poll by id.** Call [`GET /v1/email-verifications/{id}`](/docs/api/verify-email/poll/), optionally with its own `timeout` so the request waits for the result. 2. **Send the same `POST` again, while the check is still pending.** You get the same `id` back, with no second credit. Once the check has finished this no longer applies: a repeat `POST` starts a new check and costs a new credit. 3. **Read your history.** Finished checks appear in [`GET /v1/email-verifications`](/docs/api/verify-email/list/) and on the dashboard. ## See also [Section titled “See also”](#see-also) * [Poll a pending verification](/docs/api/verify-email/poll/) — the next step when you get a `202` * [The result object](/docs/api/verify-email/result/) — every field under `result` * [Verify a single email](/docs/api/verify-email/) — overview # Help Center > Guides and answers for getting the most out of VerifyEmail — verification, results, integrations, billing, and security. Welcome to the VerifyEmail Help Center. These guides cover everything you can do from the dashboard and how to interpret what VerifyEmail tells you. Looking to integrate programmatically? Head to the [API reference](/docs/api/). Getting started Create an account, find your way around the dashboard, and run your first verification. [Start here →](/docs/help/getting-started/) Verify a single email Check one address from the dashboard and read the verdict. [Verify one →](/docs/help/verifying-a-single-email/) Bulk / list verification Upload a list (CSV/XLSX) or send it via API, then download the clean file. [Verify a list →](/docs/help/bulk-verification/) Understanding results Statuses, sub-statuses, and the quality score explained. [Read results →](/docs/help/understanding-results/) Integrations Connect VerifyEmail to your ESP, CRM, and no-code tools. [See integrations →](/docs/help/integrations/) Account & billing Credits, plans, invoices, and team management. [Manage account →](/docs/help/account-and-billing/) Security & compliance How we handle your data, and our compliance posture. [Read more →](/docs/help/security/) Need a human? Can’t find what you need? Email — we usually reply within one business day. # Account & billing > How VerifyEmail credits, plans, invoices, and team access work. ## Credits [Section titled “Credits”](#credits) Verification is metered in **credits** — **one credit per address checked**, on every plan. Credits are consumed by single checks, bulk jobs, and API calls alike, from one shared wallet. | Action | Credits | | ----------------------------- | ------------- | | Single check in the dashboard | 1 per address | | Bulk job (upload or API) | 1 per address | | Real-time API verification | 1 per address | Duplicates and failures on our side are never charged. An `unknown` result, where the recipient’s mail server would not let us reach a conclusion, is a completed verification and is billed like any other. Every account starts with 100 free credits when you sign up. See the [pricing page](https://tryverifyemail.com/pricing#credits-explained) for current rates. Credits are **prepaid**. When the wallet cannot cover a job the request is refused rather than billed as an overage — buy a top-up from **Billing** and run it again. Nothing is ever charged without you completing a checkout. ## Plans [Section titled “Plans”](#plans) VerifyEmail offers a free allowance to start, then pay-as-you-go credit packs and subscription plans for higher volume. Compare current options on the [pricing page](https://tryverifyemail.com/pricing). | Plan type | Best for | | -------------- | ---------------------------------- | | Free allowance | Trying it out / occasional checks | | Pay-as-you-go | Irregular or one-off cleaning | | Subscription | Steady, higher-volume verification | ## Usage & invoices [Section titled “Usage & invoices”](#usage--invoices) * Track remaining credits and consumption in the dashboard **Usage** view. * Invoices and receipts are available under **Billing**. * Set up alerts so you’re notified before credits run low. ## Team access [Section titled “Team access”](#team-access) Invite teammates and assign roles from **Settings → Team**. Use separate [API keys](/docs/api/authentication/) per environment or service so you can rotate or revoke them independently. ## Need help with billing? [Section titled “Need help with billing?”](#need-help-with-billing) Email for invoice, refund, or plan questions. # Bulk / list verification > Upload a list or send it via API to verify thousands of addresses at once, then download the cleaned results. Bulk verification cleans an entire list in one job — by uploading a file in the dashboard, or by submitting addresses through the [Bulk jobs API](/docs/api/bulk-jobs/). ## Upload a file [Section titled “Upload a file”](#upload-a-file) 1. Open **Bulk verification** and upload a `CSV` or `XLSX` file. 2. Map the column that contains the email addresses. 3. Start the job. Large lists process in the background — you can leave the page. 4. When it finishes, download the results file. File tips * One address per row, with a header row. * Duplicates within a job are verified once and charged once, so you can upload the raw export. * Very large lists are processed in batches; progress is shown live. ## Read the results [Section titled “Read the results”](#read-the-results) The results file adds the verification columns to each row — status, sub-status, score, and flags. A typical workflow is to **keep deliverable**, **drop undeliverable**, and **review risky / accept-all** before sending. See [Understanding results](/docs/help/understanding-results/) for what each column means. ## Verify a list via API [Section titled “Verify a list via API”](#verify-a-list-via-api) If you’d rather not upload files, submit and poll jobs programmatically: * Create a job and add addresses → [Bulk jobs API](/docs/api/bulk-jobs/) * Authenticate your requests → [Authentication](/docs/api/authentication/) ## After verifying [Section titled “After verifying”](#after-verifying) * Suppress undeliverables in your ESP so they’re never emailed again. * Re-verify older lists periodically — addresses decay over time. * Verify at signup with the [API](/docs/api/quickstart/) to keep new sign-ups clean at the source. # DMARC monitoring > Monitor who is sending email as your domain — add a domain, publish one DNS record, and read aggregate DMARC reports without touching raw XML. DMARC monitoring shows you **every source sending email as your domain** — your own mail, your SaaS tools, and anyone spoofing you — and whether each one passes SPF and DKIM alignment. You publish one DNS record; we collect the reports mailbox providers send back and turn the XML into something readable. ## Adding a domain [Section titled “Adding a domain”](#adding-a-domain) You can do this two ways. Both produce the same result. **From the dashboard** — go to DMARC monitoring, choose *Add domain*, enter the domain, and copy the DNS record it shows you. **From your own code** — one API call returns the record to publish: ```bash curl -X POST https://api.tryverifyemail.com/v1/dmarc/domains \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"domain": "example.com"}' ``` The response carries a `dns_records` array with the exact TXT record — host, type and value — so there is no second call to fetch it. See the [DMARC monitoring API](/docs/api/dmarc-monitoring/) for the full endpoint set. ## Publishing the DNS record [Section titled “Publishing the DNS record”](#publishing-the-dns-record) Add the TXT record exactly as given, at the host shown (usually `_dmarc.yourdomain.com`). Two cases, and we detect which one applies before generating anything: * **No DMARC record yet** — you get a monitoring-only starter (`p=none`). It asks providers to report, not to act, so adding it **cannot affect your mail delivery**. * **You already have a DMARC record** — you get your existing record with our reporting address merged into its `rua=` list, and every policy tag you set (`p`, `pct`, `sp`, `adkim`, `aspf`) left exactly as it was. ## Checking it worked [Section titled “Checking it worked”](#checking-it-worked) Re-check from the dashboard, or `POST /v1/dmarc/domains/{id}/verify`. A domain moves through three states: | State | Meaning | | ----------- | ----------------------------------------------------------------------------------------------------- | | `pending` | Our reporting address isn’t visible in your DNS yet. Check the record, and allow for DNS propagation. | | `waiting` | DNS is correct. No reports have arrived yet — normal for the first day or two. | | `receiving` | Reports are flowing. Monitoring is live. | Reports are not instant Mailbox providers send DMARC aggregate reports on their own schedule, typically once every 24 hours. A domain sitting at `waiting` for a day is expected, not a misconfiguration. ## Moving to enforcement [Section titled “Moving to enforcement”](#moving-to-enforcement) Monitoring is the first step. Once the reports show your legitimate senders passing alignment, you can tighten your policy from `p=none` to `p=quarantine` and then `p=reject` — which is what actually stops spoofed mail being delivered. The dashboard flags when your alignment looks healthy enough to move, and warns which senders would start failing if you did. # Getting started > Create your VerifyEmail account, get oriented in the dashboard, and run your first email verification. This guide takes you from sign-up to your first verified address in a few minutes. ## 1. Create your account [Section titled “1. Create your account”](#1-create-your-account) Sign up at [tryverifyemail.com](https://tryverifyemail.com). Every new account starts with a free credit allowance so you can try verification before adding a payment method. Credits Verification is metered in **credits** — one credit per address checked, on every plan and every channel. Duplicates and failures on our side are never charged. See [Account & billing](/docs/help/account-and-billing/) for how credits and plans work. ## 2. Get oriented [Section titled “2. Get oriented”](#2-get-oriented) The dashboard has three things you’ll use most: * **Single verification** — paste one address for an instant verdict. * **Bulk verification** — upload a list or pull it from an integration. * **API keys** — create keys to verify programmatically. ## 3. Run your first verification [Section titled “3. Run your first verification”](#3-run-your-first-verification) 1. Open **Single verification** in the dashboard. 2. Enter an email address and submit. 3. Read the result — a status (e.g. *deliverable*), any sub-status, and a quality score. If you’re not sure what a result means, see [Understanding results](/docs/help/understanding-results/). ## 4. Choose how you’ll verify at scale [Section titled “4. Choose how you’ll verify at scale”](#4-choose-how-youll-verify-at-scale) | You want to… | Use | Guide | | ------------------------ | ----------------- | --------------------------------------------------------- | | Clean an existing list | Bulk verification | [Bulk / list verification](/docs/help/bulk-verification/) | | Verify from your own app | REST API | [API quickstart](/docs/api/quickstart/) | | Sync with your ESP/CRM | Integrations | [Integrations](/docs/help/integrations/) | ## Next steps [Section titled “Next steps”](#next-steps) * [Verify a single email](/docs/help/verifying-a-single-email/) * [Understand your results](/docs/help/understanding-results/) * [Make your first API call](/docs/api/quickstart/) # Integrations > Connect VerifyEmail to your email platform, CRM, e-commerce store, and no-code tools to keep lists clean automatically. Integrations let you verify addresses where they already live — your ESP, CRM, forms, and stores — without exporting and re-importing files. ## Ways to connect [Section titled “Ways to connect”](#ways-to-connect) * **Native integrations** — one-click connections to popular platforms. * **No-code (Zapier / Make / n8n)** — trigger verification on new contacts. * **API & webhooks** — build a custom flow with the [REST API](/docs/api/). Full catalog Browse every supported tool on the [Integrations page](https://tryverifyemail.com/integrations). ## Typical setups [Section titled “Typical setups”](#typical-setups) | Goal | How | | ----------------------- | --------------------------------------------------------------------- | | Verify new CRM contacts | Native CRM integration or a Zapier trigger | | Clean an ESP audience | Connect your ESP and run a bulk verification | | Validate store sign-ups | E-commerce app + the [API](/docs/api/quickstart/) on your signup form | | Custom workflow | [API](/docs/api/quickstart/) + [webhooks](/docs/api/bulk-jobs/) | ## Connecting an integration [Section titled “Connecting an integration”](#connecting-an-integration) 1. Open **Integrations** in the dashboard. 2. Choose your platform and authorize the connection. 3. Pick what to verify (e.g. new contacts, a specific list/audience). 4. Choose what happens to each result — tag, suppress, or sync back. Note Don’t see your platform? Most tools can connect through Zapier/Make/n8n, or directly via the [API](/docs/api/). Email and we’ll point you to the best route. # Security & compliance > How VerifyEmail protects your data — encryption, retention, access control, and where to request compliance documentation. We treat the lists you verify as sensitive data. This page summarizes how that data is handled. Confirm specifics before publishing externally Only state certifications and legal commitments that have been verified by the team. Fill in / confirm the bracketed items below against current policy before sharing this page publicly or with enterprise prospects. ## Data handling [Section titled “Data handling”](#data-handling) * **In transit:** all API and dashboard traffic is served over HTTPS/TLS. * **At rest:** uploaded lists and results are encrypted at rest. * **Retention:** verification data is retained for 360 days on every plan and can be deleted at any time. Bulk files can be removed from the dashboard at any time. * **We don’t sell your data** or use your lists to build shared databases. ## Access control [Section titled “Access control”](#access-control) * Scope access with per-environment [API keys](/docs/api/authentication/) and team roles. * Rotate or revoke keys immediately if one is exposed. * Never put an API key in browser code; call the API from your own backend. ## Compliance [Section titled “Compliance”](#compliance) VerifyEmail is built to support `[GDPR / applicable]` obligations. A **Data Processing Agreement (DPA)** is available on request. For a DPA, security questionnaire, or details of current certifications, contact . ## Reporting a vulnerability [Section titled “Reporting a vulnerability”](#reporting-a-vulnerability) Found a security issue? Please email with details so we can investigate. Please don’t disclose it publicly until we’ve had a chance to respond. # Understanding results > What VerifyEmail's statuses, sub-statuses, quality score, and flags mean — and how to act on each. Every verification returns a **status**, a more specific **sub-status**, a **quality score**, and a set of **flags**. This page explains each so you know what’s safe to send. Keep this in sync with the API The exact status and sub-status strings below are the canonical reference. They must match the values returned by the [verify endpoint](/docs/api/verify-email/). If your API returns a value not listed here, treat the API as the source of truth and update this page. ## Statuses [Section titled “Statuses”](#statuses) | Status | Meaning | Recommended action | | --------------- | -------------------------------------------------------------------- | ----------------------------- | | `deliverable` | The mailbox exists and can receive mail. | Send. | | `undeliverable` | The mailbox or domain cannot receive mail. | Remove / suppress. | | `risky` | Deliverable but elevated risk (e.g. accept-all, role, full mailbox). | Send with caution or segment. | | `unknown` | The mail server didn’t return a definitive answer. | Retry later; don’t bulk-send. | ## Sub-statuses [Section titled “Sub-statuses”](#sub-statuses) | Sub-status | Under | What it means | | ------------------- | ------------- | ---------------------------------------------------- | | `invalid_syntax` | undeliverable | Not a valid email format. | | `invalid_domain` | undeliverable | Domain doesn’t exist or has no website/DNS. | | `no_mx_record` | undeliverable | Domain can’t receive mail (no MX). | | `mailbox_not_found` | undeliverable | The specific mailbox doesn’t exist. | | `mailbox_full` | risky | Mailbox exists but is over quota. | | `accept_all` | risky | Domain accepts all addresses; existence unconfirmed. | | `role_based` | risky | Shared/role address (e.g. `info@`, `support@`). | | `disposable` | risky | Temporary/throwaway address provider. | | `timeout` | unknown | Mail server too slow to respond. | | `no_connect` | unknown | Couldn’t connect to the mail server. | ## Quality score [Section titled “Quality score”](#quality-score) A **0–100** score summarizing confidence and risk. Higher is safer. Use it to set your own threshold — for example, send to everything ≥ 80, review 50–79, and drop below 50. Tune the cutoff to your tolerance for bounces vs. reach. ## Flags [Section titled “Flags”](#flags) Independent signals returned alongside the status: * **Disposable** — throwaway provider. * **Role-based** — not a personal mailbox. * **Free** — free consumer provider (e.g. Gmail, Outlook). * **Accept-all** — domain accepts everything. * **Provider** — who runs the domain’s mail (Google Workspace, Microsoft 365, …), from its MX records. ## Acting on results [Section titled “Acting on results”](#acting-on-results) * **Protect deliverability:** never send to `undeliverable`. * **Segment risk:** treat `risky` separately — warm up, or exclude from cold sends. * **Re-check `unknown`:** these are often transient; retry before discarding. # Verify a single email > Check one email address from the VerifyEmail dashboard and understand the verdict you get back. Single verification is the fastest way to check one address — useful for spot checks, support tickets, or testing before you wire up the API. ## From the dashboard [Section titled “From the dashboard”](#from-the-dashboard) 1. Open **Single verification**. 2. Enter the email address (e.g. `jane@example.com`). 3. Submit. VerifyEmail runs syntax, domain/MX, and mailbox (SMTP) checks and returns a result in a second or two. ## What you get back [Section titled “What you get back”](#what-you-get-back) Every single check returns: * a **status** — the headline verdict (e.g. *deliverable*, *undeliverable*, *risky*, *unknown*); * a **sub-status** — the specific reason (e.g. *mailbox\_not\_found*, *accept\_all*, *disposable*); * a **quality score** from 0–100 indicating confidence/risk; * detected **flags** — disposable, role-based, free provider, accept-all. See [Understanding results](/docs/help/understanding-results/) for the full breakdown of each value. Verifying many addresses? Don’t paste them one at a time — use [bulk verification](/docs/help/bulk-verification/) for a list, or the [verify endpoint](/docs/api/verify-email/) to check addresses from your own app. ## Common results [Section titled “Common results”](#common-results) * **Deliverable** — safe to send. * **Undeliverable** — the mailbox doesn’t exist or the domain can’t receive mail. Remove it. * **Risky / accept-all** — the domain accepts everything, so existence can’t be confirmed. Send with caution. * **Unknown** — the mail server didn’t give a definitive answer in time. # Integrations > Use VerifyEmail from Zapier, n8n, Make, Pabbly Connect and Clay with your own API key. Each marketplace integration is a thin wrapper around the same public API. You paste your own VerifyEmail API key, every check spends credits from your account, and every marketplace returns the same flat output fields. Each card shows whether that marketplace has listed the integration yet. [CClayAvailableVerify the emails in a Clay table with an HTTP API column and your VerifyEmail API key.](/docs/integrations/clay/)[MMakeComing soonVerify email addresses inside a Make scenario with your VerifyEmail API key.](/docs/integrations/make/)[nn8nComing soonVerify email addresses inside an n8n workflow with your VerifyEmail API key.](/docs/integrations/n8n/)[PPabbly ConnectComing soonVerify email addresses inside a Pabbly Connect workflow with your VerifyEmail API key.](/docs/integrations/pabbly-connect/)[ZZapierComing soonVerify email addresses inside a Zap with your VerifyEmail API key.](/docs/integrations/zapier/) ## What every integration shares [Section titled “What every integration shares”](#what-every-integration-shares) * **Your key, your credits.** Create a key under **Developers → API keys** in the dashboard. Each verification that reaches a verdict costs 1 credit. * **Up to three operations.** Verify Email, Get Verification and Account Info. Pabbly Connect and Clay have Verify Email only. * **One output shape.** The same field names and order everywhere, so a workflow moved from one tool to another maps the same way. Two integrations work differently: [ChatGPT](/docs/integrations/chatgpt/) and the [MCP server](/docs/integrations/mcp/) connect with your VerifyEmail sign-in instead of an API key. Building against the API directly instead? Start with the [API quickstart](/docs/api/quickstart/). # ChatGPT > Verify email addresses and check your credit balance from the VerifyEmail app in ChatGPT. The VerifyEmail app in ChatGPT connects with your VerifyEmail sign-in. There is no API key to create or paste. ## What it does [Section titled “What it does”](#what-it-does) The app does three things: * Verify one email address. * Fetch a result that was still pending. * Show your credit balance. Nothing else. It has no bulk verification, no list upload and no DMARC tools. ## Connect [Section titled “Connect”](#connect) 1. Click **Connect** on the VerifyEmail app in ChatGPT. 2. A browser window opens on VerifyEmail. 3. Sign in, or create a VerifyEmail account with Google or a magic link. 4. Click **Approve**. 5. Return to ChatGPT. A new account works immediately with whatever free credits it starts with. ## Example prompts [Section titled “Example prompts”](#example-prompts) * “Is a real email address?” * “Check whether this email will bounce before I send to it” * “How many VerifyEmail credits do I have left?” ## What the verdicts mean [Section titled “What the verdicts mean”](#what-the-verdicts-mean) | Verdict | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `deliverable` | The mailbox exists and accepted the probe. Safe to send. | | `undeliverable` | The address is malformed, the domain has no mail server, or the mailbox was rejected. It will bounce. | | `risky` | The server accepted the address but the result is not trustworthy: a catch-all domain, a disposable provider, a role address, or a mailbox with a history of bouncing. Send with caution. | | `unknown` | The mail server did not give a usable answer in time (greylisting, timeout). Try again later. | ChatGPT may show the result as prose rather than a table. A slow mail server can produce a “still checking” reply; the app finishes the check on its own when you ask again. ## Credits [Section titled “Credits”](#credits) Each verification costs 1 credit from your existing VerifyEmail balance. Fetching a pending result and checking your balance are free. Top up in the VerifyEmail dashboard. ## Disconnect [Section titled “Disconnect”](#disconnect) You can disconnect from either side: * In the VerifyEmail dashboard: **Settings → Connected apps**, then remove ChatGPT. * In ChatGPT: open the app’s own settings in ChatGPT (the connected-apps screen), then disconnect VerifyEmail. Disconnecting in either place revokes the token. The other side simply shows the app as not connected the next time it is used. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) * **The app says you have 0 credits.** Verifications stop until the balance is topped up in the VerifyEmail dashboard. Balance checks still work. * **“Too many requests” or a rate-limit message.** The Free plan allows 1 request per second through the app. Wait a second and ask again. * **The app asks you to reconnect.** The access token expired or was revoked. Click **Connect** again and approve. Nothing about your account changes. # Clay > Verify the emails in a Clay table with an HTTP API column and your VerifyEmail API key. Clay has no VerifyEmail app to install. You call the VerifyEmail API from Clay’s own **HTTP API** enrichment column, with your own API key, and Clay writes the answer into each row. Each row is one live check of one address. Follow the walkthrough below in order. After it come a short checklist of the same steps, then the reference sections every VerifyEmail integration shares: output fields, errors and key notes. ## Before you start [Section titled “Before you start”](#before-you-start) * **A VerifyEmail API key.** Create one under [Developers → API keys](https://app.tryverifyemail.com/dashboard/developers/api-keys) and name it after Clay. Leave its trusted-IP list empty. Clay sends the request from its own servers, and a key limited to other addresses answers `401`. * **A Clay table with a column of email addresses.** This page calls that column `Email`. ## 1. Add the HTTP API column [Section titled “1. Add the HTTP API column”](#1-add-the-http-api-column) In the table, click **Add enrichment**, search for **HTTP API** and choose it. Clay opens it on the **Generate** tab, which drafts the request with AI. Switch to **Configure** and fill in the fields by hand ([Clay: HTTP API](https://university.clay.com/docs/http-api-integration-overview)). ## 2. Store the key in a header account [Section titled “2. Store the key in a header account”](#2-store-the-key-in-a-header-account) You can type the key into the column’s header fields, but Clay shows those values in plain text to anyone with access to the table. A header account keeps it hidden: 1. Open **Select header account** and click **+ Add account**. 2. Under **API Request Headers**, add `Authorization` with the value `Bearer sk_your_api_key`. 3. Name the account `VerifyEmail` and save. Clay keeps header accounts under **Settings → Connections**. Editing the account there changes every column that uses it, so that is where you rotate the key ([Clay: HTTP API](https://university.clay.com/docs/http-api-integration-overview)). To check the key before spending anything, test a throwaway HTTP API column with method `GET`, endpoint `https://api.tryverifyemail.com/v1/me` and the same header account. It returns your account email and `credits_available`, and costs nothing. ## 3. Fill in the request [Section titled “3. Fill in the request”](#3-fill-in-the-request) | Field | Value | | -------------- | ------------------------------------------------------------------ | | Method | `POST` | | Endpoint URL | `https://api.tryverifyemail.com/v1/email-verifications?timeout=25` | | Header account | `VerifyEmail` | | JSON body | `{"email": "/Email", "source": "clay"}` | To put the `Email` column into the body, type `/` and pick the column. Keep the quotes around it, because Clay needs a text column quoted inside JSON. Clay sets `Content-Type: application/json` on its own ([Clay: HTTP API](https://university.clay.com/docs/http-api-integration-overview)). `timeout=25` asks the API to wait up to 25 seconds for the verdict before it answers. Most checks finish inside that window. The rest come back `pending`, covered [below](#when-a-row-comes-back-pending). `"source": "clay"` labels these checks as Clay in your verification history. Leave **Field paths to return** empty. Narrowing the response to one path drops the `id` and `status` you need for pending rows. ## 4. Set the rate and when the column runs [Section titled “4. Set the rate and when the column runs”](#4-set-the-rate-and-when-the-column-runs) Under **Rate limiting**, set **Duration (ms)** to `1000` and **Request limit** to your plan’s [rate limit](/docs/api/rate-limits/). Anything faster gets `429 Too Many Requests`, and nothing is charged for that row. If a few rows show a `429`, lower the request limit and re-run just those rows. | Plan | Request limit | | ------- | ------------- | | Free | 1 | | Starter | 3 | | Growth | 10 | | Scale | 25 | Then open the column’s **Run settings**, turn on **Only run if** and enter `Email is not empty`, so rows without an address never call the API ([Clay: table settings](https://university.clay.com/docs/table-management-settings)). Clay’s auto-run is on by default. Every new row runs the column by itself and spends a credit. If you would rather start each run yourself, turn auto-run off for this column in the same **Run settings** ([Clay: table settings](https://university.clay.com/docs/table-management-settings)). ## 5. Test one row, then pull the result into columns [Section titled “5. Test one row, then pull the result into columns”](#5-test-one-row-then-pull-the-result-into-columns) Click **Test** to run one row. A finished check answers `200` with this body: ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "completed", "result": { "email": "jane@example.com", "normalized_email": "jane@example.com", "verdict": "deliverable", "sub_status": "mailbox_exists", "quality_score": 95, "confidence": 92, "provider": "google", "primary_mx": "aspmx.l.google.com", "signals": { "syntax": "pass", "domain": "pass", "mx": "pass", "smtp": "pass", "catch_all": "pass", "role_account": "pass", "disposable": "pass", "dnsbl": "pass", "toxic": "pass", "greylisting": "pass" }, "verified_at": "2026-09-05T06:35:01.000Z" } } ``` Click the cell to open the response, hover over a field and click **Add as column** ([Loops: Clay integration](https://loops.so/docs/integrations/clay) shows the same control). Add these five: | Response field | Column | What it holds | | ---------------------- | --------------- | --------------------------------------------------------------------------------- | | `id` | Verification ID | The check’s id. You need it to collect a pending check. | | `status` | Status | `completed`, or `pending` while the check is still running. | | `result.verdict` | Verdict | `deliverable`, `undeliverable`, `risky` or `unknown`. | | `result.quality_score` | Quality score | 0 to 100. Higher is safer to send to. | | `result.sub_status` | Sub-status | The reason, such as `mailbox_exists`, `catch_all`, `invalid_syntax` or `timeout`. | Any other field under `result` becomes a column the same way, for example `result.signals.catch_all` or `result.provider`. When the test row looks right, run the column on the rest of the table. The output-field table further down lists the flat fields the packaged marketplace apps return. Clay reads the raw API response, so the same values sit one level down, under `result`: `verdict` is `result.verdict` and `signal_smtp` is `result.signals.smtp`. The raw response has no `is_deliverable`. Check `result.verdict` for `deliverable` instead. Every field is defined in [the result object](/docs/api/verify-email/result/). ## 6. Filter rows by verdict [Section titled “6. Filter rows by verdict”](#6-filter-rows-by-verdict) * `deliverable`: the mailbox exists and accepts mail. Safe to send. * `undeliverable`: the address will bounce, or is malformed (`invalid_syntax`). Remove it. * `risky`: the address may work, but a signal makes it uncertain. `Sub-status` says which. * `unknown`: the mail server gave no usable answer. Try again later. A `timeout` sub-status was not charged. Filter the table on the Verdict column, for example Verdict is `deliverable`, to get a send list. To act on the verdict in a later column, such as one that pushes contacts to your sequencer, give that column an **Only run if** condition like `Verdict = "deliverable"`. ## When a row comes back pending [Section titled “When a row comes back pending”](#when-a-row-comes-back-pending) Some mail servers take longer than 25 seconds to answer. The API then replies `202` with no verdict yet: ```json { "id": "5f1c9d2e-8b1a-4f5e-9c3d-2a7b6e4d1f08", "status": "pending", "poll_after_ms": 2000, "next_retry_at": "2026-09-05T06:35:03.000Z" } ``` A `202` is a success, not an error. The row’s Status reads `pending` and its Verdict, Quality score and Sub-status cells stay empty. The check keeps running on our side and always finishes within 180 seconds. Collect it with a second HTTP API column that fetches the check by id: | Field | Value | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Method | `GET` | | Endpoint URL | `https://api.tryverifyemail.com/v1/email-verifications/`, then the Verification ID column (type `/` and pick it), then `?timeout=25` | | Header account | `VerifyEmail` | | Only run if | `Status = "pending"` | Run it a few minutes after the first column and add `status`, `result.verdict`, `result.quality_score` and `result.sub_status` from it as columns too. Fetching by id costs no credit and doesn’t count toward your rate limit, so you can run it as often as you like. The response has the same shape as the first column’s. Re-running the first column’s cell for that row also works, but only while the check is still running. You get the same `id` back, no second credit is held, and the request waits up to another 25 seconds. Don’t re-run the whole POST column to pick up a few pending rows Once a row’s check has finished, posting its address again starts a new check and spends another credit. Re-running the column does that for every row in it. Use the GET column, which is free. ## What it costs [Section titled “What it costs”](#what-it-costs) * **1 credit** for each check that reaches a verdict. * **Nothing** for an `unknown` result with `sub_status: timeout`, for a malformed address (`undeliverable` / `invalid_syntax`), for fetching a check by id, or for `GET /v1/me`. * **A pending row** has a credit held, not spent. It is charged when the check finishes with a verdict and released if the check ends as `unknown` / `timeout`. * **Out of credits**, the API answers `402` with `insufficient_credits`. Nothing was started or charged. Top up under [Billing](https://app.tryverifyemail.com/dashboard/billing) and re-run those rows. ## Reading the reference sections in Clay [Section titled “Reading the reference sections in Clay”](#reading-the-reference-sections-in-clay) The sections below are shared with the packaged marketplace apps. In Clay they map like this: * **Connect in 3 steps.** “Paste it into Clay” is the header account from step 2. Clay doesn’t test the key by itself, so use the `GET /v1/me` column from step 2 for that. * **Verify Email** is the `POST` column. Clay has no separate Get Verification step; the `GET` column above does that job. * **Pending.** Re-running the `POST` cell is free only while the check is still running. After that, use the `GET` column. * **429 and 402.** The packaged apps wait and retry a `429` once. In Clay, set the request limit so `429`s don’t happen. A row that hits either error shows it in its cell. Fix the cause, then re-run just those rows. # Make > Verify email addresses inside a Make scenario with your VerifyEmail API key. ## Module names in Make [Section titled “Module names in Make”](#module-names-in-make) Make names modules with a verb and an object, so the operations appear under these names: | Operation | Make module | | ---------------- | ----------------------- | | Verify Email | Verify an email address | | Get Verification | Get a verification | | Account Info | Get account info | ## Field names in Make [Section titled “Field names in Make”](#field-names-in-make) Make modules return the API response unchanged, so the fields are nested rather than flat, and there is no `is_deliverable`. Filter on `result.verdict` equal to `deliverable` instead. A pending result has no `result` at all, only `id`, `status`, `poll_after_ms` and `next_retry_at`. | Flat name | Key in Make | | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `id`, `status` | `id`, `status` | | `email`, `verdict`, `sub_status`, `quality_score`, `confidence`, `provider`, `primary_mx`, `verified_at` | `result.email`, `result.verdict`, … `result.verified_at` | | `signal_syntax` … `signal_greylisting` | `result.signals.syntax` … `result.signals.greylisting` | | `is_deliverable` | none; filter on `result.verdict` | The app also has **Make an API call**. It sends any request to `https://api.tryverifyemail.com` with your key already attached, for endpoints the modules don’t cover. ## Rate limits in Make [Section titled “Rate limits in Make”](#rate-limits-in-make) When the API answers `429`, the module fails with a rate-limit error that names your plan’s rate. Make then suspends the scenario and resumes it on its own, about 20 minutes later by default. It does not retry inside the same run. If you would rather skip the address and carry on, add a **Break** or **Ignore** error handler to the module. # MCP server > Connect any MCP client that supports remote servers with OAuth to VerifyEmail. VerifyEmail runs a remote MCP server. Any MCP client that supports remote servers with OAuth can connect to it with your VerifyEmail sign-in. No API key is involved. ## Server [Section titled “Server”](#server) | | | | ----------------------------- | ----------------------------------------------------------------------- | | Server URL | `https://api.tryverifyemail.com/mcp` | | Transport | Streamable HTTP | | Authentication | OAuth 2.1 with PKCE | | Authorization server metadata | `https://api.tryverifyemail.com/.well-known/oauth-authorization-server` | | Protected resource metadata | `https://api.tryverifyemail.com/.well-known/oauth-protected-resource` | | Client registration | Client ID Metadata Documents, or dynamic client registration | | Scope | A single scope, `verify` | ## Connect manually [Section titled “Connect manually”](#connect-manually) In your MCP client, add a remote server and paste `https://api.tryverifyemail.com/mcp`. The client opens a browser where you sign in to VerifyEmail (or create an account) and approve the connection. After approval the three tools below are available. The token is tied to the VerifyEmail account you signed in with, so verifications spend that account’s credits. ## Tools [Section titled “Tools”](#tools) The server exposes exactly three tools. Each is annotated read-only (`readOnlyHint: true`). ### `verify_email` [Section titled “verify\_email”](#verify_email) Verifies one email address. Input: ```json { "email": string } ``` Output is either the public verification result object or a pending stub. The result object, with fields in this order: ```json { "verdict": "deliverable" | "undeliverable" | "risky" | "unknown", "sub_status": string, "quality_score": number, "confidence": number, "provider": string, "primary_mx": string, "signals": object, "verified_at": string } ``` Every field is defined in [the result object](/docs/api/verify-email/result/) of the REST reference. The pending stub: ```json { "status": "pending", "id": string } ``` It is returned when the mail server has not answered within the request window. Pass `id` to `get_verification` to collect the result. A malformed address is not an error. It returns `verdict: "undeliverable"`. ### `get_verification` [Section titled “get\_verification”](#get_verification) Fetches a verification by id. Input: ```json { "id": string } ``` Output: the same result object as `verify_email`, or the same pending stub if the check is still running. ### `get_account` [Section titled “get\_account”](#get_account) Returns the connected account. Takes no input: ```json {} ``` Output: ```json { "email": string, "plan": string, "credits_remaining": number } ``` ## Credits [Section titled “Credits”](#credits) Each `verify_email` call that reaches a verdict costs 1 credit from the connected account. `get_verification` and `get_account` are free. ## Errors [Section titled “Errors”](#errors) | Condition | What the client sees | What to do | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | 0 credits | The tool returns an error saying the account has no credits. | Top up in the VerifyEmail dashboard. `get_account` still works. | | Account frozen | The tool returns an error saying the account is frozen. | Contact support from the dashboard. | | Rate limited | The tool returns an error saying too many requests. The Free plan allows 1 request per second. | Retry after a short wait. | | Expired or revoked token | The server returns HTTP 401 with a `WWW-Authenticate` challenge pointing at the protected-resource metadata. | The client should re-run the OAuth flow. | ## Disconnect [Section titled “Disconnect”](#disconnect) Revoke the connection from **Settings → Connected apps** in the VerifyEmail dashboard, or remove the server from your MCP client. Either one invalidates the token. # n8n > Verify email addresses inside an n8n workflow with your VerifyEmail API key. The node is the npm package `n8n-nodes-tryverifyemail`. Until n8n verifies it, it installs from **Settings → Community Nodes** on self-hosted instances only. Once verified, it will also appear in the nodes panel on n8n Cloud. The key is sent as `Authorization: Bearer `, so n8n’s own **HTTP Request** node with a **Bearer Auth** credential works against the same endpoints today. # Pabbly Connect > Verify email addresses inside a Pabbly Connect workflow with your VerifyEmail API key. ## Field names in Pabbly [Section titled “Field names in Pabbly”](#field-names-in-pabbly) Pabbly Connect shows the API response as dotted keys rather than the flat names other marketplaces use. The table below maps them. `is_deliverable` has no Pabbly key; use a Filter on `result.verdict` instead. | Flat name | Key in Pabbly | | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `id`, `status` | `id`, `status` | | `email`, `verdict`, `sub_status`, `quality_score`, `confidence`, `provider`, `primary_mx`, `verified_at` | `result.email`, `result.verdict`, … `result.verified_at` | | `signal_syntax` … `signal_greylisting` | `result.signals.syntax` … `result.signals.greylisting` | ## If a step comes back `pending` [Section titled “If a step comes back pending”](#if-a-step-comes-back-pending) The Pabbly app has only the Verify Email action, so there is no Get Verification step to call with the `id`. The check still finishes on our side within 180 seconds. Open the VerifyEmail dashboard’s Single Check history to see the result for that `id`. Re-running the step with the same address only helps while the first check is still running: then you get the same `id` and no second credit is held. Once that check has finished, re-running the step starts a new check and spends another credit. Inside one workflow, a **Delay** step followed by a second **Verify Email** step on the same address has the same limits. # Zapier > Verify email addresses inside a Zap with your VerifyEmail API key.