CourseWhatsApp Automation for Business
ModulePayments and production
Duration10 min
Progress0/7
Course material0/7 · 0%
  1. 1What we are building, and the project setup8 min
  2. 2Connecting to the WhatsApp Cloud API12 min
  3. 3Sessions and the order flow state machine12 min
  4. 4The mini CRM: contacts, orders, and labels10 min
  5. 5Payments in chat with the Midtrans sandbox12 min
  6. 6Hardening: dedupe, retries, and human takeover10 min
  7. 7Deploying with Docker, off the laptop for good12 min

Lesson 6 / 7 · 10 min

Hardening: dedupe, retries, and human takeover

The three failures every live bot meets and the code that survives them. Checkpoint: duplicates ignored, sends retried, admin takeover working.

Three things will happen to your bot in its first production week: the same webhook will arrive twice, a send will fail mid-burst, and a customer will need a human. None of these are edge cases; they are the job. Each gets a small, permanent fix now.

Fix 1: deduplicate by message id

// server.js, first thing inside the POST /webhook handler:
const fresh = db
  .prepare("INSERT OR IGNORE INTO processed(message_id) VALUES (?)")
  .run(msg.id).changes === 1;
if (!fresh) return; // we already handled this exact message
Gateways redeliver on timeouts and reconnects. One INSERT OR IGNORE makes redelivery harmless forever.

Fix 2: retry sends with backoff

// wa.js
async function withRetry(fn, tries = 3) {
  for (let i = 0; i < tries; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === tries - 1) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
    }
  }
}

// wrap the fetch inside sendText:
//   return withRetry(async () => { ...the existing fetch... });
1s, then 2s, then 4s. Most network blips heal inside the first retry; the customer never notices.

Fix 3: a real human takeover

// server.js, inside the webhook handler

// 1. Entering HUMAN state notifies you (set ADMIN_PHONE in .env):
if (session.state === "HUMAN" && previousState !== "HUMAN") {
  await sendText(process.env.ADMIN_PHONE,
    "Takeover: " + msg.from + " butuh admin. " +
    "Balas dia langsung dari WhatsApp Business. " +
    "Ketik RESUME " + msg.from + " ke bot untuk menyalakan lagi.");
}

// 2. You can hand the chat back to the bot:
if (msg.from === process.env.ADMIN_PHONE &&
    msg.text.body.toUpperCase().startsWith("RESUME ")) {
  const phone = msg.text.body.split(" ")[1];
  const s = getSession(phone);
  s.state = "MENU";
  saveSession(s);
  await sendText(process.env.ADMIN_PHONE, "Bot aktif lagi untuk " + phone);
  return;
}
The bot goes quiet, you get pinged with context, and RESUME hands the chat back. Customers never feel trapped.

And always: log decisions

Add one console.log per handled message: phone, state before, state after, reply sent. When something odd happens next month, that single line per message is the difference between a five-minute diagnosis and an evening of guessing. In production these lines go to a file or a log service, same idea.

Checkpoint: send a message, restart ngrok mid-conversation, type admin and watch your own phone get the takeover ping, then RESUME the chat. If all three behave, your bot now survives the real world's three favourite punches.

What you take away

  • INSERT OR IGNORE on message id makes webhook redelivery harmless
  • Exponential backoff (1s, 2s, 4s) absorbs most network failures invisibly
  • Takeover pings the admin with context; RESUME hands the chat back
  • One log line per message is your future debugging lifeline

Curated sources

Hand-picked references for going deeper, reviewed by our team.

Related Etzal serviceWhatsApp AI chatbotPrefer it done for you? See how we help.
Hardening: dedupe, retries, and human takeover - WhatsApp Automation for Business | Etzal Academy | Etzal Group