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.
All requests require a Bearer token in the Authorization header. Get your key from API & Usage inside the app.
Authorization: Bearer ec_live_xxxxxxxxxxxxxxxxxxxxxxxx
https://app.emailzeno.com/api
Synchronously probe one address. Returns full probe result + quality score. Costs 1 credit — deducted before probing, refunded automatically if the engine is unavailable.
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]"}'
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"
$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
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
{
"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
}
| Field | Type | Description |
|---|---|---|
email | string | Address probed |
status | string | valid · catch_all · role_based · disposable · invalid · no_mx · bad_syntax · unknown · greylisted · deferred |
flags | string[] | Additional signal tags |
smtp_code | int|null | Numeric response code from the RCPT TO probe step |
smtp_message | string|null | Full text of the receiving server's response message |
mx_host | string|null | MX record connected to |
duration_ms | int|null | Total probe time (ms) |
quality_score | int | 0–100 confidence score |
quality_band | string | excellent · good · risky · poor |
mx_provider | string|null | Detected provider (google, microsoft, …) |
is_free_mailbox | bool|null | Gmail, Outlook, Yahoo etc. |
signals[] | object[] | Per-stage checklist; each: {key, label, kind, detail?} |
signals[].key | string | syntax · disposable · role_based · mx · free_mailbox · mailbox · catch_all |
signals[].kind | string | ok · warn · fail |
credit_charged | bool | false when credit was refunded (unknown/greylisted/deferred/disposable on paid tier without deep_probe) |
credit_skip_reason | string|null | Refund reason: unknown · greylisted · deferred · disposable_paid_tier · test_mode |
| Emailable-compatible aliases — same data, different field names for drop-in migration from Emailable. | ||
state | string | deliverable · undeliverable · risky · unknown — mapped from status |
reason | string|null | Human-readable reason, e.g. accepted_email, rejected_email, invalid_smtp |
score | int | Alias for quality_score |
free | bool | Alias for is_free_mailbox |
role | bool | true when status is role_based or flag set |
disposable | bool | true when status is disposable |
accept_all | bool | true when status is catch_all |
mailbox_full | bool | Receiving server returned 4.2.2 or equivalent mailbox-full code |
smtp_provider | string|null | Alias for mx_provider |
did_you_mean | string|null | Suggested correction for typos (e.g. gnail.com → gmail.com) |
domain | string | Domain part of the address (right of @) |
user | string | Local part of the address (left of @) |
mx_record | string|null | Alias for mx_host |
duration | float|null | Probe time in seconds (Emailable convention; duration_ms is the native ms field) |
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.
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]"]}'
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}
$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}
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}
{
"job_id": 42,
"email_count": 2,
"status": "queued",
"message": "Job queued. Poll /api/jobs/{job_id} for status.",
"credits_used": 2
}
Credits are deducted before each verification. Certain statuses trigger automatic refunds. The response always includes credit_charged and credit_skip_reason.
| Status | Free tier | Paid tier |
|---|---|---|
valid · catch_all · role_based · invalid · no_mx · bad_syntax | charged | charged |
disposable | charged (free-rider gate) | refunded (unless deep_probe=true) |
unknown · greylisted · deferred | refunded | refunded |
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.
| Field | Type | Description |
|---|---|---|
deep_probe | bool | Optional, default false. Paid tier only — bypasses disposable/role-based early exit. |
| Status | Code | Meaning |
|---|---|---|
402 | insufficient_credits | Not enough credits. Top up or upgrade plan. |
422 | — | Validation error (invalid email, >1,000 emails in bulk). |
429 | — | Rate limit exceeded. Check Retry-After header. |
503 | — | Engine unavailable. Credit auto-refunded for single verify. |
{
"error": "Insufficient credits. Need 50, balance is 10.",
"code": "insufficient_credits",
"required": 50,
"balance": 10
}
| Plan | Single /v1/verify | Bulk /v1/verify/bulk |
|---|---|---|
| Free | 60 req/min | 10 req/min |
| Paid | Higher per plan | Higher per plan |
Exceeded requests receive HTTP 429 with a Retry-After header indicating how many seconds to wait.
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.
| Event | Description |
|---|---|
job.completed | Bulk verification job finished successfully. Payload includes a result summary. |
job.failed | Bulk job terminated with an error mid-run (engine fault, credit exhaustion, etc.). |
credit.low | Account credit balance dropped below your configured threshold (at most once per day per user). |
subscription.expiring | Active subscription will expire within 7 days and is not set to auto-renew. |
subscription.renewal_failed | Auto-renewal payment was declined; subscription moved to past_due. |
emailzeno signs every outbound webhook with HMAC-SHA256 over the raw request body using your personal secret. Three headers are included on every delivery:
| Header | Example value |
|---|---|
X-EC-Notification-Signature | sha256=a3f8d... |
X-EC-Notification-Event | job.completed |
X-EC-Notification-Version | 1 |
job.completed{
"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"
}
}
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
$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'] ...
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);
});
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.
Visit app.emailzeno.com/widget-builder
to create a widget configuration, then copy the generated data-config-id snippet:
<script src="https://app.emailzeno.com/widget.js" data-config-id="your-uuid-here"></script>
Generate a form API key from Settings → API Keys, then paste this script tag before </body>:
<script src="https://emailzeno.com/assets/js/form-validation-widget.js" data-key="YOUR_PUBLIC_API_KEY"></script>
Configure the widget via data-key for your API key and optional data-config for behaviour overrides:
<script src="https://emailzeno.com/assets/js/form-validation-widget.js"
data-key="YOUR_PUBLIC_API_KEY"
data-config='{"mode":"block","risky":"allow"}'></script>
| Option | Default | Description |
|---|---|---|
mode | block | block prevents form submission for invalid emails. warn shows feedback only. |
risky | allow | Handle risky/accept-all addresses: allow, warn, or block. |
validateOn | blur | blur (on field exit), input (as user types), or both. |
debounce | 500 | Delay in ms before validating when using input mode. |
selector | input[type="email"] | CSS selector for email input(s) to validate. |
noStyles | false | Set 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. |
The widget works with any website or form builder:
</body> on any page with a form.theme.liquid and paste before the closing </body> tag.useEffect to create the script element dynamically. The widget includes a MutationObserver that handles dynamic forms and SPAs automatically.The widget never blocks legitimate users due to infrastructure issues:
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).
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.
Full OpenAPI spec rendered below. Paste your API key to try requests live.
300 verifications /mo · five-minute API integration · credit refund on hard bounces. Trusted by senders who treat sender reputation like the asset it is.