Email Validation API: curl, Node, and Python (2026)

Use AI to summarize this article and ask questions

Grant Ammons
Grant Ammons – Founder May 19, 2026

Email Validation API: curl, Node, and Python (2026)

A developer's guide to using an email validation API. Real curl, Node, and Python examples, response shape, error handling, and signup-flow integration.

TL;DR: An email validation API runs syntax, MX lookup, SMTP probe, disposable detection, role detection, and catch-all detection in a single network call. This guide shows how to call one with curl, Node, and Python, parse the response, and integrate it into a signup flow. Examples use the Truelist verify_inline endpoint.

Regex tells you a string is shaped like an email. It cannot tell you whether the inbox exists, whether the domain accepts mail, or whether the address is a known spam trap. For that you need an email validation API — a service that does the SMTP probing, DNS lookups, and provider-specific checks that would otherwise eat days of engineering effort to build yourself.

This guide covers the parts that actually matter when you wire one up: the request shape, the response, the failure modes, and how to make it feel fast on a signup form.

What an email validation API does that regex can’t

A good API runs a stack of checks in sequence and aggregates the result. For each address you pass in, the service will:

  • Syntax check — does the address match RFC 5322 well enough to send
  • MX lookup — does the domain have mail-exchange records
  • SMTP probe — connect to the MX, issue HELO / MAIL FROM / RCPT TO, parse the response
  • Disposable check — match the domain against a known list of throwaway services (Mailinator, Guerrilla Mail, etc.)
  • Role-account check — flag info@, support@, noreply@, and friends
  • Catch-all detection — probe a few obviously-fake addresses at the same domain; if they all return 250, the domain accepts everything and individual mailbox existence can’t be confirmed
  • Spam-trap check — match against known seeded addresses that mailbox providers use to penalize senders

Building any one of those isn’t hard. Building all of them, maintaining the disposable-domain list, dealing with greylisting, and not getting your IP blocklisted from SMTP probes — that’s where the work compounds.

The basic request

The Truelist verification endpoint is POST https://api.truelist.io/api/v1/verify_inline. You pass the email as a query parameter and authenticate with a bearer token.

curl

curl -X POST 
  -H "Authorization: Bearer YOUR_API_KEY" 
  "https://api.truelist.io/api/v1/verify_inline?email=bob@example.com"

You can verify up to 3 addresses in a single call by space-separating them:

curl -X POST 
  -H "Authorization: Bearer YOUR_API_KEY" 
  "https://api.truelist.io/api/v1/verify_inline?email=bob@example.com%20jane@example.com"

Node (fetch)

