Skip to content
Agent Integration9 min read·

WhatsApp agent gotchas: the 24-hour window, template rejections and webhook signatures

The agent ran for six weeks, nothing was deployed, and at 2am it stopped replying while every send call still returned 200. The error codes that mean something other than what they say, the signature check that fails the first time a customer types an emoji, and the alert that should have woken you earlier.

AI AgentsAgent IntegrationWhatsAppWebhooksDebuggingCloud API

It is 2am, the agent has stopped replying, and nothing has been deployed for six days. Every send call in the log returned 200. The customer is still typing.

That is the shape of nearly every WhatsApp failure I have been called about, and it is why this is a separate page from getting an agent onto WhatsApp in the first place. That post is the build order and the gates somebody at Meta controls. This one is the triage list: the error codes that mean something other than what they say, the signature check that passes all through testing and fails the first time a customer types an emoji, and the alert that should have woken you at 10pm instead.

how long Meta retries a webhook you did not acknowledgeafter that the event is dropped, and there is no endpoint to ask what you missed
36h
life of a media URL from the moment you request itthe media id in the webhook lasts about a week, the URL does not
5 min
life of the access token on the API Setup pagethe one every tutorial tells you to copy into an env file
24h
what a successful send provesthat Meta accepted the message, not that anybody received it
200

Three kinds of failure, and only one is worth retrying

The expensive mistake in a WhatsApp integration is one retry policy applied to every error. The API returns a numeric code with every failure, and the codes fall into three groups that want opposite handling: a transient problem worth sending again later, a message that will never be accepted however many times you try, and an account-level stop that no deploy will clear.

These are the ones that actually turn up in production.

CodeWhat it meansThe right response
131047More than 24 hours since the recipient last replied to you.Not a fault, it is the customer service window. Send an approved template or hand off to a human. Never retry the free-form send.
131026Undeliverable. Not a WhatsApp number, an outdated client, or terms not accepted.Mark the contact and stop. Retrying cannot fix any of the three causes.
131049Not delivered in order to maintain healthy ecosystem engagement.A per-recipient marketing limit. Wait at least 24 hours, and do not resubmit the same message as a utility template.
131056Too many messages between this sender and this recipient in a short period.A pair limit, not an account limit. Back off for that one conversation and leave everybody else alone.
130429Throughput reached. 80 messages a second on a registered number, 20 on one shared with the Business app, inbound and outbound counted together.Rate-limit at your own sender, with jitter. Widening the worker pool makes this worse, not better.
131042A problem with your payment method.Nothing in your code. Messages beyond the free allowance stop being delivered until billing is fixed.
131031The business account is restricted or disabled, or a data check failed, such as an incorrect two-step PIN.Page a human. Do not retry, and do not let a queue drain itself against it overnight.
133010, 131045The phone number is not registered on the platform.Somebody deregistered it, or it was never registered in this environment. Check which environment you are in before you panic.

Template codes want their own branch

132001 reads as though the template does not exist, and the template nearly always exists. Meta's description is more precise: the template does not exist in the specified language, or has not been approved. In practice that is a locale mismatch nine times in ten. The template was approved as en_US, the code sends en, and the API reports a missing template that you are looking at in the dashboard while you read the error. Store the language code next to the template name and treat the pair as one key, because that is what it is.

132015 and 132016 are the two that cost real money to get wrong, because they look transient and are not. 132015 is a template paused for low quality. 132016 is one that has been paused often enough to be permanently disabled. A backoff loop will spend the night resending a message that cannot be delivered at any point in that night, and the recovery is a content change and another review rather than anything in your code. 132005 and 132007, too long and policy violation, behave the same way. 132000 and 132012, a parameter count or format that does not match the template, are genuinely yours: drop the message, fix the mapping, resend deliberately. If a template drives a Flow, add 132068 and 132069, blocked and throttled at ten sends an hour.

php
// One classifier, at the boundary, with three outcomes. Everything that sends
// a WhatsApp message goes through it, including the retry path itself.
enum Disposition
{
    case BackOff;   // the same message, later
    case Drop;      // this message will never be accepted
    case Page;      // the account is stopped and no deploy clears it
}

function disposition(int $code): Disposition
{
    return match ($code) {
        130429, 131056 => Disposition::BackOff,

        131026, 131047, 131049,                  // recipient, or a closed window
        132000, 132001, 132005, 132007, 132012,  // template content and parameters
        132015, 132016 => Disposition::Drop,     // paused, or permanently disabled

        131031, 131042, 131045, 133010 => Disposition::Page,

        default => Disposition::Page,            // an unknown code is not transient
    };
}

The signature check that passes in testing and fails on the first emoji

