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
| Evento | Disparado quando |
|---|---|
email.scheduled | Um 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.sent | O SES aceitou a mensagem para entrega. |
email.delivered | O servidor do destinatário a aceitou. |
email.delivery_delayed | A entrega está sendo repetida (ex.: caixa cheia, greylisting). |
email.bounced | A 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.complained | O destinatário marcou como spam. Também suprimido. |
email.failed | O 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.suppressed | O 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.opened | Uma 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.clicked | Uma 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.prefetched | O 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.received | Uma 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:
| Evento | Disparado quando | data |
|---|---|---|
deliverability.warning | A 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.paused | A taxa cruzou o limite de pausa; novos envios são recusados até se recuperar. | igual ao anterior |
quota.warning | 80% 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.reached | A 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.paused | Só 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.
| Evento | Disparado quando | data extra |
|---|---|---|
contact.created | Um contato foi adicionado: API, lote, importação CSV ou painel. | |
contact.updated | Nome, 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.deleted | Um 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.unsubscribed | O contato saiu de todo e-mail de marketing. | |
contact.resubscribed | Uma reinscrição explícita (unsubscribed: false). | |
contact.topic_opt_in / contact.topic_opt_out | A inscrição efetiva do contato em um tópico mudou. | topic_id, topic_name |
suppression.added / suppression.removed | Um 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.
{
"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.
{
"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.reason | O que aconteceu |
|---|---|
domain_not_verified | O domínio de envio, ou o subdomínio de MAIL FROM dele, não estava verificado na hora do envio. |
domain_deleted | O domínio de envio foi excluído enquanto o e-mail esperava para sair. |
body_missing | O conteúdo guardado do e-mail não estava lá na hora do envio. |
body_unreadable | Não deu para descriptografar o conteúdo guardado do e-mail. |
message_rejected | O SES recusou a mensagem: na hora do envio, ou depois do email.sent, por ela ter vírus. |
retries_exhausted | Todas as tentativas de envio falharam por erros temporários, e não sobrou nenhuma. |
send_interrupted | Um 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_error | Qualquer outro erro que impediu o envio. |
{
"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.reason | type | message |
|---|---|---|
recipient_suppressed | OnAccountSuppressionList | The recipient is on your suppression list. |
contact_unsubscribed | Unsubscribed | The contact unsubscribed from your emails. |
topic_unsubscribed | Unsubscribed | The recipient opted out of this email's topic. |
contact_missing | Ineligible | The contact was deleted before the email was sent. |
contact_recipient_mismatch | Ineligible | The contact's address changed before the email was sent. |
broadcast_missing | Ineligible | The broadcast was deleted before the email was sent. |
topic_missing | Ineligible | The email's topic was deleted before it was sent. |
ineligible ou qualquer outro | Ineligible | The email was no longer eligible to send. |
OnAccountSuppressionList é o valor do Resend; Unsubscribed e Ineligible
são do próprio MillionSend.
- Um
POST /emailsque a lista de supressão sozinha recusa com422 all_recipients_suppressedguarda um e-mailsuppressed, que também dispara oemail.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.
{
"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".
| Evento | Disparado quando | data, além de source e inbox_id |
|---|---|---|
inbox.created | Uma caixa foi criada. | inbox |
inbox.updated | O nome, o nome de remetente, a validade ou as configurações de segurança de uma caixa mudaram. | inbox |
inbox.deleted | Uma 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.expired | Uma 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.created | Uma 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.updated | Uma 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.updated | Uma etiqueta entrou ou saiu de uma conversa; uma etiqueta por evento. | added, removed, thread_id, thread |
inbox.email.received | Uma mensagem recebida foi entregue ou liberada numa conversa. | thread_id, email_id, thread, email |
inbox.email.sent | Um envio da caixa foi aceito. | thread_id, email_id, thread, email |
inbox.email.quarantined | Uma mensagem recebida foi para a quarentena. Do MillionSend. | thread_id, email_id, reason, sender_trust, from, subject, received_at |
inbox.draft.created | Um rascunho foi criado, inclusive um envio retido para aprovação. | draft_id, draft; thread_id e thread nos rascunhos de resposta |
inbox.draft.updated | Um rascunho mudou. | igual ao de criação |
inbox.draft.sent | Um rascunho foi enviado; dispara antes do inbox.email.sent. | thread_id, email_id, draft_id, thread, draft |
inbox.draft.deleted | Um 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),dashboardousystem(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 edraft, um rascunho.emailedraftnunca trazemhtml,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.
{
"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"
}
}
}{
"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.
Segurança de agentes
Como uma caixa de agente protege o agente de IA que a lê e as pessoas para quem ele escreve, com confiança no remetente, quarentena, análise de injeção de prompt, remoção de texto oculto, códigos privados e regras de envio.
Supressões
Proteção automática para a sua reputação de remetente.