MillionSend Docs
Conceitos

Webhooks

Entregas de eventos assinadas para o ciclo de vida do e-mail, seguindo a especificação Standard Webhooks.

Webhooks enviam eventos do ciclo de vida do e-mail aos seus endpoints conforme acontecem. Crie endpoints no painel, escolha quais tipos de evento cada um recebe e inspecione cada entrega (payload, resposta, tentativas) no log de entregas por endpoint.

Tipos de evento

EventoDisparado quando
email.scheduledUm e-mail foi aceito com um scheduled_at no futuro; leia esse horário com GET /emails/{id}. Mudar o horário com PATCH /emails/{id} não dispara nada. Veja o payload abaixo.
email.sentO SES aceitou a mensagem para entrega.
email.deliveredO servidor do destinatário a aceitou.
email.delivery_delayedA entrega está sendo repetida (ex.: caixa cheia, greylisting).
email.bouncedA mensagem voltou (bounce). data.bounce.type diz como: Permanent é um hard bounce, e o endereço também é suprimido; Transient ou Undetermined quer dizer que o SES desistiu depois de tentar de novo (caixa cheia, um domínio que não existe), e o endereço não é suprimido.
email.complainedO destinatário marcou como spam. Também suprimido.
email.failedO e-mail falhou de vez: não vai ser enviado, ou o SES o recusou depois do email.sent; data.failed.reason diz o motivo. Veja os motivos abaixo.
email.suppressedO e-mail não vai ser enviado. A lista de supressão, um descadastro ou a saída do tópico barrou o envio, ou o contato, a transmissão ou o tópico dele foi excluído ou mudou antes do envio; data.suppressed diz qual. Veja os motivos abaixo.
email.openedUma pessoa carregou o pixel de rastreamento (exige rastreamento de aberturas no domínio). data.open traz ipAddress, userAgent e timestamp da busca. Um clique em uma mensagem ainda sem abertura também registra uma, com data.open.reason: "click": ninguém clica no que nunca renderizou, e para leitores do Apple Mail é a única abertura que pode ser vista.
email.clickedUma pessoa clicou em um link reescrito (exige rastreamento de cliques). data.click traz link, ipAddress, userAgent e timestamp, como no Resend. Um link que uma máquina seguiu — gateway de segurança, pré-visualização de link, busca segundos após a entrega — é registrado como email.prefetched.
email.prefetchedO pixel foi buscado, ou um link seguido, por uma máquina — o Mail Privacy Protection da Apple, o pré-carregamento do Gmail, um scanner de segurança, uma identidade de navegador que nenhum navegador real envia, uma busca segundos após a entrega ou todos os links da mensagem em um segundo; data.open.reason ou data.click.reason diz qual (veja precisão da taxa de abertura). Um clique registrado antes de o resto da rajada chegar é registrado de novo aqui com o mesmo data.click.timestamp que seu email.clicked levou: trate isso como o clique sendo desfeito, junto com qualquer email.opened com data.open.reason: "click" marcado um milissegundo antes dele. Opt-in: entregue só a endpoints que o listam, nunca a "todos os eventos".
email.receivedUma mensagem chegou a um dos seus domínios de recebimento e foi guardada, uma vez por mensagem, assim que fica visível: o e-mail que uma caixa de entrada retém na quarentena ou durante a análise, e o e-mail infectado, só disparam depois de entregues ou liberados. data.email_id é um e-mail recebido: leia com GET /emails/receiving/{id}. Veja o payload abaixo.

Eventos de conta não carregam e-mail; data descreve a situação da equipe:

EventoDisparado quandodata
deliverability.warningA taxa de hard bounce ou de reclamação da equipe cruzou o limite de risco (uma vez por episódio).{ metric, rate, limit, window_days, dashboard_url }
deliverability.pausedA taxa cruzou o limite de pausa; novos envios são recusados até se recuperar.igual ao anterior
quota.warning80% da cota foi usada: a cota diária de hoje no Free e no Starter, o volume incluído do período de cobrança no Pro e no Scale (uma vez por dia UTC ou por período, só na nuvem).{ used, limit, period, resets_at, dashboard_url } — period é "day" ou "month"; resets_at é a próxima meia-noite UTC ou o fim do período. No dia, ceiling é onde os envios começam a ficar em espera; no mês, overage diz se os envios além de limit são cobrados ou recusados.
quota.reachedA cota foi usada. Planos diários passam até 50% a mais e depois deixam os envios em espera até a meia-noite UTC; planos mensais cobram excedente quando ele está ativo e, senão, recusam envios pela API e deixam as transmissões em espera até o período renovar.igual ao anterior
quota.pausedSó em planos diários: 50% além da cota, novos envios ficam em espera até a meia-noite UTC, ou até um upgrade de plano liberá-los (uma vez por dia UTC, só na nuvem).igual ao anterior, sempre period: "day"

