EmailZeno EmailZeno
REST API · v1

Email verification in five minutes.

One endpoint to verify a single address. One more for batches up to 1,000. HMAC-signed webhooks when jobs finish. Identical behaviour in test mode — no credits consumed, no engine calls.

Authentication

All requests require a Bearer token in the Authorization header. Get your key from API & Usage inside the app.

http
Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx
ec_live_ keys
Production. Credits consumed. Real engine probes run.
ec_test_ keys
Test mode. Synthetic result returned. No credits, no engine call.

Base URL

base url
https://app.emailzeno.com/api
POST /v1/verify

Verify a single email

Synchronously probe one address. Returns full probe result + quality score. Costs 1 credit — deducted before probing, refunded automatically if the engine is unavailable.

bash
curl -X POST https://app.emailzeno.com/api/v1/verify \
  -H "Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'
javascript
const res = await fetch('https://app.emailzeno.com/api/v1/verify', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ email: '[email protected]' }),
});
const data = await res.json();
console.log(data.status); // "valid"
php
$ch = curl_init('https://app.emailzeno.com/api/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['email' => '[email protected]']),
]);
$data = json_decode(curl_exec($ch), true);
echo $data['status']; // valid
python
import requests

response = requests.post(
    'https://app.emailzeno.com/api/v1/verify',
    headers={
        'Authorization': 'Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type': 'application/json',
    },
    json={'email': '[email protected]'},
)
data = response.json()
print(data['status'])  # valid

Response 200 OK

json
{
  "email": "[email protected]",
  "status": "valid",
  "flags": [],
  "smtp_code": 250,
  "smtp_message": "2.1.5 OK",
  "mx_host": "aspmx.l.google.com",
  "duration_ms": 312.45,
  "quality_score": 92,
  "quality_band": "excellent",
  "mx_provider": "Google Workspace",
  "is_free_mailbox": false,
  "signals": [
    { "key": "syntax",     "label": "Syntax check",      "kind": "ok" },
    { "key": "disposable", "label": "Disposable domain", "kind": "ok" },
    { "key": "mx",         "label": "MX records",        "kind": "ok" },
    { "key": "mailbox",    "label": "Mailbox probe",     "kind": "ok" }
  ],
  "credit_charged": true,
  "credit_skip_reason": null,
  "state": "deliverable",
  "reason": "accepted_email",
  "score": 92,
  "free": false,
  "role": false,
  "disposable": false,
  "accept_all": false,
  "mailbox_full": false,
  "smtp_provider": "Google Workspace",
  "did_you_mean": null,
  "domain": "example.com",
  "user": "test",
  "mx_record": "aspmx.l.google.com",
  "duration": 0.3125
}

Response fields

FieldTypeDescription
emailstringAddress probed
statusstringvalid · catch_all · role_based · disposable · invalid · no_mx · bad_syntax · unknown · greylisted · deferred
flagsstring[]Additional signal tags
smtp_codeint|nullNumeric response code from the RCPT TO probe step
smtp_messagestring|nullFull text of the receiving server's response message
mx_hoststring|nullMX record connected to
duration_msint|nullTotal probe time (ms)
quality_scoreint0–100 confidence score
quality_bandstringexcellent · good · risky · poor
mx_providerstring|nullDetected provider (google, microsoft, …)
is_free_mailboxbool|nullGmail, Outlook, Yahoo etc.
signals[]object[]Per-stage checklist; each: {key, label, kind, detail?}
signals[].keystringsyntax · disposable · role_based · mx · free_mailbox · mailbox · catch_all
signals[].kindstringok · warn · fail
credit_chargedboolfalse when credit was refunded (unknown/greylisted/deferred/disposable on paid tier without deep_probe)
credit_skip_reasonstring|nullRefund reason: unknown · greylisted · deferred · disposable_paid_tier · test_mode
Emailable-compatible aliases — same data, different field names for drop-in migration from Emailable.
statestringdeliverable · undeliverable · risky · unknown — mapped from status
reasonstring|nullHuman-readable reason, e.g. accepted_email, rejected_email, invalid_smtp
scoreintAlias for quality_score
freeboolAlias for is_free_mailbox
rolebooltrue when status is role_based or flag set
disposablebooltrue when status is disposable
accept_allbooltrue when status is catch_all
mailbox_fullboolReceiving server returned 4.2.2 or equivalent mailbox-full code
smtp_providerstring|nullAlias for mx_provider
did_you_meanstring|nullSuggested correction for typos (e.g. gnail.comgmail.com)
domainstringDomain part of the address (right of @)
userstringLocal part of the address (left of @)
mx_recordstring|nullAlias for mx_host
durationfloat|nullProbe time in seconds (Emailable convention; duration_ms is the native ms field)
POST /v1/verify/bulk

