Skip to content
DEVELOPER API

Email verification in one POST request.

Send an email address, get back a clear verdict, a 0–100 score, and every check we ran. Bearer-token auth, JSON in and out, no SDK required.

Authentication

Every request needs an Authorization: Bearer <token> header. Generate a token from API keys in your dashboard. Tokens are scoped to a single account.

Verify an email

POST /api/v1/verify with a JSON body containing an email. One verification spends one credit, whatever the verdict.

REQUEST · cURL
curl -X POST https://clearmx.app/api/v1/verify \
  -H "Authorization: Bearer cmx_live_your_token" \
  -H "Content-Type: application/json" \
  -d '{"email":"ana@getpalladio.example"}'
RESPONSE · 200 OK
{
  "email": "ana@getpalladio.example",
  "normalized_email": "ana@getpalladio.example",
  "result": "deliverable",
  "score": 100,
  "reason": "mailbox_exists",
  "checks": {
    "syntax": true,
    "domain": "getpalladio.example",
    "mx": true,
    "implicit_mx": false,
    "disposable": false,
    "role": false,
    "no_reply": false,
    "free": false,
    "accept_all": false,
    "tag": null,
    "irregular_characters": false,
    "provider": "google",
    "secure_gateway": null,
    "smtp": { "status": "ok", "code": 250, "accept_all": false },
    "smtp_skipped": null,
    "typo_suggestion": null
  },
  "suppressed": false,
  "credits_used": 1
}

Reading the result

Branch on result, not on score. result is one of four values and is always authoritative. score (0–100) is a confidence number for ranking addresses within a verdict — an unknown is deliberately scored mid-range, so a score threshold on its own would misfile it. reason gives the single deciding signal and checks gives all of them.

For completeness: once a verdict is decidable, the score thresholds are 80 for deliverable and 50 for risky. But unknown is assigned before those thresholds are ever reached, which is exactly why a score comparison is not a substitute for reading result.

deliverable additionally requires SMTP mailbox confirmation on a non-catch-all domain — a valid MX record alone caps the score at 75 and returns risky / mailbox_unconfirmed. (Breaking change note: the former valid_mx reason is retired; confirmed mailboxes return mailbox_exists.)

deliverable

A live SMTP probe confirmed this exact mailbox on a domain that is not a catch-all. Safe to send.

risky

Send at your own risk. Disposable domain, role account, catch-all domain, or a mailbox we could not confirm (mailbox_unconfirmed). Free-provider inboxes usually land here too, because those providers block mailbox probes.

undeliverable

Hard failure — it will bounce. Invalid syntax, no mail servers for the domain, or the server rejected the mailbox outright.

unknown

We could not reach a verdict: greylisting, an SMTP or DNS timeout, or a probe the server refused. Retry later; an honest unknown beats a fabricated verdict.

The checks object

Every check we ran, always present. Disposable, role and catch-all are risk signals: they deduct from the score and can never make an address deliverable.

FieldTypeMeaning
syntax boolean RFC-aware format validation. False short-circuits to undeliverable.
domain string The domain portion of the normalized address.
mx boolean Domain publishes mail servers. False short-circuits to undeliverable.
implicit_mx boolean No MX record, but an A record makes the domain mail-capable.
disposable boolean Known throwaway / temporary provider. Risk signal.
role boolean Shared role address like info@ or support@. Risk signal.
no_reply boolean Local part is noreply@ or a variant — nobody reads it.
free boolean Free mailbox provider (Gmail, Yahoo…).
accept_all boolean / null Catch-all domain: accepts every address. Risk signal. Null when catch-all detection was inconclusive.
tag string / null Plus-address tag, e.g. the "news" in jane+news@. Not stripped.
irregular_characters boolean Non-ASCII characters in the address.
provider string / null Mail provider inferred from MX hosts, e.g. google, microsoft.
secure_gateway string / null Security gateway in front of the mailbox, e.g. proofpoint. Probe results are less certain behind one.
smtp object / null Live probe: status, code, accept_all. Null when the probe was skipped.
smtp_skipped string / null Why no probe ran: disabled, no_mx, provider_blocks_probes, rate_limited.
typo_suggestion string / null A likely correction, e.g. gmial.com → gmail.com.

Errors

A syntactically invalid email is a normal result (200 + undeliverable), not an error. Errors are reserved for request problems, and every one is a JSON body with an error key. None of the responses below spends a credit.

