Operations

Send a WhatsApp when a Pix is paid: a recipe with the Asaas webhook

A customer who pays a Pix and hears nothing sends a message asking if it arrived. This recipe closes that gap: when Asaas says the charge was received, your server sends a short receipt on WhatsApp within seconds. The design works for any Asaas charge and uses only two pieces: the Asaas webhook and the ZapZap REST API. It also works if you sell through a digital product platform, as the last section shows.

Published on · 5 min read

This article is also available in: Português · Español

What you need ready

Four things: an Asaas account with an API key, an HTTPS endpoint of yours that accepts POST, a connected ZapZap instance with the x-api-key and x-api-secret keys (authentication) and a database with a table to record events. Asaas has a Sandbox, and Sandbox and Production settings are independent: build and test everything there before repeating the webhook setup in production.

The Asaas payload does not carry the customer's phone. It carries payment.customer, the customer id, and you use that id to look up the mobile number. That is why this recipe does not work with ZapZap's direct webhook trigger, which needs the phone inside the body; it goes through an endpoint of yours (or a flow in n8n). The Asaas page details the events and the token rules.

Pick the right event and avoid double notices

For a paid charge, Asaas has two close events: PAYMENT_CONFIRMED (payment made, balance not yet available) and PAYMENT_RECEIVED (charge received, amount available). Pick one for the receipt. Here we use PAYMENT_RECEIVED, which represents money in the account. If you prefer to notify earlier, use PAYMENT_CONFIRMED, but never both.

Even with one chosen, Asaas delivers each event at least once, so the same id can arrive again. The following table solves both problems: the primary key on the event id blocks redelivery, and the partial unique index on payment_id guarantees one receipt per charge.

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;

The endpoint: validate, store, answer 200, then send

Order matters. First validate the asaas-access-token header against the authToken you registered on the webhook (32 to 255 characters, no spaces, and never an Asaas API Key). Then store the event. Answer HTTP 200 right after, because slow processing before the answer causes a timeout and Asaas counts it as a failure. Only then look up the customer and send the WhatsApp.

The example below does exactly that. The phone comes from mobilePhone and, if missing, from phone; if neither exists, the function ends without an error. Any code other than 200 goes back to the Asaas retry queue, with growing intervals, and after 15 consecutive failures it pauses the 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()));
}

The message: short, useful and with an exit

A good receipt says three things: that the payment was received, how much, and what happens now (access released, shipping, deadline). Avoid promises you do not control, like 'arrives today'. Do not put sensitive data in the text: WhatsApp is not the place for a full CPF number or health data. Include a way to reply and, if the customer asks to stop, stop and record it.

On consent: the receipt is a transactional message to someone who just paid, which is different from a campaign. Even so, LGPD (Brazil's data protection law) asks for a clear legal basis and transparency about the channel. See the guide on LGPD on WhatsApp. ZapZap connects numbers through an unofficial, managed channel, and no setting removes the blocking risk; one receipt per payment, without repeating, is the lowest-risk pattern.

Test in the Sandbox, then in production

In the Asaas Sandbox, create the webhook with the PAYMENT_RECEIVED event, generate a charge for a customer whose mobile number is yours and simulate the payment. Check four things: the POST arrived, the header was validated, a row was stored in webhook_events and the message reached your WhatsApp. Then resend the same event from the Asaas webhook logs panel and confirm that a second message does NOT go out. That second test shows whether your idempotency works.

In production, watch Integrations > Webhook Logs in Asaas (HTTP code, attempts, timeouts) and set up your own alert for any 5xx from the endpoint. A queue paused after 15 failures keeps events for up to 14 days; after that they are deleted.

The same recipe on Kiwify, Hotmart and Eduzz

If you sell a digital product, the approved payment event changes name, but the recipe is the same. On Kiwify it is compra_aprovada (webhook_event_type order_approved); on Hotmart it is PURCHASE_APPROVED; on Eduzz it is myeduzz.invoice_paid. On all three, the phone comes inside the payload, so the Recuperar vendas assistant, under Fluxos, handles it with no code: Kiwify, Hotmart and Eduzz show the step by step for each. The post-purchase text gets more complete in welcoming the buyer.

Quick summary

  • Asaas does not send the phone: look up the mobile number at GET /v3/customers/{id} and send through the ZapZap API.
  • Validate asaas-access-token, store the event id with UNIQUE, answer 200 and only then send.
  • Pick PAYMENT_RECEIVED or PAYMENT_CONFIRMED for the receipt, never both.
  • Test redelivery in the Asaas logs panel: the second send must be blocked.

Also read

Frequently asked questions

Do I need n8n or a server for this recipe?

You need one of the two, because the Asaas payload does not carry the phone and something has to look up the customer. An endpoint of yours does it, and an n8n flow with Webhook, HTTP Request and the ZapZap node does the same without your own server.

PAYMENT_CONFIRMED or PAYMENT_RECEIVED?

CONFIRMED is payment made with the balance not yet available; RECEIVED is charge received with the amount available. For a receipt and access release, RECEIVED is the safest. Use only one of the two.

The customer got the receipt twice. Why?

Almost always because of missing idempotency. Asaas delivers at least once, and the same id can arrive again. Store the id with a primary key and use a unique index per payment_id.

Can I send the receipt to someone who does not know me?

The receipt goes to someone who just paid you, so there is a relationship. Even so, be brief, include no sensitive data and respect a request to stop. ZapZap is an unofficial channel and does not remove the blocking risk.

How do I test without charging anyone?

Use the Asaas Sandbox, which is independent from production, with a customer whose mobile number is yours. Then resend the event from the webhook logs to prove the second message does not go out.

What happens if my server goes down during a payment?

Asaas retries with growing intervals. After 15 consecutive failures it pauses that webhook's queue and keeps the events for up to 14 days. Reactivate the queue in the dashboard after fixing the endpoint.

Notify the customer the instant they pay

Connect a number, copy the recipe code and test in the Asaas Sandbox. R$15 per active number per month, with a welcome credit subject to the current campaign.