Email verification APIs all behave roughly the same way - submit one address or a batch, get back a structured result indicating whether the address is deliverable. The differences live in the small details: how you handle errors, how you back off on rate-limit responses, how you poll long-running bulk jobs without burning quota.
This post is the copy-paste reference: working code for single verification, bulk verification, and async polling in cURL, Python, Node.js, PHP, and Go. The patterns apply to any verification API; we use BounceZero's endpoints in the examples but the same code shape works with ZeroBounce, NeverBounce, Kickbox, or any other provider after swapping the base URL and auth header.
The Standard Request/Response Shape
Most verification APIs converge on the same shape:
Single-address request:
``
POST /v1/verify
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"email": "[email protected]"
}
`
Single-address response:
`
{
"email": "[email protected]",
"result": "verified", // verified | invalid | catch-all | risky | disposable | role | unknown
"score": 0.97, // 0.0 to 1.0 confidence
"reason": "smtp_accepted", // machine-readable reason code
"did_you_mean": null, // typo suggestion if applicable
"is_disposable": false,
"is_role": false,
"is_free": false,
"is_catchall": false,
"mx_records": ["mail.example.com"],
"verified_at": "2026-06-08T10:30:00Z"
}
`
For BounceZero specifically, the base URL is https://api.bouncezero.io and authentication is Authorization: Bearer YOUR_API_KEY`. Full API reference is at [our docs](https://bouncezero.io/docs).
cURL - Single Verification
Quickest way to test an API key and confirm an endpoint is up:
``bash
curl -X POST https://api.bouncezero.io/v1/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
`
With jq for readable output:
`bash
curl -sX POST https://api.bouncezero.io/v1/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}' | jq
`
Processing a text file of emails (one per line):
`bash
while IFS= read -r email; do
curl -sX POST https://api.bouncezero.io/v1/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$email\"}" \
| jq -r '[.email, .result, .score] | @csv'
done < emails.txt
``
For more than a few hundred addresses, switch to the bulk endpoint - it's faster and uses less quota.
Python - Single + Bulk + Backoff
Python with requests (the standard) and basic backoff on rate limits:
``python
import requests
import time
import os
API_KEY = os.environ['BOUNCEZERO_API_KEY']
BASE_URL = 'https://api.bouncezero.io'
HEADERS = {
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json',
}
def verify(email: str, max_retries: int = 3) -> dict:
"""Single email verification with exponential backoff on 429."""
for attempt in range(max_retries):
r = requests.post(
f'{BASE_URL}/v1/verify',
json={'email': email},
headers=HEADERS,
timeout=10,
)
if r.status_code == 429:
# Rate limited - backoff and retry
wait = int(r.headers.get('Retry-After', 2 attempt))
time.sleep(wait)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f'Rate limited after {max_retries} retries')
# Usage
result = verify('[email protected]')
print(f"{result['email']}: {result['result']} (score: {result['score']})")
`
Bulk verification with async polling**:
`python
def submit_bulk(emails: list) -> str:
"""Submit a list of emails; returns job_id."""
r = requests.post(
f'{BASE_URL}/v1/bulk',
json={'emails': emails},
headers=HEADERS,
timeout=30,
)
r.raise_for_status()
return r.json()['job_id']
def poll_until_complete(job_id: str, poll_interval: int = 5, max_wait_sec: int = 3600) -> dict:
"""Poll a bulk job until complete; returns final results."""
elapsed = 0
while elapsed < max_wait_sec:
r = requests.get(f'{BASE_URL}/v1/bulk/{job_id}', headers=HEADERS, timeout=10)
r.raise_for_status()
status = r.json()
if status['status'] == 'completed':
return status
if status['status'] == 'failed':
raise RuntimeError(f"Job failed: {status.get('error')}")
time.sleep(poll_interval)
elapsed += poll_interval
raise TimeoutError(f'Job did not complete within {max_wait_sec}s')
# Usage
job_id = submit_bulk(['[email protected]', '[email protected]', 'invalid@nodomain'])
result = poll_until_complete(job_id)
for row in result['results']:
print(f"{row['email']}: {row['result']}")
`
Key rules: respect Retry-After` headers; use exponential backoff; cap retries to avoid runaway; never poll faster than 5-10 seconds for bulk jobs (you waste quota and the server gives the same answer).
Node.js - Async/Await with Native fetch
Modern Node 18+ has native fetch - no need for axios for simple cases:
``javascript
const API_KEY = process.env.BOUNCEZERO_API_KEY;
const BASE_URL = 'https://api.bouncezero.io';
const headers = {
'Authorization': Bearer ${API_KEY},
'Content-Type': 'application/json',
};
async function verify(email, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(${BASE_URL}/v1/verify, {
method: 'POST',
headers,
body: JSON.stringify({ email }),
});
if (res.status === 429) {
const wait = parseInt(res.headers.get('Retry-After') || Math.pow(2, attempt), 10);
await new Promise(r => setTimeout(r, wait * 1000));
continue;
}
if (!res.ok) {
throw new Error(API error ${res.status}: ${await res.text()});
}
return await res.json();
}
throw new Error(Rate limited after ${maxRetries} retries);
}
// Usage
(async () => {
const result = await verify('[email protected]');
console.log(${result.email}: ${result.result} (score: ${result.score}));
})();
`
Bulk submission + polling:
`javascript
async function submitBulk(emails) {
const res = await fetch(${BASE_URL}/v1/bulk, {
method: 'POST',
headers,
body: JSON.stringify({ emails }),
});
if (!res.ok) throw new Error(Bulk submit failed: ${res.status});
const data = await res.json();
return data.job_id;
}
async function pollBulk(jobId, intervalMs = 5000, maxWaitMs = 3600000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const res = await fetch(${BASE_URL}/v1/bulk/${jobId}, { headers });
if (!res.ok) throw new Error(Poll failed: ${res.status});
const status = await res.json();
if (status.status === 'completed') return status;
if (status.status === 'failed') throw new Error(Job failed: ${status.error});
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error('Timed out waiting for bulk job');
}
`
For production workloads, wrap with proper logging, OpenTelemetry tracing if you have it, and circuit-breaker patterns (e.g., opossum` library) so verifier outages don't cascade into your signup flow.
PHP - Single + Bulk
Pure PHP with no Composer dependencies, using curl_exec:
``php
<?php
$apiKey = getenv('BOUNCEZERO_API_KEY');
$baseUrl = 'https://api.bouncezero.io';
function bzVerify(string $email, string $apiKey, string $baseUrl): array {
$maxRetries = 3;
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
$ch = curl_init("{$baseUrl}/v1/verify");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_HEADER => true,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$header = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
curl_close($ch);
if ($code === 429) {
preg_match('/Retry-After:\s*(\d+)/i', $header, $m);
$wait = isset($m[1]) ? (int)$m[1] : pow(2, $attempt);
sleep($wait);
continue;
}
if ($code !== 200) {
throw new Exception("API error {$code}: {$body}");
}
return json_decode($body, true);
}
throw new Exception('Rate limited after retries');
}
// Usage
$result = bzVerify('[email protected]', $apiKey, $baseUrl);
echo "{$result['email']}: {$result['result']} (score: {$result['score']})\n";
`
For Composer-based projects, Guzzle is cleaner:
`php
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.bouncezero.io',
'timeout' => 10,
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Content-Type' => 'application/json',
],
]);
$response = $client->post('/v1/verify', ['json' => ['email' => '[email protected]']]);
$result = json_decode($response->getBody(), true);
``
Go - Strongly-Typed Client
Idiomatic Go with structs and errors:
``go
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
type Client struct {
APIKey string
BaseURL string
HTTP *http.Client
}
type VerifyResult struct {
Email string json:"email"
Result string json:"result"
Score float64 json:"score"
Reason string json:"reason"
DidYouMean string json:"did_you_mean"
IsDisposable bool json:"is_disposable"
IsRole bool json:"is_role"
IsCatchall bool json:"is_catchall"
VerifiedAt string json:"verified_at"
}
func NewClient(apiKey string) *Client {
return &Client{
APIKey: apiKey,
BaseURL: "https://api.bouncezero.io",
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *Client) Verify(email string) (*VerifyResult, error) {
body, _ := json.Marshal(map[string]string{"email": email})
for attempt := 0; attempt < 3; attempt++ {
req, _ := http.NewRequest("POST", c.BaseURL+"/v1/verify", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
wait := int(math.Pow(2, float64(attempt)))
if ra := resp.Header.Get("Retry-After"); ra != "" {
if w, err := strconv.Atoi(ra); err == nil {
wait = w
}
}
time.Sleep(time.Duration(wait) * time.Second)
continue
}
if resp.StatusCode != 200 {
msg, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
}
var result VerifyResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &result, nil
}
return nil, errors.New("rate limited after retries")
}
func main() {
client := NewClient(os.Getenv("BOUNCEZERO_API_KEY"))
result, err := client.Verify("[email protected]")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("%s: %s (score: %.2f)\n", result.Email, result.Result, result.Score)
}
``
For production use add context.Context support, proper logging, and consider go-resty for the retry/backoff logic if you don't want to write it yourself.
Rate Limits and How to Stay Under Them
Every verification API has rate limits. The shape is usually:
- Per-second limit (e.g., 50/sec) - prevents burst floods.
- Per-minute or per-hour limit (e.g., 5000/hour) - overall throughput.
- Concurrent connection limit (e.g., max 10 in flight) - prevents pool exhaustion.
The right strategy for high-volume:
Retry-After headers. When you get a 429, wait the indicated time before retrying.min(maxWait, base * 2^attempt + random()). Jitter prevents thundering herd when multiple clients all hit a 429 at the same moment.asyncio.Semaphore, Node p-limit, Go buffered channel).Don't retry without backoff. Don't ignore Retry-After. Don't use unbounded concurrency. These are the three patterns that get accounts suspended.
Error Handling: What Each Status Code Means
Real-world status codes you'll see from any verification API:
| Code | Meaning | Action |
|---|---|---|
| 200 | Success | Use the result |
| 400 | Bad request (e.g., missing email field) | Fix the request; do not retry |
| 401 | Unauthorized (bad API key) | Check your API key; do not retry |
| 402 | Payment required (out of credits) | Upgrade plan or wait for renewal |
| 403 | Forbidden (API key disabled, IP blocked) | Contact support |
| 422 | Unprocessable (e.g., email syntax invalid) | The address is the answer - handle as result: invalid |
| 429 | Rate limited | Backoff per Retry-After header |
| 500/502/503/504 | Server error | Retry with backoff (capped) |
Critical rule: 4xx errors (except 429) are YOUR problem - don't retry. 5xx errors are temporary - retry with backoff. 429 is rate-limit - wait the indicated time. Confusing 5xx with 4xx (retrying on 401) wastes API calls and triggers more rate-limiting.
Frequently Asked Questions
Should I call the verification API client-side or server-side?
Server-side for any value-of-record decision (account creation, list cleanup, billing). Client-side is fine for UX (real-time form feedback) but always through a proxy endpoint you control, with rate-limiting per-IP. Never put your verification API key in client JavaScript - it's exposed and abusable.
What's the difference between single and bulk endpoints?
Single endpoint returns a verdict synchronously in 200-1500ms. Bulk submits N addresses, returns a job_id, and you poll for completion (typically seconds to minutes for thousands of addresses). Use single for real-time flows (signup forms, account creation); use bulk for batch jobs (list cleanup, weekly hygiene, CSV imports).
How should I handle the 'unknown' result?
Accept the signup and re-verify asynchronously. 'Unknown' means the verifier couldn't determine - typically due to provider throttling or temporary network issues, not because the address is bad. Blocking on 'unknown' punishes legitimate users for verifier outages. Re-check 6-12 hours later when provider rate limits have reset.
What HTTP timeout should I use?
5-10 seconds for single verification. Most calls complete in under 1.5s; a 10s timeout absorbs occasional slow paths (catch-all detection, complex domain probing). For bulk POST submission use 30s. For bulk polling use 5-10s and don't poll more often than every 5 seconds - the answer won't change faster than that.
Get an API Key - 100 Free Credits
Same endpoints, same response shape as the examples above. Working API key in 30 seconds, no credit card required.
Get Started Free