TL;DR
- El email de confirmación sale cuando se paga el pedido. El webhook de tu tienda dispara
POST /v2/messagesdesde una dirección de un dominio verificado. - Ningún mensaje sale dos veces. Cada envío lleva una
idempotencyKeyarmada con el id del pedido y el evento, así que un webhook reintentado recibe409 conflicty no envía nada. - El aviso de envío es una plantilla de utilidad de WhatsApp con el número de guía, y Meta tiene que aprobarla antes de que la uses.
- El email es el respaldo. Si el cliente no tiene WhatsApp, el envío se rechaza o falla después como
message.failed, tu código manda el aviso por email. - Los contactos quedan sincronizados. Cada pedido actualiza el contacto del cliente en Zavu.
Un cliente paga y lo primero que quiere es una prueba: un email de confirmación con lo que compró. Dos días después quiere saber dónde va su paquete, y en México, Colombia, Chile o Argentina esa pregunta llega por WhatsApp. Las notificaciones de pedidos por WhatsApp y email deberían salir solas de tu tienda online, desde los webhooks que WooCommerce, Tiendanube o Shopify ya envían.
Este tutorial arma esas notificaciones automáticas con la API v2 de Zavu, en un servicio pequeño en Node/TypeScript. Al final tienes:
- Un dominio de email verificado y una dirección como
pedidos@tutienda.mxconectada como canal. - Un endpoint que recibe los webhooks de "pedido pagado" y "pedido enviado" de tu tienda.
- Un email de confirmación que nunca sale dos veces para el mismo pedido.
- El aviso de envío como plantilla de utilidad de WhatsApp aprobada, con el número de guía.
- Un respaldo por email cuando el cliente no tiene WhatsApp o el envío falla.
- Los contactos de Zavu sincronizados con tus pedidos.
Lo que necesitas
- Una cuenta de Zavu y una API key. La v2 usa la misma key que la v1, como Bearer token. URL base:
https://api.zavu.dev/v2. - Un canal de email. Necesita un dominio tuyo y acceso a su DNS. Publicar los registros toma minutos; que se propaguen puede tomar horas, así que empieza por acá.
- WhatsApp, si quieres el aviso de envío ahí. Es el canal con más fricción: se conecta con el registro de Meta desde Canales en el dashboard, y los mensajes que inicia tu negocio necesitan una plantilla aprobada por Meta. Si no lo conectas, el mismo código manda el aviso por email.
- Una tienda que envíe webhooks. WooCommerce, Tiendanube y Shopify lo hacen. El código mapea el pedido de WooCommerce; el mapeo es una sola función, así que cambiar de plataforma es cambiar un lugar.
Los SDKs (@zavudev/sdk, zavudev) llaman a la v1 hoy. Todo lo de acá llama a la v2 por HTTP con fetch. Si la v2 es nueva para ti, Zavu 2.0 cuenta qué cambió: envías from una dirección, y un error te dice qué conectar.
1. Verifica tu dominio de email y conecta la dirección
Agrega el dominio:
terminalcurl -X POST https://api.zavu.dev/v2/email-domains \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "tutienda.mx"}'
La respuesta trae los registros DNS que debes publicar:
JSON{ "domain": { "id": "emd_abc123", "domain": "tutienda.mx", "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 } ] } }
Publica cada registro tal como viene. Los dkim son obligatorios; SPF, DMARC y MAIL FROM son recomendados y conviene publicarlos para que tus correos no terminen en spam. Si agregas un dominio que el proyecto ya tiene, la respuesta es 200 en vez de 201 y devuelve el mismo dominio.
Si verifiable viene en false, el dominio quedó sin registros DKIM y no se va a verificar publiques lo que publiques. setupError lo explica: bórralo y agrégalo de nuevo.
Después pide a Zavu que revise el DNS:
terminalcurl -X POST https://api.zavu.dev/v2/email-domains/emd_abc123/verify \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
Devuelve el dominio con su status actual. Repite hasta que sea verified, o suscribe tu webhook a domain.verified. Un 503 verification_unavailable significa que la revisión no pudo correr; nada cambió, reintenta en unos minutos.
Ahora conecta la dirección 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@tutienda.mx", "fromName": "Tu Tienda"}'
El 201 lista las entradas de esa dirección, con active. Si el dominio todavía no está verificado, la respuesta es 409 email_domain_not_verified, con el status del dominio y las llamadas que lo arreglan en details.recommendation. Un dominio que el proyecto no tiene responde 409 email_domain_missing. Si active viene en false, actívala con POST /v2/channels/pedidos@tutienda.mx/email/activate.
2. Recibe los webhooks de tu tienda
Tu tienda te llama cuando algo le pasa a un pedido. Cada plataforma lo hace a su manera:
- WooCommerce manda el pedido completo en sus webhooks de pedido (
order.created,order.updated), con el evento en el headerX-WC-Webhook-Topicy la firma enX-WC-Webhook-Signature: un HMAC-SHA256 en base64 del cuerpo, con el secreto que configuraste. Elstatusdel pedido te dice en qué va:processingcuando está pagado,completedcuando lo marcas como enviado. - Tiendanube tiene eventos como
order/paidyorder/fulfilled, pero el webhook trae el id del pedido, no el pedido. Tu función de mapeo lo pide a la API de Tiendanube antes de convertirlo. - Shopify usa
orders/paidyorders/fulfilled, firmados enX-Shopify-Hmac-Sha256.
Deja la plataforma fuera del resto del código. Una función convierte su payload en tu propio Pedido:
TypeScript// pedido.ts export interface Pedido { id: string numero: string // lo que ve el cliente, ej. "1042" nombre: string email?: string telefono?: string // E.164, ej. "+523312345678" total: string moneda: string productos: { nombre: string; cantidad: number }[] envio?: { paqueteria?: string; guia?: string; urlRastreo?: string } } // Pedido de WooCommerce -> Pedido. Para otra plataforma, cambia esta función. export function desdeWooCommerce(o: any, envio?: Pedido["envio"]): Pedido { return { id: String(o.id), numero: String(o.number), nombre: o.billing?.first_name ?? "", email: o.billing?.email || undefined, telefono: o.billing?.phone || undefined, total: o.total, moneda: o.currency, productos: (o.line_items ?? []).map((i: any) => ({ nombre: i.name, cantidad: i.quantity })), envio, } }
WooCommerce no trae guía de envío de fábrica: la agrega un plugin de seguimiento, y cada uno la guarda en un lugar distinto. Por eso envio entra como argumento: léelo de donde lo deja el tuyo.
El teléfono de facturación lo escribe el cliente, a veces sin +52. Normalízalo a E.164 antes de usarlo. La v2 acepta espacios y guiones (+52 33 1234 5678), pero rechaza dígitos sin + en un envío por WhatsApp.
El endpoint:
TypeScript// server.ts import express from "express" import crypto from "node:crypto" import { desdeWooCommerce } from "./pedido" import { enviarConfirmacion, enviarAvisoDeEnvio, sincronizarContacto } from "./notificar" import { leerEnvio } from "./envios" // donde tu plugin guarda la guía const app = express() function firmaWooOk(raw: Buffer, header: string | undefined) { if (!header) return false const digest = crypto.createHmac("sha256", process.env.WOO_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/tienda", express.raw({ type: "application/json" }), async (req, res) => { if (!firmaWooOk(req.body, req.get("X-WC-Webhook-Signature"))) return res.sendStatus(401) const o = JSON.parse(req.body.toString("utf8")) const pedido = desdeWooCommerce(o, o.status === "completed" ? await leerEnvio(o.id) : undefined) try { await sincronizarContacto(pedido) if (o.status === "processing") await enviarConfirmacion(pedido) if (o.status === "completed") await enviarAvisoDeEnvio(pedido) res.sendStatus(200) } catch (err) { console.error(err) res.sendStatus(500) // la tienda reintenta; las idempotency keys hacen seguro el reintento } }) app.listen(3000)
order.updated llega cada vez que el pedido cambia, no solo cuando cambia de estado. Va a llegar repetido, y está bien: cada envío de abajo lleva una idempotencyKey.
Un helper para las llamadas a 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 error de la v2 es plano, {code, message, details}, así que decides con json.code.
3. Envía el email de confirmación del 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 enviarConfirmacion(p: Pedido) { if (!p.email) return null const lineas = p.productos.map((i) =>) const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, // "pedidos@tutienda.mx" to: p.email, subject:${i.cantidad} x ${i.nombre}Confirmamos tu pedido #${p.numero}, text:Hola${p.nombre}, recibimos tu pedido #${p.numero}.\n\n${lineas.join("\n")}\n\nTotal: ${p.total} ${p.moneda}, htmlBody:<p>Hola${esc(p.nombre)}, recibimos tu pedido <strong>#${esc(p.numero)}</strong>.</p><ul>${lineas.map((l) => <li>${esc(l)}</li>).join("")}</ul><p>Total: ${esc(p.total)} ${esc(p.moneda)}</p>, replyTo: "ayuda@tutienda.mx", 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 // ya se envió throw new Error(${json.code}: ${json.message}) }
subject es obligatorio en un email; text es el cuerpo en texto plano y htmlBody el HTML. El 202 te dice cómo se enrutó:
JSON{ "message": { "id": "jd7x...", "to": "mariana@ejemplo.mx", "from": "pedidos@tutienda.mx", "channel": "email", "status": "queued" }, "routing": { "channel": "email", "from": "pedidos@tutienda.mx", "reason": "recipient_type", "estimatedCost": null } }
reason: "recipient_type": el to es un email, así que va por email. estimatedCost viene en null porque el email no tiene precio por mensaje al momento del envío.
La segunda vez que llega la misma key (un webhook reintentado), no se envía nada. La respuesta es 409 conflict y el mensaje original viene en details.message. Zavu recuerda la key para todo el proyecto, así que ármala con el id del pedido y el evento, nunca con una fecha.
4. Notificaciones de pedidos por WhatsApp: el aviso de envío, con email de respaldo
Un mensaje que inicias fuera de la ventana de 24 horas de atención tiene que ser una plantilla. Crea una de utilidad para el aviso de envío:
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": "es_MX", "whatsappCategory": "UTILITY", "body": "Hola {{1}}, tu pedido #{{2}} ya va en camino con {{3}}. Número de guía: {{4}}. Te avisamos si hay cambios." }'
WhatsApp no acepta una variable al principio ni al final del cuerpo, y Zavu rechaza la plantilla con 400 si lo intentas. Un nombre que el proyecto ya usa responde 409 conflict.
Envíala a Meta, indicando el número de WhatsApp cuya cuenta de negocio la recibe:
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": "+523398765432", "category": "UTILITY"}'
El 202 la pasa a pending. Si from existe pero no tiene WhatsApp, la respuesta es 400 no_whatsapp, con lo que sí tiene esa dirección en details.types. La decisión de Meta llega como template.status_changed, o léela con GET /v2/templates/{templateId}. Envía solo cuando esté approved.
El envío. channel: "whatsapp" va explícito a propósito: sin channel, una plantilla sigue el orden automático, y en un número que además tiene SMS podría salir como SMS. fallbackEnabled: false desactiva el respaldo de WhatsApp a SMS de Zavu, para que sea tu código el que decide qué pasa cuando WhatsApp falla.
TypeScript// notificar.ts (continúa) const RESPALDO_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.telefono && e.guia && process.env.ZAVU_WHATSAPP_FROM) { const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_WHATSAPP_FROM, // "+523398765432" to: p.telefono, channel: "whatsapp", messageType: "template", content: { templateId: process.env.ZAVU_PLANTILLA_ENVIADO_ID, templateVariables: { "1": p.nombre || "cliente", "2": p.numero, "3": e.paqueteria ?? "nuestra paquetería", "4": e.guia }, }, fallbackEnabled: false, idempotencyKey:${p.id}:enviado${key}:whatsapp, metadata: { pedidoId: p.id, evento: "enviado" }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id if (!RESPALDO_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:Tu pedido #${p.numero} va en camino, text:Hola${p.nombre}, tu pedido #${p.numero} ya salió${e.paqueteria ? con ${e.paqueteria}: ""}.${e.guia ? Número de guía: ${e.guia}.: ""}${e.urlRastreo ? \nRastréalo aquí: ${e.urlRastreo}: ""}, idempotencyKey, metadata: { pedidoId: p.id, evento: "enviado" }, }) 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}) }
El intento por WhatsApp y el respaldo por email usan keys distintas. Tienen que serlo: una key que ya envió el WhatsApp fallido respondería el email con 409 conflict y no mandaría nada.
Lo que activa el respaldo antes de que se envíe nada:
| Código | Qué significa |
|---|---|
409 no_channel | La dirección from no tiene WhatsApp. details.connect dice cómo conectarlo. |
403 channel_inactive | WhatsApp está conectado pero apagado. |
404 not_found | Ningún canal del proyecto tiene esa dirección from. |
El otro caso es un WhatsApp aceptado con 202 que falla después: el número no tiene WhatsApp, o Meta lo rechaza. Eso llega como evento message.failed al webhook del proyecto. Suscríbete una vez:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://notificaciones.tutienda.mx/webhooks/zavu", "events": ["message.failed", "message.delivered", "template.status_changed", "domain.verified"]}'
El 201 devuelve webhook.secret una sola vez; guárdalo. Cada entrega va firmada en X-Zavu-Signature como t=...,v2=..., donde v2 es el HMAC-SHA256 en hex de "{t}.{body}". El handler la valida y después lee el mensaje para encontrar el pedido por la metadata que le pusiste:
TypeScript// server.ts (continúa) import { zavu } from "./zavu" import { enviarAvisoPorEmail } from "./notificar" import { cargarPedido } from "./db" // tu propia base de pedidos function firmaZavuOk(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 (!firmaZavuOk(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 cargarPedido(m.metadata.pedidoId) await enviarAvisoPorEmail(pedido,${m.metadata.pedidoId}:enviado:email) })
GET /v2/messages/{messageId} también te da el errorCode en palabras de Zavu (por ejemplo WHATSAPP_REJECTED, con el código del proveedor en errorDetails.providerCode) y attemptedChannels, donde aparecería un respaldo de WhatsApp a SMS si lo hubieras dejado activo. Zavu reintenta una entrega que no recibe un 2xx, hasta 5 intentos; la key del respaldo hace inofensivo un evento repetido.
5. Mantén los contactos sincronizados
Cada pedido es también la ocasión de tener al día el contacto del cliente, para que la bandeja de Conversaciones muestre su nombre y puedas segmentar después.
TypeScript// notificar.ts (continúa) export async function sincronizarContacto(p: Pedido) { const channels = [ ...(p.email ? [{ channel: "email", identifier: p.email, isPrimary: true }] : []), ...(p.telefono ? [{ channel: "whatsapp", identifier: p.telefono, isPrimary: true }] : []), ] if (channels.length === 0) return null const metadata = { origen: "woocommerce", ultimoPedidoId: p.id, ultimoPedidoNumero: p.numero } const { status, json } = await zavu("/contacts", "POST", { displayName: p.nombre || 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}) }
Tres cosas que conviene saber. Los valores de metadata son strings. Un identificador de teléfono en whatsapp o sms debe ir en E.164, y la v2 rechaza otra cosa con 400 invalid_request, sugiriendo la forma con + cuando los dígitos parecen un número. Y PATCH reemplaza el objeto metadata completo, así que manda todas las claves que quieras conservar. La v2 todavía no agrega un canal nuevo a un contacto existente; POST /v1/contacts/{contactId}/channels sí lo hace, con la misma API key.
Probarlo, y lo que puede fallar
Prueba con una key live y tu propio email y teléfono. Una key de test siempre envía desde el sandbox de WhatsApp, así que no sirve para probar el camino del email. Apunta los webhooks de la tienda a un túnel hacia tu máquina y haz un pedido de prueba.
| Ves | Significa | Haz esto |
|---|---|---|
409 email_domain_not_verified | El DNS del dominio aún no está verificado | Publica los registros, POST /v2/email-domains/{id}/verify, reintenta |
400 invalid_request con path: ["subject"] | Email sin asunto | Manda siempre subject |
400 invalid_request en to | Un teléfono sin +, leído como chat id | Guarda los números en E.164 |
409 no_channel | Nada conectado llega a ese to | Lee details.connect |
409 conflict | La key ya se usó | Normal en reintentos; el original está en details.message |
402 insufficient_balance | El saldo no cubre el email | Recarga saldo desde el dashboard |
429 daily_limit_exceeded | El tope diario de un canal | Espera a las 00:00 UTC, o súbelo |
message.failed con EMAIL_RECIPIENT_SUPPRESSED | Esa dirección rebotó o se quejó antes | Pide al cliente otro email |
Zavu revisa el destinatario de cada email antes de despacharlo. Una dirección mal escrita, un dominio sin registros de correo o una dirección en tu lista de supresión hacen fallar el mensaje con EMAIL_INVALID_RECIPIENT, EMAIL_DOMAIN_NOT_FOUND o EMAIL_RECIPIENT_SUPPRESSED, en vez de mandar un rebote seguro.
Pasar a producción
Cobro del email. En los planes actuales el email se cobra de tu saldo prepago en bloques de 1.000 emails transaccionales, a USD 0,40 por bloque; con saldo en cero los envíos se detienen con 402 insufficient_balance. El plan Free además limita el email a 100 por día y 3.000 por mes. Los equipos en planes anteriores conservan sus condiciones de email. Todo plan tiene un tope móvil según la antigüedad de la cuenta, desde 200 por hora y 1.000 cada 24 horas la primera semana hasta 5.000 por hora y 50.000 cada 24 horas después del día 30; pasado ese tope, el mensaje falla con EMAIL_RATE_LIMIT_HOURLY o EMAIL_RATE_LIMIT_DAILY. El email saliente también pasa por la revisión de riesgo de Zavu, y un mensaje retenido queda en queued hasta que una persona lo aprueba.
Límites y cobro de WhatsApp. En Free, WhatsApp comparte con Telegram, Instagram y Messenger una cuota mensual de 2.000 mensajes, que cuenta en ambas direcciones, y un tope diario de 250 con la cuenta verificada (100 antes). Los planes pagos no tienen ninguno de los dos. Meta cobra las plantillas de utilidad a tu cuenta de WhatsApp Business según la tarifa del país del cliente; Zavu no les suma margen. El detalle está en WhatsApp empieza a cobrar tus respuestas.
Aprobación de plantillas. La aprobación es de Meta, no de Zavu, y puede tardar desde minutos hasta más. Deja el camino por email funcionando desde el primer día, así una plantilla que sigue en pending o queda rejected no te cuesta más que el canal.
Links. Los links en email y WhatsApp no pasan por verificación de URLs; Zavu revisa URLs solo en SMS, SMS de una vía y voz, y ahí bloquea los acortadores. Si más adelante mandas el aviso por SMS, verifica antes el dominio de rastreo con POST /v1/urls y usa la URL completa.
Consentimiento. Meta exige el consentimiento del cliente antes de escribirle por WhatsApp. Pídelo en el checkout y deja el respaldo por email para quien no lo dio.
La referencia completa está en el resumen de la API v2, la guía del webhook y la lista de errores. Si tu proyecto sigue en v1, migrar desde v1 traduce cada llamada; las dos versiones funcionan con la misma key. Si vas a mandar el aviso a muchos clientes a la vez, mira también cómo enviar mensajes masivos por WhatsApp.