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 Zhex | Quando | |
|---|---|---|
payment_intent.succeeded | Recibo de compra + acesso liberado | Imediato |
payment_intent.payment_failed | "Tente outro cartão" + link de retry | Imediato |
charge.dispute.created | Confirmaçã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 clienteA 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/mailimport 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 resendimport { 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 postmarkimport { 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-sesv2import { 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:
| Code | Mensagem 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.livemodeem prod. Nunca dispare email real a partir de webhook test. - Dedup duplo: evento (
processed_eventstable) + email (sent_emailstable comevent_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.emailem texto plano. Hash ou mascarar (maria@***.com).
Próximos passos
Reconciliation
Match diário entre Zhex e seu BD
Webhooks
Verificação HMAC, retry, replay
Idempotência
Como não duplicar nada
Atualizado em