No plano Free, as equipes Free que você criou dividem uma cota diária e um só histórico de bounces e reclamações: cada um desses eventos dispara uma vez para todas elas, numa das equipes que enviaram, com os números somados.

Eventos de audiência disparam quando um contato ou a lista de supressão muda, seja quem for que mudou. data traz o contato no formato do Resend — id, email, first_name, last_name, unsubscribed, created_at, updated_at — mais source: api, dashboard, hosted_page (a central de preferências) ou one_click (um POST de header RFC 8058). Dos eventos abaixo, o Resend emite contact.created, contact.updated, contact.deleted, suppression.added e suppression.removed; os demais são extensões do MillionSend. O MillionSend não envia o contact.topics.updated do Resend: escolha contact.topic_opt_in e contact.topic_opt_out no lugar dele.

EventoDisparado quandodata extra
contact.createdUm contato foi adicionado: API, lote, importação CSV ou painel.
contact.updatedNome, propriedades ou o flag de descadastro mudaram pela API ou pelo painel. Uma escrita que repete os valores já guardados (uma reimportação completa, por exemplo) não emite nada e deixa updated_at intacto.
contact.deletedUm contato foi excluído. Depois de um apagamento (erase=true na API, ou a ação de apagar do painel) o email guardado vem como [erased]; use o id.
contact.unsubscribedO contato saiu de todo e-mail de marketing.
contact.resubscribedUma reinscrição explícita (unsubscribed: false).
contact.topic_opt_in / contact.topic_opt_outA inscrição efetiva do contato em um tópico mudou.topic_id, topic_name
suppression.added / suppression.removedUm endereço entrou ou saiu da lista de supressão. Linhas de bounce e reclamação vêm do SES, com source: null; source_id é o e-mail cujo bounce ou reclamação criou a entrada, ou null.data é { id, email, origin, source, source_id, created_at }

E-mails agendados, com falha e suprimidos

Como todo evento de um e-mail que você envia, esses três trazem email_id, from, to, subject e created_at, a hora do evento.

email.scheduled
{
  "type": "email.scheduled",
  "created_at": "2026-10-13T09:30:12.084Z",
  "data": {
    "email_id": "b10f77bb-a99a-48c9-bd0a-a5d2cb1ba4a1",
    "from": "Acme <hello@example.com>",
    "to": ["ada@example.org"],
    "subject": "Your trial ends tomorrow",
    "created_at": "2026-10-13T09:30:12.084Z"
  }
}

email.failed e email.suppressed disparam uma vez por e-mail que termina como failed ou suppressed, e acrescentam um objeto que diz o motivo.

