How to promote group admins 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 and Guzzle the admin sync is a short CLI script: read the roles, promote what your source of truth says, demote the rest, one request each. Because Guzzle throws on 4xx, the demotion loop needs an explicit catch, and the creator's superadmin entry has to be removed from the candidate array with array_filter before that loop starts.
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
See who holds a role today
The participant list carries each member's role, including which one is the creator. Read it before you change anything: the creator cannot be demoted, so an automation that tries will fail on exactly that member.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/participants', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP the roll arrives from json_decode as an array of arrays; array_filter it for role === 'admin' to get the current admins and find the superadmin with a foreach that breaks on the first match. Keep the identities as strings in a plain array; array_column($members, 'role', 'id') gives you the map in one line.
API reference for this stepPromote the people who should moderate
Promotion is a single call that can carry several participants. Promote from your own source of truth - a team list, a rota, a role in your CRM - rather than from whoever happens to be talking in the group.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/admins', [ 'body' => '{"participants":["+15551234567"]}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP promotion is a POST whose JSON body lists the identities from array_diff($rota, $currentAdmins); encode with json_encode and let Guzzle send the string. Decode the response to confirm the roles rather than trusting the status code alone, and write the new admins to your table in the same run.
API reference for this stepDemote anyone who no longer needs it
Demotion is visible to the group, so do it as part of a clear process rather than silently. The linked number must itself be an admin, and it cannot demote the group's creator.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('DELETE', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/admins/+15551234567', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getStatusCode();In PHP each demotion is $client->request('DELETE', $baseUrl . '/groups/' . $id . '/admins/' . rawurlencode($pid)); catch GuzzleHttp\Exception\ClientException and read $e->getResponse()->getStatusCode(): a 403 means the linked number was demoted and the script should stop, a 404 means the person already left.
API reference for this stepTrack role changes over the webhook
Promotions and demotions made by anyone, including other admins on their phones, arrive as events. This is how your system learns that the linked number was demoted before its next write fails.
Arrives on your webhook as
groups.participants.In PHP the Slim route reads $payload['data']['action'] and $payload['data']['participant'] after verifying the signature over the raw body; update the role column and return the 200 before doing anything slower. A demotion event naming the linked number is the one to alert on, because every later write will be refused.
The whole script
Every step above in one runnable file. Save it as group-admins.php, put your token in the environment, and run it.
<?php
// Group Admins with the Wapito WhatsApp API.
//
// Read the current roles, promote from your own source of truth, demote what is stale, and follow role changes on the webhook.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php group-admins.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- See who holds a role today ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/groups/120363041234567890@g.us/participants', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Promote the people who should moderate ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/groups/120363041234567890@g.us/admins', [
'body' => '{"participants":["+15551234567"]}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Demote anyone who no longer needs it ---
$client = new \GuzzleHttp\Client();
$response = $client->request('DELETE', $baseUrl . '/groups/120363041234567890@g.us/admins/+15551234567', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getStatusCode();
Receive the webhook
Slim 4 receiver for groups.participants — 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.participants'];
/** 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
- array_diff compares values as strings, which is what you want, but only if you never cast an identity to int first; a linked identity is not a number and an (int) cast turns it into zero.
- Guzzle's exception on the first 403 ends the script unless it is caught, and a script that dies mid-loop has promoted some people and demoted none, which is worse than doing nothing.
Frequently asked questions
Can I promote someone who is not in the group?
No. Roles apply to participants, so the person has to be a member first. Add or invite them, wait for the participants event that confirms they actually joined, and only then promote - a promotion aimed at a non-member fails rather than adding them.
What is the difference between admin and superadmin?
An admin can change the group's settings, its icon and its membership, and can promote or demote other admins. The superadmin is the creator: they have the same powers and additionally cannot be demoted or removed by anyone else, which makes the choice of creating number a long-lived decision.
Will people be notified that they were promoted?
Yes. Role changes appear as system messages in the group, visible to everyone, and the affected person sees it in their chat. Treat promotion as a public act: it is not a quiet permission change, and demoting someone without warning is usually worth a message first.
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.