--- title: Notificaciones de pedidos por WhatsApp y email para tu tienda online, desde los webhooks de tu tienda description: Notificaciones de pedidos por WhatsApp y email automáticas para tu tienda online: confirmación por email y aviso de envío con guía, desde WooCommerce. date: 2026-10-19 author: Zavu Team locale: es source: https://www.zavu.dev/es/blog/order-notifications-ecommerce tags: Tutorial, API v2, E-commerce, Email, WhatsApp, Webhooks --- # Notificaciones de pedidos por WhatsApp y email para tu tienda online, desde los webhooks de tu tienda ## TL;DR - **El email de confirmación sale cuando se paga el pedido.** El webhook de tu tienda dispara `POST /v2/messages` desde una dirección de un dominio verificado. - **Ningún mensaje sale dos veces.** Cada envío lleva una `idempotencyKey` armada con el id del pedido y el evento, así que un webhook reintentado recibe `409 conflict` y 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.mx` conectada 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](/es/blog/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: ```bash curl -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: ```bash curl -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: ```bash curl -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 header `X-WC-Webhook-Topic` y la firma en `X-WC-Webhook-Signature`: un HMAC-SHA256 en base64 del cuerpo, con el secreto que configuraste. El `status` del pedido te dice en qué va: `processing` cuando está pagado, `completed` cuando lo marcas como enviado. - **Tiendanube** tiene eventos como `order/paid` y `order/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/paid` y `orders/fulfilled`, firmados en `X-Shopify-Hmac-Sha256`. Deja la plataforma fuera del resto del código. Una función convierte su payload en tu propio `Pedido`: ```ts // 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: ```ts // 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: ```ts // zavu.ts export async function zavu(path: string, method = "POST", body?: unknown) { const res = await fetch(`https://api.zavu.dev/v2${path}`, { method, headers: { Authorization: `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 ```ts // 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) => `${i.cantidad} x ${i.nombre}`) const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, // "pedidos@tutienda.mx" to: p.email, subject: `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: `
Hola ${esc(p.nombre)}, recibimos tu pedido #${esc(p.numero)}.
Total: ${esc(p.total)} ${esc(p.moneda)}
`, 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: ```bash curl -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: ```bash curl -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. ```ts // 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 = `${p.id}:enviado` 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: `${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: ```bash curl -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: ```ts // 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(`${partes.t}.${raw.toString("utf8")}`).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(`/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. ```ts // 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(`/contacts/${contactId}`, "PATCH", { metadata }) return contactId } throw new Error(`${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](/es/blog/whatsapp-per-message-pricing-explained). **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](https://docs.zavu.dev/api-v2/overview), la [guía del webhook](https://docs.zavu.dev/api-v2/webhook) y la [lista de errores](https://docs.zavu.dev/api-v2/errors). Si tu proyecto sigue en v1, [migrar desde v1](https://docs.zavu.dev/api-v2/migrate-from-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](/es/blog/mensajes-masivos-whatsapp).