Email Verification API for Developers: Python, Node & curl Integration Guide

By , founder of InboxPolicy · Updated July 9, 2026

A verification API does three things in sequence: check the address is syntactically valid, confirm the domain has a receiving mail server (MX lookup), then open an SMTP conversation with that server to see whether it accepts or rejects the specific mailbox. Everything below is copy-paste code against InboxPolicy's real request and response shapes, no placeholder fields.

There are two integration patterns worth picking between up front. A pre-send single check calls /v1/decide synchronously right before you send, so you catch a bad address at the moment it would have bounced. A batch clean submits a whole list to /v1/batches/verify and polls for results, which is cheaper per call to orchestrate when you're processing thousands of rows that don't need a real-time answer. Most pipelines end up using both: batch-clean a list on import, then single-check anything new or re-added before it goes out.

1. Single decision call

POST /v1/decide always answers with exactly one action field: send, send_with_caution, review, retry_later, or avoid. Malformed emails return avoid at no charge instead of throwing an error, so you don't need a separate syntax-validation step before calling it.

curl -X POST https://api.inboxpolicy.com/v1/decide \
  -H "x-api-key: ip_live_..." \
  -H "content-type: application/json" \
  -d '{"email": "someone@example.com"}'
{
  "request_id": "vrf_...",
  "action": "send",
  "reason": "Verified mailbox with acceptable risk.",
  "confidence": 95,
  "status": "valid",
  "signals": { "catch_all": false, "disposable": false, "free": false, "role_account": false },
  "from_cache": false,
  "cost": { "credits": 1, "billable": true }
}

Python, branching on all five actions:

import requests

resp = requests.post(
    "https://api.inboxpolicy.com/v1/decide",
    headers={"x-api-key": "ip_live_...", "content-type": "application/json"},
    json={"email": "[email protected]"},
)
result = resp.json()

match result["action"]:
    case "send":
        deliver(result)
    case "send_with_caution":
        deliver(result, flag_for_monitoring=True)
    case "review":
        queue_for_manual_review(result)   # e.g. catch-all domains
    case "retry_later":
        schedule_retry(result, delay_seconds=3600)  # e.g. greylisting
    case "avoid":
        drop(result)

Node, same call with fetch:

const res = await fetch("https://api.inboxpolicy.com/v1/decide", {
  method: "POST",
  headers: {
    "x-api-key": "ip_live_...",
    "content-type": "application/json",
  },
  body: JSON.stringify({ email: "[email protected]" }),
});
const result = await res.json();

switch (result.action) {
  case "send":
    await deliver(result);
    break;
  case "send_with_caution":
    await deliver(result, { flag: true });
    break;
  case "review":
    await queueForReview(result);
    break;
  case "retry_later":
    await scheduleRetry(result, 60 * 60 * 1000);
    break;
  case "avoid":
    await drop(result);
    break;
}

Need the full SMTP evidence instead of a single decision field, MX records, deliverability and reachability flags, confidence breakdown? POST /v1/verify takes the same request body ({"email", "strictness?", "force_refresh?"}) and same price, but returns 422 on malformed input instead of a free avoid.

2. Safe retries with idempotency keys

Network calls fail. If your retry logic just calls /v1/decide again, you risk a second billable verification for the same email. Send an idempotency-key header instead: exactly one request with that key executes, and a retry with the same key and payload replays the stored response for free. Reusing the key with a different payload returns 409, so a bug that reuses keys across different emails fails loudly instead of silently returning the wrong result.

import time, uuid
import requests

def verify_with_retry(email, max_attempts=3):
    idem_key = str(uuid.uuid4())  # one key per logical verification, reused across retries
    for attempt in range(max_attempts):
        resp = requests.post(
            "https://api.inboxpolicy.com/v1/decide",
            headers={
                "x-api-key": "ip_live_...",
                "content-type": "application/json",
                "idempotency-key": idem_key,
            },
            json={"email": email},
        )
        if resp.status_code == 409:
            # another request with this key is still in flight; back off and retry
            time.sleep(2 ** attempt)
            continue
        resp.raise_for_status()
        return resp.json()
    raise TimeoutError(f"verification for {email} did not resolve after {max_attempts} attempts")

A crashed request releases its key after roughly 5 minutes, so a stuck retry loop doesn't wedge that email forever.

3. Batch verify a list

POST /v1/batches/verify takes up to 50,000 emails per call with an API key ({"emails": [...], "strictness?", "webhook_url?"}) and returns 202 immediately with a batch_id, a worst-case estimated_credits figure, and a webhook_secret if you passed a webhook URL. Duplicate emails in the same call are deduplicated internally and billed once.

curl -X POST https://api.inboxpolicy.com/v1/batches/verify \
  -H "x-api-key: ip_live_..." \
  -H "content-type: application/json" \
  -d '{"emails": ["a@x.com", "b@y.com", "c@z.com"], "strictness": "default"}'
{
  "batch_id": "bat_...",
  "status": "queued",
  "total_items": 3,
  "estimated_credits": 3
}

Poll GET /v1/batches/:batchId for the summary (total_items, processed_items, valid/invalid/unknown/error counts, timestamps), then pull individual results from GET /v1/batches/:batchId/items, which is paginated and filterable by status:

import time
import requests

headers = {"x-api-key": "ip_live_..."}