async function verifyEmail(email) {
  const url = new URL('https://api.truelist.io/api/v1/verify_inline')
  url.searchParams.set('email', email)

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TRUELIST_API_KEY}`,
    },
  })

  if (!response.ok) {
    throw new Error(`Verification failed: ${response.status}`)
  }

  const data = await response.json()
  return data.emails[0]
}

Python (requests)

import os
import requests

def verify_email(email: str) -> dict:
    response = requests.post(
        'https://api.truelist.io/api/v1/verify_inline',
        params={'email': email},
        headers={
            'Authorization': f"Bearer {os.environ['TRUELIST_API_KEY']}",
        },
        timeout=35,  # full SMTP probe can take ~30s
    )
    response.raise_for_status()
    return response.json()['emails'][0]

Reading the response

A successful call returns a JSON body with an emails array. Each entry has the same shape:

{
  "emails": [
    {
      "address": "bob@example.com",
      "domain": "example.com",
      "canonical": "bob",
      "mx_record": "mx.example.com",
      "email_state": "ok",
      "email_sub_state": "email_ok",
      "verified_at": "2026-05-19T12:00:00Z"
    }
  ]
}

Two fields drive most decisions.

email_state

The headline result. One of:

Value Meaning Common action
ok Verified deliverable Accept the signup
email_invalid Hard fail (syntax, no MX, mailbox doesn’t exist) Reject
risky Soft fail (role-based, low-confidence SMTP) Allow but flag
accept_all Domain is a catch-all; individual mailbox can’t be confirmed Allow but flag, watch for bounce
unknown Greylisted or otherwise inconclusive Retry later or allow with flag

email_sub_state

The reason behind the state. The values you’ll see most often:

  • email_ok — passes everything
  • is_disposable — Mailinator, Guerrilla Mail, etc.
  • is_roleinfo@, support@, sales@
  • failed_mx_check — no MX records
  • failed_smtp_check — SMTP server explicitly rejected
  • failed_spam_trap — matches a known trap address
  • failed_no_mailbox — domain valid, mailbox doesn’t exist
  • failed_greylisted — server is asking for a retry
  • failed_syntax_check — never made it past basic shape
  • unknown_error

Use email_state for branching logic and email_sub_state to write a useful error message back to the user.

Make it feel fast: the checks parameter

Full validation including SMTP can take up to 30 seconds in the worst case (slow server + greylisting retries). For a signup form that’s unacceptable. The solution is to skip SMTP on the synchronous call and run it asynchronously later.

Pass a checks query parameter listing only the fast checks:

curl -X POST 
  -H "Authorization: Bearer YOUR_API_KEY" 
  "https://api.truelist.io/api/v1/verify_inline?email=bob@example.com&checks=syntax,mx,disposable,role"

This typically returns in under 200ms. You get immediate feedback to the user — “looks good, let’s continue” — while you queue the full SMTP probe to run async (via a background job, queue, or webhook). When the SMTP check completes, you can flag the account, send a re-confirmation email, or quietly suppress it from future campaigns if it bounced.

Integrating into a signup form

The minimum production-grade flow looks like this:

// server.js — Express endpoint your frontend calls
app.post('/api/signup', async (req, res) => {
  const { email, password } = req.body

  // Fast check (under 200ms) — block obvious bad signups synchronously
  const fast = await verifyFast(email)
  if (fast.email_state === 'email_invalid') {
    return res.status(400).json({
      error: 'That email address does not look valid. Please double-check it.',
    })
  }
  if (fast.email_sub_state === 'is_disposable') {
    return res.status(400).json({
      error: 'Disposable email addresses are not supported.',
    })
  }

  // Create the user with emailStatus='pending_verification'
  const user = await db.users.create({
    email,
    password,
    emailStatus: 'pending_verification',
  })

  // Queue the full SMTP probe as a background job
  await queue.add('verify-email-full', { userId: user.id, email })

  res.json({ ok: true, userId: user.id })
})

async function verifyFast(email) {
  const url = new URL('https://api.truelist.io/api/v1/verify_inline')
  url.searchParams.set('email', email)
  url.searchParams.set('checks', 'syntax,mx,disposable,role')

  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.TRUELIST_API_KEY}` },
  })
  const data = await response.json()
  return data.emails[0]
}

The async job runs the full validation, updates emailStatus based on the result, and decides whether to send the welcome email immediately or hold it for review.

Error handling

Four things to plan for:

  • 401 Unauthorized — bad or missing API key. Surface as a configuration error in your logs, not a user-facing error.
  • 429 Too Many Requests — you’ve hit a rate limit. Back off with exponential jitter. Don’t retry on the same request thread.
  • Timeouts — set your HTTP client timeout above the server’s internal timeout (35s is safe for the full validation; 5s for the fast path).
  • Network errors — your verification logic should fail open. If the API is unreachable, accept the signup and queue a retry. Failing closed means your signup flow breaks every time an upstream has an incident.
async function verifyWithFallback(email) {
  try {
    return await verifyFast(email)
  } catch (error) {
    console.error('Email verification failed', { email, error })
    return { email_state: 'unknown', email_sub_state: 'unknown_error' }
  }
}

Validation strategies