Before any of that there is the handshake, and it eats an afternoon for one specific reason if you are on PHP. Meta sends a GET carrying hub.mode, hub.challenge and hub.verify_token, and PHP rewrites periods in parameter names to underscores. Every parameter named in the documentation reads as null, the endpoint returns nothing, and the dashboard reports that verification failed without saying why.

php
// routes/web.php
Route::get('/webhooks/whatsapp', function (Request $request) {
    // hub.mode arrives as hub_mode. This is PHP, not Meta.
    $mode = $request->query('hub_mode');
    $token = (string) $request->query('hub_verify_token');

    abort_unless(
        $mode === 'subscribe'
            && hash_equals(config('services.whatsapp.verify_token'), $token),
        403
    );

    // Echoed back verbatim, as plain text. Anything wrapped in JSON fails.
    return response($request->query('hub_challenge'))
        ->header('Content-Type', 'text/plain');
});

Then the part the tutorials skip. Every POST from Meta carries an X-Hub-Signature-256 header: an HMAC-SHA256 of the payload, keyed with your app secret, prefixed with sha256=. Skip the check and your webhook is an unauthenticated endpoint that will accept a message claiming to come from any customer you have, which matters a great deal more once the agent on the other side of it has tools attached.

php
// app/Http/Middleware/VerifyWhatsAppSignature.php
// Registered on the webhook route only, and ahead of anything that parses
// the body. Once a framework has decoded and re-encoded the JSON, the bytes
// Meta signed no longer exist.
public function handle(Request $request, Closure $next)
{
    $raw = $request->getContent();   // the exact bytes, not a re-encode
    $expected = 'sha256=' . hash_hmac(
        'sha256',
        $raw,
        config('services.whatsapp.app_secret')
    );

    $given = (string) $request->header('X-Hub-Signature-256');

    // Timing-safe, and it fails closed: a missing header is a rejected request.
    if (! hash_equals($expected, $given)) {
        Log::warning('whatsapp.signature_mismatch', ['bytes' => strlen($raw)]);
        abort(403);
    }

    return $next($request);
}

The reason this check gets quietly deleted a week after somebody adds it is the word payload. The signature covers the exact bytes Meta sent. Parse the JSON and re-encode it and the bytes differ, because Meta escapes non-ASCII characters and most encoders do not. All of which works perfectly while the test messages are in English. The first message containing an accent, an emoji or a word of Urdu produces a digest that does not match, every webhook starts failing verification at once, and the obvious conclusion is that signature checking is broken. It is not. The raw body was gone before the check ran.

So read the bytes before anything parses them. In Laravel that is $request->getContent() in middleware bound to the webhook route alone. In a Next.js route handler it is await req.text() once, before any req.json(). In Express it is the verify callback on the body parser, stashing the buffer on the request. And a mismatch should raise an alert rather than a log line, because there are only two causes: somebody is forging requests, or your own raw-body handling broke in a deploy and you are now dropping every inbound message.

Acknowledge in milliseconds, deduplicate on Meta's id

Meta wants a 200 and wants it quickly. Miss it and the event is retried immediately, then a few more times with decreasing frequency across the next 36 hours, after which it is dropped permanently. There is no endpoint that will tell you what you missed, so anything you need has to be written down when it arrives rather than after the model call returns.

Those retries are also why deduplication is not optional, and why it has to key on the message id in the payload rather than one you generate on receipt. A slow handler becomes a retry, the retry becomes a second reply, and a second reply to a message that triggered a tool is a refund issued twice. One delivery can also carry up to a thousand batched updates while promising nothing about batching, so handle every entry independently and never assume a webhook is a single message.

Media expires faster than your queue drains

Voice notes arrive constantly, because people treat WhatsApp as a walkie talkie, and the download path has two different expiries in it. The media id in the webhook is good for about a week. The URL you exchange it for is good for five minutes and needs your access token on the request, so it cannot be opened in a browser and cannot be handed to a worker that will get to it when the queue clears.

Which makes one retry correct and the other useless. A job that stored the URL and retries the download gets a 404 for as long as it keeps trying. A job that stored the id and re-resolves it gets a fresh URL and succeeds. Store the id, pull the bytes into your own storage on arrival, and let transcription, moderation and everything downstream work from your copy rather than from Meta's.

The token that expires while you sleep

The access token on the API Setup page expires after 24 hours. It is the token in every tutorial, which is exactly how it ends up in an env file on a production server, and the symptom is unmistakable: an integration that worked beautifully on the day it was built and is dead the following morning, every call failing at once with a 190. Production wants a system user token, generated in business settings with an expiry chosen deliberately, because the default is not never. Put that expiry date in the calendar of whoever is on call rather than in a comment nobody reads.