def poll_batch(batch_id, interval_seconds=5):
    while True:
        status = requests.get(
            f"https://api.inboxpolicy.com/v1/batches/{batch_id}", headers=headers
        ).json()
        if status["status"] == "completed":
            break
        time.sleep(interval_seconds)

    items = requests.get(
        f"https://api.inboxpolicy.com/v1/batches/{batch_id}/items",
        headers=headers,
        params={"status": "completed", "limit": 100, "offset": 0},
    ).json()
    return items

If a webhook URL was set, skip the polling loop entirely, the completion callback is signed with x-inboxpolicy-signature: HMAC-SHA256(secret, body) against your webhook_secret so you can verify it came from InboxPolicy.

4. Keyless: paying per call with x402

None of the above requires an account if you'd rather pay per call. Skip the x-api-key header entirely and a request to /v1/decide or /v1/verify returns 402 with an x402 PAYMENT-REQUIRED quote (currently $0.01 in USDC on Base). A v2 x402 client reads that quote, signs a payment, and retries the identical request with a PAYMENT-SIGNATURE header:

// x402 client + encodePaymentSignatureHeader come from the v2 x402 SDK —
// wallet setup is covered in the x402 guide linked below
const request = {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email: "[email protected]" }),
};
let res = await fetch("https://api.inboxpolicy.com/v1/decide", request);
if (res.status === 402) {
  const payment = await x402.createPaymentPayload(await res.json());
  res = await fetch("https://api.inboxpolicy.com/v1/decide", {
    ...request,
    headers: { ...request.headers, "PAYMENT-SIGNATURE": encodePaymentSignatureHeader(payment) },
  });
}
// { action: "send", confidence: 0.97, ... }

The settlement receipt comes back in a PAYMENT-RESPONSE header. Batches accept x402 too, keyless, capped at 5,000 emails per call (vs. 50,000 with an API key). Full wallet setup (viem, the EVM signer, network registration) is in the x402 email verification guide rather than repeated here.

Handling the hard cases

Catch-all domains. Roughly 30-40% of B2B addresses sit on domains that accept mail for any address, so SMTP alone can't confirm a specific mailbox exists. InboxPolicy returns action: review for these (or send_with_caution if you've configured an aggressive policy), never a silent send. Route review results to a separate queue instead of guessing, see the catch-all verification guide for how to split them further.

Greylisting. A receiving server can temporarily defer an unfamiliar sender with a 4xx response, which is a different situation from catch-all and resolves cleanly on a retry. These come back as action: retry_later, not review, so your code should schedule a delayed retry (an hour is a reasonable default) rather than treating them as permanently unresolved. Unknown verdicts can also escalate automatically to a wholesale fallback verifier at no extra charge.

Rate limits. The default limit is 10 requests/second per API key; exceeding it returns 429. If you're single-checking on a hot send path, add a token-bucket limiter client-side rather than discovering the ceiling in production, and prefer batching when you're processing more than a handful of addresses at once.

Caching. Re-verifying the same address within 72 hours is free and comes back with from_cache: true. Design your pipeline to re-check liberally instead of maintaining your own dedupe cache, an address you verified this morning costs nothing to check again this afternoon.

Budget math for developers

Keyless x402 calls are $0.01 per fresh decision, no account, no minimum, which is the right default while you're integrating or running low volume. Once you're verifying steadily, prepaid credit packs bought with a card drop the per-check cost, from $5/1,000 on the Starter pack down to $3.16/1,000 on the Growth pack, and a cache hit inside the 72-hour window never costs anything either way. If you're trying to model total spend against a specific list size or send volume, the email verification API pricing guide has the full per-1,000 breakdown against other vendors.

Frequently asked questions

How do I verify an email address with an API in Python?

Send a POST request to /v1/decide with your API key in an x-api-key header and a JSON body of {"email": "..."} using the requests library. The response includes an action field (send, send_with_caution, review, retry_later, or avoid) that your code should branch on directly instead of re-deriving a decision from raw status fields.

How do I verify emails without getting charged twice for retries?

Send an idempotency-key header with every /v1/decide or /v1/verify call. Exactly one request with that key executes; a retry with the same key and payload replays the stored response instead of billing again, while reusing the key with a different payload returns 409 so you catch the bug instead of silently mismatching results.

Can I verify emails via API without an account?

Yes. Call /v1/decide or /v1/verify without an x-api-key header and the API responds 402 with an x402 PAYMENT-REQUIRED quote, currently $0.01 in USDC on Base. A v2 x402 client signs the quote and retries the same request with a PAYMENT-SIGNATURE header, no signup, email, or API key required.

How should my code handle catch-all results?

Treat catch-all addresses as a routing decision, not a verdict. InboxPolicy returns action: review with a catch_all: true signal for these, since SMTP alone can't confirm the specific mailbox exists. Route review results to a separate queue, for example a slower drip or manual check, rather than sending or dropping them automatically.

What's the difference between /v1/decide and /v1/verify?

/v1/decide returns a single actionable field to branch your code on, plus reason, confidence, and signals, and never errors on malformed input (it returns avoid at no charge instead). /v1/verify returns the full verification detail, status, deliverability and reachability flags, MX context, and SMTP evidence, and returns a 422 on malformed input instead of a decision. Same request body and pricing either way.

Related guides

See full pricing →