Operação

Enviar WhatsApp quando o Pix for pago: receita com o webhook do Asaas

Cliente que paga um Pix e fica sem resposta manda mensagem perguntando se caiu. A receita abaixo fecha esse buraco: quando o Asaas avisa que a cobrança foi recebida, o seu servidor manda um recibo curto pelo WhatsApp em segundos. O desenho vale para qualquer cobrança do Asaas e usa só duas peças: o webhook do Asaas e a API REST da ZapZap. Vale também para quem vende por plataforma de infoproduto, como mostra a última seção.

Publicado em · 5 min de leitura

Este artigo também está disponível em: English · Español

O que você precisa ter pronto

Quatro coisas: uma conta no Asaas com a chave de API, um endpoint HTTPS seu que aceite POST, uma instância da ZapZap conectada com as chaves x-api-key e x-api-secret (autenticação) e um banco com uma tabela para registrar os eventos. O Asaas tem Sandbox, e as configurações de Sandbox e Produção são independentes: monte e teste tudo lá antes de repetir o cadastro do webhook em produção.

O payload do Asaas não traz o telefone do cliente. Ele traz payment.customer, o id do cliente, e é com esse id que você busca o celular. Por isso esta receita não funciona com o gatilho de webhook direto da ZapZap, que precisa do telefone dentro do corpo; ela passa por um endpoint seu (ou por um fluxo no n8n). A página Asaas detalha os eventos e as regras do token.

Escolha o evento certo e evite aviso em dobro

Para cobrança paga, o Asaas tem dois eventos próximos: PAYMENT_CONFIRMED (pagamento efetuado, com saldo ainda não disponibilizado) e PAYMENT_RECEIVED (cobrança recebida, com valor disponível). Escolha um para o recibo. Aqui usamos PAYMENT_RECEIVED, que é o que representa dinheiro na conta. Se você prefere avisar mais cedo, use PAYMENT_CONFIRMED, mas nunca os dois.

Mesmo escolhendo um, o Asaas entrega cada evento pelo menos uma vez, então o mesmo id pode chegar repetido. A tabela a seguir resolve as duas coisas: a chave primária no id do evento barra a reentrega, e o índice único parcial por payment_id garante um recibo por cobrança.

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;

O endpoint: valide, grave, responda 200 e só então envie

A ordem importa. Primeiro valide o cabeçalho asaas-access-token contra o authToken que você cadastrou no webhook (de 32 a 255 caracteres, sem espaços, e nunca uma API Key do Asaas). Depois grave o evento. Responda HTTP 200 logo em seguida, porque processamento lento antes da resposta provoca timeout e o Asaas conta como falha. Só depois busque o cliente e envie o WhatsApp.

O exemplo abaixo faz exatamente isso. O telefone vem de mobilePhone e, se faltar, de phone; se nenhum dos dois existir, a função encerra sem erro. Qualquer outro código diferente de 200 volta para a fila de retentativa do Asaas, com intervalos crescentes, e depois de 15 falhas consecutivas ele pausa o 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()));
}

A mensagem: curta, útil e com saída

Um recibo bom diz três coisas: que o pagamento foi recebido, de quanto e o que acontece agora (acesso liberado, envio, prazo). Evite promessa que você não controla, como 'chega hoje'. Não coloque dados sensíveis no texto: o WhatsApp não é o lugar de CPF completo ou de dados de saúde. Inclua um jeito de responder e, se o cliente pedir para parar, pare e registre.

Sobre consentimento: o recibo é uma mensagem transacional para quem acabou de pagar, o que é diferente de campanha. Ainda assim, a LGPD pede base legal clara e transparência sobre o canal. Veja o guia de LGPD no WhatsApp. A ZapZap conecta números por um canal não oficial e gerenciado, e nenhum ajuste elimina o risco de bloqueio; um recibo por pagamento, sem repetir, é o padrão de menor risco.

Teste no Sandbox, depois em produção

No Sandbox do Asaas, crie o webhook com o evento PAYMENT_RECEIVED, gere uma cobrança com um cliente cujo celular seja o seu e simule o pagamento. Confira quatro coisas: o POST chegou, o cabeçalho foi validado, uma linha foi gravada em webhook_events e a mensagem chegou no seu WhatsApp. Depois reenvie o mesmo evento pelo painel de logs de webhook do Asaas e confirme que NÃO sai uma segunda mensagem. Esse segundo teste é o que mostra se a sua idempotência funciona.

Em produção, acompanhe Integrações > Logs de Webhooks no Asaas (código HTTP, tentativas, timeouts) e crie um alerta seu para qualquer 5xx do endpoint. Fila pausada por 15 falhas guarda eventos por até 14 dias; passado esse prazo eles são apagados.

Mesma receita em Kiwify, Hotmart e Eduzz

Se você vende infoproduto, o evento de pagamento aprovado muda de nome, mas a receita é a mesma. Na Kiwify é compra_aprovada (webhook_event_type order_approved); na Hotmart é PURCHASE_APPROVED; na Eduzz é myeduzz.invoice_paid. Nessas três, o telefone vem dentro do payload, então o assistente Recuperar vendas, em Fluxos, resolve sem código: Kiwify, Hotmart e Eduzz mostram o passo a passo de cada uma. O texto de pós-compra fica mais completo em boas-vindas ao comprador.

Resumo rápido

  • O Asaas não manda o telefone: busque o celular em GET /v3/customers/{id} e envie pela API da ZapZap.
  • Valide asaas-access-token, grave o id do evento com UNIQUE, responda 200 e só então envie.
  • Escolha PAYMENT_RECEIVED ou PAYMENT_CONFIRMED para o recibo, nunca os dois.
  • Teste a reentrega no painel de logs do Asaas: o segundo envio tem que ser barrado.

Perguntas frequentes

Preciso do n8n ou de um servidor para essa receita?

Precisa de um dos dois, porque o payload do Asaas não traz o telefone e alguém tem de buscar o cliente. Um endpoint seu resolve, e um fluxo no n8n com Webhook, HTTP Request e o nó da ZapZap faz o mesmo sem servidor próprio.

PAYMENT_CONFIRMED ou PAYMENT_RECEIVED?

CONFIRMED é pagamento efetuado com saldo ainda não disponibilizado; RECEIVED é cobrança recebida com valor disponível. Para recibo e liberação de acesso, RECEIVED é o mais seguro. Use só um dos dois.

O cliente recebeu o recibo duas vezes. Por quê?

Quase sempre por falta de idempotência. O Asaas entrega pelo menos uma vez, e o mesmo id pode chegar repetido. Grave o id com chave primária e use um índice único por payment_id.

Posso mandar o recibo para quem não me conhece?

O recibo vai para quem acabou de pagar você, então há relação. Ainda assim seja breve, não inclua dado sensível e respeite o pedido de parar. A ZapZap é um canal não oficial e não elimina o risco de bloqueio.

Como testo sem cobrar ninguém?

Use o Sandbox do Asaas, que é independente da produção, com um cliente cujo celular seja o seu. Depois reenvie o evento pelos logs de webhook para provar que a segunda mensagem não sai.

O que acontece se meu servidor cair durante um pagamento?

O Asaas tenta de novo com intervalos crescentes. Depois de 15 falhas consecutivas ele pausa a fila daquele webhook e guarda os eventos por até 14 dias. Reative a fila no painel depois de corrigir o endpoint.

Avise o cliente no instante em que ele paga

Conecte um número, copie o código da receita e teste no Sandbox do Asaas. R$15 por número ativo ao mês, com crédito de boas-vindas sujeito à campanha vigente.