Alert on the status webhook, not on the send response

A 200 from the messages endpoint means Meta accepted the message for delivery. It is not delivery, and it is the single reason the logs look healthy during an outage. What actually happened arrives afterwards on the status webhook as sent, delivered, read or failed, and failed carries the code. Alert on that: the failed rate broken down by code, and silence per template, because a paused template and a billing problem both present as an absence rather than as an exception. The build post covers the version of this that catches people out most often, where messages past the monthly free allowance simply stop being delivered when there is no payment method on file.

Silence detection is the part teams skip and the part that pays for itself. A template that sent four hundred messages a day last week and none since Tuesday is an incident, whatever your error rate says, and it is invisible to any monitor watching only for things that threw.

The 2am runbook

  1. 1Is it one conversation or all of them? One is a closed window, a pair limit or a recipient who blocked you. All of them is the token, billing, the account, or your own webhook.
  2. 2Look at the status webhook for the last hour before looking at your code. If sends are being accepted and nothing is reaching delivered, the problem is on Meta's side of the boundary.
  3. 3Read the code, not the message. error_data.details is the sentence; the top-level message is a category that fits a dozen different causes.
  4. 4If every send failed at once, check the token first. A 190 at 2am is almost always a 24 hour token that reached hour 24, and it takes thirty seconds to rule out.
  5. 5Then check billing and account status. 131042 and 131031 are not engineering problems and nothing you deploy will clear either of them.
  6. 6If inbound stopped but outbound works, it is the webhook. A signature mismatch, a subscription somebody changed in the dashboard, or a handler returning non-200 and quietly burning through the 36 hour retry window.
  7. 7Never let a template error into a retry loop. Classify once at the boundary, route anything account-level to a person, and drop what cannot be delivered instead of backing off against it.
  8. 8Write the incident down with the fbtrace_id in it. It is the only thing that makes a support ticket actionable, and it will not be in your logs by the time anybody replies.
A WhatsApp agent does not usually break. It gets refused, by a window that closed, a reviewer who paused a template, a card that expired or a digest that no longer matches, and every one of those refusals arrives as a number rather than as a sentence.

None of this is exotic. It is the ordinary operational surface of a channel where somebody else owns the account, the templates, the delivery and the price, and the whole of it is a day's work if it goes in during week one. The codes are stable and short enough to classify in one match. The signature check is thirty lines. The alert that matters watches somebody else's delivery receipts rather than your own 200s, which is the habit that separates a WhatsApp integration people trust from one that is quietly failing on a Tuesday afternoon while the dashboard stays green. The same instinct, applied to the agent loop rather than to the channel, is in bounding an agent so it finishes the job.

If you have an agent on WhatsApp failing in a way the logs do not explain, or one going live soon that nobody has instrumented, tell me what it has to do and I will come back with a fixed scope and a fixed price that has the operational work in it rather than in a contingency line. Start that conversation here, or see what this sort of work looks like on the AI capabilities page.

Questions people ask

Why did my WhatsApp AI agent stop replying when every send returns 200?

Because a 200 only means Meta accepted the message, not that it was delivered. First check whether one conversation or all of them failed. Then read the status webhook for the last hour. If every send failed at once, check for error 190, which is almost always a 24-hour token that expired. Then check billing and account status: 131042 is a payment method problem and 131031 is a restricted account, and no deploy will clear either.

How do I verify WhatsApp Business API webhook signatures?

Compute sha256= plus an HMAC-SHA256 of the exact request bytes, keyed with your app secret, and compare it timing-safely with the X-Hub-Signature-256 header, rejecting requests where the header is missing. The raw body must be read before anything parses it: $request->getContent() in Laravel middleware, await req.text() before req.json() in Next.js, or the verify callback in Express. Treat a mismatch as an alert, because it means forged requests or broken raw-body handling.

What does WhatsApp error 132001 mean when the template exists?

It means the template does not exist in the language you requested, or has not been approved. In practice it is a locale mismatch nine times in ten: the template was approved as en_US and the code sends en. Store the language code next to the template name and treat the pair as one key. Unlike 132015 and 132016, which mean a paused or permanently disabled template, this one is fixed in your own code. The full build is in putting an AI agent on WhatsApp.

Why does WhatsApp webhook verification fail in PHP?

PHP rewrites periods in query parameter names to underscores, so hub.mode, hub.verify_token and hub.challenge arrive as hub_mode, hub_verify_token and hub_challenge. Code that reads the documented names gets null, returns nothing, and Meta's dashboard reports a failed verification without saying why. Read the underscored names, check the mode is subscribe, compare the verify token with hash_equals, and echo the challenge back verbatim as plain text, not wrapped in JSON.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading