How to set a group icon in PHP with the Wapito WhatsApp API

Upload a square image, set it on the group, read it back for your dashboard, and clear it when the group is retired.

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.

With PHP the icon is uploaded as a base64 data URI in a JSON body and then applied with a PUT, two Guzzle requests that fit a CLI script or a queued job. GD or Imagick can square the image first, which is worth the extra lines because a landscape logo comes out of WhatsApp's circular crop missing its ends. Keep the upload's media id; later steps reuse it.

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. Upload the image

    Send a square JPEG or PNG and keep the source file to hand. A rectangular image is cropped by WhatsApp rather than letterboxed, so anything with text near the edge will lose it.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/media', [
      'body' => '{"data":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6…","filename":"launch-team.png"}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP build the body 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 holds the media id under 'id' plus an expires_at you should respect. mime_content_type needs the fileinfo extension, which most builds have enabled; on a stripped Docker image install it explicitly.

    API reference for this step
  2. Set it as the group icon

    Apply the uploaded media to the group. The change is announced in the group, so avoid running this on a schedule that flips the icon back and forth.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('PUT', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon', [
      'body' => '{"media":"https://acme.example/brand/launch-team.png"}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP apply the icon with $client->request('PUT', $url, ['body' => json_encode(['media' => $mediaId]), 'headers' => [...]]); Guzzle sends PUT like POST. Catch GuzzleHttp\Exception\ClientException and branch on getStatusCode(): 403 for a non-admin number, 415 for an upload that was not an image, 404 for a group id that was cast to an integer somewhere upstream and lost digits.

    API reference for this step
  3. Read the current icon

    Fetch the current picture to show it in your own dashboard or to check whether an admin has replaced the one your automation set.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP read the current icon with a GET on /groups/{id}/icon and compare $data['url'] in the decoded array with the value you stored after your last PUT; when they differ an admin replaced it from a phone, and a null means the group has no picture at all. Keep that boolean, not the image bytes, in your dashboard table.

    API reference for this step
  4. Remove it when the group is retired

    Clearing the icon is a cheap, visible signal that a group is closed, which works well alongside renaming it and locking it to admins only.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('DELETE', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getStatusCode();

    In PHP clear the icon with a DELETE request; the reply is a 204 with an empty body, so json_decode('') returns null and reading a field from it warns. Check $response->getStatusCode() === 204 and pair the call with the rename and the admin-only lock in one CLI command when you retire the group, so nobody runs half of it.

    API reference for this step

The whole script

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

<?php
// Group Icon with the Wapito WhatsApp API.
//
// Upload a square image, set it on the group, read it back for your dashboard, and clear it when the group is retired.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php group-icon.php
require __DIR__ . '/vendor/autoload.php';

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

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

$response = $client->request('POST', $baseUrl . '/media', [
  'body' => '{"data":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6…","filename":"launch-team.png"}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Set it as the group icon ---
$client = new \GuzzleHttp\Client();

$response = $client->request('PUT', $baseUrl . '/groups/120363041234567890@g.us/icon', [
  'body' => '{"media":"https://acme.example/brand/launch-team.png"}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Read the current icon ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/groups/120363041234567890@g.us/icon', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Remove it when the group is retired ---
$client = new \GuzzleHttp\Client();

$response = $client->request('DELETE', $baseUrl . '/groups/120363041234567890@g.us/icon', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getStatusCode();

Receive the webhook

Slim 4 receiver for groups — 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 = ['groups'];

/** 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

  • imagecreatefromjpeg followed by imagecopyresampled into a non-square canvas stretches the logo; compute the square crop box from the shorter side first, then resample, and write the result to a temporary file that you upload instead of the original.
  • memory_limit at the default 128M is enough for a normal icon but a multi-megapixel photo loaded through GD can exceed it and kill the script silently on shared hosting; resize from a smaller source, or call ini_set('memory_limit', '256M') at the top of the job and check getimagesize() before decoding.
  • Guzzle's 'json' request option would encode the array for you, but the generated samples pass 'body' with a pre-encoded string; mixing the two in one script sends a body of null for the call where you forgot to encode, and the API answers 400 invalid_request with a message that names the missing media field.

Frequently asked questions

What size should the image be?

Square, and large enough that the client's downscale looks clean rather than soft - a few hundred pixels on a side is plenty. WhatsApp compresses aggressively, so fine detail and small text will not survive; a simple mark on a solid background reads far better in a chat list.

Can I read the icon of a group I am not in?

No. Like everything else about a group, the icon is visible only to members. Resolving an invite code gives you the group's name and size before joining, but not its picture, so a preview screen has to make do with the metadata the code returns.

Does changing the icon notify everyone?

It appears as a system message in the group naming the admin who changed it, which every member sees in the thread. It does not usually generate a push notification, but it does take up a line in the conversation, so batch changes rather than flipping the icon repeatedly.

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.