Operations

Overdue boleto reminder on WhatsApp: a recipe with webhook, queue and opt-out

An overdue charge is the kind of message where a mistake costs a lot: notifying someone who already paid is irritating, pushing too hard becomes a complaint and the wrong tone becomes a legal problem. This recipe builds a defensive reminder: it fires from the Asaas overdue charge event, checks the status right before each send, sends at most two messages and respects anyone who asks to stop.

Published on · 4 min read

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

Design the cadence before writing code

Define the rule in one sentence: on the day the charge falls due, a helpful reminder; three days later, if it is still open, a second one; after that, nothing on WhatsApp. Two messages are enough for someone who forgot, and someone who does not answer two probably needs another channel (a call, email, negotiation). More than that raises reports and the risk of the number being limited.

Golden rule of tone: reminder, not aggressive billing. Brazil's Consumer Defense Code (art. 42) forbids exposing a delinquent consumer to ridicule or embarrassment, so no text that accuses, threatens or mentions credit bureau listing. If your operation has old debt, handle it with your legal counsel; this recipe is for a delay of days.

The trigger: PAYMENT_OVERDUE on Asaas

Asaas sends PAYMENT_OVERDUE when the charge falls due. Configure the webhook with that event and an authToken (32 to 255 characters, no spaces), and validate the asaas-access-token header on every request. There is another event that looks useful and is not: PAYMENT_BANK_SLIP_CANCELLED only indicates that the boleto registration was canceled after the due date, and it does not mean the charge was removed.

Remember that delivery is at least once: store the event id with a uniqueness constraint before scheduling anything, as in the Pix paid recipe. The Asaas page has the event table and the paused queue rules.

The endpoint and the worker that rechecks before sending

The core of the recipe is in the worker: it never trusts what the event said hours or days ago. Before sending, it queries the charge in Asaas and only proceeds if the status is still OVERDUE. A customer who paid between the event and the reminder gets nothing. Check the Asaas reference for the status values of your API version before going to production.

Scheduling (the 'schedule' queue) is yours: it can be a table with an execution time and a cron every minute, a queue like BullMQ or an n8n flow with a Wait node. The example below shows the logic without tying it to a tool.

Node.js (Express)

// POST /asaas : first reminder when a charge becomes overdue, second one 3 days later if still unpaid
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;
  const fresh = await saveEventOnce(id, req.body); // INSERT ... ON CONFLICT DO NOTHING
  res.sendStatus(200);
  if (!fresh || event !== "PAYMENT_OVERDUE") return;

  if (await isOptedOut(payment.customer)) return;   // suppression list, see the opt-out section
  await schedule("reminder-1", payment.id, 0);      // your queue: run now
  await schedule("reminder-2", payment.id, 3 * 24 * 3600); // run in 3 days
});

// Worker: always re-check the charge right before sending
async function runReminder(kind, paymentId) {
  const p = await fetch("https://api.asaas.com/v3/payments/" + paymentId, {
    headers: { access_token: process.env.ASAAS_API_KEY },
  }).then((r) => r.json());
  if (p.status !== "OVERDUE") return;               // paid, canceled or deleted meanwhile: say nothing
  const c = await fetch("https://api.asaas.com/v3/customers/" + p.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 || (await isOptedOut(p.customer))) return;
  const first = c.name.split(" ")[0];
  const text = kind === "reminder-1"
    ? "Oi, " + first + ". Sua cobrança venceu e ainda consta em aberto. Se já pagou, pode ignorar. Se precisar de uma segunda via ou de outra data, é só responder aqui. Para não receber esses avisos, responda PARAR."
    : first + ", passando só para confirmar se deu tudo certo com a sua cobrança. Posso ajudar com a segunda via? Para não receber mais avisos, responda PARAR.";
  await sendWhatsApp(phone.startsWith("55") ? phone : "55" + phone, text);
}

// 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()));
}

How to stop: an opt-out that works

Every message in the example ends with 'reply STOP'. That only counts if someone reads the reply. Register the instance's incoming messages webhook (how to set it up), discard your own messages (the fromMe field) and, when the text is STOP, store the customer in a suppression list that the isOptedOut function checks. Without that list, the stop button is decoration, and it is the first thing an annoyed customer will test.

Also record the reason and date of each send. If one day you need to explain why a customer received a message, the log is your defense. The guide on LGPD on WhatsApp lists what to record.

Variations for Kiwify and Eduzz

On Kiwify, a generated boleto becomes the boleto_gerado event (webhook_event_type billet_created), and payment becomes compra_aprovada. Kiwify has no 'boleto overdue' trigger: the usual pattern is to wait some time after the boleto is generated and check whether the purchase was approved, which is exactly what the assistant flow does with the 1-hour second reminder. See the Kiwify page.

On Eduzz, myeduzz.invoice_expired indicates an expired invoice and the payload of open invoices carries bankslipUrl and saleRecoveryUrl, which can go into the message. The Eduzz page explains x-signature. On both, the phone comes in the payload, and the Recuperar vendas assistant covers the Pix and boleto cases with no code.

Checklist before turning it on

Test with your number and a Sandbox charge. Confirm that someone who pays between the two reminders does not get the second. Confirm that STOP really blocks. Confirm that the text threatens nothing. And remember that ZapZap connects numbers through an unofficial, managed channel: no setting guarantees the absence of blocking, and the combination of few messages, a respectful tone and a clean list is what reduces the risk.

Quick summary

  • At most two reminders, and the worker rechecks the charge status before each send.
  • PAYMENT_OVERDUE fires the cadence; PAYMENT_BANK_SLIP_CANCELLED does not mean the charge was removed.
  • STOP only works with an incoming messages webhook and a suppression list checked before sending.
  • A helpful tone, with no threat or embarrassment (art. 42 of the Consumer Defense Code).

Also read

Frequently asked questions

How many messages should I send for an overdue charge?

Two are usually enough: one on the due date and another a few days later, if it is still open. Going beyond that raises complaints and the risk of the number being limited. For a long delay, use another channel.

Why check the status before every send?

Because the event is a snapshot of when it happened. The customer may have paid hours later, and sending a reminder to someone who already paid is the most irritating mistake of this message. The worker queries the charge and only sends if it is still overdue.

What does the Consumer Defense Code say about billing?

Article 42 forbids exposing a delinquent consumer to ridicule and to any kind of embarrassment or threat. In practice: a reminder message, with no accusation and no exposure to third parties. For legal questions, consult a lawyer.

Does Kiwify have an overdue boleto event?

Not among the webhook triggers we checked. There are boleto_gerado and compra_aprovada. The usual design is to wait after the boleto is generated and check whether the purchase was approved.

How do I respect people who ask to stop?

Receive the instance's messages by webhook, ignore your own (fromMe) and, when the customer writes STOP, store them in a suppression list that sending always checks. Without that check, the stop notice has no effect.

Can this get my number blocked?

It can. ZapZap uses an unofficial channel, against WhatsApp's terms, and no feature removes the risk. Few messages, a respectful tone, a recipient who has a relationship with you and a clean list reduce the chance, but do not bring it to zero.

Bill without burning the relationship

Build the reminder with few messages, a checked status and a way to stop. R$15 per active number per month, with a welcome credit subject to the current campaign.