For the synchronous call without the checks filter, the validation_strategy query parameter controls accuracy vs latency:

Strategy Behavior When to use
fast Skip retry logic, fastest response Bulk validation where some false-positives are tolerable
accurate (default) Standard checks with normal retry Most signup flows
thorough 5-minute retry delay for greylisted servers High-value lists, batch jobs
enhanced Extra heuristics post-validation Maximum accuracy at the cost of latency

Rate limiting and 429 handling

Rate limits are plan-tied. When you cross the threshold, the API returns 429. Respond with exponential backoff and jitter — never a tight retry loop.

async function verifyWithBackoff(email, attempt = 0) {
  const response = await fetch(buildVerifyUrl(email), {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.TRUELIST_API_KEY}` },
  })

  if (response.status === 429) {
    if (attempt >= 4) throw new Error('Rate limited after 4 retries')
    const delay = 500 * Math.pow(2, attempt) + Math.random() * 250
    await new Promise(r => setTimeout(r, delay))
    return verifyWithBackoff(email, attempt + 1)
  }
  if (!response.ok) throw new Error(`Verify failed: ${response.status}`)
  return (await response.json()).emails[0]
}

Two system-level rules: cap background concurrency with a semaphore (5-10 is a good default), and keep the synchronous signup path on a separate key from bulk jobs so a batch run can’t starve a live form. For lists, use the batch endpoint. See API management best practices.

Security best practices

The Truelist API key has full account access. Treat it like a database password.

  • Never call from a browser. A client-side key leaks via devtools. Proxy through a server endpoint.
  • Store in a secrets manager. Vercel Environment Variables, AWS Secrets Manager, Doppler — never git.
  • Rotate quarterly. Multiple active keys are supported: provision the new one, deploy, then deactivate the old. No downtime.
  • Scope per environment. Distinct keys for production, staging, and local dev mean a staging leak doesn’t touch production.
  • Log states, not addresses. Email addresses are PII; log email_state and email_sub_state instead.

Observability: what to log and chart

Validation is the kind of integration you set up once and forget — until a provider changes SMTP behavior overnight. Build the dashboards first.

Log every result with email_state, email_sub_state, validation_strategy, checks, latency, and request source. Three charts worth building:

  1. email_state distribution over time. A spike in email_invalid signals bot traffic; a spike in accept_all means a popular provider flipped to catch-all mode.
  2. email_sub_state heatmap by hour. failed_greylisted clustering at specific hours points to a provider rate-limiting your probes.
  3. p95 latency on the inline path. Alert at 750ms.

Pair with bounce checker data: validation predicts, bounces confirm. Any ok that hard-bounces means your strategy is too lenient. See email list cleaning services.

Common integration patterns

Most teams use the API in one of four ways.

1. Signup-flow gating (synchronous fast path)

Covered above. The fast path gives sub-200ms feedback; the full SMTP probe runs async. Blocking 2-3% of bad signups upstream saves bounce damage to your sender reputation.

2. List import (batch + webhook)

For lists over a few hundred addresses, use POST /api/v1/batches with a webhook_url so Truelist calls you back when complete:

curl -X POST https://api.truelist.io/api/v1/batches 
  -H "Authorization: Bearer YOUR_API_KEY" 
  -F "file=@subscribers.csv" 
  -F "webhook_url=https://yourapp.com/webhooks/truelist-batch" 
  -F "validation_strategy=accurate"

The response returns a batch.id. When batch_state reaches completed, Truelist POSTs the batch_id to your webhook. Your handler then fetches one of the result CSVs:

  • safest_bet_csv_url — only ok results, maximum deliverability
  • highest_reach_csv_urlok plus accept_all and risky
  • only_invalid_csv_url — addresses to suppress
  • annotated_csv_url — every row with validation columns appended

If you can’t accept webhooks, poll GET /api/v1/batches/{uuid} until completed. See webhook vs REST API.

Rule of thumb: anything over 500 addresses goes through batch. Cheaper, no pressure on your inline rate limit, pre-segmented result CSVs.

3. CRM enrichment hook

A HubSpot/Salesforce webhook fires on contact create, your worker calls verify_inline with the full SMTP check, and you set a custom property (email_validation_state) on the contact. Marketing automation segments ok and accept_all for nurture; the rest goes to a manual queue.

4. Scheduled re-validation

B2B lists decay roughly 2-3% per month. A monthly cron re-batches addresses last validated >90 days ago with validation_strategy=fast; spend accurate calls only on unknown or risky results. See data cleansing techniques and data quality management software.

Go and Ruby examples

The fast path translates into any HTTP client. For PHP, see the dedicated walkthrough.

Go

func VerifyEmail(email string) (*EmailResult, error) {
	u, _ := url.Parse("https://api.truelist.io/api/v1/verify_inline")
	q := u.Query()
	q.Set("email", email)
	q.Set("checks", "syntax,mx,disposable,role")
	u.RawQuery = q.Encode()

	req, _ := http.NewRequest("POST", u.String(), nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TRUELIST_API_KEY"))

	client := &http.Client{Timeout: 5 * time.Second}
	resp, err := client.Do(req)
	if err != nil { return nil, err }
	defer resp.Body.Close()

	var v VerifyResponse
	json.NewDecoder(resp.Body).Decode(&v)
	return &v.Emails[0], nil
}

Ruby

def verify_email(email)
  uri = URI('https://api.truelist.io/api/v1/verify_inline')
  uri.query = URI.encode_www_form(email: email, checks: 'syntax,mx,disposable,role')

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  req = Net::HTTP::Post.new(uri.request_uri)
  req['Authorization'] = "Bearer #{ENV['TRUELIST_API_KEY']}"
  JSON.parse(http.request(req).body)['emails'].first
end

The 2026 angle: validation in AI-driven flows

Two trends make server-side validation more load-bearing in 2026. Agent-driven form completion — AI assistants fill signup forms on behalf of users, sometimes with hallucinated addresses. Only a synchronous server-side verify_inline is a reliable gate. AI-generated lead lists — LLM prospecting tools guess at email patterns and get them wrong 20-40% of the time. Batch-validating before the first send is the difference between a clean campaign and a deliverability incident. See B2B lead generation strategies and email prospecting best practices.

Pricing the verification layer

Most email validation APIs charge per call. Truelist charges a flat monthly rate with unlimited validations rather than per-credit — useful when you’re integrating into a signup flow and don’t want to be price-anxious about every request. The 100-call free tier is enough to wire up the integration and test it end-to-end before you commit.

For language-specific walkthroughs, see JavaScript email validation, Python email validation, and PHP email verification.

FAQ

How accurate is SMTP validation?

For domains that respond honestly, SMTP validation correctly identifies non-existent mailboxes ~98% of the time. Catch-all domains are the main limitation — that’s why accept_all is its own state.

Should I validate on the client or the server?

Server, always. A client-side API key leaks to anyone with devtools. Client-side regex for typing feedback is fine — gating belongs on the server.

What’s the difference between risky and accept_all?

risky means the address passed every check but something suggests it may not perform well (role accounts, low-confidence SMTP). accept_all means the domain returns 250 for any probe, so mailbox existence is unverifiable. Pair both with bounce checker data.

How often should I re-validate?

Every 90 days for an active list. For dormant addresses (6+ months no send), validate before the next campaign. Transactional flows don’t need re-validation unless bounces start.

Does validation guarantee delivery?

No — validation confirms reachability. Delivery also depends on sender reputation, content, authentication (SPF/DKIM/DMARC), and receiving-server policy. See why do emails bounce back and how to prevent emails from going to spam.

Ready to put Truelist
to the test?

Find out if Truelist is right for you in under 10 minutes.

Free plan available. No credit card required.