Enviar WhatsApp cuando se pague el Pix: receta con el webhook de Asaas
El cliente que paga un Pix y se queda sin respuesta escribe preguntando si llegó. La receta de abajo cierra ese hueco: cuando Asaas avisa que el cobro fue recibido, tu servidor envía un recibo corto por WhatsApp en segundos. El diseño sirve para cualquier cobro de Asaas y usa solo dos piezas: el webhook de Asaas y la API REST de ZapZap. También sirve para quien vende por plataformas de infoproductos, como muestra la última sección.
Publicado el · 5 min de lectura
Este artículo también está disponible en: Português · English
Lo que necesitas tener listo
Cuatro cosas: una cuenta en Asaas con la clave de API, un endpoint HTTPS tuyo que acepte POST, una instancia de ZapZap conectada con las claves x-api-key y x-api-secret (autenticación) y una base de datos con una tabla para registrar los eventos. Asaas tiene Sandbox, y las configuraciones de Sandbox y Producción son independientes: arma y prueba todo allí antes de repetir el registro del webhook en producción.
El payload de Asaas no trae el teléfono del cliente. Trae payment.customer, el id del cliente, y con ese id buscas el celular. Por eso esta receta no funciona con el disparador de webhook directo de ZapZap, que necesita el teléfono dentro del cuerpo; pasa por un endpoint tuyo (o por un flujo en n8n). La página de Asaas detalla los eventos y las reglas del token.
Elige el evento correcto y evita el aviso doble
Para un cobro pagado, Asaas tiene dos eventos cercanos: PAYMENT_CONFIRMED (pago efectuado, con el saldo aún no disponible) y PAYMENT_RECEIVED (cobro recibido, con el valor disponible). Elige uno para el recibo. Aquí usamos PAYMENT_RECEIVED, que es el que representa dinero en la cuenta. Si prefieres avisar antes, usa PAYMENT_CONFIRMED, pero nunca los dos.
Aun eligiendo uno, Asaas entrega cada evento al menos una vez, así que el mismo id puede llegar repetido. La siguiente tabla resuelve ambas cosas: la clave primaria en el id del evento frena la reentrega, y el índice único parcial por payment_id garantiza un recibo por cobro.
SQL (PostgreSQL)
-- One row per event id: the UNIQUE constraint is the idempotency lock
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- Asaas event id (evt_...)
payment_id TEXT NOT NULL,
event TEXT NOT NULL,
payload JSONB NOT NULL,
notified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One notification per payment and purpose, even if CONFIRMED and RECEIVED both arrive
CREATE UNIQUE INDEX one_receipt_per_payment ON webhook_events (payment_id) WHERE notified;El endpoint: valida, guarda, responde 200 y solo entonces envía
El orden importa. Primero valida el encabezado asaas-access-token contra el authToken que registraste en el webhook (de 32 a 255 caracteres, sin espacios, y nunca una API Key de Asaas). Luego guarda el evento. Responde HTTP 200 enseguida, porque un procesamiento lento antes de responder provoca timeout y Asaas lo cuenta como falla. Solo después busca al cliente y envía el WhatsApp.
El ejemplo de abajo hace exactamente eso. El teléfono sale de mobilePhone y, si falta, de phone; si ninguno existe, la función termina sin error. Cualquier código distinto de 200 vuelve a la cola de reintentos de Asaas, con intervalos crecientes, y tras 15 fallas consecutivas pausa el webhook.
Node.js (Express)
// POST /asaas : receipt when a charge is paid
app.post("/asaas", express.json(), async (req, res) => {
if (req.get("asaas-access-token") !== process.env.ASAAS_WEBHOOK_TOKEN) return res.sendStatus(401);
const { id, event, payment } = req.body;
// 1) persist first (ON CONFLICT DO NOTHING returns 0 rows for a repeated id)
const fresh = await db.query(
"INSERT INTO webhook_events (id, payment_id, event, payload) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING",
[id, payment?.id, event, req.body]
);
res.sendStatus(200); // 2) answer 200 right after persisting
if (!fresh.rowCount || event !== "PAYMENT_RECEIVED") return;
// 3) claim the notification for this payment (second claim fails on the partial unique index)
const claim = await db.query(
"UPDATE webhook_events SET notified = TRUE WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM webhook_events WHERE payment_id = $2 AND notified)",
[id, payment.id]
).catch(() => null);
if (!claim?.rowCount) return;
// 4) customer lookup, then send
const c = await fetch("https://api.asaas.com/v3/customers/" + payment.customer, {
headers: { access_token: process.env.ASAAS_API_KEY },
}).then((r) => r.json());
const phone = String(c.mobilePhone || c.phone || "").replace(/\D/g, "");
if (phone.length < 10) return;
const value = Number(payment.value).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
await sendWhatsApp(phone.startsWith("55") ? phone : "55" + phone,
"Oi, " + c.name.split(" ")[0] + "! Recebemos o seu pagamento de " + value + ". Obrigado!");
});
// Shared helper: one send through the ZapZap API
async function sendWhatsApp(number, text) {
const r = await fetch(`https://api.zapzapapi.com/api/v1/${process.env.ZAPZAP_INSTANCE}/send/text`, {
method: "POST",
headers: {
"x-api-key": process.env.ZAPZAP_API_KEY,
"x-api-secret": process.env.ZAPZAP_API_SECRET,
"Content-Type": "application/json",
},
body: JSON.stringify({ number, text }),
});
if (!r.ok) throw new Error("ZapZap " + r.status + ": " + (await r.text()));
}El mensaje: corto, útil y con salida
Un buen recibo dice tres cosas: que el pago fue recibido, de cuánto y qué pasa ahora (acceso liberado, envío, plazo). Evita promesas que no controlas, como 'llega hoy'. No pongas datos sensibles en el texto: WhatsApp no es el lugar para el CPF completo ni para datos de salud. Incluye una forma de responder y, si el cliente pide parar, para y regístralo.
Sobre el consentimiento: el recibo es un mensaje transaccional para quien acaba de pagar, lo cual es distinto de una campaña. Aun así, la LGPD (la ley de protección de datos de Brasil) pide una base legal clara y transparencia sobre el canal. Mira la guía de LGPD en WhatsApp. ZapZap conecta números por un canal no oficial y gestionado, y ningún ajuste elimina el riesgo de bloqueo; un recibo por pago, sin repetir, es el patrón de menor riesgo.
Prueba en el Sandbox y luego en producción
En el Sandbox de Asaas, crea el webhook con el evento PAYMENT_RECEIVED, genera un cobro con un cliente cuyo celular sea el tuyo y simula el pago. Comprueba cuatro cosas: llegó el POST, se validó el encabezado, se guardó una fila en webhook_events y el mensaje llegó a tu WhatsApp. Después reenvía el mismo evento desde el panel de logs de webhook de Asaas y confirma que NO sale un segundo mensaje. Esa segunda prueba es la que muestra si tu idempotencia funciona.
En producción, sigue Integrações > Logs de Webhooks en Asaas (código HTTP, intentos, timeouts) y crea una alerta propia para cualquier 5xx del endpoint. Una cola pausada por 15 fallas guarda los eventos hasta 14 días; pasado ese plazo se eliminan.
La misma receta en Kiwify, Hotmart y Eduzz
Si vendes infoproductos, el evento de pago aprobado cambia de nombre, pero la receta es la misma. En Kiwify es compra_aprovada (webhook_event_type order_approved); en Hotmart es PURCHASE_APPROVED; en Eduzz es myeduzz.invoice_paid. En las tres, el teléfono viene dentro del payload, así que el asistente Recuperar vendas, en Fluxos, lo resuelve sin código: Kiwify, Hotmart y Eduzz muestran el paso a paso de cada una. El texto de poscompra queda más completo en bienvenida al comprador.
Resumen rápido
- Asaas no envía el teléfono: busca el celular en GET /v3/customers/{id} y envía por la API de ZapZap.
- Valida asaas-access-token, guarda el id del evento con UNIQUE, responde 200 y solo entonces envía.
- Elige PAYMENT_RECEIVED o PAYMENT_CONFIRMED para el recibo, nunca los dos.
- Prueba la reentrega en el panel de logs de Asaas: el segundo envío debe ser frenado.
Sigue leyendo
Lee también
Preguntas frecuentes
¿Necesito n8n o un servidor para esta receta?
Necesitas uno de los dos, porque el payload de Asaas no trae el teléfono y alguien tiene que buscar al cliente. Un endpoint tuyo lo resuelve, y un flujo en n8n con Webhook, HTTP Request y el nodo de ZapZap hace lo mismo sin servidor propio.
¿PAYMENT_CONFIRMED o PAYMENT_RECEIVED?
CONFIRMED es pago efectuado con el saldo aún no disponible; RECEIVED es cobro recibido con el valor disponible. Para el recibo y la liberación de acceso, RECEIVED es lo más seguro. Usa solo uno de los dos.
El cliente recibió el recibo dos veces. ¿Por qué?
Casi siempre por falta de idempotencia. Asaas entrega al menos una vez, y el mismo id puede llegar repetido. Guarda el id con clave primaria y usa un índice único por payment_id.
¿Puedo enviar el recibo a quien no me conoce?
El recibo va a quien acaba de pagarte, así que hay relación. Aun así sé breve, no incluyas datos sensibles y respeta el pedido de parar. ZapZap es un canal no oficial y no elimina el riesgo de bloqueo.
¿Cómo pruebo sin cobrarle a nadie?
Usa el Sandbox de Asaas, que es independiente de producción, con un cliente cuyo celular sea el tuyo. Luego reenvía el evento desde los logs de webhook para comprobar que el segundo mensaje no sale.
¿Qué pasa si mi servidor se cae durante un pago?
Asaas reintenta con intervalos crecientes. Tras 15 fallas consecutivas pausa la cola de ese webhook y guarda los eventos hasta 14 días. Reactiva la cola en el panel después de corregir el endpoint.
Avisa al cliente en el instante en que paga
Conecta un número, copia el código de la receta y prueba en el Sandbox de Asaas. R$15 por número activo al mes, con crédito de bienvenida sujeto a la campaña vigente.