email.failed
{
  "type": "email.failed",
  "created_at": "2026-10-13T10:00:02.317Z",
  "data": {
    "email_id": "3f7b2c71-5274-4246-82f3-57bc1563c276",
    "from": "Acme <billing@example.com>",
    "to": ["ada@example.org"],
    "subject": "Your receipt",
    "created_at": "2026-10-13T10:00:02.317Z",
    "failed": {
      "reason": "domain_not_verified"
    }
  }
}
failed.reasonO que aconteceu
domain_not_verifiedO domínio de envio, ou o subdomínio de MAIL FROM dele, não estava verificado na hora do envio.
domain_deletedO domínio de envio foi excluído enquanto o e-mail esperava para sair.
body_missingO conteúdo guardado do e-mail não estava lá na hora do envio.
body_unreadableNão deu para descriptografar o conteúdo guardado do e-mail.
message_rejectedO SES recusou a mensagem: na hora do envio, ou depois do email.sent, por ela ter vírus.
retries_exhaustedTodas as tentativas de envio falharam por erros temporários, e não sobrou nenhuma.
send_interruptedUm envio foi interrompido antes de o SES confirmar, então o e-mail pode ter saído ou não. Não há nova tentativa de envio.
provider_errorQualquer outro erro que impediu o envio.
email.suppressed
{
  "type": "email.suppressed",
  "created_at": "2026-10-14T09:30:00.912Z",
  "data": {
    "email_id": "b10f77bb-a99a-48c9-bd0a-a5d2cb1ba4a1",
    "from": "Acme <hello@example.com>",
    "to": ["ada@example.org"],
    "subject": "Your trial ends tomorrow",
    "created_at": "2026-10-14T09:30:00.912Z",
    "suppressed": {
      "type": "OnAccountSuppressionList",
      "message": "The recipient is on your suppression list.",
      "reason": "recipient_suppressed"
    }
  }
}
suppressed.reasontypemessage
recipient_suppressedOnAccountSuppressionListThe recipient is on your suppression list.
contact_unsubscribedUnsubscribedThe contact unsubscribed from your emails.
topic_unsubscribedUnsubscribedThe recipient opted out of this email's topic.
contact_missingIneligibleThe contact was deleted before the email was sent.
contact_recipient_mismatchIneligibleThe contact's address changed before the email was sent.
broadcast_missingIneligibleThe broadcast was deleted before the email was sent.
topic_missingIneligibleThe email's topic was deleted before it was sent.
ineligible ou qualquer outroIneligibleThe email was no longer eligible to send.

OnAccountSuppressionList é o valor do Resend; Unsubscribed e Ineligible são do próprio MillionSend.

  • Um POST /emails que a lista de supressão sozinha recusa com 422 all_recipients_suppressed guarda um e-mail suppressed, que também dispara o email.suppressed; veja Supressões.
  • Remover só alguns destinatários, quando a API recebe o envio ou na hora de enviar, não dispara o email.suppressed: o e-mail vai para os outros.
  • Cancelar uma transmissão não dispara nada para os e-mails que deixam de sair.

E-mails recebidos

email.received traz o payload do Resend, só com metadados: sem corpo, sem cabeçalhos e sem tamanho dos anexos, e from é só o endereço. received_for lista os endereços dos seus domínios para os quais a mensagem foi entregue, incluindo quem estava em Cco. Quando uma entrega posterior da mesma mensagem chega a mais endereços seus, outro email.received com o mesmo email_id lista só esses.

email.received
{
  "type": "email.received",
  "created_at": "2026-10-11T14:37:40.951Z",
  "data": {
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "created_at": "2026-10-11T14:37:40.951Z",
    "from": "ada@example.org",
    "to": ["support@inbound.example.com"],
    "bcc": [],
    "cc": [],
    "received_for": ["support@inbound.example.com"],
    "message_id": "<CAF1x7Qm9@mail.example.org>",
    "subject": "Order #1042",
    "attachments": [
      {
        "id": "b344102e-962e-8943-aef6-271c7743e1dd",
        "filename": "invoice.pdf",
        "content_type": "application/pdf",
        "content_disposition": "attachment",
        "content_id": null
      }
    ]
  }
}

Eventos de caixas de entrada

As caixas de entrada emitem os eventos inbox.* do Resend, mais dois do próprio MillionSend. Eles são opt-in: um endpoint só os recebe quando os escolhe pelo nome, nunca por "todos os eventos".

