Pular para o conteúdo
Node.js

Resend: enviando e recebendo e-mails com Node.js

Ilustração em neon de uma coruja mágica saindo de um computador antigo com um envelope, outros envelopes voando até uma caixa de correio encantada, setas de luz voltando para um castelo com velas flutuantes e um unicórnio de crina colorida olhando a cena

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.

A janela Add API Key da Resend com o nome tutorial-blog, a permissão Full access e o domínio All domains

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:

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.

A lista de e-mails enviados no painel da Resend, com destinatários e assuntos borrados e os status Sent, Delivered, Opened e Bounced

🌐 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).

  1. No menu Domains, clique em Add domain.
  2. Digite o domínio e escolha a região. Eu escolhi São Paulo (sa-east-1), que é onde está o meu público.
  3. A Resend mostra os registros que você precisa criar no DNS.
A tabela DNS Records de um domínio verificado na Resend, com o TXT do DKIM, os dois CNAME de envio e o MX de recebimento, todos com status Verified e os nomes borrados

Cada bloco da tela tem um papel:

  • DKIM (um registro TXT com nome resend._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:

A aba Receiving do painel da Resend, ainda sem e-mails recebidos, oferecendo um endereço pronto no formato anything arroba um subdomínio resend.app, borrado

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 janela Add webhook da Resend com a URL https://seudominio.com.br/webhook/resend e o evento email.received selecionado

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?
Tem plano gratuito. Na página de preços, ele permite enviar até 100 e-mails por dia, usar 3 domínios próprios e cadastrar 1 webhook, e o recebimento de e-mails já vem incluído. Para passar disso, os planos pagos tiram o limite diário.
Por que dá o erro "You can only send testing emails to your own email address"?
Porque você está enviando de [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?
Não. O evento 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?
Quase sempre é um destes três: usar o segredo 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?
Não precisa mexer no domínio principal. Para começar, a Resend dá um endereço pronto do tipo 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?
É assim que a API responde hoje: o 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