A bot on WhatsApp is not hard because of the model. It is hard because of the timing, the identity and the rules the platform puts on a number that writes to strangers. This post is the shape of a working bot, the parts that decide whether it feels human, and the limits that are not negotiable.
#The loop, end to end
There are five steps and only one of them involves intelligence. The rest is protocol discipline.
time runs down the page · 6 steps
One message in, one reply out
The step most bot tutorials skip is the third. A bot that acts on an unverified event can be told by anyone to reply to anything, and the reply comes out of a number your customer owns.
#Verify before you think
Read the raw body, check the signature, and only then look at the message. The webhook signatures post has the check in full; this is the shape of the handler.
import { verifyWebhook, WebhookVerificationError } from "@wuapidev/sdk"
export async function POST(request: Request) {
// 1. The bytes, before any parsing.
const rawBody = await request.text()
let event: Awaited<ReturnType<typeof verifyWebhook>>
try {
event = await verifyWebhook(rawBody, request.headers.get("wuapi-signature"), process.env.WUAPI_WEBHOOK_SECRET!)
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 })
throw err
}
// 2. Idempotency: a retry re-delivers the same event.
if (await alreadyHandled(event.id)) return new Response(null, { status: 204 })
await markHandled(event.id)
// 3. Only now, the contents.
if (event.type === "message.received") await reply(event.data.object)
return new Response(null, { status: 204 })
}Step 2 matters because deliveries are retried up to six times on a fixed schedule. Without an idempotency key you will answer the same message six times, and your customer watches a bot repeat itself.
#What makes it feel human
Three things, in order of how much they matter.
- The typing indicator before the send. wuapi shows "typing…" for 0.8 to 6 seconds by default, scaled by message length, before every send. A bot that replies in 40ms with no indicator reads as automated, because it is.
- Not replying instantly to everything. A reply in under a second is the single strongest bot tell on the platform.
- Varying length. Identical replies to ten different people is the second.
await wuapi.chats.setPresence(accountId, chatId, { state: "typing" })
const answer = await draft(text)
await wuapi.chats.setPresence(accountId, chatId, { state: "paused" })
await wuapi.messages.send({ accountId, to: chatId, text: answer })#The limits that shape the design
A number that answers strangers runs on a pace, and the pace is enforced rather than suggested.
| limit | value | what it means for a bot |
|---|---|---|
| sends per minute | 12 per account | a spike of new questions waits in the queue |
| to a new contact | 5 per minute | the cold-start window is deliberately slower |
| queue timeout | 60 minutes | a message waiting longer fails as rate_limited |
| first message to a number not on WhatsApp | never sent | the send fails as not_on_whatsapp |
| edits | about 15 minutes | after that WhatsApp refuses |
The pace is the feature. A bot that fires 200 answers at once is exactly the pattern that gets a number restricted, and why WhatsApp bans numbers covers the rest of it.
#Keep the conversation, not just the reply
The state a bot needs is more than the last message. Three things are worth storing per chat.
- The contact's identity.
fromis a phone number or alid:id. A contact who hides their number reaches you aslid:201843727138927, never as a number, and replying to thelid:id is what reaches them. - The open thread. The last few turns, so the model is not answering from nothing.
- What you already sent them. Order confirmations and their ids, so the bot does not send a second one.
for await (const message of wuapi.messages.list({ accountId, chatId, direction: "inbound", limit: 20 })) {
console.log(message.createdAt, message.from, message.text)
}The identity point is the one that costs a day of debugging. A contact who hides their number arrives as a lid: id, and if your code assumes E.164 it will treat every reply as a new conversation, because the two id formats do not match each other.
const contact = "lid:201843727138927"
// Reply to the lid id, not to a number you guessed.
await wuapi.messages.send({ accountId, to: contact, text: "On it." })A contact who picks a username can also be addressed as @lina.morales, but only once your account already has a chat with them, because WhatsApp will not let a linked device look up an unknown username.
Marking a chat read is a separate call from a read receipt, and they are not the same thing. /mark-read changes the chat list on the phone, /read sends blue ticks. A bot usually wants both, and only the first, when it has read the message.
#The eleven send types, and which ones a bot needs
A bot does not need all eleven send types, and reaching for the wrong one is a common source of a reply that looks like a document and arrives as a link.
| type | a bot's usual use |
|---|---|
text | nearly always, including the reply itself |
image and document | a product photo, a PDF invoice, a QR for a payment |
location | a store, a technician on the way, a delivery point |
contact | sharing a colleague's number so the human can take over |
poll | "did that help", or choosing a slot, without a form |
voice | an audio answer, which reads as more human than text on a long reply |
video, audio, sticker | rarely for a bot, and expensive in proxy bytes |
contacts | a set of cards, usually wrong for a bot |
calendar_event | an appointment, which is a better handoff than a sentence |
Two of these are worth a sentence each because they are the ones a bot uses to get out of the way. A contact card naming a human turns the conversation from "with a bot" into "with somebody", and a calendar_event closes a loop that a text reply never does.
The one a bot should avoid is linkPreview on a first message, because a preview fetches a URL and the first message to a new contact is better off plain.
#Handle the failure modes
A bot has more failure modes than a person, and each one has a different code.
| what happened | `error.code` | what to do |
|---|---|---|
| the account was not ready | account_not_ready | reconnect and retry, do not drop the message |
| the number is not on WhatsApp | not_on_whatsapp | stop; retrying will not change it |
| it waited past the queue timeout | rate_limited | slow down or raise the pace deliberately |
| the account went offline mid-send | account_offline | retry when it is back |
| a media URL was refused | send_failed | host the file somewhere that will serve it |
A message whose connection dropped while sending is not failed. It waits for the account and goes again with the same WhatsApp message id, so it cannot be delivered twice. That is why you should not add your own retry on top of it.
#Give people a way out
- Answer the word "stop" immediately, whatever the model would have said.
- Let them block you, and do not try to work around it.
- Take the first message plain. No links, no attachments. A contact who asked to be messaged is fine; a contact who did not is a report.
if (/\b(stop|para|alto|cancelar|leave)\b/i.test(text) && !optedIn(contactId)) {
await wuapi.messages.send({ accountId, to: chatId, text: STOP_REPLY })
await optOut(contactId)
return
}#A bot on a platform, not just one number
If you are giving customers their own number, the bot is per project, and the API key that drives it is scoped to that project. A project key reaches one project and nothing else, which is what makes it safe to hand someone.
const endpoint = await wuapi.webhookEndpoints.create({
url: "https://platform.example.com/hooks/wuapi",
events: ["message.received"],
projectId: "p3jk8n2m5r7t9v4x6z1c0b8d",
})GET /v1/usage/by-project then reports accounts, proxy bytes and messages per project per month, so you can bill each customer for what their bot actually did.
That scoping is the difference between a bot you run and a bot you sell. With one key per customer, a bug in your dispatcher cannot make your bot answer from a stranger's number, and a customer who churns takes their key and nothing else. The key is revoked in one request and stops working on the next one, which is a much better answer than working out whose session is whose.
#The parts a bot usually gets wrong
Six things account for nearly every bot that feels wrong on WhatsApp, and none of them is the model.
- No typing indicator. wuapi shows one by default, but turning it off is one field and it removes the main human signal.
- Replying in under a second. A number that answers faster than a person reads is the second signal, and it is the hardest to fake convincingly.
- The same reply every time. Ten identical answers is worse than a slightly worse bot that varies.
- No way to stop. Not honouring a stop word is the one that becomes a complaint and then a restriction.
- Re-replying on retries. A handler that is not idempotent turns one delivery into six replies.
- Answering when the number is not ready. A send to an account that is not
readyfails, and a bot that queues hundreds of them turns a five minute reconnect into an hour of failures.
The last one is worth a sentence of its own, because there is a specific behaviour designed for it. An account that dropped from ready less than three minutes ago still accepts a message: the send waits for the account and goes out once it is back. A short reconnect is invisible to the bot, which is what you want.
#The shape of a good answer
Not the content. The shape.
- One idea per message. Two messages are fine; a paragraph per question is not.
- Answer the question that was asked. The most common bot failure is answering the question the model found interesting.
- Say what happens next. A bot that answers and leaves the customer waiting is worse than one that says "I am checking".
- Escalate early. A bot that hands over to a human on request is trusted. One that never does is resented.
#Where a bot and a human take over from each other
The hardest part of a WhatsApp bot is not the replies, it is the handover in both directions, and the mechanics of it are worth spelling out because they are four calls and a piece of state.
Bot to human. The moment the model decides it cannot help, it does two things: it sends a contact card naming the person, and it sets a flag on the chat. The flag matters more than the card, because the next inbound message has to route to the human rather than back to the model. Without it you get the worst possible outcome, a conversation that ping-pongs between a bot and a person who keeps getting overridden.
Human to bot. The flag clears when the human closes the conversation, and it should clear on a timer as well, because a chat that is permanently "human" is a chat that silently stopped being a bot. Two hours of no activity is a reasonable default.
Quiet hours. If a contact writes at 3am, a bot that answers at 3am is doing the thing that gets numbers into trouble. Queue it and send it in the morning, or answer with an out-of-hours message and pick it up when they are awake. The pace is enforced either way; the quiet-hours part is a decision, and it should be one.
- Handover
- a
contactcard plus a routing flag on the chat - Flag lifetime
- cleared by the human, and by a timer as a backstop
- Out of hours
- queued, or answered and picked up later
- The first reply
- plain, no links, for a contact who never wrote to you
None of this needs a machine learning decision, which is the point. It is four API calls and a boolean, and it is the difference between a bot people tolerate and one they recommend.
#Questions people ask
How do I build a chatbot for WhatsApp?
Link a number as a device, register a webhook endpoint for `message.received`, verify the signature on the raw body, keep the handler idempotent, show the typing indicator, and reply over REST. The model is one step in the loop. What decides whether it feels human is the indicator, the delay and the varying length.
Can a WhatsApp bot send templates or buttons?
Not on a linked number. Template sending, buttons and list messages belong to the official WhatsApp Business Platform, which has its own rules about message windows and approved templates. A linked number sends what you send it, across eleven types including text, media, polls and location.
How fast can a WhatsApp bot reply?
As fast as you want technically, and that is the problem. A reply in under a second with no typing indicator is the strongest bot tell on the platform. wuapi shows the typing indicator for 0.8 to 6 seconds by default, scaled by message length, so the delay is built into the send rather than something you fake.
Will a WhatsApp bot get my number banned?
A bot answering strangers in volume is the risk, and it is the volume rather than the automation. wuapi paces every number at 12 a minute and 5 to contacts who have never written, but the habits are yours: let people write first, keep the first message plain, honour a stop word, and grow only while replies keep up.
How do I stop a bot from replying twice?
Make the handler idempotent on the event id. Deliveries are retried up to six times, so the same event arrives more than once whenever your endpoint does not answer 2xx. Key your processing on the event id, store that you handled it, and return 204 on the second copy.
#Where to go next
Verifying a WhatsApp webhook signature is the step your handler cannot skip. Why WhatsApp bans numbers covers the habits that keep a bot's number up, and group or community is about what changes when the bot is answering a group rather than one person.