EmailCheckerEmailChecker

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.

Por João Costa (Engenharia)·Publicado em 12/06/2026·8 min de leitura

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 risky em vez de descartar contato bom, catch-all assumido como unknown em vez de certeza falsa, e unknown que 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

Comece agora

Pronto pra parar de mandar email pra endereço morto?

Comece grátis com 500 créditos. Sem cartão, sem compromisso.