EmailChecker vs mails.so — comparativo técnico
Comparativo técnico honesto entre EmailChecker e mails.so: o que mapeia direto, o que não tem equivalente, campos de resposta, rate limits e o diff de migração.
Antes de entrar no código: esse artigo não foi escrito pra te convencer a migrar. Foi escrito porque essa é a pergunta que aparece com mais frequência no nosso suporte dev — "já uso mails.so, vale a pena trocar?" — e a resposta honesta é "depende". Vou mostrar exatamente do que depende, incluindo as duas coisas que a mails.so tem e o EmailChecker não tem.
Se quiser a versão em tabela, feature a feature, tem em mails.so vs EmailChecker. Aqui o foco é: código, diff de migração, e quando não migrar.
Contexto: por que comparar esses dois
mails.so e EmailChecker resolvem o mesmo problema na superfície — validar se um endereço de email existe e aceita mensagens antes de você enviar. A diferença começa quando você olha o ciclo de vida da chamada e a semântica do resultado.
mails.so é um serviço internacional, cobrado em moeda estrangeira, com uma proposta "no-code first" — Zapier e Make são cidadãos de primeira classe na documentação deles. A API existe, funciona e cobre tanto checagem única quanto lote.
EmailChecker é brasileiro, cobra em BRL e expõe via API exatamente o mesmo motor de validação que roda no painel — sem endpoint "lite" para integração e endpoint "completo" só no portal. Em compensação, a superfície da API é bem menor: duas rotas, e só no plano Gold.
Com isso dito, vamos ao que interessa.
Endpoints: o que mapeia e o que não existe
Base URL do EmailChecker: https://app.emailchecker.email.
| Operação | mails.so | EmailChecker |
|---|---|---|
| Enviar lote | POST /v1/batch |
POST /api/v1/batch |
| Consultar lote | GET /v1/batch/{id} |
GET /api/v1/batch/{id} |
| Checagem única síncrona | GET /v1/check?email= |
não existe |
| Callback de conclusão do lote | sim | não existe — só polling |
| Autenticação | header x-mails-api-key |
Authorization: Bearer ec_live_... |
As duas primeiras linhas são equivalência real: mesmo verbo, mesma ideia, mesmo formato de payload. As três seguintes são onde mora a decisão.
Não existe endpoint síncrono no EmailChecker. Nenhum path do tipo /validate, /validate/single ou /verify responde — dá 404. Se você precisa do veredito dentro do request do usuário, o equivalente é mandar um lote de 1 email e fazer polling, o que muda a arquitetura do seu signup, não só a URL.
Não existe webhook de conclusão. A plataforma não faz requisições para o seu servidor. Quando o lote termina, ninguém te avisa — quem descobre é o GET.
Não existe endpoint de saldo. O consumo você acompanha no painel, ou contabilizando do seu lado o size de cada lote enviado.
Referência completa das duas rotas na documentação da API REST. A seção 8 do guia de validação de email via API detalha o mapeamento de migração campo a campo.
Auth: troca de header, mesma ideia
mails.so usa um header proprietário:
x-mails-api-key: mso_sua_chave_aqui
EmailChecker usa o padrão Bearer do RFC 6750:
Authorization: Bearer ec_live_sua_chave_aqui
Na prática, é uma linha de diff no seu cliente HTTP. Se você usa uma constante API_KEY_HEADER, é literalmente uma mudança de string. O prefixo da chave também muda (mso_ para ec_live_) — guarde isso pro momento de rotacionar as credenciais, porque copiar a chave sem o Bearer na frente gera 401 silencioso. Vale conferir também o plano: a API do EmailChecker é exclusiva do Gold, e sem ele a chave não passa da autenticação.
Formato do lote: aqui a migração é fácil
Essa é a parte boa da notícia, e é verdade sem asterisco: o formato do lote e os nomes dos campos de resposta seguem o mesmo padrão da mails.so. Quem já manda lote não precisa reescrever o parser.
O request:
POST /api/v1/batch HTTP/1.1
Host: app.emailchecker.email
Authorization: Bearer ec_live_sua_chave_aqui
Content-Type: application/json
{
"emails": ["joao@empresa.com.br", "contato@outra.com.br"],
"name": "minha-lista"
}
De 1 a 100.000 emails por chamada; name é opcional. A resposta 201:
{
"data": {
"id": "8f2c1b7e-4a9d-4c31-9f0e-2b6d5a7c1e34",
"name": "minha-lista",
"created_at": "2026-06-12T14:02:11.000Z",
"user_id": "3d1a…",
"size": 2
}
}
E o GET /api/v1/batch/{id} depois que o lote fecha:
{
"data": {
"id": "8f2c1b7e-4a9d-4c31-9f0e-2b6d5a7c1e34",
"status": "completed",
"created_at": "2026-06-12T14:02:11.000Z",
"finished_at": "2026-06-12T14:03:47.000Z",
"size": 2,
"emails": [
{
"email": "joao@empresa.com.br",
"result": "deliverable",
"reason": "accepted_email",
"score": 96,
"isv_format": true,
"isv_domain": true,
"isv_mx": true,
"isv_noblock": true,
"isv_nocatchall": true,
"isv_nogeneric": true,
"is_free": false
}
]
}
}
Response shape: campo a campo
| Conceito | mails.so | EmailChecker | Observação |
|---|---|---|---|
| Status do email | result |
result |
Mesmo nome e mesmo enum |
| Motivo | reason |
reason |
Mesmo nome |
| Score | score |
score |
0 a 100 |
| Sintaxe / domínio / MX | isv_format, isv_domain, isv_mx |
mesmos nomes | Booleanos |
| Sonda, catch-all, genérico | isv_noblock, isv_nocatchall, isv_nogeneric |
mesmos nomes | Booleanos, true = leitura boa |
| Provedor gratuito | is_free |
is_free |
Boolean |
| Qualquer outro campo | — | não retornado | Os dez acima são tudo que vem por email |
O enum de result é o mesmo dos dois lados: deliverable, undeliverable, risky, unknown.
O que não é igual é onde esses campos moram e quando aparecem. No EmailChecker cada item vive dentro de data.emails[] do GET, e o array só vem preenchido quando data.status é completed — em pending e processing ele volta vazio. Ler data.emails sem checar o status antes é o bug número um de quem migra.
Ou seja: não é drop-in e não são "os mesmos endpoints". O que é compatível é o vocabulário. Se o seu código já entende result, score e isv_*, a camada de interpretação sobrevive intacta; o que muda é a camada de transporte.
Erro, quando acontece, tem sempre o mesmo formato — uma string, não um objeto estruturado:
{
"data": null,
"error": "Batch not found"
}
Semântica: onde os dois discordam de propósito
Nomes de campo iguais não significam decisões iguais. Quatro diferenças de comportamento que valem mais que a tabela acima:
Sonda SMTP bloqueada não vira undeliverable. Vários provedores brasileiros (bol, terra, uol e afins) bloqueiam ou envenenam a verificação SMTP. Quando o EmailChecker detecta que a sonda foi barrada — e não que a caixa não existe —, um guard reclassifica o resultado de undeliverable para risky, com isv_noblock: false explicando por quê. Serve para você não jogar fora um contato bom só porque o provedor dele não deixa perguntar.
Catch-all vira unknown, não deliverable. Domínio catch-all aceita qualquer endereço no handshake, então a resposta positiva não prova nada. Fingir certeza ali é o jeito mais comum de inflar taxa de acerto em material de marketing e entregar bounce em produção. O EmailChecker devolve unknown com isv_nocatchall: false e deixa a decisão com você.
unknown não consome crédito. Se o motor não conseguiu concluir, você não paga por um resultado que não te serve. Isso muda a conta de custo real por lista — principalmente em bases antigas, onde a proporção de indefinidos é alta.
O lote libera resultado progressivo. No painel, você já vê o que ficou pronto enquanto os casos difíceis continuam sendo revalidados em segundo plano. Via API o contrato é mais conservador: você lê a lista quando o lote fecha, com o status em completed.
Rate limits: números concretos
EmailChecker, por chave de API:
- 300 requisições por minuto
- 10.000 requisições por hora
Toda resposta traz x-rate-limit-remaining-minute e x-rate-limit-remaining-hour. Exporte os dois como gauge no seu monitoramento: são janelas independentes, e a de hora zerada não resolve esperando 60 segundos.
Não existe header Retry-After no 429 — o backoff é responsabilidade sua. E uma regra que vale tatuar: repita automaticamente só o GET. O POST /api/v1/batch não é idempotente; retry cego cria um lote novo e cobra os créditos de novo.
Na prática o limite raramente aperta, porque uma única requisição submete até 100.000 emails. Quem estoura é quem faz polling agressivo de muitos lotes ao mesmo tempo.
Do lado da mails.so os limites variam por plano — confira a tabela vigente na documentação deles antes de dimensionar o seu worker.
Polling: o padrão que substitui o webhook
Se você usa o callback de conclusão da mails.so hoje, essa é a parte da migração que é código novo de verdade. Não tem equivalente: no EmailChecker você pergunta, ninguém te conta.
O consumer mínimo em Node (18+, fetch nativo, sem dependência):
const BASE = 'https://app.emailchecker.email/api/v1';
const HEADERS = {
Authorization: `Bearer ${process.env.EC_API_KEY}`,
'Content-Type': 'application/json',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function enviarLote(emails, name) {
const r = await fetch(`${BASE}/batch`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({ emails, name }),
});
const body = await r.json();
if (!r.ok) throw new Error(`EmailChecker ${r.status}: ${body.error}`);
return body.data.id; // grave no banco ANTES de qualquer outra coisa
}
async function aguardarLote(id, { intervaloMs = 5_000, prazoMs = 1_800_000 } = {}) {
const limite = Date.now() + prazoMs;
while (Date.now() < limite) {
const r = await fetch(`${BASE}/batch/${id}`, { headers: HEADERS });
const body = await r.json();
if (!r.ok) throw new Error(`EmailChecker ${r.status}: ${body.error}`);
const { status, emails } = body.data;
if (status === 'completed') return emails; // só aqui o array vem preenchido
if (status === 'failed' || status === 'cancelled') {
throw new Error(`lote ${status}`);
}
await sleep(intervaloMs); // pending | processing
}
throw new Error(`prazo esgotado aguardando o lote ${id}`);
}
Três coisas que o webhook te dava de graça e agora são suas: gravar o id antes de sair do POST (sem ele o resultado é irrecuperável), um teto de tempo para o loop não girar para sempre, e um worker único percorrendo os lotes pendentes em vez de N timers concorrentes na mesma chave.
O ganho colateral é que o polling não exige endpoint público, verificação de assinatura, tolerância a replay nem dead letter queue — o consumer roda atrás do seu firewall e a autenticação é a mesma chave que você já usa.
Diff de migração: código real
Caso 1: você já usava lote — 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;
Caso 2: parsing — mesmos nomes, endereço novo
- // resultado do lote na mails.so
- for (const item of resposta.data) {
+ // no EmailChecker os itens vivem em data.emails[] e só quando status === "completed"
+ if (resposta.data.status !== 'completed') return;
+ for (const item of resposta.data.emails) {
salvar({
email: item.email,
result: item.result, // mesmo enum
score: item.score, // mesmo nome
catchAll: !item.isv_nocatchall,
sondaBloqueada: !item.isv_noblock,
});
}
Caso 3: você usava a checagem única síncrona — refatoração de verdade
- const r = await fetch(`${BASE}/check?email=${encodeURIComponent(email)}`, {
- headers: { 'x-mails-api-key': KEY },
- });
- const { data } = await r.json();
- if (data.result === 'deliverable') { /* libera o cadastro */ }
+ // não há endpoint síncrono: lote de 1 + polling, fora do caminho do request
+ const id = await enviarLote([email], 'signup');
+ await salvarLead({ email, emailStatus: 'pending', batchId: id });
+ // um worker consome os pendentes e atualiza o registro depois
Repare que a mudança não é de URL: é de arquitetura. O cadastro passa a nascer em pending, e a sua aplicação precisa saber o que fazer com um lead nesse estado — normalmente não disparar campanha e não passar para SDR, mas também não bloquear o signup.
Caso 4: você usava o webhook de conclusão
- app.post('/webhooks/mails-so', async (req, res) => {
- await processarResultado(req.body); // o provedor avisava; você só reagia
- res.sendStatus(200);
- });
+ // cron a cada 5 minutos: nenhum endpoint público envolvido
+ async function tick() {
+ for (const lote of await lotesPendentes()) {
+ const r = await fetch(`${BASE}/batch/${lote.id}`, { headers: HEADERS });
+ const { data } = await r.json();
+ if (data.status === 'completed') await processarResultado(data.emails, lote.id);
+ if (data.status === 'failed' || data.status === 'cancelled') await marcarErro(lote.id, data.status);
+ }
+ }
Tempo até o resultado
Aqui vale corrigir a pergunta antes de responder: como o EmailChecker não tem endpoint síncrono, latência de request única não é a métrica certa. O POST volta em milissegundos porque ele só enfileira — o número que importa é quanto tempo o lote leva para fechar.
A resposta vem do próprio contrato: meça created_at e finished_at de cada lote e acompanhe a mediana. É a métrica que detecta fila crescendo antes do usuário perceber, e é ela que você deve colocar no dashboard, não o RTT do POST.
Se o seu produto exige veredito dentro do request do usuário, o ponto não é comparar milissegundos: é que o EmailChecker não atende esse caso de uso, e a mails.so atende.
Custo
Planos do EmailChecker, em BRL, com créditos que não expiram:
| Plano | Preço | Créditos | API REST |
|---|---|---|---|
| Bronze | R$ 47 | 5.000 | não |
| Prata | R$ 97 | 50.000 | não |
| Gold | R$ 197 | 500.000 | sim |
Duas leituras importantes dessa tabela. A primeira: a API só existe no Gold. Se o seu volume não justifica o Gold, a integração programática não está no cardápio — e isso pesa mais que qualquer diferença de centavo por email.
A segunda: cobrança em BRL significa não ter variação cambial no orçamento mensal. Do lado da mails.so, os planos são em moeda estrangeira e alguns têm mensalidade recorrente; consulte a tabela vigente deles e compare com o seu volume real, não com o volume que você imagina.
Quando NÃO migrar
Honestamente: se você está satisfeito com mails.so, não migre.
Casos específicos onde faz sentido ficar:
Você depende da checagem única síncrona. Esse é o motivo mais forte da lista. Se o seu signup bloqueia o cadastro no ato com base no resultado, migrar não é trocar URL — é reescrever o fluxo para assíncrono, com estado pending no banco e worker de polling. Se isso não cabe no roadmap, fica onde está.
Você depende do callback de conclusão. O EmailChecker não te avisa quando o lote termina. Trocar webhook por polling é código novo, teste novo e um worker a mais para operar.
Integração no-code sem loop. Se você usa mails.so via Zapier e não tem backend, atenção: Zapier não tem passo de espera com loop, então não dá para enviar o lote e ler o resultado no mesmo Zap. Dá para contornar com dois Zaps agendados, mas se hoje funciona em um passo só, você está trocando simplicidade por nada.
Custo é o driver e o volume é baixo. Se você valida poucos milhares de emails por mês, o Gold não se paga só pela API.
Migrar tem custo real: QA do novo ciclo de vida da chamada, worker de polling, rotação de chaves, atualização de dashboards internos. Isso é hora de engenharia, e precisa valer a pena.
Quando faz sentido migrar
- Você já usa o endpoint de lote. Aí a migração é de string: base URL, header, e ler os itens em
data.emails[]. Um dia de trabalho, com folga. - Você quer preço em BRL e suporte em português, sem fila de ticket internacional nem exposição cambial.
- Você prefere a semântica conservadora: sonda bloqueada virando
riskyem vez de descartar contato bom, catch-all assumido comounknownem vez de certeza falsa, eunknownque não consome crédito. - Você precisa de LGPD documentada com política de privacidade e processo de exclusão descritos em português.
Conclusão
A migração de mails.so para EmailChecker é fácil ou difícil dependendo de uma única pergunta: você usa lote ou checagem síncrona?
Se usa lote, é quase indolor — os campos de resposta têm os mesmos nomes, o enum de result é o mesmo, e o trabalho fica em trocar base URL, header e o caminho onde os itens moram. Se usa checagem síncrona ou webhook, você tem refatoração de arquitetura pela frente, e é justo pesar isso contra o que ganha do outro lado.
O que o EmailChecker entrega de diferente não é superfície de API — é menos superfície, com semântica mais conservadora, preço em BRL e suporte em PT-BR. O que ele não entrega é validação síncrona e callback de conclusão, e nenhuma dessas duas está no roadmap desse texto.
Para os detalhes, a documentação da API cobre as duas rotas e o tratamento de erro, e a seção 8 do guia de validação de email via API tem o mapeamento de migração completo. A tabela feature a feature está no comparativo mails.so vs EmailChecker.
Se a sua dúvida é mais ampla — qual validador escolher no geral, não só entre esses dois — veja o review de 10 ferramentas de validação de email. E se a comparação for entre os dois validadores brasileiros, o comparativo entre EmailChecker e SafetyMails cobre esse caso.
João Costa — Engenharia, EmailChecker