←all writing
1022 Jul 2026/7 min read

Building a team WhatsApp inbox on the WhatsApp Cloud API

What we learned building a two-way WhatsApp inbox into the Happy Pet Tech SaaS on Meta's Cloud API: per-business webhooks, a verify bug from a sanitizer, the 24 hour window, media, and keeping the inbox live.

WhatsAppNode.jsWebhooks

In June we shipped a WhatsApp inbox inside the Happy Pet Tech SaaS. A pet business connects its own WhatsApp Business number, and its whole team reads and answers customers from one screen in our app. We built it directly on Meta’s WhatsApp Cloud API.

Sending a message with this API is one HTTP call. Everything else took five weeks of fixes after launch. This post is about that “everything else”.

Each business has its own webhook

Meta sends incoming messages to a webhook URL on your server. Our first version had one shared URL for all businesses. Five days after launch we replaced it.

Now each business has its own URL, with the business’s ID in the path. Each business also brings its own Meta app, so each has its own app secret and verify token. We store those encrypted.

The reason is trust. With one shared URL and one secret, every business’s messages are signed with the same key. With a URL per business, the handler knows from the path which business the request claims to be for, loads that business’s secret, and checks the signature against it.

const tenant = await getDecryptedConfig({ webhook_id: webhookId });
if (!tenant) return res.sendStatus(200); // unknown id: ack, so Meta stops retrying

const signature = req.headers['x-hub-signature-256'] || '';
const expected = 'sha256=' +
  crypto.createHmac('sha256', tenant.app_secret).update(req.rawBody).digest('hex');
if (!constantTimeMatch(signature, expected)) return res.sendStatus(401);

res.sendStatus(200); // Meta wants a 200 within 5 seconds
setImmediate(() => {
  processWebhookPayload(tenant, body).catch((err) => logger.error(err.message));
});

Three details in that code:

  • The signature is calculated over the raw request body. If a JSON parser has already touched the body, the hash will not match. So this route keeps the raw bytes.
  • An unknown ID gets a 200. If you answer with an error, Meta keeps retrying.
  • The 200 goes out before the work starts. Meta expects an answer in 5 seconds. Saving a message, finding the customer and notifying the team can take longer on a bad day.

The verify bug that came from a security library

Before Meta sends anything, it verifies your URL. It calls it with three query parameters: hub.mode, hub.verify_token and hub.challenge. You answer with the challenge.

Ours failed, and the code looked correct.

Our API uses express-mongo-sanitize. It protects against MongoDB injection by rewriting keys that contain a dot or a dollar sign. hub.mode has a dot. So by the time our handler ran, the key was hub_mode, and req.query['hub.mode'] was undefined.

// express-mongo-sanitize rewrites dotted keys (hub.mode -> hub_mode)
const mode = req.query['hub.mode'] ?? req.query.hub_mode;
const token = req.query['hub.verify_token'] ?? req.query.hub_verify_token;
const challenge = req.query['hub.challenge'] ?? req.query.hub_challenge;

If your webhook verification fails and you sanitize input, print req.query and look at the keys.

The 24 hour window

A business cannot message a customer freely. After a customer writes, the business can reply with anything for 24 hours. After that, only a pre-approved template is allowed, until the customer writes again.

We store last_inbound_at on every conversation and check it in two places.

const WINDOW_MS = 24 * 60 * 60 * 1000;
const isWithin24hWindow = (lastInboundAt) =>
  Boolean(lastInboundAt) && Date.now() - new Date(lastInboundAt).getTime() < WINDOW_MS;

The front end uses it to close the typing box and offer templates in its place. We also added a “Can reply” filter, so staff see only the chats they can still answer.

The server checks it again before sending. If the window is closed it returns its own error code, and the app shows a clear message. We do not let the request go to Meta and come back with an error the user cannot read.

One thing we did not know at the start: a reaction from the customer also reopens the window. If someone reacts with a thumbs up, the business can write again.

Phone numbers

The API wants the full number with the country code and no plus sign. Staff save numbers the way people say them, without a code and sometimes with a leading zero.

So before sending, we build the number. If it starts with +, we trust it. If not, we remove leading zeros and add the dial code of the business’s own country. A salon in India gets 91 in front. A salon in Dubai gets 971.

This is a guess, and it is the right guess for almost every customer of a local pet business.

Media: we store the ID, not the file

When a customer sends a photo, the webhook does not contain the photo. It contains a media ID.

We store only that ID and the file type. When someone opens the chat, our API fetches the file from Meta at that moment and passes it to the browser. We do not copy every incoming photo into our own storage.

The trade-off is that Meta keeps media for about 30 days. After that, the file is gone. At first an expired file made our API return a 502, which looks like our server is broken. Now an expired file returns a 404, and the chat shows that the media is no longer available.

Sending has limits we check before upload: 5 MB for images, 16 MB for video and audio, 100 MB for documents. Phone photos are often too large, and iPhones send HEIC files. So images that are too big, or in HEIC, are re-encoded on the server: resized to 1600 pixels, then quality is stepped down from 82 until the file fits, and if it still does not fit, resized again to 1024 pixels.

Keeping the inbox live

A new message must appear without a refresh. When the webhook saves a message, the server emits a Socket.IO event to the room of that business. Every open inbox in that business gets it. There are three events: new message, status change (sent, delivered, read) and reaction.

Sockets drop. So the inbox also has a fallback: if the socket is down, it polls every 8 seconds.

We got that fallback wrong the first time. Polling was switched on by a flag, and the flag was never reset after the first connection. So when a socket dropped later, polling did not start. The inbox just sat there, silently out of date, and looked fine. Nothing showed an error, which is what made it a bad bug.

Notifications

Every incoming message also sends a push notification and adds a row to the in-app notification list. The person who has that chat open does not get the popup.

The list had a problem of scale. A customer who writes five short messages in a row created five rows for every staff member. In a company with 47 users, one message made 47 new rows. Now a conversation has one notification row that is updated, and it lasts 48 hours.

Retries: only for reading

If a call to Meta fails with a network error or a 5xx, we retry once. But only for calls that read: listing templates, fetching media.

We never retry a send. If the send reached Meta and only the answer got lost, a retry delivers the message twice. A customer who gets the same message two times thinks the business is careless. A message that failed once can be sent again by a person who sees the “failed” mark.

Two smaller bugs worth knowing

The newest messages disappeared in long chats. We loaded messages oldest first, 100 per page. Once a chat passed 100 messages, page one was the oldest 100 and the newest were on page two. Now we fetch newest first and reverse the page for display.

Meta’s error messages are not for users. “Error validating access token” means nothing to a groomer. We map Meta’s error codes to plain sentences. An expired token (code 190) is reported as a problem the owner can fix by reconnecting, and we return it as a 400. It is not a server error and should not page anyone.

Broadcasts

A business can send one approved template to many customers, for example a holiday closure. We cap a broadcast at 100 recipients and send them one after another, not all at once. That is slower, and it is kinder to the number’s sending limits and quality rating at Meta.

Templates have their own rules and their own bugs. That is the next post.

← all writing nextFrom Liquid to running a SaaS: the tech I had to learn, in order →