EventoDisparado quandodata, além de source e inbox_id
inbox.createdUma caixa foi criada.inbox
inbox.updatedO nome, o nome de remetente, a validade ou as configurações de segurança de uma caixa mudaram.inbox
inbox.deletedUma caixa foi excluída, por uma pessoa, pela API, por ter expirado, por ser uma caixa compartilhada sem uso há 90 dias ou porque o domínio dela foi excluído.
inbox.expiredUma caixa chegou ao expires_at, ou uma caixa do domínio compartilhado ficou 90 dias sem uso; o inbox.deleted vem em seguida. Do MillionSend.inbox, como estava
inbox.thread.createdUma conversa ganhou a primeira mensagem: a primeira entregue ou liberada, ou a primeira que a caixa enviou.thread_id, email_id, thread, email
inbox.thread.folder.updatedUma conversa mudou de pasta, à mão ou porque um e-mail novo a trouxe de volta para inbox.from, to, thread_id, thread
inbox.thread.labels.updatedUma etiqueta entrou ou saiu de uma conversa; uma etiqueta por evento.added, removed, thread_id, thread
inbox.email.receivedUma mensagem recebida foi entregue ou liberada numa conversa.thread_id, email_id, thread, email
inbox.email.sentUm envio da caixa foi aceito.thread_id, email_id, thread, email
inbox.email.quarantinedUma mensagem recebida foi para a quarentena. Do MillionSend.thread_id, email_id, reason, sender_trust, from, subject, received_at
inbox.draft.createdUm rascunho foi criado, inclusive um envio retido para aprovação.draft_id, draft; thread_id e thread nos rascunhos de resposta
inbox.draft.updatedUm rascunho mudou.igual ao de criação
inbox.draft.sentUm rascunho foi enviado; dispara antes do inbox.email.sent.thread_id, email_id, draft_id, thread, draft
inbox.draft.deletedUm rascunho foi excluído, recusado ou expirou.draft_id; thread_id e thread nos rascunhos de resposta
  • O source é api (uma chave de API ou um cliente MCP), dashboard ou system (e-mails chegando, envios, caixas expirando). Nunca é agent.
  • inbox é o objeto da caixa, thread é { object, id, subject, folder, labels, read }, email é um e-mail de conversa e draft, um rascunho. email e draft nunca trazem html, text, extracted_text, códigos ou links: busque o conteúdo pela API.
  • Os objetos são capturados quando o evento acontece, não quando ele é entregue, então uma segunda mudança rápida não reescreve o primeiro evento.
  • Chaves que não se aplicam ficam de fora, nunca vêm como null.
inbox.email.received
{
  "type": "inbox.email.received",
  "created_at": "2026-10-12T14:03:12.301Z",
  "data": {
    "source": "system",
    "inbox_id": "7eeb9517-aed7-40d7-ad65-500be42ed147",
    "thread_id": "b9e1837b-c150-45c6-a055-73b02ec9f1dc",
    "email_id": "155252fb-e93e-45ee-8c19-5080a929c28b",
    "thread": {
      "object": "inbox_thread",
      "id": "b9e1837b-c150-45c6-a055-73b02ec9f1dc",
      "subject": "Where is my order?",
      "folder": "inbox",
      "labels": [],
      "read": false
    },
    "email": {
      "id": "155252fb-e93e-45ee-8c19-5080a929c28b",
      "direction": "inbound",
      "from": "Maria Silva <maria@example.org>",
      "to": ["orders@inbound.example.com"],
      "cc": [],
      "bcc": [],
      "reply_to": [],
      "subject": "Where is my order?",
      "message_id": "<CAF7c1f0a2e5d3b@mail.example.org>",
      "attachments": [],
      "read": false,
      "received_at": "2026-10-12T14:03:11.229Z"
    }
  }
}
inbox.email.quarantined
{
  "type": "inbox.email.quarantined",
  "created_at": "2026-10-12T14:05:40.017Z",
  "data": {
    "source": "system",
    "inbox_id": "7eeb9517-aed7-40d7-ad65-500be42ed147",
    "thread_id": "6581da0f-148e-49c7-8ba3-b1c2fcabc5ef",
    "email_id": "6166ec82-4ea7-481a-a78d-42c6f84af265",
    "reason": "injection",
    "sender_trust": "external",
    "from": "billing@example.net",
    "subject": "Invoice overdue",
    "received_at": "2026-10-12T14:05:38.442Z"
  }
}

Só algumas caixas. POST /webhooks e PATCH /webhooks/{id} aceitam inbox_ids, até 100 caixas da equipe (null, o padrão, quer dizer todas), e os objetos de webhook o devolvem. Um endpoint assim só recebe os eventos de caixas e o email.received dessas caixas, então os e-mails de endereços do pega-tudo não chegam a ele. No painel: Só estas caixas.

Os SDKs de .NET e de Rust do Resend não conhecem os tipos de evento inbox.*: ler um webhook inscrito em algum deles pelo GET /webhooks falha nos dois, e o leitor de webhooks do .NET dá erro com esses payloads. Os próprios eventos de caixas do Resend fazem o mesmo hoje. Leia esses endpoints e as entregas deles com um SDK do MillionSend ou por HTTP direto.

Assinaturas (Standard Webhooks)

As entregas são assinadas seguindo a especificação Standard Webhooks — o mesmo esquema que Resend e Svix usam, então código de verificação existente funciona sem mudanças.

Cada endpoint tem um segredo whsec_..., exibido uma única vez na criação. Toda requisição carrega:

webhook-id: <message id>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64 HMAC-SHA256>

Os mesmos três valores também vão como svix-id, svix-timestamp e svix-signature — os nomes que a documentação do Resend manda ler. Uma assinatura, dois nomes de header: um handler escrito para qualquer uma das famílias verifica sem mudanças.

O conteúdo assinado é {webhook-id}.{webhook-timestamp}.{raw body}. Verifique com qualquer biblioteca Standard Webhooks, por exemplo em Node:

import { Webhook } from "standardwebhooks";

const wh = new Webhook("whsec_...");
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
});

Sempre verifique contra o corpo bruto da requisição, e rejeite timestamps antigos.

Trazendo seu próprio segredo

POST /webhooks aceita um signing_secret opcional: whsec_ seguido de base64 padrão de 24 a 64 bytes — o formato que Resend e Svix emitem. Passe o segredo com que seu receptor já verifica e o endpoint continua funcionando sem novo deploy; omita e o MillionSend gera um. Qualquer outra coisa é rejeitada com 422 signing_secret must be whsec_ followed by base64 of 24-64 bytes.

Para trazer um segredo de outro provedor, leia-o na API ou no painel dele (o Resend o retorna em GET /webhooks/{id}) e crie o endpoint aqui com o mesmo valor:

curl -X POST "https://api.millionsend.com/webhooks" \
  -H "Authorization: Bearer ms_..." \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://example.com/webhooks/email",
    "events": ["email.delivered", "email.bounced"],
    "signing_secret": "whsec_..."
  }'

O segredo é retornado na criação e em GET /webhooks/{id}, nunca nas linhas da listagem.

Rotacionando o segredo

POST /webhooks/{id}/rotate (ou Rotacionar segredo no painel) gera um novo segredo, ou usa o de signing_secret, e o retorna. Durante overlap_hours (padrão 24, até 72) o segredo anterior continua assinando: toda entrega nessa janela leva as duas assinaturas, separadas por espaço em webhook-signature, então um receptor com qualquer um dos dois verifica. Troque o receptor em qualquer momento da janela; depois dela só o novo assina. 0 descarta o antigo na hora, para um segredo vazado. GET /webhooks/{id} informa o fim da janela em previous_secret_expires_at, e uma segunda rotação dentro da janela substitui o segredo anterior.

curl -X POST "https://api.millionsend.com/webhooks/{id}/rotate" \
  -H "Authorization: Bearer ms_..." \
  -H "Content-Type: application/json" \
  -d '{ "overlap_hours": 24 }'

Entrega

Uma entrega é bem-sucedida em qualquer resposta 2xx. Cada endpoint tem a própria fila, iniciada na ordem de vencimento com até oito requisições em andamento a até 50 requisições por segundo; uma rajada de eventos espera na fila em vez de atingir o receptor de uma vez.

Uma tentativa que falha (não-2xx, timeout, erro de conexão) é repetida em um cronograma fixo: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h — seis tentativas em cerca de 18 horas. Um 429 com header Retry-After é respeitado (até uma hora) e não conta como tentativa: é o receptor pedindo espaço, não falhando. Um evento ainda não entregue 24 horas depois de enfileirado é descartado como exhausted, sem nova tentativa.

Após 20 entregas esgotadas consecutivas o endpoint é desativado automaticamente e não recebe mais nada até você reativá-lo na página dele; os eventos do intervalo não são reenviados. Os proprietários da equipe recebem um e-mail quando as entregas de um endpoint começam a falhar (as últimas dez entregas encerradas todas esgotadas), quando ele é desativado e — no máximo uma vez por dia — quando a fila dele tem mais de seis horas de atraso. O painel mostra a profundidade da fila de cada endpoint e há quanto tempo a entrega mais antiga espera.

Um job de reconciliação rearma filas perdidas em quedas, então a entrega é pelo-menos-uma-vez — torne seus handlers idempotentes, chaveados por webhook-id. As linhas de entrega (payload, resposta, tentativas) são mantidas por WEBHOOK_DELIVERY_RETENTION_DAYS (padrão 30) e depois apagadas.

Inscreva cada endpoint só nos eventos de que ele precisa. Uma reimportação completa de contatos não emite nada para os contatos que não mudaram, mas cada contato novo é uma entrega contact.created — um endpoint inscrito em "todos os eventos" recebe todas elas.

Nesta página