Free API · No signup

API Documentation

Integrate email verification into your app in minutes. No API key required - quota is tracked by IP.

Base URL: https://www.mailtruster.com

Endpoints

POST/api/check-emailVerify a single email address
GET/api/quotaGet your remaining daily quota
POST/api/bulk-check-emailSubmit a bulk verification job (max 100 emails)
GET/api/verification-result/:job_idPoll a bulk job result

Check a single email

POST/api/check-email

Request body

FieldTypeDescription
emailstringThe email address to verify
validation_typestringsmtp · mx · mx_blacklist · regex — required. Any other value returns 400.

Example

curl -X POST https://www.mailtruster.com/api/check-email \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "validation_type": "smtp"}'

Response · 200

{
  "success": true,
  "data": {
    "email": "marie.durand@example.com",
    "validation_type": "smtp",
    "result": "valid",
    "details": null,
    "reasons": [],
    "checks": {
      "syntax": true,
      "dns": true,
      "smtp_port": true,
      "smtp_connection": true,
      "smtp_rcptto": true,
      "smtp": true
    },
    "reason": null,
    "errors": null,
    "role": false,
    "spamtrap": false,
    "disposable": false,
    "free": false,
    "catch_all": false,
    "protected": false,
    "metadata": {
      "validated_at": "2026-08-10 10:15:51 +0000",
      "smtp_host": "203.0.113.10"
    }
  },
  "meta": {
    "request_id": "018e6b3c-...",
    "timestamp": "2026-08-10T10:15:51+00:00",
    "version": "v1"
  }
}

success tells you the request worked. It says nothing about the address — that is data.result. A 200 carrying "result": "invalid" is a perfectly successful request.

The verdict · data.result

Always present, always one of these four. Handle all four — a shortcut such as ["valid","risky"].includes(result) loses the distinction that matters most.

validThe mailbox exists and nothing suspicious was found. Send.
riskyA signal makes the address risky. Depending on the reason the mailbox is known to exist (catch_all, role) or not (spamtrap, disposable). Send with caution, or exclude from mass campaigns.
invalidEstablished refusal: bad syntax, no MX, or the mailbox does not exist. Do not send. The contact can be dropped.
unknownNothing could be established — the server blocked, greylisted or dropped the connection. Do not conclude, schedule another check, and do NOT delete the contact.
unknown is not a validation failure. It is an absence of conclusion, and it is not a synonym for invalid. Filing it under invalid deletes good contacts because a remote server was protective.
switch (data.result) {
  case "valid":   return send();
  case "risky":   return sendWithCaution();  // look at data.reasons
  case "invalid": return dropContact();
  case "unknown": return scheduleRetry();    // DO NOT delete the contact
}

Response fields

FieldTypePresentDescription
emailstringalwaysThe address, as returned upstream.
validation_typestringalwaysThe type actually used.
resultstringalwaysThe verdict — valid, risky, invalid or unknown.
detailsstring | nullalwaysDominant reason, equal to reasons[0]. null when nothing was flagged.
reasonsstring[]alwaysEvery reason, in precedence order. [] when none.
checksobjectalwaysPer-sub-check booleans. A check that does not apply is an absent key, never null.
roleboolwhen knownLocal part is a role prefix (contact@, admin@…).
spamtrapboolwhen knownDomain is a known spamtrap.
disposableboolwhen knownDomain is a throwaway provider.
freeboolwhen knownDomain is a consumer mail provider. Commercial signal only — it does not affect the verdict.
catch_allbool | nullsmtp onlytrue = the server accepts any address. null = could not be established.
protectedbool | nullsmtp onlytrue = the server blocks us on connection or reputation. When true, result is unknown.
reasonstring | nullalwaysUpstream failure_reason: the uppercase error code of the first error. Not the reason vocabulary — use details and reasons for that.
errorsarray | nullalwaysA list of { code, message }, or null.
metadataobjectwhen non-emptyNon-business info: validated_at, mx_records, smtp_host, smtp_error…

An absent flag is not false. role, spamtrap, disposable and free are omitted when they were never determined — they are never null and never guessed. In JavaScript if (data.disposable) reads a missing key as falsy and silently reports not disposable about an address that was never checked. Test presence: 'disposable' in data ? data.disposable : null.

