1. O que é uma API de validação de email
API de validação de email é um endpoint HTTP que recebe uma lista de endereços e retorna se cada um é entregável, sem mandar mensagem real. É a forma técnica de integrar validação em qualquer aplicação — signup form, ETL de CRM, fila de campanha — independente de linguagem ou framework.
Diferente de bibliotecas regex client-side (que só checam formato), uma API faz o trabalho completo: parse RFC 5322, resolução DNS/MX, conexão SMTP e negociação RCPT TO. Esse trabalho exige rede + estado + dados de reputação que não fazem sentido rodar no browser ou no app cliente. Pra entender as 4 camadas técnicas (sintaxe, DNS, SMTP, RCPT), veja a pillar conceitual de validação.
O EmailChecker expõe a API em https://app.emailchecker.email/api/v1. Contrato REST padrão: requests com JSON, autenticação Bearer, status codes HTTP convencionais, headers de rate limit. Sem SDK proprietário obrigatório — qualquer cliente HTTP serve. A API é exclusiva do plano Gold.
Uma coisa que muda o desenho da sua integração e é melhor você saber agora: a API é inteiramente assíncrona e baseada em lote. Não existe endpoint de validação única síncrona e não existe webhook de conclusão. São dois endpoints, e o fluxo é sempre o mesmo: envia o lote → pola até completed → lê os resultados.
2. Os dois endpoints: enviar lote e consultar resultado
É isso. Não tem terceiro endpoint.
2.1 POST /api/v1/batch — enviar lote
Recebe de 1 a 100.000 emails em um único request e devolve 201 com o id do lote. O campo name é opcional (rótulo pra você achar o lote no painel depois). O processamento roda em background — essa resposta não traz nenhum resultado de validação.
curl -X POST https://app.emailchecker.email/api/v1/batch \
-H "Authorization: Bearer ec_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"emails": ["joao@empresa.com.br", "maria@empresa.com.br"],
"name": "leads-webinar-2026-05"
}'
# 201 Created
{
"data": {
"id": "3f0c8a12-9d4e-4a7b-b1c2-8e5f6a7b9c01",
"name": "leads-webinar-2026-05",
"created_at": "2026-05-20T14:03:11.482Z",
"user_id": "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"size": 2
}
}Persista o id antes de qualquer outra coisa. Ele é a única chave pra buscar o resultado depois — se você perder o id, perdeu o lote (e os créditos). name vem null quando você não manda.
2.2 GET /api/v1/batch/{id} — consultar resultado
Devolve o estado do lote. status assume pending, processing, completed, failed ou cancelled. O array emails só vem preenchido quando status === "completed" — antes disso ele vem vazio, o que não significa "nenhum email válido".
curl https://app.emailchecker.email/api/v1/batch/3f0c8a12-9d4e-4a7b-b1c2-8e5f6a7b9c01 \
-H "Authorization: Bearer ec_live_xxxxxxxxxxxx"
# 200 — ainda rodando: emails[] vazio
{
"data": {
"id": "3f0c8a12-9d4e-4a7b-b1c2-8e5f6a7b9c01",
"status": "processing",
"created_at": "2026-05-20T14:03:11.482Z",
"finished_at": null,
"size": 2,
"emails": []
}
}
# 200 — terminou: emails[] preenchido
{
"data": {
"id": "3f0c8a12-9d4e-4a7b-b1c2-8e5f6a7b9c01",
"status": "completed",
"created_at": "2026-05-20T14:03:11.482Z",
"finished_at": "2026-05-20T14:03:19.907Z",
"size": 2,
"emails": [
{
"email": "joao@empresa.com.br",
"result": "deliverable",
"reason": "accepted_email",
"score": 92,
"isv_format": true,
"isv_domain": true,
"isv_mx": true,
"isv_noblock": true,
"isv_nocatchall": true,
"isv_nogeneric": true,
"is_free": false
},
{
"email": "maria@empresa.com.br",
"result": "undeliverable",
"reason": "rejected_email",
"score": 0,
"isv_format": true,
"isv_domain": true,
"isv_mx": true,
"isv_noblock": true,
"isv_nocatchall": true,
"isv_nogeneric": true,
"is_free": false
}
]
}
}2.3 Os campos de cada email
result—deliverable,undeliverable,riskyouunknown. É o campo que a sua regra de negócio deve ler.reason— texto do motivo por trás doresult.score— 0 a 100. Útil pra escalonar decisão (bloquear abaixo de X, pedir confirmação entre X e Y).isv_format,isv_domain,isv_mx— booleans das camadas básicas: sintaxe RFC 5322, domínio resolve, MX resolve.isv_noblock,isv_nocatchall,isv_nogeneric— booleans no positivo:truesignifica que o endereço não foi bloqueado, não é catch-all e não é genérico/role.is_free— provedor gratuito (gmail, hotmail, etc.).
Code examples em Node, Python, PHP e Go disponíveis em /docs-api/exemplos.
2.4 Envelope de erro
Qualquer erro vem no mesmo formato, em qualquer status code:
{
"data": null,
"error": "Batch not found"
}error é texto legível pra humano, não código estável. Sua lógica de tratamento deve olhar o status code HTTP; a string serve pra log e pra você entender o que aconteceu.
3. Validação em tempo real — o padrão correto
3.1 Não existe endpoint síncrono
Toda validação passa por lote, mesmo que o lote tenha 1 email. E o resultado leva alguns segundos, porque depende de DNS + handshake SMTP com o servidor do destinatário. Conclusão prática: não dá pra prender o request do seu formulário esperando a resposta. Se você tentar, vai acoplar o tempo de resposta do seu signup ao MX de terceiros — e um MX lento vira timeout no seu cadastro.
3.2 O padrão recomendado: validar em fila
- Sintaxe + resolução de MX no seu próprio backend (barato,
<100ms, custo zero) — isso já barra erro de digitação na hora. - Aceita o cadastro e enfileira o email pra validação.
- Um worker envia o lote (dá pra agrupar os cadastros dos últimos N segundos em um lote só) e guarda o
id. - O mesmo worker pola o
GET; quando viercompleted, gravaresultescoreno lead. - A ação de negócio (bloquear conta, exigir confirmação, marcar como suspeito) acontece nesse momento, não no submit.
3.3 Quando o usuário precisa ver o resultado
Se a UX exige feedback, faça o feedback ser assíncrono também: aceita o cadastro, mostra "verificando email…" e atualiza a tela via polling do seu backend ou websocket. Um lote de 1 email costuma terminar em poucos segundos, mas isso não é contrato — trate sempre como assíncrono e você nunca vai ter um formulário travado.
3.4 Volume alto e limpeza de lista
Aqui o modelo de lote joga a favor: 1 request submete até 100.000 emails. O anti-padrão é o inverso do de sempre — nunca quebre uma lista de 5.000 emails em 5.000 lotes de 1. É mais lento, gera 10.000 requests de polling e não melhora nada.
3.5 Código: enviar e aguardar
const BASE = 'https://app.emailchecker.email/api/v1';
const headers = { Authorization: `Bearer ${process.env.EC_API_KEY}` };
async function enviarLote(emails, name) {
const r = await fetch(`${BASE}/batch`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ emails, name }),
});
const json = await r.json();
if (!r.ok) throw new Error(json.error ?? `HTTP ${r.status}`);
return json.data.id; // guarde ISSO no seu banco, antes de qualquer outra coisa
}
async function aguardarLote(id, { intervaloMs = 5000, tetoMs = 60_000, prazoMs = 1_800_000 } = {}) {
const limite = Date.now() + prazoMs;
let espera = intervaloMs;
while (Date.now() < limite) {
await new Promise((res) => setTimeout(res, espera));
const r = await fetch(`${BASE}/batch/${id}`, { headers });
const json = await r.json();
if (!r.ok) throw new Error(json.error ?? `HTTP ${r.status}`);
const { status, emails } = json.data;
if (status === 'completed') return emails; // só aqui emails[] vem preenchido
if (status === 'failed' || status === 'cancelled') {
throw new Error(`lote ${id} terminou como ${status}`);
}
espera = Math.min(Math.round(espera * 1.5), tetoMs); // backoff até o teto
}
throw new Error(`prazo esgotado aguardando o lote ${id}`);
}
// uso
const id = await enviarLote(['joao@empresa.com.br'], 'signup');
const [resultado] = await aguardarLote(id);
if (resultado?.result === 'undeliverable') { /* bloqueia o lead */ }4. Autenticação Bearer e rotação de API keys
4.1 Formato do header
Todas as requests precisam do header Authorization: Bearer ec_live_xxxxxxxx. A key é gerada no painel em app.emailchecker.email e vale pros dois endpoints.
# OK
Authorization: Bearer ec_live_a1b2c3d4e5f6
# Erros comuns
Authorization: ec_live_a1b2c3d4e5f6 # falta "Bearer "
Authorization: Bearer ec_live_xxx,Bearer y # múltiplos tokens
authorization: bearer ec_live_xxx # OK (HTTP headers são case-insensitive)4.2 Storage seguro de keys
Nunca commit keys em repo, mesmo em .env. Padrões:
- Local dev:
.env.localem.gitignore+ exemplo em.env.example. - Cloud (Vercel/Netlify/AWS): env vars no painel, scoped por ambiente.
- Self-hosted: AWS Secrets Manager, HashiCorp Vault, Doppler. NUNCA env var em image Docker pública.
- Frontend (browser): NUNCA expor key no client — proxy pelo seu backend. Como a validação é assíncrona de qualquer jeito, o browser nem precisa falar com a API.
4.3 Rotação de keys sem downtime
Cenário: time de segurança detectou possível vazamento, precisa trocar a key sem quebrar produção.
- No painel, gera a nova key (mantém a antiga ativa).
- Deploy do app lendo a nova key do env var.
- Confirma nos seus próprios logs que 100% das chamadas já saem com a key nova — não existe endpoint que reporte uso por key.
- Revoga a antiga no painel.
Se o painel permitir mais de uma key ativa ao mesmo tempo, mantenha sempre duas em produção: a janela de troca simplesmente deixa de existir, e qualquer vazamento futuro tem rota de recuperação imediata.
5. Rate limiting (300/min, 10.000/h) e como respeitar
Limites reais: 300 requisições por minuto e 10.000 por hora, por API key (não por IP). São duas janelas independentes — você pode ter folga no minuto e estar no teto da hora.
5.1 Headers de rate limit
Toda resposta vem com dois headers que te dizem exatamente onde você está:
x-rate-limit-remaining-minute: 297
x-rate-limit-remaining-hour: 9863Exporte os dois como gauge no seu Prometheus/Datadog. Ver o remaining-hour descendo em rampa é o aviso de que o seu polling vai virar 429 antes do fim do dia.
5.2 Quando você bate o limite (HTTP 429)
HTTP/1.1 429 Too Many Requests
x-rate-limit-remaining-minute: 0
x-rate-limit-remaining-hour: 8420
{
"data": null,
"error": "Too many requests"
}Não existe header Retry-After — quem decide quanto esperar é você, com base nos dois headers acima. Se remaining-minute zerou mas remaining-hour tem folga, esperar a virada do minuto resolve. Se o de hora zerou, esperar 60 segundos não adianta nada: você precisa reduzir a frequência, não repetir mais rápido.
5.3 Client-side throttling
O jeito confiável de nunca ver 429 é passar todas as chamadas por um limitador. Em Node, com bottleneck:
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
reservoir: 300, // 300 requests
reservoirRefreshAmount: 300,
reservoirRefreshInterval: 60_000, // por minuto
maxConcurrent: 10,
});
// POST /batch e GET /batch/{id} passam pelo mesmo limitador —
// os dois consomem a MESMA cota de 300/min por key.
export const ecFetch = (path, init = {}) =>
limiter.schedule(() =>
fetch(`https://app.emailchecker.email/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.EC_API_KEY}`,
...init.headers,
},
})
);5.4 Estratégia: lote grande > muitos lotes pequenos
Enviar 100.000 emails custa 1 request do seu orçamento de rate limit. Enviar os mesmos 100.000 em lotes de 100 custa 1.000 requests de envio mais o polling de 1.000 lotes — e aí sim você estoura. Junte o máximo que der em cada lote: o rate limit conta requests, não emails.
6. Tratamento de erros e retry exponencial
6.1 Categorias de erro
Status codes HTTP têm semântica precisa — você deve tratar cada categoria diferente. O corpo sempre vem como { "data": null, "error": "..." }.
Bad Request
Payload inválido: JSON malformado, campo emails ausente ou vazio, lote acima de 100.000, email malformado. NÃO retry — corrija o request.
Auth
Key inválida, revogada ou conta sem acesso à API (ela é exclusiva do plano Gold). NÃO retry — verifique a key e o plano.
Not Found
Id de lote inexistente, digitado errado, ou pertencente a outra conta. NÃO retry.
Rate Limit
Passou de 300/min ou 10.000/h. Leia os headers x-rate-limit-remaining-* pra saber qual janela estourou e espere a janela virar.
Server
Erro nosso ou indisponibilidade temporária. Retry com exponential backoff — e apenas em GET (veja 6.3).
6.2 Exponential backoff pra 5xx + 429
// Use SOMENTE em GET /batch/{id}. Nunca em POST /batch — veja 6.3.
async function getComRetry(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const r = await fetch(url, options);
// sucesso ou erro do cliente — retorna sem retry
if (r.ok) return r;
if (r.status >= 400 && r.status < 500 && r.status !== 429) return r;
// 429: não há Retry-After. Espera a janela do minuto virar;
// se foi a cota de HORA que zerou, desiste e deixa o caller reduzir a frequência.
if (r.status === 429) {
if (Number(r.headers.get('x-rate-limit-remaining-hour') ?? 1) === 0) {
throw new Error('cota horária esgotada — reduza a frequência de polling');
}
await new Promise((res) => setTimeout(res, 60_000));
continue;
}
// 5xx: exponential backoff com jitter
const base = Math.min(1000 * 2 ** attempt, 30_000); // cap em 30s
const jitter = Math.random() * base * 0.3;
await new Promise((res) => setTimeout(res, base + jitter));
}
throw new Error('max retries exceeded');
}6.3 POST /api/v1/batch não é idempotente
Não existe header de idempotência. Se você faz retry de um POST que já tinha completado do lado do servidor (resposta perdida na rede, timeout do seu cliente), você cria um segundo lote — e paga os créditos duas vezes. Regras que evitam isso:
- Nunca coloque
POST /batchna sua função genérica de retry. Retry automático é só praGET, que é idempotente por natureza. - Marque o envio no seu banco antes de disparar o request e grave o
idassim que a resposta chegar. Um registro semidé o seu sinal de "não sei se foi". - Em caso de dúvida, confira no painel se o lote apareceu antes de reenviar — o
nameserve exatamente pra você reconhecer o lote lá. - Timeout generoso no
POST: ele só grava o lote e responde, não espera validação nenhuma. Cortar em 2s cria falso negativo.
7. Polling eficiente: intervalo, backoff e estados terminais
Como não existe webhook, callback ou qualquer notificação de saída — a plataforma nunca faz request pro seu servidor — o polling é o mecanismo de entrega. Vale a pena fazer direito.
7.1 Intervalo e backoff
- Primeiro poll ~5s depois do envio. Antes disso o lote quase certamente ainda está
pending. - Backoff suave: multiplique o intervalo por 1,5 a cada tentativa, com teto de 60s. Lote de 10 emails resolve nos primeiros polls; lote de 100k vai levar bem mais, e aí um poll por minuto é mais que suficiente.
- Prazo máximo: defina um teto (30 min pra lotes pequenos, horas pra lotes enormes) e dispare alerta em vez de pollar pra sempre.
- Nunca abaixo de 1s. Não acelera nada e só queima rate limit.
7.2 A conta que evita o 429
O limite é por API key, e todos os seus lotes em voo dividem a mesma cota. A conta é direta:
requests_por_minuto = lotes_em_voo × (60 / intervalo_em_segundos)
# 50 lotes abertos, poll a cada 5s → 50 × 12 = 600 req/min ❌ estoura os 300
# 50 lotes abertos, poll a cada 30s → 50 × 2 = 100 req/min ✅
# e cuidado com a janela de hora: 100 req/min × 60 = 6.000/h (teto 10.000)Padrão que escala melhor: um único worker varrendo os lotes abertos em ciclos, em vez de um timer independente por lote. Com um worker só, você controla a frequência total num lugar e o backoff vira propriedade do ciclo, não de cada lote.
7.3 Estados terminais: completed, failed, cancelled
Três dos cinco status são finais. Parar de pollar neles não é otimização, é correção — um loop que só sai no completed pola pra sempre um lote que falhou.
completed— leiaemails[]. É o único status em que o array vem preenchido.failed— o processamento quebrou. Pare o polling, registre oide trate como erro de negócio: reenviar o lote é decisão sua (vai consumir créditos de novo), então não faça isso em loop automático.cancelled— alguém cancelou o lote (normalmente pelo painel). Não é erro transitório: pare o polling e não reenvie sozinho, porque provavelmente o cancelamento foi intencional.pending/processing— continue polando com backoff.
7.4 Checklist do consumer
- Gravou o
idno banco assim que o201chegou. - Um worker (não N timers) percorre os lotes com status não-terminal.
- Backoff com teto, prazo máximo e alerta ao estourar o prazo.
- Os cinco status tratados explicitamente — sem
elsegenérico que engolefailed. emails[]vazio só é lido quandostatus === "completed".
8. Migração de mails.so: o que mapeia e o que não existe
Se você já integra mails.so, metade da migração é troca de string. A outra metade depende de você estar usando o endpoint síncrono ou não — e vale ser honesto: se estiver, tem refatoração de verdade pela frente. Veja o comparativo técnico completo com tabela de feature matching.
8.1 Mapping de endpoints
| mails.so | EmailChecker | Nota |
|---|---|---|
POST /v1/batch | POST /api/v1/batch | Equivalente. Body { emails, name }, de 1 a 100.000. Devolve data.id. |
GET /v1/batch/{id} | GET /api/v1/batch/{id} | Equivalente. Resultados em data.emails[], só quando status = completed. |
Header x-mails-api-key | Authorization: Bearer ec_live_... | Equivalente. Só muda o nome e o formato do header. |
Checagem única síncrona (GET /v1/check?email=) | Não existe | Sem equivalente. Mande um lote de 1 email e pole o id. Nenhum path /validate/... responde — dá 404. |
| Webhook / callback de conclusão | Não existe | Sem equivalente. A plataforma não faz requests pro seu servidor: o único jeito de saber que terminou é polling (seção 7). |
8.2 Mapping de response fields
Aqui a migração é quase indolor: os nomes dos campos batem. O que muda é onde eles moram — no EmailChecker cada item vive dentro de data.emails[] do GET, não em data de uma resposta síncrona.
| mails.so | EmailChecker | Nota |
|---|---|---|
data.result | data.emails[].result | Mesmo enum: deliverable / undeliverable / risky / unknown |
data.reason | data.emails[].reason | Texto do motivo |
data.score | data.emails[].score | 0 a 100 |
data.isv_format / isv_domain / isv_mx | mesmos nomes | Booleans — sintaxe, domínio, MX |
data.isv_noblock / isv_nocatchall / isv_nogeneric | mesmos nomes | Booleans — sem bloqueio, sem catch-all, não genérico |
data.is_free | data.emails[].is_free | Boolean — provedor gratuito |
| Qualquer campo fora da lista acima | Não retornado | Os sete campos isv_*/is_free acima, mais result, reason e score, são tudo que vem por email. |
8.3 Diff típico de migração
Quem já usava lote troca três linhas:
- const BASE = 'https://api.mails.so/v1';
+ const BASE = 'https://app.emailchecker.email/api/v1';
const r = await fetch(`${BASE}/batch`, {
method: 'POST',
- headers: { 'x-mails-api-key': KEY },
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ emails, name: 'minha-lista' }),
});
const { data } = await r.json();
const id = data.id;Quem usava a checagem única síncrona precisa trocar a chamada por um envio de lote de 1 + polling — e, junto, mover a validação pra fora do caminho síncrono do usuário (seção 3):
- const r = await fetch(`${BASE}/check?email=${encodeURIComponent(email)}`, {
- headers: { 'x-mails-api-key': KEY },
- });
- const { data } = await r.json();
- if (data.result === 'deliverable') { /* ... */ }
+ // não há endpoint síncrono: lote de 1 + polling (enviarLote/aguardarLote da seção 3.5)
+ const id = await enviarLote([email], 'signup');
+ const [resultado] = await aguardarLote(id);
+ if (resultado?.result === 'deliverable') { /* ... */ }9. Custos, plano Gold e monitoramento
9.1 O que conta como crédito
- 1 crédito = 1 email do lote. O campo
sizedevolvido no201é exatamente quantos emails entraram — e quantos créditos aquele lote vai consumir. - Requests rejeitadas antes do processamento (4xx por payload inválido ou auth) não consomem — nenhum lote foi criado.
- Emails que rodam o pipeline mas vêm
undeliverablepor sintaxe/DNS consomem — porque parse + DNS lookup aconteceu. - Erros 5xx do nosso lado não consomem — você só paga por trabalho real entregue.
- A API é exclusiva do plano Gold. Sem Gold, a key não passa da autenticação.
9.2 Saldo
Não existe endpoint de saldo — o consumo e o que sobra você acompanha no painel, em app.emailchecker.email. Se você precisa de alerta automático de saldo baixo, o caminho é contabilizar do seu lado: some os size dos lotes que você enviou no período e compare com a cota que contratou. É informação que você já tem no seu banco, já que está gravando o id e o size de cada envio.
9.3 Estratégias de redução de custo
- Dedupe antes do lote — listas com 30% de duplicatas custam 30% a mais sem ganho nenhum. O EC NÃO dedupa automático (decisão deliberada — você pode querer revalidar o mesmo email em momentos diferentes).
- Pré-filtro de sintaxe + MX no seu backend — não gaste crédito com email obviamente quebrado ou domínio que nem resolve.
- Cache do resultado — se você validou
joao@x.comhoje, não mande de novo no reenvio da mesma campanha. Guarderesult+score+ data. - Sample em listas suspeitas — antes de validar lista comprada de 100k, valide um lote de 1.000 aleatórios pra ver a projeção de bounce.
9.4 Observabilidade em produção
- Id do lote: logue sempre. É a única chave pra recuperar o resultado e a única referência útil pro suporte.
- Tempo de fila:
finished_at − created_atpor lote. Se a média sobe, o seu prazo máximo de polling precisa subir junto. - Taxa de
failed: proporção de lotes que terminam nesse status. Deve ser perto de zero; alerte se passar de 1%. - Rate limit remaining: exporte
x-rate-limit-remaining-minuteex-rate-limit-remaining-hourcomo gauge — é o indicador antecedente do 429. - Lotes órfãos: registros no seu banco com status não-terminal há mais tempo que o prazo definido. Se essa fila cresce, seu worker de polling parou.