docs

Webhook → email transacional

Receba payment_intent.succeeded e dispare recibo, boas-vindas e dunning. Setup com SendGrid + alternativas (Resend, Postmark, AWS SES). Idempotência, retry e templates prontos.

Cenário: quando uma cobrança liquida, o cliente espera o recibo no email em segundos. Quando uma cobrança falha, espera saber por que e como corrigir. Webhook é o ponto certo para disparar tudo isso — chega assinado, é retentado pela Zhex até 7×, e carrega exatamente o estado da transação.

Este recipe mostra o pipeline completo: receber webhook → validar → enfileirar → enviar email → tracking. Setup principal com SendGrid (mais usado em BR), com adaptações para Resend, Postmark e AWS SES.

Tipos de email que você quer disparar

Evento ZhexEmailQuando
payment_intent.succeededRecibo de compra + acesso liberadoImediato
payment_intent.payment_failed"Tente outro cartão" + link de retryImediato
charge.dispute.createdConfirmação de estorno (cliente já viu na fatura)Imediato
(interno) Trial T-3 dias"Seu trial acaba em 3 dias"Cron próprio (a Zhex não emite ainda)
(interno) Cartão expira mês que vem"Atualize seu cartão"Cron próprio

Foco aqui são os 3 primeiros (event-driven). Os 2 últimos viram cron lendo o estado das customer_subscriptions ou payment_methods.

Arquitetura

Zhex
  ↓ POST webhook signed (HMAC)
seu /api/webhook (responde 2xx rápido, menos de 30s)
  ↓ enqueue BullMQ/SQS/Cloud Tasks
worker
  ↓ fetch template + variáveis
SendGrid/Resend/SES
  ↓ delivery
caixa do cliente

A regra-de-ouro: webhook handler responde 2xx imediato, fila empurra o email. Se o SendGrid demorar 8s, sua URL retorna 200 em 50ms e a Zhex não retenta. Sem fila, o webhook timeout retentaria 7× e mandaria 7 emails iguais.

Passo 1 — Webhook handler com fila

import Zhex, { ZhexWebhookSignatureError } from '@zhexio/node';
import { Queue } from 'bullmq';
import express from 'express';

const emailQueue = new Queue('emails', {
  connection: { host: process.env.REDIS_HOST, port: 6379 },
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: 'exponential', delay: 5_000 },
    removeOnComplete: 1000,
    removeOnFail: 5000,
  },
});

app.post(
  '/api/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event;
    try {
      event = Zhex.webhooks.constructEvent(
        req.body,
        req.headers['zhex-signature'],
        process.env.ZHEX_WEBHOOK_SECRET,
      );
    } catch (err) {
      if (err instanceof ZhexWebhookSignatureError) {
        return res.status(400).send('invalid signature');
      }
      throw err;
    }

    // Filtra test events em produção — não queremos disparar emails reais
    // a partir de webhooks de sandbox.
    if (process.env.NODE_ENV === 'production' && !event.livemode) {
      return res.json({ received: true, ignored: 'test event' });
    }

    // Dedup at-least-once. INSERT IGNORE é o caminho — webhook pode chegar
    // 2× e a Zhex não vai parar de tentar até receber 2xx.
    const inserted = await db.processedEvents
      .insert({ id: event.id, type: event.type })
      .onConflict('id').ignore();
    if (!inserted) return res.json({ received: true, deduped: true });

    // Enfileira; retorna 2xx imediato.
    await emailQueue.add(event.type, { eventId: event.id, eventData: event });

    res.json({ received: true });
  },
);

Por que não responder 200 e processar inline?

Funciona para volumes baixos (< 100 cobranças/dia). Quando passa, qualquer falha de email não retorna como falha de webhook — você perde a chance de retry da Zhex. Em produção real, fila é não-negociável.

Passo 2 — Worker / handler de email

import { Worker } from 'bullmq';

new Worker('emails', async (job) => {
  const { eventData: event } = job.data;
  const intent = event.data?.object;

  switch (event.type) {
    case 'payment_intent.succeeded':
      return sendReceipt(intent);
    case 'payment_intent.payment_failed':
      return sendDunning(intent);
    case 'charge.dispute.created':
      return sendChargebackNotice(intent);
    default:
      return; // tipos não cobertos: ignora
  }
}, { connection: { host: process.env.REDIS_HOST } });

Cada handler busca o customer (e-mail, nome) via @zhexio/node:

import Zhex from '@zhexio/node';
const zhex = new Zhex(process.env.ZHEX_SECRET_KEY);

async function sendReceipt(intent) {
  const customer = await zhex.customers.retrieve(intent.customer);
  return sendEmail({
    to: customer.email,
    template: 'receipt',
    data: {
      customerName: customer.name,
      amount: formatBRL(intent.amount),
      paymentIntentId: intent.id,
      receiptUrl: `https://meusite.com/recibo/${intent.id}`,
    },
  });
}

Passo 3 — Provider de email

SendGrid

npm install @sendgrid/mail
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

const TEMPLATE_IDS = {
  receipt: 'd-abc123',
  dunning: 'd-def456',
  chargeback: 'd-xyz789',
};

export async function sendEmail({ to, template, data }) {
  await sgMail.send({
    to,
    from: { email: 'cobranca@meusite.com', name: 'MeuSite Pagamentos' },
    templateId: TEMPLATE_IDS[template],
    dynamicTemplateData: data,
  });
}

Crie os 3 templates em SendGrid → Email API → Dynamic Templates. Variáveis disponíveis: {{customerName}}, {{amount}}, {{paymentIntentId}}, {{receiptUrl}}.