catch_all and protected are emitted if and only if validation_type is smtp, and in that case they are always present, including with the value null. Here null means an SMTP session happened but was inconclusive, whereas absent means the question was never asked. Do not collapse the two, and do not treat catch_all: null as false.

details and reasons values

details is the first entry of reasons, which holds them all in precedence order. This vocabulary is open and may grow — always handle an unrecognised value.

invalidsyntax_error · no_mx_record · mailbox_not_found
riskyspamtrap · disposable · catch_all · role
unknowngreylisted · sender_blocked · timeout · connection_dropped · port_closed · mailbox_full · no_smtp_data · inconclusive · protected · throttled · rate_limited
validnull — no signal at all

Two orderings worth knowing: spamtrap and disposable outrank unknown (they hold whether or not the mailbox exists), while catch_all and role rank below it (they assume a completed SMTP session). And mailbox_full is unknown, not invalid — the mailbox exists and will become deliverable again. reasons stays exhaustive: a role address that timed out gives result: "unknown", details: "timeout", reasons: ["timeout", "role"].

When the service is saturated

Two distinct events come back as result: "unknown" with checks: {} and an HTTP 200 — never a 502. The four address flags are absent from these responses, so render them as not determined rather than as no.

throttledThe verification queue is saturated. It drains on its own, so waiting helps. On the bulk path this appears only after automatic retries are exhausted, several minutes in.
rate_limitedA per-IP rate limiter refused the request. This one is never retried, so it can settle within seconds of queueing and usually hits a whole batch at once.

Treat both like any other unknown: nothing was established, retry later. The address itself is untouched.

Validation types

smtpSyntax + DNS + SMTP session. A valid here means the mailbox exists and nothing was flagged. The only type that tests the mailbox itself.
mxSyntax + MX record lookup. A valid here means the domain can receive mail — NOT that this address exists.
mx_blacklistMX check + MX blacklist lookup. A valid here means the MX is not blacklisted — NOT that this address exists.
regexSyntax only, no network call. A valid here means the syntax is well-formed — NOT that the domain exists.

A verdict only covers what the type checked. Only smtp opens an SMTP session, so for the other three details is null and reasons is []. The address flags are computed for every type — they need no network — but they only influence the verdict in smtp. To spot a throwaway or a role address outside of smtp, read the flags rather than the verdict.

Get quota

GET/api/quota

Example

curl https://www.mailtruster.com/api/quota

Response · 200

{
  "daily_limit": 100,
  "used": 14,
  "remaining": 86
}

Bulk verification

Async workflow - submit a list, get a job_id per email, then poll each one individually.

1 - Submit a job

POST/api/bulk-check-email
curl -X POST https://www.mailtruster.com/api/bulk-check-email \
  -H "Content-Type: application/json" \
  -d '{
    "emails": ["alice@example.com", "bob@test.com"],
    "validation_type": "mx"
  }'

Maximum 100 emails per request. Returns 202 Accepted. Nothing is verified yet.

{
  "success": true,
  "data": {
    "results": [
      {
        "email": "ok@example.com",
        "status": "queued",
        "message": "Queued for verification",
        "job_id": "018e6b3c-1111-..."
      },
      {
        "email": "oops",
        "status": "error",
        "message": "Invalid email format",
        "job_id": "018e6b3c-2222-..."
      }
    ]
  },
  "meta": { ... }
}

Every input line gets one entry and its own job_id — there is no batch-level id, poll one result per address. Addresses are verified independently and in no guaranteed order, so results arrive out of order.

A malformed address is rejected immediately with status: "error", is never queued, and costs no quota. Its job_id will never resolve — polling it returns 404 forever. Filter those out of your polling set using this response. Here: poll 018e6b3c-1111-..., never 018e6b3c-2222-....

Quota is consumed at submission, for the count of valid addresses only. A bulk request is all or nothing: if that count exceeds what remains, the whole request is refused with 429 and nothing is queued.

2 - Poll a result

GET/api/verification-result/:job_id
curl https://www.mailtruster.com/api/verification-result/018e6b3c-1111-...

data.result is the record, and it has exactly three keys: email, status and verification. While the job is running there is nothing to serve:

{
  "success": true,
  "data": {
    "job_id": "018e6b3c-1111-...",
    "result": {
      "email": "ok@example.com",
      "status": "processing",
      "verification": null
    }
  },
  "meta": { ... }
}