Verify a batch (up to 1,000)

Enqueue an async job. Returns HTTP 202 immediately with a job_id. Poll GET /api/jobs/{job_id} for progress and results. Costs 1 credit per email, all deducted upfront — no partial runs on low balance.

bash
curl -X POST https://app.emailzeno.com/api/v1/verify/bulk \
  -H "Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["[email protected]", "[email protected]"]}'
javascript
const res = await fetch('https://app.emailzeno.com/api/v1/verify/bulk', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ emails: ['[email protected]', '[email protected]'] }),
});
const { job_id } = await res.json(); // 202 Accepted
// Poll: GET /api/jobs/${job_id}
php
$ch = curl_init('https://app.emailzeno.com/api/v1/verify/bulk');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'emails' => ['[email protected]', '[email protected]'],
    ]),
]);
$data  = json_decode(curl_exec($ch), true);
$jobId = $data['job_id']; // poll GET /api/jobs/{$jobId}
python
import requests

response = requests.post(
    'https://app.emailzeno.com/api/v1/verify/bulk',
    headers={
        'Authorization': 'Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type': 'application/json',
    },
    json={'emails': ['[email protected]', '[email protected]']},
)
job_id = response.json()['job_id']  # poll GET /api/jobs/{job_id}

Response 202 Accepted

json
{
  "job_id": 42,
  "email_count": 2,
  "status": "queued",
  "message": "Job queued. Poll /api/jobs/{job_id} for status.",
  "credits_used": 2
}

Credit billing & refund taxonomy

Credits are deducted before each verification. Certain statuses trigger automatic refunds. The response always includes credit_charged and credit_skip_reason.

StatusFree tierPaid tier
valid · catch_all · role_based · invalid · no_mx · bad_syntaxchargedcharged
disposablecharged (free-rider gate)refunded (unless deep_probe=true)
unknown · greylisted · deferredrefundedrefunded

deep_probe request param

Paid-tier only. When deep_probe: true, disposable and role-based addresses receive a full mailbox probe instead of early-exit. Credits are always charged. Free-tier ignores the flag silently.

FieldTypeDescription
deep_probeboolOptional, default false. Paid tier only — bypasses disposable/role-based early exit.

Error codes

StatusCodeMeaning
402insufficient_creditsNot enough credits. Top up or upgrade plan.
422Validation error (invalid email, >1,000 emails in bulk).
429Rate limit exceeded. Check Retry-After header.
503Engine unavailable. Credit auto-refunded for single verify.

402 example (bulk)

json
{
  "error": "Insufficient credits. Need 50, balance is 10.",
  "code": "insufficient_credits",
  "required": 50,
  "balance": 10
}

Rate limits

PlanSingle /v1/verifyBulk /v1/verify/bulk
Free60 req/min10 req/min
PaidHigher per planHigher per plan

Exceeded requests receive HTTP 429 with a Retry-After header indicating how many seconds to wait.

Notifications

Subscribe to delivery events via webhook, Telegram, or Pushbullet. Configure channels at Settings → Notifications. Every enabled channel fires for each event you opt in to — no polling required.

Events

EventDescription
job.completedBulk verification job finished successfully. Payload includes a result summary.
job.failedBulk job terminated with an error mid-run (engine fault, credit exhaustion, etc.).
credit.lowAccount credit balance dropped below your configured threshold (at most once per day per user).
subscription.expiringActive subscription will expire within 7 days and is not set to auto-renew.
subscription.renewal_failedAuto-renewal payment was declined; subscription moved to past_due.

Webhook delivery

emailzeno signs every outbound webhook with HMAC-SHA256 over the raw request body using your personal secret. Three headers are included on every delivery:

HeaderExample value
X-EC-Notification-Signaturesha256=a3f8d...
X-EC-Notification-Eventjob.completed
X-EC-Notification-Version1

Payload example — job.completed

json
{
  "event": "job.completed",
  "occurred_at": "2026-05-13T07:10:00Z",
  "user_id": 42,
  "data": {
    "job_id": 1234,
    "status": "done",
    "summary": { "total": 5000, "valid": 4200, "invalid": 800 },
    "job_url": "https://app.emailzeno.com/jobs/1234"
  }
}

