How to send a group message in PHP with the Wapito WhatsApp API
A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.
From PHP a group message is one Guzzle POST with the group id as the recipient, and the work is in pacing and receipts: send from a queue worker rather than a page request, upload media once and reuse its id, and have the Slim receiver aggregate the per-participant status events instead of inserting each one. Guzzle throws on the 429 you get when pacing is wrong.
Before you start
composer require guzzlehttp/guzzlegetenv('WAPITO_TOKEN')- A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.
How it works
Send text to the group id
There is no separate group endpoint: you send to the group's id exactly as any other recipient. Mentioning participants inside the body is what turns a message into a notification for them specifically.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/messages/text', [ 'body' => '{"to":"+15551234567","body":"Your order #4182 has shipped. Track it here: https://acme.example/t/4182","typing_time":3}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP post the text with $client->request('POST', $baseUrl . '/messages/text', ['body' => json_encode(['to' => $groupId, 'text' => $text]), 'headers' => [...]]) and decode the reply for the message id, which the status events reference. Mentions belong in the structured payload, and json_encode needs JSON_UNESCAPED_UNICODE for any non-Latin text.
API reference for this stepAttach an image or a document
Upload once and reuse the media id across groups rather than re-uploading the same file for each. A caption on the image carries far better than a separate text message immediately afterwards.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/messages/image', [ 'body' => '{"to":"+15551234567","media":"https://acme.example/labels/4182.png","caption":"Your shipping label for order #4182"}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP send an image by media id with the caption in the JSON body; upload the file once in a separate request and keep its id in a variable for the loop over groups. The body here is JSON, so there is no multipart option and the request looks like the text send with different fields.
API reference for this stepAsk with a poll rather than free text
In a group, free-text replies from dozens of people are unusable. A poll returns structured votes keyed to the participant, which a bot can count without guessing what somebody meant.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/messages/poll', [ 'body' => '{"to":"120363041234567890@g.us","title":"When should we run the launch standup?","options":["Monday 09:00","Tuesday 10:00","Wednesday 16:00"],"multiple":false}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP a poll is a POST to /messages/poll whose JSON body carries the question and an array of options; store the returned message id against the group in your table so vote events, which reference that id, can be counted per question.
API reference for this stepWatch delivery and replies
Group delivery receipts arrive per participant, so a status event stream from a large group is noisy. Aggregate rather than storing every receipt, and use the messages event for the replies you actually want.
Arrives on your webhook as
messages.status.In PHP the Slim route receives one messages.status event per participant; increment a counter in Redis or APCu keyed by message id and return the 200, leaving the flush to a cron job. A row insert per receipt in the handler is what makes a large group's delivery burst time out.
The whole script
Every step above in one runnable file. Save it as send-group-message.php, put your token in the environment, and run it.
<?php
// Send Group Message with the Wapito WhatsApp API.
//
// Address the group id like any recipient, attach media by id, ask questions as polls, and aggregate the per-participant delivery receipts.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php send-group-message.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Send text to the group id ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/messages/text', [
'body' => '{"to":"+15551234567","body":"Your order #4182 has shipped. Track it here: https://acme.example/t/4182","typing_time":3}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Attach an image or a document ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/messages/image', [
'body' => '{"to":"+15551234567","media":"https://acme.example/labels/4182.png","caption":"Your shipping label for order #4182"}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Ask with a poll rather than free text ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/messages/poll', [
'body' => '{"to":"120363041234567890@g.us","title":"When should we run the launch standup?","options":["Monday 09:00","Tuesday 10:00","Wednesday 16:00"],"multiple":false}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
Receive the webhook
Slim 4 receiver for messages, messages.status, polls — it verifies the signature, answers immediately and does the work after. Install it with composer require slim/slim:^4 slim/psr7 and save it as webhook.php.
<?php
// public/webhook.php - run with: php -S 0.0.0.0:8000 -t public
require __DIR__ . '/../vendor/autoload.php';
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
$secret = getenv('WAPITO_WEBHOOK_SECRET');
$events = ['messages', 'messages.status', 'polls'];
/** Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>. */
function wapito_verify(string $raw, ?string $header, string $secret): bool
{
if (!$header) {
return false;
}
$parts = [];
foreach (explode(',', $header) as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, null);
$parts[$k] = $v;
}
if (empty($parts['t']) || empty($parts['v1'])) {
return false;
}
if (abs((int) (microtime(true) * 1000) - (int) $parts['t']) > 300000) {
return false; // 5 minute clock skew
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
return hash_equals($expected, $parts['v1']);
}
$app = AppFactory::create();
$app->post('/wapito', function (Request $request, Response $response) use ($secret, $events) {
$raw = (string) $request->getBody();
if (!wapito_verify($raw, $request->getHeaderLine('X-Wapito-Signature'), $secret)) {
return $response->withStatus(401);
}
$payload = json_decode($raw, true);
if (in_array($payload['event'], $events, true)) {
error_log($payload['event'] . ' ' . json_encode($payload['data']));
}
return $response->withStatus(200); // answer 2xx fast; do the real work in a queue
});
$app->run();
Errors you may hit
Gotchas
- Guzzle throws a ClientException on a 4xx by default, and the useful part - the API error code - is inside $e->getResponse()->getBody(). Either catch it and decode the body, or pass http_errors => false and branch on the status yourself.
- json_decode($raw, true) turns an empty JSON object into an empty PHP array, so is_array() tells you nothing about whether a field arrived. Check array_key_exists() before you read a webhook field.
- PHP running behind Apache or shared hosting often gets a request body that has already been consumed. Read the raw webhook body with file_get_contents("php://input") exactly once and pass it around; a second read returns an empty string and signature checks fail.
- Integer ids overflow on 32-bit builds. WhatsApp group ids and timestamps are strings in Wapito responses - keep them as strings and never cast with (int).
- The default max_execution_time of 30 seconds will kill a bulk loop halfway. Run bulk sends from the CLI SAPI (php send.php), not from a web request.
Where to run it
- Shared hosting or cPanel with a single public webhook.php endpoint
- A VPS managed by Laravel Forge or Ploi, with the sender on a supervisor-managed queue worker
- Google Cloud Run using the official php:8.3-cli image
- Any Heroku-style buildpack platform for the Slim receiver
Pitfalls in PHP
- Sending from a web request under Apache means the 30-second execution limit ends a loop over groups halfway; run the sender as a supervisor-managed worker from the CLI.
- Guzzle's ClientException on a 429 carries a retry hint in the body; catching it and immediately retrying in a while loop is the pattern that gets the queue throttled further. Sleep with usleep before trying again.
- json_encode of a text with an emoji and no JSON_UNESCAPED_UNICODE flag sends \ud83d escapes that WhatsApp renders as the emoji anyway, but a caption with a slash gets \/ unless JSON_UNESCAPED_SLASHES is set too.
Frequently asked questions
Is there a separate endpoint for group messages?
No, and that is deliberate. Every send endpoint takes a recipient, and a group id is simply one of the recipient forms it accepts. The same call that messages a person messages a group, which means your sending code does not need a special case for groups at all.
How do I mention someone in a group message?
Include the mention in the message body using the participant's identity, and the client renders it as a tap-able name that notifies them. Getting the identity right matters more than it used to, because in newer groups participants may be represented by a linked identity rather than a phone number.
Can I send to many groups at once?
Only by sending to each one in turn. The send queue serialises per channel deliberately, so a fan-out across fifty groups will be paced rather than parallel, and pushing harder returns a saturation error instead of sending faster. Build the loop to expect that pacing.
Related
Try it on your own number
Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.