StatusBodyWhen
401 {"error":"Unauthorized"} Missing or invalid bearer token.
402 {"error":"Out of credits","remaining":0,"upgrade_url":"…"} The account has no credits left. Buy more at upgrade_url and retry — no credit is spent and no result is kept.
422 {"error":"Email is required"} The email parameter was blank. The same status with "No account available for this token" means the token resolves to no account.
500 {"error":"Internal error"} Something went wrong on our side — safe to retry.

Volume and limits

Requests are limited to 120 per minute per API key (counted on the bearer token; unauthenticated calls are counted per IP). Request 121 inside a minute returns HTTP 429 with {"error":"Too many requests"} — back off and retry after the window. Two more things throttle in practice: live mailbox probes are rate-limited per destination domain (so hammering one domain returns smtp_skipped: "rate_limited" rather than a verdict), and large one-off lists are far better served by a CSV bulk import in the dashboard than by thousands of concurrent single calls. Bulk verification is dashboard-only for now — there is no bulk API endpoint yet.

Usage

GET /api/v1/usage returns your account's request counts, error counts and credits spent over a period (?period=24h|7d|30d), per token — names and prefixes only, never raw keys.

curl https://clearmx.app/api/v1/usage?period=7d \
  -H "Authorization: Bearer [REDACTED]"

Bounce intelligence

Verification happens before a send. These endpoints close the loop after it, and ClearMX still never carries your mail: you keep your ESP, we hand you a return path per message, and whatever bounces comes back to us. The address lands on your suppression list, your endpoint hears about it, and the next verify call for that address answers "suppressed": true.

10 tracked messages cost one credit. The suppression list, webhooks and sender-domain health are free. Setup, feedback loops and ESP settings: Set up bounce handling.

Track a message

POST /api/v1/tracked_messages before or right after you send, then use verp_return_path as the message's Return-Path (envelope sender). external_ref is yours and unique per account — re-posting it returns the same row with 200 and costs nothing, so a retried send is idempotent on our side. Single tracks run down a prepaid counter of 10 and debit one credit when it is empty; POST /api/v1/tracked_messages/batch with {"messages":[…]} (up to 1000) pays ceil(n/10) up front and is all-or-nothing: a malformed item or an unaffordable batch writes no rows. A recipient already on your suppression list still gets a return path, with "suppressed": true so you can skip the send.

REQUEST · cURL
curl -X POST https://clearmx.app/api/v1/tracked_messages \
  -H "Authorization: Bearer cmx_live_your_token" \
  -H "Content-Type: application/json" \
  -d '{"recipient":"ana@getpalladio.example","external_ref":"campaign:12:recipient:345","sending_domain":"news.yourapp.example"}'
RESPONSE · 201 Created
{
  "id": "tm_8fJ3kQ2mZp7Xw1Lr",
  "external_ref": "campaign:12:recipient:345",
  "recipient": "ana@getpalladio.example",
  "verp_return_path": "b+8fJ3kQ2mZp7Xw1LrTq9Vb0@bounce.clearmx.app",
  "verp_token": "8fJ3kQ2mZp7Xw1LrTq9Vb0",
  "status": "tracked",
  "suppressed": false,
  "suppression_reason": null,
  "credits_used": 1,
  "tracking_prepaid_remaining": 9
}

Suppression list

Free, per account, never shared. GET /api/v1/suppressions with optional since (ISO 8601, filters on last_seen_at), page and per_page (up to 500) returns {"suppressions":[{"email","reason","source","first_seen_at","last_seen_at","hit_count"}],"page","per_page","total"}. POST {"email","reason","notes"} adds one (reason defaults to manual; hard_bounce and complaint are accepted so an existing list can be imported; a complaint is never downgraded). DELETE /api/v1/suppressions/:email removes one. Hard bounces suppress at once, complaints at once, soft bounces after 3 in a row to the same address; a 5.7.x reputation block is reported with "policy": true and suppresses nobody — it is about the sender, not the address.

Webhooks

POST /api/v1/webhook_endpoints with {"url":"https://…","events":["bounce.hard","bounce.soft","complaint"]} returns the signing secret once. GET lists, DELETE /:id removes, POST /:id/rotate_secret issues a new secret, POST /:id/test sends a ping. Each bounce or complaint is one JSON POST to your URL:

DELIVERY · bounce.hard
POST https://yourapp.example/hooks/clearmx
X-ClearMX-Event: bounce.hard
X-ClearMX-Event-Id: 5f0c2e4a-7b1d-4d3e-9a6f-2c8b1e7d4a90
X-ClearMX-Timestamp: 1789041600
X-ClearMX-Signature: v1=3f1a…c9

