Resend: enviando e recebendo e-mails com Node.js
Olá meus Unicórnios! 🦄✨
Sabe aquele e-mail de "confirme seu cadastro" que o seu sistema manda? Ele parece a parte mais boba do projeto, até ir parar na caixa de spam do cliente. 😅 Aí você descobre que mandar e-mail direito envolve SPF, DKIM, reputação de IP e mais uma sopa de letrinhas que ninguém pediu.
Hoje eu uso a Resend para isso, e quero mostrar o
caminho inteiro: criar a conta, pegar a chave, mandar o primeiro e-mail com
um fetch de Node.js, verificar o domínio e, a parte que quase
ninguém explica, receber e-mails no seu código. Tudo com
dois arquivos pequenos, sem instalar nenhum pacote.
Se você só quer testar envio sem mandar nada de verdade, eu já mostrei o Mailtrap em como testar o envio de e-mails via SMTP. E se você já recebe e-mail pela Amazon, o caminho está em AWS SES: recebendo e-mails e mandando pra um webhook. Este artigo é o caminho mais curto dos dois lados.
💌 Por que a Resend
A Resend é um serviço de envio de e-mail feito para quem programa. Em vez de configurar um servidor SMTP, você manda um JSON para uma URL com a sua chave no cabeçalho, e pronto: ela cuida da entrega, da assinatura DKIM e dos IPs.
O que me fez ficar:
- Plano gratuito de verdade: 100 e-mails por dia, 3 domínios e 1 webhook, com o recebimento de e-mails incluído.
- Endereços de teste que simulam entrega, devolução e denúncia de spam, sem estragar a reputação de ninguém.
- Recebimento no mesmo painel: a Resend recebe o e-mail e avisa o seu sistema por webhook.
- Um painel que mostra cada e-mail enviado e o que aconteceu com ele.
🔑 A conta e a chave de API
Crie a conta em resend.com. No menu da esquerda, clique em API keys e depois em Create API key.
São três campos:
- Name: um apelido só para você lembrar para que serve a chave.
- Permission: Sending access só envia;
Full access faz tudo. Repare no detalhe: para ler
os e-mails recebidos, a chave precisa ser Full access. Com
Sending access, a Resend responde
restricted_api_key, "This API key is restricted to only send emails". - Domain: deixa em All domains por enquanto.
Ao clicar em Add, a chave aparece uma única
vez. Ela começa com re_. Copie na hora e guarde: se
fechar a janela, não tem como ver de novo, só criando outra. 🙏
📁 Preparando a pasta
Você precisa do Node.js 20.6 ou mais novo, porque vamos usar duas coisas
que já vêm nele: o fetch e a opção --env-file,
que lê a chave de um arquivo. Para conferir a versão, abra o terminal e
digite:
node -v
Se aparecer algo como v20.6.0 ou maior (o meu mostrou
v24.18.0), está tudo certo. Não precisa de npm
install: nenhum dos dois arquivos deste artigo usa pacote de
fora.
Crie uma pasta para o projeto e, dentro dela, um arquivo chamado
.env (isso mesmo, começando com ponto e sem nada antes). Pode
ser pelo Bloco de Notas, pelo VS Code ou, num servidor Linux, com
nano .env (para salvar no nano: Ctrl+O,
Enter e Ctrl+X para sair). O conteúdo é este:
RESEND_API_KEY=SUA_CHAVE_AQUI
RESEND_WEBHOOK_SECRET=SEU_SEGREDO_AQUI
Troque SUA_CHAVE_AQUI pela chave que você copiou. O
segredo do webhook vem mais adiante; por ora pode deixar assim.
✉️ Enviando o primeiro e-mail
Crie o arquivo enviar.js na mesma pasta, com este
conteúdo:
// enviar.js: manda um e-mail pela API da Resend, com o fetch que já vem no Node
// Rode com: node --env-file=.env enviar.js
const CHAVE = process.env.RESEND_API_KEY;
if (!CHAVE) {
console.error("Faltou a chave: coloque RESEND_API_KEY no arquivo .env");
process.exit(1);
}
async function enviarEmail(para, assunto, html) {
const resposta = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": "Bearer " + CHAVE,
"Content-Type": "application/json",
},
body: JSON.stringify({
// Sem domínio verificado, o remetente tem de ser o [email protected]
from: "Meu Site <[email protected]>",
to: [para],
subject: assunto,
html: html,
}),
});
const corpo = await resposta.json();
// ATENÇÃO: o fetch não dá erro quando a Resend recusa o envio.
// Um 401 ou 403 chega aqui como resposta normal; quem avisa é o resposta.ok.
if (!resposta.ok) {
throw new Error("a Resend recusou (" + resposta.status + "): " + corpo.message);
}
return corpo.id;
}
async function principal() {
try {
const id = await enviarEmail(
"[email protected]",
"Olá do Node.js",
"<p>Meu primeiro e-mail pela <strong>Resend</strong>!</p>"
);
console.log("E-mail aceito pela Resend. id:", id);
} catch (erro) {
console.error("Não foi possível enviar:", erro.message);
process.exit(1);
}
}
principal();
O coração é um POST em https://api.resend.com/emails
com quatro campos: from, to, subject
e html. A chave vai no cabeçalho Authorization,
depois da palavra Bearer e de um espaço.
Dois detalhes que mordem na primeira vez:
O remetente. Enquanto você não verifica um domínio seu,
o from tem de ser [email protected]. O nome
antes dele (Meu Site) você escolhe à vontade.
O fetch não avisa que deu errado. Esse
foi o que me pegou. 😳 Quando a Resend recusa o envio, ela responde
401 ou 403 com um JSON explicando o motivo, e para
o fetch isso é uma resposta como outra qualquer: nada de
exceção, nada de catch. Se você não olhar o
resposta.ok, o script lê corpo.id, recebe
undefined e segue feliz, jurando que mandou. É por isso que o
if (!resposta.ok) está ali, transformando a recusa num erro
de verdade com a mensagem da própria Resend.
Para rodar, no terminal, dentro da pasta:
node --env-file=.env enviar.js
Quando dá certo, a Resend devolve só o identificador do e-mail, neste formato (o exemplo é o da documentação dela):
{
"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
}
Esse id quer dizer "aceitei o e-mail", não "entreguei". A
entrega acontece depois, e é no painel que você acompanha.
Os endereços de teste
Repare que o script manda para [email protected]. É um
endereço de teste da própria Resend, que sempre aceita. Existem outros, e
eles valem ouro para ver o seu sistema lidando com o problema antes que
ele aconteça de verdade:
[email protected]: simula entrega com sucesso.[email protected]: simula a devolução, aquele "usuário não existe" (SMTP550 5.1.1).[email protected]: simula o destinatário marcando como spam.
Todos aceitam um rótulo depois do +, como
[email protected], para você separar um teste do
outro no painel. Só não saia inventando endereço falso em domínio de
verdade para testar: e-mail devolvido pesa na sua reputação.
🚨 Quando a Resend diz não
Vale conhecer as recusas antes de precisar delas. Sem o
.env, o script para antes de chamar a API:
$ node enviar.js
Faltou a chave: coloque RESEND_API_KEY no arquivo .env
Com uma chave errada (aqui, um re_chave_errada colocado de
propósito no .env), quem responde é a Resend, e a mensagem
chega inteira:
$ node --env-file=.env enviar.js
Não foi possível enviar: a Resend recusou (401): API key is invalid
E a recusa mais famosa de todas, que aparece quando você tenta mandar de
[email protected] para o e-mail de outra pessoa, é um
403 que começa assim: "You can only send testing emails to
your own email address". Não é bug: o remetente de teste só entrega no
e-mail da sua conta e nos endereços @resend.dev. Para mandar
para o mundo, é preciso verificar um domínio, que é o próximo passo.
Tudo o que você manda aparece em Emails, na aba Sending, com o status de cada um. Na minha conta (borrei destinatários e assuntos) dá para ver os três destinos possíveis: entregue, aberto e devolvido.
🌐 Verificando o seu domínio
Para o from ser [email protected], a
Resend precisa provar que o domínio é seu. Isso se faz no DNS, que é a
"lista telefônica" do domínio, lá no painel de onde ele está hospedado
(Cloudflare, Registro.br, a hospedagem do site).
- No menu Domains, clique em Add domain.
- Digite o domínio e escolha a região. Eu escolhi São Paulo (sa-east-1), que é onde está o meu público.
- A Resend mostra os registros que você precisa criar no DNS.
Cada bloco da tela tem um papel:
- DKIM (um registro
TXTcom nomeresend._domainkey): é a assinatura que prova que o e-mail saiu mesmo de você. - Enable Sending (os registros
CNAME): liberam o envio em nome do domínio. - Enable Receiving (o
MX): só é preciso para receber, e é assunto da próxima seção.
Copie cada linha exatamente como está, campo por campo: tipo, nome e
conteúdo. Se o seu DNS estiver na Cloudflare, o botão Auto
configure cria tudo sozinho. Na Cloudflare, deixe os
CNAME como DNS Only (a nuvem cinza), que é o
que a tabela pede na coluna Proxy status.
Um detalhe que confunde: na minha tela os nomes vêm com um pedaço a mais
no fim, porque eu cadastrei um subdomínio e não o domínio
principal. É o que eu recomendo, por um motivo que vai ficar claro na parte
de recebimento. Quando tudo ficar Verified (comigo levou
uns quatro minutos), troque o from do enviar.js
para um endereço do domínio, como Minha Loja
<[email protected]>, e o limite de "só para mim"
some.
📥 Recebendo e-mails: o endereço pronto e o MX
Agora a outra metade. Em Emails, abra a aba Receiving:
Toda conta ganha um endereço pronto, no formato
qualquercoisa@<id>.resend.app (o meu está borrado na
imagem). Qualquer nome antes do @ funciona, e o e-mail aparece
nessa aba. É o jeito mais rápido de começar, sem mexer em DNS nenhum.
Para receber no seu domínio, ligue a chave
Enable Receiving na página do domínio e crie o registro
MX que ela mostra, com prioridade 10. Aqui mora o
aviso mais importante do artigo:
Uma curiosidade para quem leu o artigo do SES: o conteúdo desse MX
termina em amazonaws.com, e o da minha região começa com
inbound-smtp.sa-east-1. O recebimento da Resend passa pela
infraestrutura da Amazon, com o mesmo endereço que a gente configurava
na mão lá.
🪝 O webhook que avisa quando chega
Receber o e-mail é metade do trabalho: o seu sistema precisa saber que ele chegou. Para isso, a Resend chama uma URL sua, o webhook, a cada e-mail recebido.
No menu Webhooks, clique em Add webhook,
informe a URL e escolha o evento email.received:
A URL precisa ser pública: a Resend não enxerga o
localhost do seu computador. Na prática, é um endereço do seu
servidor. Depois de criado, a página do webhook mostra o signing
secret, um texto que começa com whsec_. Ele vai no
RESEND_WEBHOOK_SECRET do .env.
O que chega no seu servidor é um POST com este formato (o
exemplo é da documentação da Resend):
{
"type": "email.received",
"created_at": "2026-02-22T23:41:12.126Z",
"data": {
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"created_at": "2026-02-22T23:41:11.894Z",
"from": "[email protected]",
"to": [
"[email protected]"
],
"bcc": [],
"cc": [],
"received_for": [
"[email protected]"
],
"message_id": "<[email protected]>",
"subject": "Sending this example",
"attachments": [
{
"id": "2a0c9ce0-3112-4728-976e-47ddcd16a318",
"filename": "avatar.png",
"content_type": "image/png",
"content_disposition": "inline",
"content_id": "img001"
}
]
}
}
Procure o texto do e-mail aí dentro. Não tem! 🤯 O webhook traz só o
envelope: quem mandou, para quem, o assunto e a lista de
anexos. O corpo você busca pela API, com o email_id. A Resend
faz assim de propósito, para um e-mail com anexo grande não virar um webhook
gigante.
🔐 O receptor em Node.js
Crie o receber.js. Vou mostrar em três partes, mas é um
arquivo só: cole uma embaixo da outra. A primeira confere se o aviso veio
mesmo da Resend:
// receber.js: recebe o aviso "email.received" da Resend e busca o e-mail que chegou
// Rode com: node --env-file=.env receber.js
const http = require("node:http");
const crypto = require("node:crypto");
const CHAVE = process.env.RESEND_API_KEY;
const SEGREDO = process.env.RESEND_WEBHOOK_SECRET; // aquele que começa com whsec_
const PORTA = 3009;
if (!CHAVE || !SEGREDO) {
console.error("Faltou RESEND_API_KEY ou RESEND_WEBHOOK_SECRET no arquivo .env");
process.exit(1);
}
function assinaturaValida(corpoCru, cabecalhos) {
const id = cabecalhos["svix-id"];
const momento = cabecalhos["svix-timestamp"];
const assinaturas = cabecalhos["svix-signature"];
if (!id || !momento || !assinaturas) {
return false;
}
// Aviso com mais de 5 minutos é recusado: pode ser alguém reenviando um webhook antigo
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - Number(momento)) > 5 * 60) {
return false;
}
// ARMADILHA: a chave do HMAC não é o texto do segredo.
// Tira o "whsec_" da frente e decodifica o resto, que está em base64.
const chaveSecreta = Buffer.from(SEGREDO.replace("whsec_", ""), "base64");
const conteudo = id + "." + momento + "." + corpoCru;
const esperada = crypto
.createHmac("sha256", chaveSecreta)
.update(conteudo)
.digest("base64");
// O cabeçalho pode trazer mais de uma assinatura, separadas por espaço: "v1,abc v1,xyz"
const lista = assinaturas.split(" ");
for (const item of lista) {
const recebida = item.split(",")[1];
if (recebida && recebida.length === esperada.length) {
if (crypto.timingSafeEqual(Buffer.from(recebida), Buffer.from(esperada))) {
return true;
}
}
}
return false;
}
Sem essa conferência, qualquer pessoa que descobrir a sua URL pode
mandar um "chegou e-mail" falso para o seu sistema. A Resend assina cada
aviso com três cabeçalhos (svix-id, svix-timestamp
e svix-signature), e a conta é esta: um HMAC-SHA256 de
id.momento.corpo, em base64.
A armadilha está no segredo. O whsec_... que o painel
mostra não é a chave: a chave é o que vem depois do
whsec_, decodificado de base64. Quem passa o texto inteiro
para o createHmac calcula uma assinatura que nunca bate, e
passa a tarde achando que a Resend está mandando errado.
O teste do tempo recusa aviso com mais de cinco minutos. É a proteção
contra alguém que captura um webhook legítimo e o reenvia depois: a
assinatura continuaria válida, o horário não. E o
timingSafeEqual compara as duas assinaturas sempre no mesmo
tempo, para ninguém descobrir a certa medindo quanto o seu servidor demora
para dizer não.
A segunda parte busca o e-mail completo:
async function buscarEmailRecebido(idDoEmail) {
const resposta = await fetch("https://api.resend.com/emails/receiving/" + idDoEmail, {
headers: { "Authorization": "Bearer " + CHAVE },
});
const corpo = await resposta.json();
if (!resposta.ok) {
throw new Error("a Resend recusou (" + resposta.status + "): " + corpo.message);
}
return corpo;
}
É o mesmo padrão do envio, agora com um GET em
/emails/receiving/ mais o email_id. Lembra da
permissão? Aqui a chave precisa ser Full access.
A terceira parte é o servidor:
const servidor = http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/webhook/resend") {
res.writeHead(404);
res.end();
return;
}
// Junta os pedaços como bytes e só converte para texto no fim.
// Somar os pedaços como texto quebra um "ç" que chegue dividido entre dois deles.
const pedacos = [];
for await (const pedaco of req) {
pedacos.push(pedaco);
}
const corpoCru = Buffer.concat(pedacos).toString("utf8");
// A assinatura é conferida sobre o texto CRU, antes de qualquer JSON.parse
if (!assinaturaValida(corpoCru, req.headers)) {
console.log("Webhook recusado: assinatura inválida");
res.writeHead(400);
res.end("assinatura inválida");
return;
}
const evento = JSON.parse(corpoCru);
// Responde logo; buscar o e-mail pode demorar, e a Resend não precisa esperar
res.writeHead(200);
res.end("ok");
if (evento.type !== "email.received") {
return;
}
console.log("Chegou e-mail de", evento.data.from, "com o assunto:", evento.data.subject);
// O webhook traz só o envelope. O corpo do e-mail é buscado pela API.
try {
const email = await buscarEmailRecebido(evento.data.email_id);
console.log("Conteúdo do e-mail:");
if (email.text) {
console.log(email.text);
} else {
// E-mail enviado só em HTML chega com text = null
console.log(email.html);
}
} catch (erro) {
console.error("Não consegui buscar o conteúdo:", erro.message);
}
});
servidor.listen(PORTA, () => {
console.log("Esperando webhooks em http://localhost:" + PORTA + "/webhook/resend");
});
Repare na ordem das coisas. Primeiro o corpo é lido cru,
do jeito que chegou; depois a assinatura é conferida; só então vem o
JSON.parse. A assinatura foi calculada sobre os bytes exatos
que a Resend mandou. Se você converter para objeto e voltar para texto
antes de conferir, qualquer espaço ou ordem de campo diferente derruba a
conta.
E aquele Buffer.concat que parece exagero? O jeito que
todo mundo escreve é corpoCru += pedaco, e ele funciona
quase sempre. O corpo chega em pedaços, e somar pedaços como texto converte
cada um separadamente. Se um ú (que ocupa dois bytes) cair
bem na divisa entre dois pedaços, cada metade vira um caractere estragado,
e a assinatura de um aviso legítimo falha. Eu troquei o
Buffer.concat pela soma e mandei um aviso assinado, com o
assunto "Dúvida sobre o pedido", partido no meio do ú: resposta
400 assinatura inválida. Com o Buffer.concat, o
mesmo aviso passou. É o tipo de bug que só aparece com e-mail em
português. 😅
Por fim, o servidor responde 200 antes de
buscar o conteúdo. A Resend só quer saber se você recebeu o aviso. Se o seu
servidor falhar, ela tenta de novo sozinha: na hora, depois de 5 segundos,
5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas e mais 10 horas. Não precisa (nem deve)
segurar a resposta enquanto conversa com a API.
Para rodar:
node --env-file=.env receber.js
🧪 Batendo na porta sem assinatura
Dá para ver a proteção funcionando sem esperar e-mail nenhum. Com o
receber.js rodando, mande um aviso falso, sem os cabeçalhos da
Resend, de outro terminal:
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:3009/webhook/resend \
-H "Content-Type: application/json" \
-d '{"type":"email.received","data":{"email_id":"falso"}}'
A resposta (o -w pede para o curl mostrar o
código HTTP na última linha):
assinatura inválida
HTTP 400
E no terminal do servidor:
$ node --env-file=.env receber.js
Esperando webhooks em http://localhost:3009/webhook/resend
Webhook recusado: assinatura inválida
O aviso falso nem chegou perto da API. É esse o comportamento que você quer em produção.
A mesma chave errada, dois códigos
Um último detalhe, que eu achei comparando as recusas. A mesma chave inválida recebe respostas diferentes dependendo de onde você bate. No envio:
curl -s -X POST https://api.resend.com/emails \
-H "Authorization: Bearer re_chave_errada" \
-H "Content-Type: application/json" -d '{}'
{
"statusCode": 401,
"name": "validation_error",
"message": "API key is invalid"
}
E na busca de um e-mail recebido:
curl -s https://api.resend.com/emails/receiving/56761188-7520-42d8-8898-ff6fc54ce618 \
-H "Authorization: Bearer re_chave_errada"
{
"statusCode": 400,
"message": "API key is invalid",
"name": "validation_error"
}
401 num, 400 no outro, com a mesma mensagem.
Se o seu código tratar "chave errada" olhando só para o 401, a
falha do recebimento passa como "pedido mal feito" e você vai procurar o
problema no lugar errado. Por isso os dois arquivos mostram a
message que vem no corpo: ela diz a mesma coisa nos dois
casos.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
A Resend é gratuita?
Por que dá o erro "You can only send testing emails to your own email address"?
[email protected] para alguém que não é você. Esse remetente de teste só entrega no e-mail da sua própria conta e nos endereços de teste, como [email protected]. Para mandar para qualquer pessoa, verifique um domínio seu no painel e troque o from para um endereço desse domínio.O webhook da Resend traz o texto do e-mail recebido?
email.received traz só o envelope: quem mandou, para quem, o assunto, o email_id e a lista de anexos. O corpo (HTML e texto) e os cabeçalhos você busca com um GET em /emails/receiving/ seguido do email_id, usando a sua chave de API.Por que a assinatura do webhook nunca bate?
whsec_... como texto, quando a chave do HMAC é o que vem depois do whsec_ decodificado de base64; conferir a assinatura sobre um JSON que já passou por JSON.parse e JSON.stringify; ou montar o corpo somando pedaços como texto, o que quebra um acento que chegue dividido entre dois pedaços.Preciso mudar o MX do meu domínio para receber e-mails na Resend?
qualquercoisa@<id>.resend.app. Para usar o seu domínio, crie o MX num subdomínio: se você puser o MX da Resend no domínio que já recebe os seus e-mails, ou ela não recebe nada, ou atrapalha a caixa que você já usa.Por que a mesma chave errada dá 401 no envio e 400 no recebimento?
POST /emails devolve 401 e o GET /emails/receiving/ devolve 400, os dois com a mensagem API key is invalid. Por isso o código do artigo mostra a message do corpo, e não tenta adivinhar o problema só pelo número do status.Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Node.js: testando scripts de scraping no ScrapingCourse
O ScrapingCourse é um site feito para treinar scraping. Cinco desafios dele em Node.js, e a armadilha real que cada um esconde.