TL;DR
- O email de confirmação sai quando o pedido é pago. O webhook da sua loja dispara
POST /v2/messagesa partir de um endereço num domínio verificado. - Nenhuma mensagem sai duas vezes. Cada envio leva uma
idempotencyKeymontada com o id do pedido e o evento, então um webhook repetido recebe409 conflicte nada é enviado. - O aviso de envio é um template de utilidade do WhatsApp com o código de rastreio, e a Meta precisa aprová-lo antes de você usar.
- O email é o fallback. Se o cliente não tem WhatsApp, o envio é recusado ou falha depois como
message.failed, o seu código manda o aviso por email. - Os contatos ficam sincronizados. Cada pedido atualiza o contato do cliente na Zavu.
O cliente paga e a primeira coisa que ele quer é uma prova: um email de confirmação com o que comprou. Dois dias depois ele quer saber onde está o pacote, e no Brasil essa pergunta chega pelo WhatsApp. A notificação de pedido pelo WhatsApp e o aviso de envio automático deveriam sair sozinhos da sua loja, a partir dos webhooks que Nuvemshop, Shopify ou WooCommerce já enviam.
Este tutorial monta isso com a API v2 da Zavu, num serviço pequeno em Node/TypeScript. No final você tem:
- Um domínio de email verificado e um endereço como
pedidos@sualoja.com.brconectado como canal. - Um endpoint que recebe os webhooks de "pedido pago" e "pedido enviado" da loja.
- Um email de confirmação que nunca sai duas vezes para o mesmo pedido.
- O aviso de envio como template de utilidade do WhatsApp aprovado, com o código de rastreio.
- Um fallback por email quando o cliente não tem WhatsApp ou o envio falha.
- Os contatos da Zavu sincronizados com os seus pedidos.
O que você precisa
- Uma conta Zavu e uma API key. A v2 usa a mesma key da v1, como Bearer token. URL base:
https://api.zavu.dev/v2. - Um canal de email. Precisa de um domínio seu e acesso ao DNS dele. Publicar os registros leva minutos; a propagação pode levar horas, então comece por aqui.
- WhatsApp, se você quer o aviso de envio lá. É o canal com mais atrito: ele se conecta pelo cadastro da Meta em Canais no dashboard, e as mensagens que a sua empresa inicia precisam de um template aprovado pela Meta. Se você não conectar, o mesmo código manda o aviso por email.
- Uma loja que envie webhooks. Nuvemshop, Shopify e WooCommerce enviam. O código mapeia o pedido da Shopify; o mapeamento é uma função só, então trocar de plataforma é mudar um lugar.
Os SDKs (@zavudev/sdk, zavudev) chamam a v1 hoje. Tudo aqui chama a v2 por HTTP com fetch. Se a v2 é nova para você, Zavu 2.0 explica o que mudou: você envia from um endereço, e um erro diz o que conectar.
1. Verifique o domínio de email e conecte o endereço
Adicione o domínio:
terminalcurl -X POST https://api.zavu.dev/v2/email-domains \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "sualoja.com.br"}'
A resposta traz os registros DNS a publicar:
JSON{ "domain": { "id": "emd_abc123", "domain": "sualoja.com.br", "status": "pending", "verifiable": true, "dnsRecords": [ { "type": "CNAME", "name": "...", "value": "...", "purpose": "dkim", "required": true }, { "type": "TXT", "name": "...", "value": "...", "purpose": "spf", "required": false }, { "type": "TXT", "name": "...", "value": "...", "purpose": "dmarc", "required": false } ] } }
Publique cada registro exatamente como veio. Os dkim são obrigatórios; SPF, DMARC e MAIL FROM são recomendados e valem a pena para a entregabilidade. Adicionar um domínio que o projeto já tem responde 200 em vez de 201 e devolve o mesmo domínio.
Se verifiable vier false, o domínio ficou sem registros DKIM e não vai verificar, publique o que publicar. setupError explica: apague o domínio e adicione de novo.
Depois peça para a Zavu checar o DNS:
terminalcurl -X POST https://api.zavu.dev/v2/email-domains/emd_abc123/verify \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
Ela devolve o domínio com o status atual. Repita até ficar verified, ou assine o evento domain.verified no seu webhook. Um 503 verification_unavailable quer dizer que a checagem não conseguiu rodar; nada mudou, tente de novo em alguns minutos.
Agora conecte o endereço como canal:
terminalcurl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "email", "address": "pedidos@sualoja.com.br", "fromName": "Sua Loja"}'
O 201 lista as entradas desse endereço, com active. Se o domínio ainda não foi verificado, a resposta é 409 email_domain_not_verified, com o status do domínio e as chamadas que resolvem em details.recommendation. Um domínio que o projeto não tem responde 409 email_domain_missing. Se active vier false, ative com POST /v2/channels/pedidos@sualoja.com.br/email/activate.
2. Receba os webhooks da loja
A loja chama você quando algo acontece com um pedido. Cada plataforma faz do seu jeito:
- Shopify usa as assinaturas
orders/paideorders/fulfilled, com o tópico no headerX-Shopify-Topice a assinatura emX-Shopify-Hmac-Sha256: um HMAC-SHA256 em base64 do corpo bruto. - Nuvemshop tem eventos como
order/paideorder/fulfilled, mas o webhook traz o id do pedido, não o pedido. A sua função de mapeamento busca o pedido na API da Nuvemshop antes de converter. - WooCommerce manda o pedido inteiro em
order.createdeorder.updated, assinado emX-WC-Webhook-Signature.
Deixe a plataforma fora do resto do código. Uma função transforma o payload no seu próprio Pedido:
TypeScript// pedido.ts export interface Pedido { id: string numero: string // o que o cliente vê, ex. "#1042" nome: string email?: string telefone?: string // E.164, ex. "+5511987654321" total: string moeda: string itens: { nome: string; quantidade: number }[] envio?: { id: string; transportadora?: string; rastreio?: string; urlRastreio?: string } } // Pedido da Shopify -> Pedido. Para outra plataforma, troque esta função. export function daShopify(o: any): Pedido { const f = o.fulfillments?.[o.fulfillments.length - 1] return { id: String(o.id), numero: o.name, nome: o.customer?.first_name ?? "", email: o.email || undefined, telefone: o.phone || o.shipping_address?.phone || undefined, total: o.total_price, moeda: o.currency, itens: (o.line_items ?? []).map((i: any) => ({ nome: i.name, quantidade: i.quantity })), envio: f ? { id: String(f.id), transportadora: f.tracking_company ?? undefined, rastreio: f.tracking_number ?? undefined, urlRastreio: f.tracking_url ?? undefined } : undefined, } }
O telefone que o cliente digita no checkout nem sempre vem com +55. Normalize para E.164 antes de usar. A v2 aceita espaços, parênteses e hífen (+55 (11) 98765-4321), mas recusa dígitos sem + num envio por WhatsApp.
O endpoint:
TypeScript// server.ts import express from "express" import crypto from "node:crypto" import { daShopify } from "./pedido" import { enviarConfirmacao, enviarAvisoDeEnvio, sincronizarContato } from "./notificar" const app = express() function assinaturaShopifyOk(raw: Buffer, header: string | undefined) { if (!header) return false const digest = crypto.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!).update(raw).digest("base64") const a = Buffer.from(digest), b = Buffer.from(header) return a.length === b.length && crypto.timingSafeEqual(a, b) } app.post("/webhooks/loja", express.raw({ type: "application/json" }), async (req, res) => { if (!assinaturaShopifyOk(req.body, req.get("X-Shopify-Hmac-Sha256"))) return res.sendStatus(401) const topico = req.get("X-Shopify-Topic") const pedido = daShopify(JSON.parse(req.body.toString("utf8"))) try { await sincronizarContato(pedido) if (topico === "orders/paid") await enviarConfirmacao(pedido) if (topico === "orders/fulfilled") await enviarAvisoDeEnvio(pedido) res.sendStatus(200) } catch (err) { console.error(err) res.sendStatus(500) // a loja tenta de novo; as idempotency keys tornam a nova tentativa segura } }) app.listen(3000)
Lojas repetem webhooks que consideram falhos, e às vezes entregam o mesmo duas vezes. Aqui isso não é problema: cada envio abaixo leva uma idempotencyKey.
Um helper para as chamadas à Zavu:
TypeScript// zavu.ts export async function zavu(path: string, method = "POST", body?: unknown) { const res = await fetch(, { method, headers: { Authorization:https://api.zavu.dev/v2${path}Bearer${process.env.ZAVUDEV_API_KEY}, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }) const json = res.status === 204 ? null : await res.json() return { status: res.status, json } }
Todo erro da v2 é plano, {code, message, details}, então você decide pelo json.code.
3. Envie o email de confirmação do pedido
TypeScript// notificar.ts import { zavu } from "./zavu" import type { Pedido } from "./pedido" const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]!)) export async function enviarConfirmacao(p: Pedido) { if (!p.email) return null const linhas = p.itens.map((i) =>) const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, // "pedidos@sualoja.com.br" to: p.email, subject:${i.quantidade} x ${i.nome}Pedido${p.numero} confirmado, text:Olá${p.nome}, recebemos o seu pedido ${p.numero}.\n\n${linhas.join("\n")}\n\nTotal: ${p.total} ${p.moeda}, htmlBody:<p>Olá${esc(p.nome)}, recebemos o seu pedido <strong>${esc(p.numero)}</strong>.</p><ul>${linhas.map((l) => <li>${esc(l)}</li>).join("")}</ul><p>Total: ${esc(p.total)} ${esc(p.moeda)}</p>, replyTo: "atendimento@sualoja.com.br", idempotencyKey:${p.id}:confirmado, metadata: { pedidoId: p.id, evento: "confirmado" }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id // já foi enviado throw new Error(${json.code}: ${json.message}) }
subject é obrigatório num email; text é o corpo em texto puro e htmlBody o HTML. O 202 diz como o envio foi roteado:
JSON{ "message": { "id": "jd7x...", "to": "ana@exemplo.com.br", "from": "pedidos@sualoja.com.br", "channel": "email", "status": "queued" }, "routing": { "channel": "email", "from": "pedidos@sualoja.com.br", "reason": "recipient_type", "estimatedCost": null } }
reason: "recipient_type": o to é um email, então vai por email. estimatedCost vem null porque email não tem preço por mensagem no momento do envio.
Na segunda vez que a mesma key chega (um webhook repetido), nada é enviado. A resposta é 409 conflict e a mensagem original vem em details.message. A Zavu guarda a key para o projeto inteiro, então monte-a com o id do pedido e o evento, nunca com um horário.
4. Notificação de pedido pelo WhatsApp: o aviso de envio, com fallback por email
Uma mensagem que você inicia fora da janela de 24 horas de atendimento precisa ser um template. Crie um de utilidade para o aviso de envio:
terminalcurl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "pedido_enviado", "language": "pt_BR", "whatsappCategory": "UTILITY", "body": "Olá {{1}}, o seu pedido {{2}} já está a caminho pela {{3}}. Código de rastreio: {{4}}. Avisamos se algo mudar." }'
O WhatsApp não aceita uma variável no começo nem no fim do corpo, e a Zavu recusa o template com 400 se você tentar. Um nome que o projeto já usa responde 409 conflict.
Envie para a Meta, indicando o número de WhatsApp cuja conta comercial recebe o template:
terminalcurl -X POST https://api.zavu.dev/v2/templates/tmpl_abc123/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from": "+5511912345678", "category": "UTILITY"}'
O 202 passa o template para pending. Se from existe mas não tem WhatsApp, a resposta é 400 no_whatsapp, com o que esse endereço tem em details.types. A decisão da Meta chega como template.status_changed, ou leia com GET /v2/templates/{templateId}. Envie só quando estiver approved.
O envio. channel: "whatsapp" vai explícito de propósito: sem channel, um template segue a ordem automática, e num número que também tem SMS ele poderia sair como SMS. fallbackEnabled: false desliga o fallback de WhatsApp para SMS da própria Zavu, para que o seu código decida o que acontece quando o WhatsApp falha.
TypeScript// notificar.ts (continuação) const FALLBACK_POR_EMAIL = new Set(["no_channel", "channel_inactive", "not_found"]) export async function enviarAvisoDeEnvio(p: Pedido) { const e = p.envio if (!e) return null const key =if (p.telefone && e.rastreio && process.env.ZAVU_WHATSAPP_FROM) { const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_WHATSAPP_FROM, // "+5511912345678" to: p.telefone, channel: "whatsapp", messageType: "template", content: { templateId: process.env.ZAVU_TEMPLATE_ENVIADO_ID, templateVariables: { "1": p.nome || "cliente", "2": p.numero, "3": e.transportadora ?? "transportadora", "4": e.rastreio }, }, fallbackEnabled: false, idempotencyKey:${p.id}:enviado:${e.id}${key}:whatsapp, metadata: { pedidoId: p.id, evento: "enviado", envioId: e.id }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id if (!FALLBACK_POR_EMAIL.has(json.code)) throw new Error(${json.code}: ${json.message}) } return enviarAvisoPorEmail(p,${key}:email) } export async function enviarAvisoPorEmail(p: Pedido, idempotencyKey: string) { const e = p.envio! if (!p.email) return null const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, to: p.email, subject:O seu pedido${p.numero} foi enviado, text:Olá${p.nome}, o seu pedido ${p.numero} já está a caminho${e.transportadora ? pela ${e.transportadora}: ""}.${e.rastreio ? Código de rastreio: ${e.rastreio}.: ""}${e.urlRastreio ? \nAcompanhe aqui: ${e.urlRastreio}: ""}, idempotencyKey, metadata: { pedidoId: p.id, evento: "enviado", envioId: e.id }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id throw new Error(${json.code}: ${json.message}) }
A tentativa pelo WhatsApp e o fallback por email usam keys diferentes. Precisam usar: uma key que já enviou o WhatsApp que falhou responderia o email com 409 conflict e não mandaria nada.
O que dispara o fallback antes de qualquer envio:
| Código | O que significa |
|---|---|
409 no_channel | O endereço from não tem WhatsApp. details.connect diz como conectar. |
403 channel_inactive | O WhatsApp está conectado mas desligado. |
404 not_found | Nenhum canal do projeto tem esse endereço from. |
O outro caso é um WhatsApp aceito com 202 que falha depois: o número não tem WhatsApp, ou a Meta recusa. Isso chega como evento message.failed no webhook do projeto. Assine uma vez:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://notificacoes.sualoja.com.br/webhooks/zavu", "events": ["message.failed", "message.delivered", "template.status_changed", "domain.verified"]}'
O 201 devolve webhook.secret uma única vez; guarde. Cada entrega vem assinada em X-Zavu-Signature como t=...,v2=..., onde v2 é o HMAC-SHA256 em hex de "{t}.{body}". O handler valida a assinatura e depois lê a mensagem para achar o pedido pela metadata que você colocou:
TypeScript// server.ts (continuação) import { zavu } from "./zavu" import { enviarAvisoPorEmail } from "./notificar" import { carregarPedido } from "./db" // a sua própria base de pedidos function assinaturaZavuOk(raw: Buffer, header: string | undefined) { const partes = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=") as [string, string])) if (!partes.t || !partes.v2) return false const esperado = crypto.createHmac("sha256", process.env.ZAVU_WEBHOOK_SECRET!).update().digest("hex") const a = Buffer.from(esperado), b = Buffer.from(partes.v2) return a.length === b.length && crypto.timingSafeEqual(a, b) } app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { if (!assinaturaZavuOk(req.body, req.get("X-Zavu-Signature"))) return res.sendStatus(401) const evt = JSON.parse(req.body.toString("utf8")) res.sendStatus(200) if (evt.event !== "message.failed" || evt.type !== "whatsapp") return const { json } = await zavu(${partes.t}.${raw.toString("utf8")}/messages/${evt.data.messageId}, "GET") const m = json.message if (m.metadata?.evento !== "enviado") return const pedido = await carregarPedido(m.metadata.pedidoId) await enviarAvisoPorEmail(pedido,${m.metadata.pedidoId}:enviado:${m.metadata.envioId}:email) })
GET /v2/messages/{messageId} também traz o errorCode nas palavras da Zavu (por exemplo WHATSAPP_REJECTED, com o código do provedor em errorDetails.providerCode) e attemptedChannels, onde apareceria um fallback de WhatsApp para SMS se você o tivesse deixado ligado. A Zavu repete uma entrega que não recebe 2xx, até 5 tentativas; a key do fallback torna inofensivo um evento repetido.
5. Mantenha os contatos sincronizados
Cada pedido também é a chance de manter o contato do cliente atualizado, para que a caixa de Conversas mostre o nome e você possa segmentar depois.
TypeScript// notificar.ts (continuação) export async function sincronizarContato(p: Pedido) { const channels = [ ...(p.email ? [{ channel: "email", identifier: p.email, isPrimary: true }] : []), ...(p.telefone ? [{ channel: "whatsapp", identifier: p.telefone, isPrimary: true }] : []), ] if (channels.length === 0) return null const metadata = { origem: "shopify", ultimoPedidoId: p.id, ultimoPedidoNumero: p.numero } const { status, json } = await zavu("/contacts", "POST", { displayName: p.nome || undefined, channels, metadata }) if (status === 201) return json.id if (status === 400 && json.code === "duplicate_identifier") { const contactId = json.details.conflicts[0].contactId await zavu(, "PATCH", { metadata }) return contactId } throw new Error(/contacts/${contactId}${json.code}: ${json.message}) }
Três coisas para saber. Os valores de metadata são strings. Um identificador de telefone em whatsapp ou sms precisa estar em E.164, e a v2 recusa outra coisa com 400 invalid_request, sugerindo a forma com + quando os dígitos parecem um número. E PATCH substitui o objeto metadata inteiro, então mande todas as chaves que quer manter. A v2 ainda não adiciona um canal novo a um contato existente; POST /v1/contacts/{contactId}/channels adiciona, com a mesma API key.
Sobre a LGPD: guarde em metadata só o que a notificação precisa, como o id do último pedido. Se um cliente pedir a exclusão dos dados, DELETE /v2/contacts/{contactId} apaga o contato e os seus canais.
Testando, e o que pode dar errado
Teste com uma key live e o seu próprio email e telefone. Uma key de teste sempre envia pelo sandbox do WhatsApp, então não serve para testar o caminho do email. Aponte os webhooks da loja para um túnel até a sua máquina e faça um pedido de teste.
| Você vê | Significa | Faça isto |
|---|---|---|
409 email_domain_not_verified | O DNS do domínio ainda não foi verificado | Publique os registros, POST /v2/email-domains/{id}/verify, tente de novo |
400 invalid_request com path: ["subject"] | Email sem assunto | Mande sempre subject |
400 invalid_request no to | Telefone sem +, lido como chat id | Guarde os números em E.164 |
409 no_channel | Nada conectado alcança esse to | Leia details.connect |
409 conflict | A key já foi usada | Normal em novas tentativas; o original está em details.message |
402 insufficient_balance | O saldo não cobre o email | Adicione saldo pelo dashboard |
429 daily_limit_exceeded | O teto diário de um canal | Espere até 00:00 UTC, ou aumente o teto |
message.failed com EMAIL_RECIPIENT_SUPPRESSED | Esse endereço já voltou ou reclamou antes | Peça outro email ao cliente |
A Zavu confere o destinatário de cada email antes de despachar. Um endereço malformado, um domínio sem registros de email ou um endereço na sua lista de supressão fazem a mensagem falhar com EMAIL_INVALID_RECIPIENT, EMAIL_DOMAIN_NOT_FOUND ou EMAIL_RECIPIENT_SUPPRESSED, em vez de mandar um bounce certo.
Indo para produção
Cobrança do email. Nos planos atuais o email é cobrado do seu saldo pré-pago em blocos de 1.000 emails transacionais, a US$ 0,40 por bloco; com saldo zero os envios param com 402 insufficient_balance. O plano Free ainda limita o email a 100 por dia e 3.000 por mês. Times em planos anteriores mantêm as condições de email originais. Todo plano tem um teto móvel pela idade da conta, de 200 por hora e 1.000 a cada 24 horas na primeira semana até 5.000 por hora e 50.000 a cada 24 horas depois do dia 30; passado esse teto, a mensagem falha com EMAIL_RATE_LIMIT_HOURLY ou EMAIL_RATE_LIMIT_DAILY. O email de saída também passa pela revisão de risco da Zavu, e uma mensagem retida fica em queued até uma pessoa aprovar.
Limites e cobrança do WhatsApp. No Free, o WhatsApp divide com Telegram, Instagram e Messenger uma franquia mensal de 2.000 mensagens, contando as duas direções, e um teto diário de 250 com a conta verificada (100 antes). Os planos pagos não têm nenhum dos dois. A Meta cobra os templates de utilidade na sua conta do WhatsApp Business pela tarifa do país do cliente; a Zavu não soma margem. Os detalhes estão em o WhatsApp começa a cobrar as suas respostas, e o que fazer antes da mensagem 1.001 em mensagens de serviço e forma de pagamento.
Aprovação de templates. A aprovação é da Meta, não da Zavu, e pode levar de minutos a mais tempo. Deixe o caminho por email funcionando desde o primeiro dia, assim um template ainda pending ou rejected não custa nada além do canal.
Links. Links em email e WhatsApp não passam por verificação de URL; a Zavu confere URLs só em SMS, SMS unidirecional e voz, e bloqueia encurtadores lá. Se depois você mandar o aviso por SMS, verifique antes o domínio de rastreio com POST /v1/urls e use a URL completa.
Opt-in. A Meta exige o opt-in do cliente antes de você escrever pelo WhatsApp. Peça no checkout e deixe o fallback por email para quem não deu.
A referência completa está na visão geral da API v2, no guia do webhook e na lista de erros. Se o seu projeto ainda roda na v1, migrar da v1 traduz cada chamada; as duas versões funcionam com a mesma key.