Once it is finished, verification is exactly the payload of the single check above, key for key — so the same parsing code handles both endpoints:

{
  "success": true,
  "data": {
    "job_id": "018e6b3c-1111-...",
    "result": {
      "email": "ok@example.com",
      "status": "done",
      "verification": {
        "email": "ok@example.com",
        "validation_type": "smtp",
        "result": "valid",
        "details": null,
        "reasons": [],
        "checks": { "syntax": true, "dns": true, "smtp": true },
        "reason": null,
        "errors": null,
        "role": false,
        "spamtrap": false,
        "disposable": false,
        "free": false,
        "catch_all": false,
        "protected": false,
        "metadata": { "validated_at": "..." }
      }
    }
  },
  "meta": { ... }
}

status is not the verdict

Two different questions, kept apart. data.result.status answers where is the processing; data.result.verification.result answers what is the verdict. status only ever takes these four values:

queuedAccepted, waiting in the queue. Keep polling, show as pending.
processingA worker is on it, or a retry is pending. Keep polling, show as pending.
doneFinished successfully — terminal. Stop polling and read verification. It says the check completed, not that the address is good.
errorThe check itself failed for technical reasons — terminal. Stop polling, verification is null, offer a retry. This is NOT a verdict about the address: do not show it as invalid.
The terminal success status is done, not valid or invalid. Those are result values, and status will never take them. Waiting for status === "valid" polls forever.
const FINAL_STATUSES = ["done", "error"];   // and nothing else

const { status, verification } = res.data.result;

if (status === "queued" || status === "processing") return pending();
if (status === "error") return technicalFailure();   // verification is null

// status === "done" — verification is the single-check payload
switch (verification.result) {
  case "valid":   return good();
  case "risky":   return warn();          // look at verification.reasons
  case "invalid": return bad();
  case "unknown": return inconclusive();  // do NOT delete the contact
}

Polling practice

CadenceEvery 2s for the first 30s, every 5s up to 2 min, every 15s beyond. Poll only the ids still pending, and drop each one as it settles.
Stop ondone or error. Both are terminal — error is a final answer, not a stage on the way to one. Stop that job_id only, the others are independent.
Early 404Right after the POST a 404 is normal: the record may not exist yet. Treat an early 404 as pending, and only as a failure after a few consecutive attempts.
Give up afterAbout 15 minutes. A job still pending past that points at a real incident. Show a clear failure rather than spinning forever.
Slow jobsA processing lasting several minutes is normal under load, not a bug: a saturated queue is retried with backoff for about 12 minutes before settling as unknown / throttled. Consider telling the user the service is busy rather than showing a stuck spinner.
Large batchesWith 100 addresses, stagger your requests rather than firing 100 in parallel.

Counting for a summary

Count on verification.result, over the records whose status is done. Records in error have no verdict and belong in their own bucket — folding them into invalid misreports technical failures as bad addresses. A defensible breakdown has five buckets: valid, risky, invalid, unknown, error.

Error codes

An error answers with "success": false and an error object holding code, message and, when there is something to add, details.

{
  "success": false,
  "error": {
    "code": "QUOTA_EXCEEDED",
    "message": "Daily verification limit reached",
    "details": { "limit": 100, "remaining": 0, "requested": 1 }
  },
  "meta": { ... }
}
EMAIL_REQUIRED400Missing, empty or malformed email field
INVALID_VALIDATION_TYPE400validation_type outside the allowed list - details.allowed lists them
BULK_LIMIT_EXCEEDED422More than 100 emails in a single bulk request - details carries limit and received
QUOTA_EXCEEDED429Daily limit reached - details carries limit, remaining and requested
JOB_NOT_FOUND404No verification record for that job_id
UPSTREAM_ERROR502The verification service is unreachable or errored
UPSTREAM_SCHEMA_MISMATCH502The verification service answered without a usable result verdict
INTERNAL_ERROR500Unexpected server error

Requests rejected before they reach the verification service answer with a shorter envelope — { "error": { "code", "message" } }, no success and no meta — and their own codes: E1002 (invalid email or emails array), E1003 (invalid or missing validation_type), E1006 (missing job_id), E1004 and E1005 (unreadable or unavailable upstream), E1001 (server misconfigured). Handle both shapes: test error.code without assuming it is one of the names in the table above.