{
  "event_id": "5f0c2e4a-7b1d-4d3e-9a6f-2c8b1e7d4a90",
  "event": "bounce.hard",
  "occurred_at": "2026-09-10T08:00:00Z",
  "external_ref": "campaign:12:recipient:345",
  "recipient": "ana@getpalladio.example",
  "kind": "hard", "smtp_code": "550", "enhanced_status": "5.1.1",
  "diagnostic": "550 5.1.1 The email account that you tried to reach does not exist",
  "policy": false,
  "suppressed": true, "suppression_reason": "hard_bounce",
  "consecutive_soft": 0
}

X-ClearMX-Signature is v1= followed by the hex HMAC-SHA256 of "<timestamp>.<raw body>" under your secret. Verify it over the raw bytes before parsing, compare in constant time, and refuse a timestamp more than five minutes old. Retries on a non-2xx answer or a 5-second timeout come at 1 min, 5 min, 30 min, 2 h, 12 h and 24 h; after the sixth failure the endpoint is marked failing in your dashboard. We may deliver an event twice — be idempotent on event_id.

VERIFY · Ruby (Rails)
timestamp = request.headers["X-ClearMX-Timestamp"]
expected  = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{request.raw_post}")
given     = request.headers["X-ClearMX-Signature"].to_s.delete_prefix("v1=")
fresh     = (Time.now.to_i - timestamp.to_i).abs < 300
ok        = fresh && ActiveSupport::SecurityUtils.secure_compare(expected, given)
VERIFY · Node (Express, raw body)
const crypto = require("crypto");
const ts = req.get("X-ClearMX-Timestamp");
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = (req.get("X-ClearMX-Signature") || "").replace(/^v1=/, "");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const ok = fresh && given.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));

Sender domain health

One DNS pass over the domain you send from, free, cached for an hour per domain, selector and IP. SPF missing or +all, a missing or unparseable DKIM key, a missing DMARC record, or a blacklist listing grade red; ~all, more than ten SPF lookups, a DKIM key under 1024 bits, DMARC p=none or a policy bounce this week grade amber. A lookup that fails grades unknown — never green. Pass sending_ip when you send from shared infrastructure: that is what the IP lists are actually about.

REQUEST + RESPONSE · 200 OK
GET /api/v1/sender_domains/news.yourapp.example/health?dkim_selector=news&sending_ip=203.0.113.10

{
  "domain": "news.yourapp.example", "checked_at": "2026-09-10T08:00:00Z", "overall": "amber",
  "spf":   { "status": "green", "record": "v=spf1 include:_spf.yourapp.example -all", "lookups": 3, "issues": [] },
  "dkim":  { "status": "green", "selector": "news", "key_bits": 2048, "issues": [] },
  "dmarc": { "status": "amber", "record": "v=DMARC1; p=none; rua=mailto:dmarc@yourapp.example",
             "policy": "none", "rua": true,
             "issues": ["policy is none — move to quarantine after 30 clean days"] },
  "blacklists": { "status": "green", "listed_on": [],
                  "checked": ["zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org", "dbl.spamhaus.org"] },
  "policy_bounces_7d": 0
}

API FAQ

Can ClearMX tell me when a message bounces?

Yes, without ever carrying your mail. Ask POST /api/v1/tracked_messages for a return path before you send, use it as the message's Return-Path, and whatever bounces comes to us: the address lands on your suppression list, your webhook endpoint receives a signed bounce.hard, bounce.soft or complaint event, and the next verify call for that address says suppressed: true. 10 tracked messages cost one credit; the list, the webhooks and sender-domain health are free.

How do I get an API token?

Create a free ClearMX account, open API keys in the dashboard, and generate a token. Each token is scoped to one account.

Is verification synchronous?

Yes. A single POST returns the full result in the response body — no polling, no callback. Syntax and DNS answers come from cache where possible; when a live mailbox probe runs it adds a round trip to the receiving mail server, bounded by a 5-second timeout.

What happens when I run out of credits?

The API returns HTTP 402 with {"error":"Out of credits"} and an upgrade_url pointing at pricing. Every new account starts with 100 free verification credits, and credits never expire.

Do you send email to the address?

No. ClearMX never sends email. The optional SMTP check talks to the receiving mail server and stops before DATA — no message is ever transmitted.

See also: Email verification API guide · Bulk verification