How to approve join requests 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.
In PHP the request queue is worked by a CLI script on a cron schedule: a do-while over the paged list with Guzzle, an in-memory allow-list loaded once, and a POST or DELETE per pending person. It should not run behind a web request, since a big queue means hundreds of sequential calls and the execution limit would end it partway.
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
Turn on approval for the group
Join approval is a group setting. Switch it on before you publish the invite link anywhere public, otherwise the first hour of a campaign fills the group with accounts nobody has looked at.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('PATCH', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/settings', [ 'body' => '{"messages_admin_only":false,"membership_approval":true}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP turn approval on with a PATCH request carrying the setting as a JSON string body, then confirm with a GET and array_key_exists on the settings array, so the script is sure the flag is really set before the link is published. A 403 raises ClientException, which is the moment to stop and alert.
API reference for this stepRead the pending queue
The queue lists everyone waiting, with the identity that will become the participant. Page through it rather than assuming it is short; a link that was shared widely can produce hundreds of requests overnight.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/applications', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP page with a do-while: request the first page, foreach over the decoded items, read the next cursor with $data['next'] ?? null, and loop while it is not null. Keep the whole thing in one function that returns a generator with yield so the decision loop stays free of paging logic.
API reference for this stepApprove the ones you recognise
Match each request against your own list - paid subscribers, enrolled students, staff numbers - and approve only those. Approving everything defeats the point of turning the setting on.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/applications/+15551234567/approve', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP approve with $client->request('POST', $url . '/applications/' . rawurlencode($pid) . '/approve') and no body option. Test membership with isset($allowed[$identity]) on an array keyed by identity that you built once from your database, which is far cheaper than a query per pending person.
API reference for this stepReject the rest and record why
Rejection is quiet: the person is not told why. Keep your own log so a mistaken rejection can be explained when the person asks, because WhatsApp gives them no way to appeal.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('DELETE', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/applications/+15551234567', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getStatusCode();In PHP reject with a DELETE on the same application path, and write the identity, the rule that failed and the time into your own table before the request, because nothing tells the person why. Catch ClientException for a 404 and continue: the person withdrew while you were deciding.
API reference for this step
The whole script
Every step above in one runnable file. Save it as group-join-requests.php, put your token in the environment, and run it.
<?php
// Group Join Requests with the Wapito WhatsApp API.
//
// Turn approval on, read the pending queue, approve the people you can identify, reject the rest and keep your own audit log.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php group-join-requests.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Turn on approval for the group ---
$client = new \GuzzleHttp\Client();
$response = $client->request('PATCH', $baseUrl . '/groups/120363041234567890@g.us/settings', [
'body' => '{"messages_admin_only":false,"membership_approval":true}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Read the pending queue ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/groups/120363041234567890@g.us/applications', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Approve the ones you recognise ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/groups/120363041234567890@g.us/applications/+15551234567/approve', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Reject the rest and record why ---
$client = new \GuzzleHttp\Client();
$response = $client->request('DELETE', $baseUrl . '/groups/120363041234567890@g.us/applications/+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
- Building the allow-list with array_flip on phone numbers loses every applicant who arrives as a linked identity, and the script rejects real subscribers; key the array on the identity string the API returns.
- A cron entry without flock lets a slow run overlap the next one, and both approve the same page; wrap the script in flock or a lock file so a single instance works the queue.
Frequently asked questions
How long do pending requests stay in the queue?
WhatsApp expires them after a period rather than keeping them indefinitely, so a queue that is only drained weekly will lose requests. Drain it on a schedule measured in minutes or hours, and tell people roughly how long approval takes on the page where you publish the link.
Does the person know they were rejected?
They see that they are not in the group, but they are not given a reason and there is no appeal inside WhatsApp. If rejection is part of a business process - an expired subscription, for example - tell them through the channel you already have with them rather than leaving them guessing.
Can I approve everyone automatically?
You can, but then the setting is doing nothing except adding delay. Approval is worth turning on only when you have something to check against: a subscriber list, an accreditation list, a CRM segment. Otherwise leave it off and let people join directly by link.
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.