Human-in-the-loop approvals on WhatsApp: what it takes to build, and what breaks
Last updated 8 November 2026
Published 8 November 2026.
The pattern has settled. Before an agent does something expensive to undo, it asks a person. It waits. It acts on the answer.
Anthropic's guidance on agent safety recommends human oversight for actions with irreversible consequences, and most of the interesting ones qualify: issuing a refund, sending a quote, posting something publicly, emailing a customer list.
WhatsApp is a reasonable place to ask, because it is the one app the person approving actually has open. Getting the question there is where the work is.
This is what we found building it, and the six things that break. If you are about to build your own, the list is worth reading first. We have also published the API we ended up with, which you can use instead.
Who is doing this already
Enough people that the pattern has a shape. On 8 November 2026 we found four public projects doing roughly this:
- meharajM/whatsapp-mcp, an MCP server for approvals and notifications over WhatsApp.
- adstastic/claude-code-whatsapp-approval, which intercepts tool calls, sends a Twilio message with quick replies, and tracks state in SQLite.
- A WhatsApp Claude plugin where you approve or deny with a thumbs-up reaction.
- Several commercial tools: Pushary at $9.99 and $99 a month, Orako routing to Slack or Discord, Pane and Omnigent open source.
The odd part of that list is who is missing. HumanLayer was the reference implementation of this idea. Its site now sells an AI coding IDE at $100 a seat and no longer offers the approvals API it was known for.
So the category has demand, several half-built answers, and no obvious default.
Problem 1: the template
You cannot message somebody on WhatsApp whenever you like. Meta allows free-form messages only inside 24 hours of that person messaging you. Outside it, you need a template Meta has approved in advance.
An approval request usually arrives outside the window, because the whole point is that it is unexpected.
So you write a template, submit it, wait, and then discover the real constraint: a template's text is fixed at approval time. Parameters go in named slots and nothing else varies. Change the wording and you resubmit.
That is survivable. What is not obvious until you are in it is that template parameters cannot contain newlines, tabs, or more than four consecutive spaces. Meta rejects the send, and it rejects it at delivery rather than at validation, so your logs say the message went and the person says it did not arrive. Flatten your text before it goes.
Problem 2: two people tapping at once
Two people see the request. Both tap. One taps Approve and one taps Reject, half a second apart.
Read-then-write loses here: both reads see pending, both writes succeed, and the last one wins silently. Whichever of your two users was slower believes they decided something they did not.
Settle it in one statement:
UPDATE approvals
SET status = $2, responded_at = now(), responded_by = $3
WHERE id = $1 AND status = 'pending'
RETURNING *
No row returned means somebody else won. Tell the second person what already happened rather than failing silently, and fire your webhook exactly once.
Problem 3: nobody answers
The person is in a meeting. On a plane. Has left the company.
Without an expiry, your agent waits forever and whatever it was doing hangs. With one, you need something that actually closes the request when the time passes, which means a sweep rather than a check-on-read: a request nobody ever reads again stays pending forever if the only thing that expires it is somebody looking.
Pick an expiry per request rather than one global number. A refund can wait four hours. A deploy gate cannot wait more than ten minutes before the useful moment has passed.
Problem 4: the raw body
When the answer comes back to your webhook, you verify the signature. Almost everyone gets this wrong the first time, in the same way.
The signature is computed over the exact bytes that were sent. Your framework parses the JSON before your handler runs, you re-serialise the parsed object to check the signature, and the string you produce is not the string that was signed. Key order changed. A space went missing. It never matches, and the error tells you nothing useful.
// Raw on this route only. express.json() consumes the body.
app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Signature"), SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// ...
});
Two more rules while you are in there. Compare in constant time, because a byte-by-byte comparison leaks the signature one character at a time. And refuse a timestamp more than a few minutes old, or a captured delivery can be replayed at leisure.
Problem 5: at-least-once delivery
Your endpoint was down for ninety seconds. The sender retried. Now you have the same approval twice, and if "approved" triggers a refund, you have refunded twice.
Every retry should carry a delivery id that is stable for that attempt and unique across attempts. Keep the last few thousand you have seen, and return 200 to one you recognise without doing the work again.
This is boring and everybody skips it, and it is the one on this list that costs real money when it goes wrong.
Problem 6: the number everybody shares
If you build this as a service for other people, every customer's approvals go out from your number. One Meta quality rating covers all of them.
So a customer whose team ignores every request is not only their own problem. Enough unanswered templates and the number's rating drops, and the rating is what decides whether your messages get delivered at all.
Our answer: after five requests in a row that a handset never answers, stop sending there and email the account owner. Email keeps working, so nothing is lost, and sending any message to our number starts WhatsApp again. It is a strange thing to advertise, which is why it is on the alerts and approvals page rather than buried.
If you are building this for yourself alone, you can skip this one. It is the price of the shared number, and the shared number is what means nobody has to register their own.
What it costs to not build it
Two days to a working prototype. A week to the version that survives two people tapping at once, a retry, and a Meta template rejection at 2am.
The trade is the usual one. If approvals are your product, build it. If approvals are the thing standing between your agent and production, the week is better spent on the agent.
Ours is $29 a month, needs no phone number of your own because the number and the template are already ours, and the documentation has the signature code in Node and Python.
Questions
Why WhatsApp rather than Slack or email? Because of who is approving. A warehouse manager, a shop owner or an on-call engineer has WhatsApp open and may not have Slack at all. If your approvers live in Slack, use Slack.
Can I do this with the free WhatsApp Business app? No. The free app has no API. You need the Cloud API, which means a Meta developer account, a WhatsApp Business Account and a number that is not already on WhatsApp.
How do I handle an answer that is not yes or no? Give the person an Edit option that asks for free text and returns it to you. Approve and reject are not enough for a quote that needs changing by 50 dirhams rather than refusing.
What happens if the approval arrives after my agent has moved on? Decide this before you build it. Either the agent waits, which means a durable job rather than an in-memory promise, or the approval triggers a new piece of work when it lands. Both are fine; assuming the first while writing the second is not.
Is there an MCP server for this? Several of the community projects are MCP servers. Ours is not, yet. If you want one, say so.
How long should an approval be valid for? As long as the decision is still useful. Four hours for a refund, ten minutes for a deploy gate. A request with no expiry is a request that hangs something forever.