· campaign reliability & auto-responder triage · Founder, Klaros

Status: Resolved. This postmortem is a historical log. The issue discussed below has been fully fixed.

The WhatsApp broadcast that started talking to itself: Eliminating auto-responder loops

Out-of-office auto-responders and BSP system error messages trigger infinite bot ping-pong loops on WhatsApp campaigns. Meta Cloud API provides no is_bot header, so standard CRMs either loop indefinitely or mute real customers. Here is how we engineered deterministic correspondent triage, context-aware thresholding, and prompt refusal gates in Klaros.

TL;DR / Executive Summary

The Incident: A promotional broadcast reaches businesses as well as people, and a business number answers it with its own autoresponder. A CRM that treats that text as a customer message replies to it, the autoresponder replies again, and both sides pay for every turn while the sender’s quality rating takes the complaints.

The Architectural Fix: Replies are classified as a person, a suspected machine or a machine, and only a machine is silenced, which takes two independent signals. After one of our campaign sends those are timing (back within 15 seconds, at least 60 characters) and a known autoresponder phrase. A phrase alone never silences anyone. A prompt guard covers what the classifier misses, and button taps and Flow submissions always count as a person.

Legacy CRMs (Naïve Loop) 1. Broadcast Outreach Send 2. Handset OOO Auto-Reply 3. Un-gated AI Auto-Reply Infinite Ping-Pong Loop Klaros (Refusal Engine) 1. Broadcast Outreach Send 2. Handset OOO Auto-Reply 100% Suppressed Gate WABA Quality Protected
Figure 1: Legacy un-gated auto-replies vs Klaros deterministic refusal gate on broadcast out-of-office responses.

The Absence of `Auto-Submitted` Headers on WhatsApp

In conventional messaging protocols, machine-generated traffic carries explicit structural headers:

The Meta WhatsApp Cloud API provides no such header. An inbound webhook payload delivers identical fields (from, wa_id, text) whether written by a human typing on a keyboard or generated by an Android out-of-office auto-reply app:

Inbound Meta Cloud API Payload (Auto-Responder vs Human)
{
  "object": "whatsapp_business_account",
  "entry": [{
    "changes": [{
      "value": {
        "messaging_product": "whatsapp",
        "contacts": [{ "profile": { "name": "Customer" }, "wa_id": "34600000001" }],
        "messages": [{
          "from": "34600000001",
          "id": "wamid.HBgL...",
          "timestamp": "1717584000",
          "text": { "body": "Thank you for reaching out! We are currently unavailable." },
          "type": "text"
        }]
      }
    }]
  }]
}
src/services/correspondent.mjs — Multilingual Auto-Responder Matcher
// One signal, never a verdict. Courtesy a person uses ("shukriya", "gracias por tu mensaje")
// was on an earlier version of this list and classified real customers as bots.
const AUTOREPLY_PATTERNS = [
  /unknown\s+input\s+type\s+received/i,
  /kindly\s+reply\s+with\s+a\s+valid/i,
  /thank\s+you\s+for\s+(reaching\s+out|contacting|messaging)\b/i,
  /gracias\s+por\s+(escribirnos|contactarnos)/i,
  /we\s*(?:['’]\s*re|\s+are)\s+(?:currently\s+)?(?:unavailable|away|closed)/i,
  /out\s+of\s+(?:the\s+)?office/i,
  /fuera\s+de\s+(?:la\s+)?oficina/i,
  /\bauto[-\s]?reply\b|automated\s+(?:response|message|reply)|mensaje\s+autom[áa]tico/i,
  /\b(?:office|business|working)\s+(?:hours|timings?)\b/i,
  /horario\s+de\s+atenci[óo]n/i,
];

// In classifyCorrespondent: the phrase counts only on a reply to a campaign send, and
// only two independent signals (phrase, timing, exact repeat) make a verdict.
if (signals.length >= 2) return { kind: CORRESPONDENT.AUTOMATED, /* ... */ };
Dynamic Threshold Classification Boundary Response Timing Gap (seconds) Length (chars) Organic Gap (3s, 200c) Broadcast Adaptive Zone (15s, 60c)
Figure 2: Dynamic threshold expansion for broadcast context vs organic 1-on-1 conversations.

Broadcast Attribution & Interactive Immunity

The broadcast context is read from our most recent outbound message only: whether the campaign ledger (outbound_messages) holds a campaign for it. An earlier version read the conversation’s origin_campaign_id, which is set once and never cleared, so a conversation that a campaign opened months ago kept being treated as a fresh broadcast reply.

Crucially, interactive button taps and WhatsApp Flow completions take unconditional priority (isInteractive = true). No matter how fast an interactive payload arrives, a button press is treated as HUMAN 100% of the time.

3-Tier Classification State Machine & Immunity Gate Inbound Message Interactive? HUMAN (Immunity) Classify Rules AUTOMATED (Suppressed)
Figure 3: 3-tier classification state machine and interactive button tap immunity gate.

Generative LLM Prompt Refusal Guards

Even if an automated text bypasses regex matching, generative AI models must be restrained from entering conversational loops. Klaros adds an explicit AUTO-RESPONDER GUARD to the model’s instructions (src/services/ai-auto-reply.mjs):

src/services/ai-auto-reply.mjs — Prompt Refusal Guard
AUTO-RESPONDER GUARD: If the incoming message is plainly another business’s automatic reply to a
template message we sent (an out-of-office or away notice, "unknown input type received", a canned
receipt naming THEIR business) AND it asks us nothing, do not reply: return
{"canAnswer": false, "reply": "", "action": "handoff"}. A person being polite ("thank you",
"shukriya", "thanks for reaching out") or asking anything at all is NOT an automatic reply: answer them.

Empirical Verification & Test Proofs

The behaviour described here is covered by tests that run against a real SQLite database built from the production migrations:

Verification Test Commands
# The classifier, with negative controls (people who must never read as machines)
node --test test/uni-cloud-broadcast-autoresponder.test.mjs

# The classifier as the inbound handler actually calls it
node --test test/uni-cloud-correspondent-self-match.test.mjs

Run WhatsApp campaigns without bot loop panic

Connect your own WhatsApp Business Account with zero per-message markup, full consent ledgers, and deterministic loop protection.

Questions people ask about broadcast auto-responder loop suppression

Why do WhatsApp broadcasts trigger auto-responder loops?

When a mass promotional broadcast is sent to thousands of contacts, handsets with out-of-office auto-reply enabled respond automatically. Naive CRMs treat these inbound texts as human messages and trigger auto-replies or AI model responses, starting an infinite messaging loop.

Does Meta WhatsApp Cloud API supply an is_bot header?

No. Unlike email (RFC 3834 Auto-Submitted) or Slack/Telegram APIs, Meta's Cloud API webhook delivers identical message structures regardless of whether the sender is a human or an auto-reply app.

How does Klaros prevent auto-responder loops without silencing human replies?

Klaros classifies each reply as HUMAN, SUSPECTED_AUTOMATED or AUTOMATED, and only AUTOMATED stops replies, which takes two independent signals. For a reply to one of our campaign sends, those are timing (back within 15 seconds and at least 60 characters long) and a known autoresponder phrase such as an out-of-office notice. A phrase alone is never enough, so a customer who writes "thank you for reaching out, what is the price?" is still answered. Quick-reply button taps and WhatsApp Flow submissions always count as a person.

How does zero-markup messaging save costs during mass broadcasts?

Every message in a bot-to-bot loop is a billable message. Klaros adds no per-message markup: you connect your own WhatsApp Business Account and pay Meta’s rates directly, so a loop that is stopped early costs only what Meta charges for the messages already sent.

Written 23 September 2026. Klaros — Built to Remember Your Story. Related: our philosophy, Trengo alternative, Twilio alternative, WATI alternative, reliability notes, all build notes.