How to get a profile photo 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.
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/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
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 stepRead 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 stepSet 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 stepRefresh 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();
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.