Sending

Building a WhatsApp chatbot on a signed webhook

The shape of a bot that answers on WhatsApp, from a verified inbound event to a reply, and the three things that make it feel human or not.

wuapiSending7 min read
A reply loop: a contact's message arrives as message.received at your bot, which answers with POST /v1/messages, and the reply moves through 202 queued, message.sent, message.delivered and message.read.CONTACT+5511 9...YOUR BOTwebhookmessage.receivedPOST /v1/messages202 queuedmessage.sentmessage.deliveredmessage.read[GETTING STARTED]ONE WEBHOOK IN, ONE CALL OUT

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.

Contact
Engine
Your bot
Model
Writes to your number
Signs and deliversmessage.received
Verifies the signatureWuapi-Signature
Answers, given the history
Shows typing, then sendschat.presence_updated
Delivers, then reports receiptsmessage.delivered

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.

  1. 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.
  2. Not replying instantly to everything. A reply in under a second is the single strongest bot tell on the platform.
  3. 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.

limitvaluewhat it means for a bot
sends per minute12 per accounta spike of new questions waits in the queue
to a new contact5 per minutethe cold-start window is deliberately slower
queue timeout60 minutesa message waiting longer fails as rate_limited
first message to a number not on WhatsAppnever sentthe send fails as not_on_whatsapp
editsabout 15 minutesafter 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. from is a phone number or a lid: id. A contact who hides their number reaches you as lid:201843727138927, never as a number, and replying to the lid: 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.

typea bot's usual use
textnearly always, including the reply itself
image and documenta product photo, a PDF invoice, a QR for a payment
locationa store, a technician on the way, a delivery point
contactsharing a colleague's number so the human can take over
poll"did that help", or choosing a slot, without a form
voicean audio answer, which reads as more human than text on a long reply
video, audio, stickerrarely for a bot, and expensive in proxy bytes
contactsa set of cards, usually wrong for a bot
calendar_eventan 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 readyaccount_not_readyreconnect and retry, do not drop the message
the number is not on WhatsAppnot_on_whatsappstop; retrying will not change it
it waited past the queue timeoutrate_limitedslow down or raise the pace deliberately
the account went offline mid-sendaccount_offlineretry when it is back
a media URL was refusedsend_failedhost 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

  1. Answer the word "stop" immediately, whatever the model would have said.
  2. Let them block you, and do not try to work around it.
  3. 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.

  1. No typing indicator. wuapi shows one by default, but turning it off is one field and it removes the main human signal.
  2. 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.
  3. The same reply every time. Ten identical answers is worse than a slightly worse bot that varies.
  4. No way to stop. Not honouring a stop word is the one that becomes a complaint and then a restriction.
  5. Re-replying on retries. A handler that is not idempotent turns one delivery into six replies.
  6. Answering when the number is not ready. A send to an account that is not ready fails, 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 contact card 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.

wuapi is an independent service. It is not affiliated with, endorsed or sponsored by WhatsApp. WhatsApp is a trademark of its respective owner.

03/What to read next

Every post

Link a number and send your first message.

One REST call, a typed SDK, and webhooks signed with HMAC-SHA256 over the raw body. No per-message fees.

The whole API is in the docs, and the docs are in one file if you are handing the work to a coding agent.

All posts · openapi.json