How to post to a channel 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.
Posting to a Channel in PHP goes through the ordinary message endpoints with the newsletter id as the recipient, which makes it three Guzzle POSTs from a scheduled CLI script rather than a page. Upload media once and reuse its id, space posts out with a cron cadence instead of a loop, and let the Slim receiver record the status event as the moment the post went live.
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
Publish a text post
There is no dedicated publish endpoint: you send to the channel id exactly as you would to a person. Keep posts self-contained, because followers cannot ask a follow-up question in the thread.
<?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 publish with $client->request('POST', $baseUrl . '/messages/text', ['body' => json_encode(['to' => $newsletterId, 'text' => $post], JSON_UNESCAPED_UNICODE), 'headers' => [...]]) and keep the decoded id for the status match. A heredoc keeps a multi-line post readable and preserves the line breaks.
API reference for this stepPost an image with a caption
Upload once and reuse the media id if the same asset goes to several channels. The caption is the post, so write it as the whole message rather than as a label for the picture.
<?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 an image post carries the media id and caption in the JSON body; upload the file once in a separate request and hold its id in a variable for every channel you post to in this run. The caption is the whole message a follower reads, so write it as prose rather than a label.
API reference for this stepShare a link with a preview
A link post with a preview is the highest-performing format for driving people off WhatsApp, which is usually the point of a channel. Put the destination on a URL you control so you can measure it.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/messages/link', [ 'body' => '{"to":"+15551234567","url":"https://acme.example/t/4182","title":"Track order #4182","description":"Out for delivery, arriving before 18:00.","image":"https://acme.example/og/tracking.png","body":"Your parcel is on the van:"}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP a link post is a POST to /messages/link with the URL and preview text; build the URL with http_build_query for the campaign parameters on a page you own, so clicks are measurable. The preview is derived from that page's meta tags, so make sure they exist before scheduling.
API reference for this stepConfirm the post landed
Channel posts produce their own status events. Use them to confirm publication and to key your own record of what went out and when, rather than trusting the send response alone.
Arrives on your webhook as
messages.status.In PHP the Slim route decodes the messages.status event, finds the post by $payload['data']['id'] and stores the published timestamp before returning the 200. The send's 2xx means accepted; this event means it went out, and the difference is what a scheduling dashboard should display.
The whole script
Every step above in one runnable file. Save it as post-to-channel.php, put your token in the environment, and run it.
<?php
// Post to Channel with the Wapito WhatsApp API.
//
// Send to the channel id like any recipient, upload media once and reuse it, prefer link posts for traffic, and confirm from the status event.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php post-to-channel.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Publish a text post ---
$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();
// --- Post an image with a caption ---
$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();
// --- Share a link with a preview ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/messages/link', [
'body' => '{"to":"+15551234567","url":"https://acme.example/t/4182","title":"Track order #4182","description":"Out for delivery, arriving before 18:00.","image":"https://acme.example/og/tracking.png","body":"Your parcel is on the van:"}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
Receive the webhook
Slim 4 receiver for messages, messages.status — 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'];
/** 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
- Guzzle throws ClientException on the 429 that pacing violations produce; catching it and retrying without usleep in a loop keeps the queue throttled and delays every later post.
- A cron entry running every minute that re-reads a posts table without marking rows as sent publishes the same post repeatedly; update the row before the request, not after.
- json_encode escapes forward slashes by default, so a URL in the text arrives as https:\/\/ in your logs; it decodes correctly on the API side, but set JSON_UNESCAPED_SLASHES so log lines are legible.
Frequently asked questions
Is there a separate endpoint for channel posts?
No. The channel id is simply another recipient form accepted by the ordinary send endpoints, which means your publishing code is the same code that sends messages. The only difference is the id you address and the fact that nobody can reply to what you post.
Can I schedule posts in advance?
Not inside WhatsApp - there is no scheduled post object. Schedule it on your side with a job runner and call the send endpoint at the moment you want it published. That also means your scheduler, rather than WhatsApp, owns the retry behaviour if a post fails.
Can I edit or delete a post after publishing?
Editing and deleting follow the same rules as ordinary messages, so both are possible within WhatsApp's own time window and only for posts the linked number sent. Beyond that window the post stands, which is a good reason to have a human approve anything automated before it goes out.
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.