How to post a status update in PHP with the Wapito WhatsApp API

Post a text card, or upload media once and post it as an image, video or voice status with a caption that carries the detail.

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 status is posted with four Guzzle requests whose bodies are small JSON documents; the upload is a base64 data URI from file_get_contents, and its media id is reused by the media and audio posts. A daily cron entry is the natural home, and the same id can feed a channel post in the same run. Keep the script idempotent by recording the ids it posted.

Before you start

  • composer require guzzlehttp/guzzle
  • getenv('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

  1. Post a text card

    A text status is a short line on a coloured background. Keep it under a couple of dozen words: the card is read in a second by someone tapping through, not studied.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/stories/text', [
      'body' => '{"body":"Workshop closed Friday for stocktake. Orders ship Monday.","background_color":"#0B7F5C","font":2,"contacts":["+15551234567","+15559876543"]}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP post a text card with $client->request('POST', $baseUrl . '/stories/text', ['body' => json_encode(['body' => $text, 'background_color' => $colour]), 'headers' => [...]]) and keep the decoded id. Guard the length with str_word_count before sending, since a long card is skipped by the reader, and pass an optional contacts array when the status is for a subset only.

    API reference for this step
  2. Upload the image or video

    Upload once and reuse the media id if you post the same asset to status and to a channel. Portrait assets fill the screen; landscape ones are letterboxed and look like an afterthought.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/media', [
      'body' => '{"data":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…","filename":"new-colours.jpg"}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP build the upload with json_encode(['data' => 'data:' . mime_content_type($path) . ';base64,' . base64_encode(file_get_contents($path)), 'filename' => basename($path)]) and POST it to /media; the decoded reply's id is the media id you reuse for the status and a channel post in this run, and expires_at tells you how long that reuse window lasts.

    API reference for this step
  3. Post the media status

    Attach the uploaded media with a caption. This is the format shops use for a daily menu or new stock, because the picture does the work and the caption carries the price or the time.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/stories/media', [
      'body' => '{"media":"https://acme.example/status/new-colours.jpg","caption":"New colours, same price.","contacts":["+15551234567"]}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP post the media status with the media id and caption as JSON, catching GuzzleHttp\Exception\ClientException and reading getStatusCode(): 413 is payload_too_large and 415 is unsupported_media_type. Log the file name with the error so the wrong asset is obvious, and treat a 429 warmup_limit as a signal to skip today's post rather than to retry it.

    API reference for this step
  4. Post a voice status

    A short voice note as a status is unusual enough to get attention and personal enough to be worth it occasionally. The audio must be Opus-encoded, the same as a voice message.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/stories/audio', [
      'body' => '{"media":"https://acme.example/status/monday-update.m4a","background_color":"#1D2B3A"}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP a voice status needs Opus audio; run ffmpeg through proc_open or the Symfony Process component to convert, upload the result, and POST the media id to /stories/audio with a background_color. An MP3 raises a ClientException with a 415, which is the signal that the conversion step was skipped; check the exit code of ffmpeg before you upload.

    API reference for this step

The whole script

Every step above in one runnable file. Save it as post-status.php, put your token in the environment, and run it.

<?php
// Post Status with the Wapito WhatsApp API.
//
// Post a text card, or upload media once and post it as an image, video or voice status with a caption that carries the detail.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php post-status.php
require __DIR__ . '/vendor/autoload.php';

$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');

// --- Post a text card ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/stories/text', [
  'body' => '{"body":"Workshop closed Friday for stocktake. Orders ship Monday.","background_color":"#0B7F5C","font":2,"contacts":["+15551234567","+15559876543"]}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Upload the image or video ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/media', [
  'body' => '{"data":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…","filename":"new-colours.jpg"}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Post the media status ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/stories/media', [
  'body' => '{"media":"https://acme.example/status/new-colours.jpg","caption":"New colours, same price.","contacts":["+15551234567"]}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Post a voice status ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/stories/audio', [
  'body' => '{"media":"https://acme.example/status/monday-update.m4a","background_color":"#1D2B3A"}',
  '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();
Every event, its payload and the retry rules

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

  • file_get_contents on a large video plus base64_encode plus json_encode holds three copies in memory and can exceed memory_limit; check filesize() against the cap first and skip, or transcode to a smaller bitrate with ffmpeg before the upload.
  • mime_content_type returns false without the fileinfo extension, and 'data:' . false . ';base64,' produces an invalid URI the API rejects; check the return value or map the extension yourself with a small array of the types WhatsApp accepts.
  • A cron entry at 0 0 * * * on a UTC server posts at the wrong local hour for most numbers; compute the time in the number's zone with DateTimeZone before scheduling, and keep the cron line itself at a fixed UTC minute the script can reason about.

Frequently asked questions

Who actually sees my status?

People who have saved your number in their contacts and have not restricted status from you. There is no subscriber list and no way to add someone, so growing reach means getting more people to save your number - which is a marketing problem rather than an API one.

Can I schedule status posts?

Not inside WhatsApp, but that is exactly what the API is for: run a scheduler on your side and call the endpoint at the moment you want the post to appear. Since statuses expire after a day, posting at the right hour matters more here than for almost anything else.

Does posting status count as sending?

It is activity on the linked number and is paced through the same queue, so treat it as part of your daily budget rather than as free. It does not consume a per-recipient send the way a broadcast would, which is part of why it is an efficient way to reach saved contacts.

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.