Smart Services

by wizewerx

Docs
PricingConsole

Verify

Check an email address before you trust it: syntax, disposable-domain lists and live MX lookups, behind a shared DNS cache.

What it is

Verify checks an email address before you trust it: syntax, known typo domains, disposable-domain lists and a live MX lookup on the domain. It runs against a shared portfolio-wide DNS cache and a durable store of past verdicts, so the second time anyone asks about an address the answer is a database read.

Before you start

You need your app's API key. Get it from the Smart Services console: your app → Settings.

The base URL is https://smart-services.io.

Send the key in either header — both are accepted, and Authorization wins if you send both:

Authorization: Bearer <key>
x-api-key: <key>

Verify must be enabled for the app, or every route answers 403 with Verify service not enabled for this app. The async job endpoints additionally need Jobs enabled, because they run on the jobs pipeline.

Your first call

Verify one address.

curl -X POST "https://smart-services.io/api/v1/verify" \
  -H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "treasurer@example.org" }'

The same call in JavaScript:

const res = await fetch('https://smart-services.io/api/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ email: 'treasurer@example.org' }),
});

const { result } = await res.json();

Response:

{
  "success": true,
  "result": {
    "email": "treasurer@example.org",
    "result": "deliverable",
    "reason": "syntax_mx_ok",
    "role": true,
    "didYouMean": null
  },
  "metadata": {
    "verifier": "own",
    "cached": false,
    "processingTimeMs": 412
  }
}

result.result is one of four verdicts:

VerdictMeans
deliverableValid syntax and a domain that accepts mail
riskyReserved. In the contract and counted by /verify/lookup, but the verifier running today never returns it — do not build a branch that waits for it.
undeliverableSyntax junk, a typo domain, a disposable domain, or a domain authoritatively without mail. Never send
unknownCould not be determined, typically a transient DNS failure. Re-checkable

result.reason says which check decided it: syntax, typo_domain, disposable, mx, mx_unresolved, or syntax_mx_ok.

The rest of the endpoints

MethodPathWhat it does
POST/api/v1/verifyVerify one address. Body { email }, optional fresh: false to skip the stored-verdict cache
POST/api/v1/verify/lookupAsk which of a list you already hold verdicts for. Storage read only — no DNS, no verification, no quota. Up to 50,000 addresses. Optional countsOnly: true
POST/api/v1/verify/batchVerify up to 500 addresses synchronously. Body { emails }, optional fresh: false
POST/api/v1/verify/jobsQueue up to 100,000 addresses. Returns 202 with jobId and statusUrl. Optional webhookUrl, webhookHeaders, correlationId
GET/api/v1/verify/jobs/{id}Status and results of a queued job. ?summaryOnly=true omits per-address detail

/verify/lookup returns a summary with total, verified, notVerified, stale, deliverable, risky and undeliverable, and — unless you pass countsOnly — a results array where each entry has status of verified, stale or not_verified.

/verify/jobs reports total, alreadyVerified and toVerify on acceptance, so you can see how much work is actually pending. GET /verify/jobs/{id} returns status of queued, processing, done or failed.

Limits and errors

Errors are JSON with success: false and an error string:

{ "success": false, "error": "At most 500 email addresses per request; chunk larger lists client-side" }

Status codes: 400 invalid JSON, a missing email/emails, or a list over the endpoint's cap; 401 missing or invalid key; 403 Verify (or, for jobs, Jobs) not enabled for the app; 404 job id not found for your app; 429 quota exceeded; 500 verification failed.

A 429 carries the numbers so you can act on it rather than guess:

{
  "success": false,
  "error": "Verify quota exceeded",
  "quota": "5000",
  "used": "4980",
  "requested": 100,
  "available": "20",
  "hint": "Reduce the batch size to the available allowance, or upgrade the Verify tier."
}

Verify is a tiered service with a monthly reset, metered in verified addresses, not bytes. Your app's current tier, quota and usage are shown in the console under App Settings → Services — read the live number there.

Hard caps, enforced by the handlers: 500 addresses per /verify/batch call, 50,000 per /verify/lookup call, 100,000 per /verify/jobs call.

Gotchas

deliverable does not mean the mailbox exists. There is no SMTP mailbox probe — port 25 is blocked outbound — so deliverable means the address is well-formed and its domain accepts mail. On a large cold list a meaningful share of addresses on live domains have no mailbox behind them. Plan for bounces; do not treat this as proof.

unknown is not undeliverable. A DNS failure is not evidence a domain cannot receive mail. The MX layer distinguishes three states — live, an authoritative dead (NXDOMAIN/ENODATA), and unknown for a timeout, SERVFAIL or refusal — and only the first two are ever cached. This exists because an earlier version caught every DNS error as "no MX" and falsely marked tens of thousands of btinternet.com, outlook.com, yahoo and proton.me addresses undeliverable. Re-check unknown; never suppress it.

A cache hit still consumes quota. Billing your app less because another venture happened to verify the same address first would make your bill unpredictable. The shared cache is a platform margin win, not a customer discount. If you want the free answer, that is what /verify/lookup is for: it reads storage only and costs nothing.

Quota is checked up front for the whole batch, and a batch that would exceed it is refused outright, not truncated. A partial run would leave you with an unusable half-result and no clean way to resume. Usage is incremented only after the work succeeds, so a failed verification does not burn the allowance twice.

The verdict is always keyed to the address you sent, never to a typo correction. A typo'd address as written will not deliver — only the correction would — so the original comes back undeliverable with reason: "typo_domain" and the fix in didYouMean. Correction picks the domain to MX-check and nothing else. If you key your own records off the returned email, they still line up.

Addresses are normalised — trimmed and lowercased — before anything else. The verdict comes back against the normalised form.

Role addresses are flagged, never dropped. info@, treasurer@ and the like set role: true and carry an ordinary verdict. For organisations, info@ is often the only address there is.

Stored verdicts go stale and are re-verified, not served. A verdict is trusted for 90 days by default. /verify/lookup reports a stale row with status: "stale" and still shows you the old verdict; /verify/batch and /verify/jobs treat stale rows as misses and re-check them, which means they cost quota.

fresh: false forces a full re-verify. The flag reads backwards from most APIs: the cache is used unless you pass fresh: false, which skips it.

/verify/lookup answers about the shared pool, not only your own verifications. A verdict any venture on the platform paid for answers for every venture — that is the economic point. Which venture supplied an address is never disclosed, only the verdict.

Async jobs need Jobs enabled too, and the 403 says so. The error carries details pointing you at /verify/batch for up to 500 addresses if you do not want the jobs pipeline.

webhookUrl must be HTTPS, and it is validated at submit time, not on first delivery. The URL goes through the platform's SSRF check and an HTTPS-only rule, so an integration mistake surfaces as a 400 on the call you just made rather than silently later. HTTPS is not decoration: we do not sign these callbacks, so if you want to verify a delivery is really ours you put your own token in webhookHeaders and check it on receipt — over plain HTTP that token would cross the wire in cleartext. We store and echo the headers opaquely and never interpret them. Headers are sanitised on the way in.

Poll with ?summaryOnly=true. A 100,000-address result set is a large payload to pull on every poll. Poll cheaply, fetch the detail once at the end.

Jobs are scoped to your app. A job id from another app returns 404, and a one-off job that is not a verification job returns 400 Not a verification job — the underlying table is shared across job kinds.

A job's emails are de-duplicated before anything is queued, so total in the response may be lower than the length of the list you sent.