How to get a profile photo in PHP with the Wapito WhatsApp API

Read a contact or chat picture where privacy allows, set your own number's photo, and refresh your cache from contact events.

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 avatar work is two Guzzle GETs, a PATCH after an upload, and a Slim route that invalidates a cached picture on a contacts event. Since Guzzle throws on 404, the read wrapper catches ClientException and returns null for a contact that is not on WhatsApp, while a withheld photo arrives as an empty field in a 200.

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. Read a contact's profile

    Fetch the display name and picture for a contact. Privacy settings apply: a person who shows their photo only to contacts will return nothing to a number they have not saved, and that is a normal result rather than a failure.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/contacts/+15551234567/profile', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP read a contact's profile with a GET on /contacts/ . rawurlencode($id) . '/profile' and return $data['picture'] ?? null; an empty field is the privacy case. Catch GuzzleHttp\Exception\ClientException for a 404 and return null as well, so the caller sees one shape for both.

    API reference for this step
  2. Read a chat picture

    The same call pattern works for a chat, which covers groups as well as people. Use it to populate an agent dashboard so a human sees the same avatar they would see on their phone.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/chats/15551234567@s.whatsapp.net/picture', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP the chat picture is a GET on /chats/ . rawurlencode($id) . '/picture' with the same wrapper; a group id works in that path. Cache the URL in APCu or a table with the fetch time so an agent dashboard can show it without a request per page view.

    API reference for this step
  3. Set the linked number's own photo

    Upload a square image and set it as the number's own picture. A number with a real photo and a real name looks like a business rather than a burner, which measurably affects how people respond to it.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('PATCH', 'https://api.wapito.com/v1/users/profile', [
      'body' => '{"name":"Acme Support","status":"Replies Mon-Fri, 9 to 6 UK time."}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP set your own photo with a PATCH whose body is json_encode(['picture' => $mediaId]) after uploading a square image cropped with GD; catch ClientException and check for 415, which means the file type was not accepted. The generated body is a JSON string, not a form.

    API reference for this step
  4. Refresh when a contact changes theirs

    Contact updates arrive as events, so a cached avatar can be invalidated at the moment it changes instead of being re-fetched on a timer for thousands of contacts.

    Arrives on your webhook as contacts.

    In PHP the Slim handler decodes the contacts event, deletes the cache entry for $payload['data']['id'] with apcu_delete or a DELETE statement, and returns the 200. The next read fetches the fresh picture, so no timer needs to sweep thousands of contacts.

The whole script

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

<?php
// Profile Picture with the Wapito WhatsApp API.
//
// Read a contact or chat picture where privacy allows, set your own number's photo, and refresh your cache from contact events.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php profile-picture.php
require __DIR__ . '/vendor/autoload.php';

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

// --- Read a contact's profile ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/contacts/+15551234567/profile', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Read a chat picture ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/chats/15551234567@s.whatsapp.net/picture', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Set the linked number's own photo ---
$client = new \GuzzleHttp\Client();

$response = $client->request('PATCH', $baseUrl . '/users/profile', [
  'body' => '{"name":"Acme Support","status":"Replies Mon-Fri, 9 to 6 UK time."}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

Receive the webhook

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

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

  • APCu is per-process under php-fpm and empty after a restart, so an invalidation from the webhook handler does not reach the worker that cached the value; use a shared store when more than one process runs.
  • Caching null in APCu is indistinguishable from a miss unless you wrap it in an array, and the wrapper then refetches on every hit; store ['picture' => null, 'at' => time()].
  • A PATCH sent with ['form_params' => ...] posts form encoding and the API answers 400 invalid_request; use the body option with a JSON string and the JSON Content-Type header.

Frequently asked questions

Why does a contact's photo come back empty?

Almost always because of their privacy settings. WhatsApp lets everyone choose who can see their picture, and a linked number that is not in their contacts will often see nothing. It is the expected outcome for a cold contact rather than something to retry or work around.

How large should my own profile picture be?

Square and a few hundred pixels on a side is plenty; WhatsApp compresses it heavily and renders it in a small circle. A simple mark on a solid background survives that treatment far better than a detailed photograph or anything with small text in it.

Can I read the picture of a group?

Yes, through the chat picture call, provided the linked number is a member of that group. For groups the picture is the icon an admin set, and there is a dedicated group icon endpoint if you also need to change it rather than only read it.

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.