How to send a 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.
In PHP free-form sends are Guzzle POSTs from a queue worker, never from a page request, with a hard cap per run and usleep between messages. Guzzle throws on the 4xx that a wrong body or an exhausted plan produces, so the catch block is where you decide whether to stop, and the Slim receiver records delivery from the status event. A Laravel job or a CLI loop both fit.
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 the text you actually wrote
One call, one message, no template id and no approval queue. The body is whatever you want to say, which means the copy can change with the deploy rather than with Meta's review cycle.
<?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 send with $client->request('POST', $baseUrl . '/messages/text', ['body' => json_encode(['to' => $to, 'body' => $text], JSON_UNESCAPED_UNICODE), 'headers' => [...]]) and store the decoded id against the recipient. The text is a plain string built with sprintf or a heredoc; nothing is submitted for approval, and a typing_time of a few seconds makes the send read naturally.
API reference for this stepAttach media in the same flow
Images, video, documents and voice notes all follow the same recipient-plus-payload shape. Upload once and reuse the media id when the same asset goes to many recipients.
<?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 attach media by putting the media id and caption in the JSON body, the same recipient-plus-payload shape as text; upload the file once and reuse the id across recipients within the run. Documents and Opus voice notes differ only in what sits behind the media id, and a filename key lets a document arrive with a readable name.
API reference for this stepAsk a structured question
A poll turns a question into machine-readable answers, which is far more reliable than asking people to reply with a number and then parsing whatever they type.
<?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 ask with a POST to /messages/poll whose body carries the title, an options array and a multiple flag; write the returned message id with the recipient so vote events can be counted per question with an array lookup instead of a regex over free text. Keep the options short, because they render as buttons on a phone screen.
API reference for this stepFollow delivery on the webhook
A 2xx from the send endpoint means accepted, not delivered. The status event carries the real outcome keyed by message id, and it is what your retry logic should watch.
Arrives on your webhook as
messages.status.In PHP the Slim route decodes messages.status, updates the message row found by $payload['data']['id'] with the delivered or failed state, and returns the 200; a queued job handles any follow-up. Retry decisions read that row, because the send's 2xx only meant accepted, and a failed state with a reason tells you whether the recipient or the channel was the problem.
The whole script
Every step above in one runnable file. Save it as send-message-without-template.php, put your token in the environment, and run it.
<?php
// Send Without Template with the Wapito WhatsApp API.
//
// Send the text you wrote, attach media by id, ask questions as polls, and treat the status webhook rather than the send response as delivery.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php send-message-without-template.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Send the text you actually wrote ---
$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 media in the same flow ---
$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 a structured question ---
$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
- A worker without a per-run cap that re-reads the same rows after a database error can send the whole quota in minutes; count sends and exit at a hard limit before the loop, and mark each row as sent in the same transaction that stores the message id.
- Guzzle's ClientException on a 402 plan_required stops the script with a stack trace unless caught, and a supervisor restarts it into the same error in a loop; catch it and pause the worker with a flag file the next start checks.
- usleep takes microseconds, so usleep(2) between messages is no pause at all and the queue answers with send_rate_limited; write the delay as usleep(2000000) or use sleep(2), and keep the value in a constant the whole worker shares.
Frequently asked questions
Is sending without a template against WhatsApp's rules?
It is outside the official platform, which is a different statement. Wapito drives a real linked device, the same way the desktop app does, and WhatsApp can ban a number it judges to be misbehaving. No provider can promise otherwise, which is why every page here carries a ban-risk note rather than a guarantee.
What replaces the 24-hour window?
Nothing technical - and that is precisely why your own discipline has to. The window existed to stop businesses messaging people who had not asked. Keep an opt-in record, honour opt-outs immediately, and pace first contacts, because the abuse systems still exist even when the window does not.
Can I still use templates if I want to?
There is no template system here to use, because there is no approval layer. If your use case genuinely fits templates - predictable transactional notices to customers who expect them - the official Cloud API is the better tool and we will say so on the comparison page.
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.