Resend

npm install resend
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendEmail({ to, template, data }) {
  await resend.emails.send({
    from: 'MeuSite <cobranca@meusite.com>',
    to,
    subject: SUBJECTS[template],
    react: TEMPLATES[template](data),     // React Email components
  });
}

Resend usa React Email para templates — mais flexível que dynamic templates do SendGrid. Veja react.email para componentes prontos (<Receipt />, <DunningEmail />).

Postmark

npm install postmark
import { ServerClient } from 'postmark';
const client = new ServerClient(process.env.POSTMARK_TOKEN);

export async function sendEmail({ to, template, data }) {
  await client.sendEmailWithTemplate({
    From: 'cobranca@meusite.com',
    To: to,
    TemplateAlias: template,
    TemplateModel: data,
  });
}

Postmark tem deliverability altíssima para transacional (sem mistura com marketing). Recomendado se você for SaaS B2B sério.

AWS SES (mais barato em volume)

npm install @aws-sdk/client-sesv2
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: 'us-east-1' });

export async function sendEmail({ to, template, data }) {
  await ses.send(new SendEmailCommand({
    FromEmailAddress: 'cobranca@meusite.com',
    Destination: { ToAddresses: [to] },
    Content: {
      Template: {
        TemplateName: template,
        TemplateData: JSON.stringify(data),
      },
    },
  }));
}

Custo: ~$0.10 por mil emails (vs ~$1 do SendGrid). Trade-off: você cuida de SPF/DKIM/DMARC + reputation warmup.

Passo 4 — Templates

Sugestão de copy por evento. Adapte ao seu produto.

Receipt (payment_intent.succeeded)

Assunto: Pagamento aprovado · Pedido #{{paymentIntentId}}

Olá, {{customerName}}!

Seu pagamento de {{amount}} foi aprovado.

[ Acessar produto ]({{accessUrl}})
[ Ver recibo ]({{receiptUrl}})

Qualquer dúvida, é só responder este email.

Dunning (payment_intent.payment_failed)

Assunto: Não conseguimos aprovar seu cartão

Oi, {{customerName}}.

Tentamos cobrar {{amount}} mas o cartão recusou ({{declineMessage}}).

O que fazer:
1. Verifique saldo/limite no app do banco
2. Ou tente outro cartão: [Tentar de novo]({{retryUrl}})

A oferta fica disponível por 24h.

{{declineMessage}} deriva de intent.last_payment_error.code:

CodeMensagem ao cliente
insufficient_funds"saldo insuficiente"
card_declined"recusado pelo emissor"
expired_card"cartão vencido"
incorrect_cvc"CVV não confere"

Chargeback notice (charge.dispute.created)

Assunto: Confirmação de estorno · Pedido #{{paymentIntentId}}

Olá, {{customerName}}.

Vimos que você abriu uma disputa no seu cartão e o valor de {{amount}} foi estornado para sua conta.

Se foi um engano, é só responder este email — abrimos um novo pedido sem custo extra.

Se foi proposital, está tudo certo. Acesso ao produto foi removido.

Passo 5 — Idempotência no envio

Mesmo com dedup no webhook, o email API pode falhar e o BullMQ retenta. Sem cuidado, mesmo customer recebe 2 recibos. Use o event.id como messageId (SendGrid customArgs, Resend headers['Message-ID']):

await sgMail.send({
  to: customer.email,
  from: '...',
  templateId: 'd-receipt',
  dynamicTemplateData: data,
  customArgs: {
    zhex_event_id: event.id,    // dedup do lado do SendGrid
  },
});

SendGrid não dedup nativamente baseado em customArgs — é só metadata. Para dedup verdadeiro, mantenha tabela sent_emails { event_id, email_to, sent_at } e cheque antes de enviar.

async function sendOnce(eventId, to, template, data) {
  const existing = await db.sentEmails.findFirst({
    where: { eventId, to, template },
  });
  if (existing) return; // já mandei

  await sendEmail({ to, template, data });
  await db.sentEmails.create({ data: { eventId, to, template, sentAt: new Date() } });
}

Passo 6 — Filtros de produção

Não enviar em test mode

if (process.env.NODE_ENV === 'production' && !event.livemode) return;

A regra inversa também vale: em staging, queremos test events para validar fluxo. Setar NODE_ENV=staging e tratar separadamente.

Não enviar para emails inválidos

Customer com email: null ou bounce conhecido — você não vai querer queimar reputação do domain SMTP. Mantenha lista local de bounces (SendGrid Webhook → marca cliente como email_invalid).

Rate limit no provider

SendGrid free permite ~100/dia. Se você manda 200 emails de uma vez, BullMQ retenta com 429. Configure defaultJobOptions.attempts: 5 e backoff exponencial.

Boas práticas

  • Webhook handler responde 2xx em menos de 30s. Tudo que demora vai pra fila.
  • Filtre por event.livemode em prod. Nunca dispare email real a partir de webhook test.
  • Dedup duplo: evento (processed_events table) + email (sent_emails table com event_id + to).
  • Templates dinâmicos com variáveis claras — {{customerName}} não {{user}}.
  • Reply-To configurado. Cliente responde "obrigado!" e não vai pra noreply@.
  • Unsubscribe NÃO em transacional — recibo não tem opt-out. Apenas em marketing/newsletter.
  • DKIM + SPF + DMARC configurados no domain. Sem isso, Gmail/Outlook caem em spam.
  • Cuidado com PII em logs — não logar customer.email em texto plano. Hash ou mascarar (maria@***.com).

Próximos passos

Esta página foi útil?

Atualizado em

Nesta página