Verifying the signature

Compare the computed HMAC against the value in X-EC-Notification-Signature using a constant-time comparison. Use the raw request body — before any JSON parsing.

php
<?php
$secret    = getenv('EC_WEBHOOK_SECRET'); // your per-account secret
$rawBody   = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_EC_NOTIFICATION_SIGNATURE'] ?? '';

// Header format: "sha256={hex}"
[, $receivedHex] = explode('=', $sigHeader, 2);

$expected = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $receivedHex)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($rawBody, true);
// process $payload['event'] ...
javascript
import crypto from 'crypto';

// Express example — use express.raw() so body is a Buffer
app.post('/webhooks/ec', express.raw({ type: 'application/json' }), (req, res) => {
  const secret      = process.env.EC_WEBHOOK_SECRET;
  const sigHeader   = req.headers['x-ec-notification-signature'] ?? '';
  const receivedHex = sigHeader.replace('sha256=', '');

  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body) // raw Buffer, not parsed JSON
    .digest('hex');

  const valid = crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(receivedHex),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const payload = JSON.parse(req.body.toString());
  // process payload.event ...
  res.sendStatus(200);
});
Retry behaviour
Failed deliveries (non-2xx or timeout) are retried 3 times with exponential backoff (5 s, 25 s, 125 s). After all retries are exhausted the webhook channel is marked unhealthy and you receive a fallback email alert. Re-enable the channel from Settings → Notifications.

Form Validation Widget

NEW: Use the Widget Generator to visually configure, preview, and generate your widget snippet — no manual key editing required. The manual method below still works for quick setups.

Add real-time email validation to any web form with a single line of code. The widget automatically validates email addresses as your visitors type, blocking invalid emails before they enter your system.

Method 1 (recommended): Widget Generator

Visit app.emailzeno.com/widget-builder to create a widget configuration, then copy the generated data-config-id snippet:

html
<script src="https://app.emailzeno.com/widget.js"
  data-config-id="your-uuid-here"></script>

Method 2 (manual): data-key attribute

Generate a form API key from Settings → API Keys, then paste this script tag before </body>:

html
<script src="https://emailzeno.com/assets/js/form-validation-widget.js"
  data-key="YOUR_PUBLIC_API_KEY"></script>

Embed format

Configure the widget via data-key for your API key and optional data-config for behaviour overrides:

html
<script src="https://emailzeno.com/assets/js/form-validation-widget.js"
  data-key="YOUR_PUBLIC_API_KEY"
  data-config='{"mode":"block","risky":"allow"}'></script>

Configuration options

OptionDefaultDescription
modeblockblock prevents form submission for invalid emails. warn shows feedback only.
riskyallowHandle risky/accept-all addresses: allow, warn, or block.
validateOnblurblur (on field exit), input (as user types), or both.
debounce500Delay in ms before validating when using input mode.
selectorinput[type="email"]CSS selector for email input(s) to validate.
noStylesfalseSet true to disable default styles and provide your own CSS.
errorMessage"This email address appears to be invalid."Message shown for invalid emails.
suggestionMessage"Did you mean {suggestion}?"Template for typo suggestions. Use {suggestion} placeholder.

Platform guides

The widget works with any website or form builder:

  • Plain HTML: Add the script tag before </body> on any page with a form.
  • WordPress: Paste in Appearance → Theme File Editor (footer.php) or use a header/footer plugin.
  • Webflow: Go to Project Settings → Custom Code and paste in Footer Code section.
  • Shopify: Open theme.liquid and paste before the closing </body> tag.
  • React / Next.js: Use useEffect to create the script element dynamically. The widget includes a MutationObserver that handles dynamic forms and SPAs automatically.

Fail-open behaviour

The widget never blocks legitimate users due to infrastructure issues:

  • If the API is unreachable, the form submits normally
  • Block mode only prevents submission when the API explicitly returns invalid

Security

Form API keys are designed for public embedding and are protected by domain restrictions, per-IP rate limiting, and prefix-based scoping (test_ for dev, live_ for production).

Credit usage

Form validations use the same credit system. Credits are consumed for definitive results (valid / invalid) but not for uncertain results (unknown). All form validation keys share the same credit pool as your API keys.

Interactive API explorer

Full OpenAPI spec rendered below. Paste your API key to try requests live.

Free forever — no credit card

Clean lists ship faster.
Start free.

300 verifications /mo · five-minute API integration · credit refund on hard bounces. Trusted by senders who treat sender reputation like the asset it is.