How to create a group 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 group is created with Guzzle from a CLI script rather than from a page request, because three sequential calls plus a read-back is more than a web timeout wants to hold. Guzzle throws on any 4xx by default, so the create step either returns the group array or raises a ClientException whose response body names the reason, and the script branches on that code.
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
Create the group
Post a subject and the founding participants. The linked number becomes the creator and superadmin, so every later change - settings, admins, icon - is allowed without any extra step. Keep the founding list to people who already expect the group; adding strangers here is the fastest route to a report.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/groups', [ 'body' => '{"subject":"Acme Launch Team","participants":["+15551234567","+15559876543"],"description":"Coordination for the Q3 launch. Keep it on topic."}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP the create is $client->request('POST', $baseUrl . '/groups', ['body' => $json, 'headers' => [...]]) with the body pre-encoded; the generated sample passes a JSON string, so if you build the array yourself use json_encode with JSON_UNESCAPED_UNICODE. Decode the reply with json_decode((string) $response->getBody(), true) and keep $group['id'] as a string, together with the participants array it already carries.
API reference for this stepRead the group back
Fetch the group by the id you just received and store that id as a string. It is longer than a 64-bit integer, so a numeric column or an eager JSON parser will silently corrupt it and every later call will answer 404.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/groups/120363041234567890@g.us', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP the read-back is a GET with the id concatenated into the path; it stays a string in PHP as long as you never cast it, and on a 32-bit build an (int) cast would overflow silently. A 404 here raises GuzzleHttp\Exception\ClientException, your signal that the id was mangled between the two calls, usually by an integer column.
API reference for this stepShare the invite link
Read the invite code and publish the link rather than adding people directly. Joining by link is a deliberate act by the person, which is both better manners and materially safer for the number than pushing unknown participants into a group.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/groups/120363041234567890@g.us/invite', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP the invite read is the same GET shape with /invite on the end, and the decoded array carries the code and the full link. Store the code in your database and build the public URL through a redirect you own, so rotating the code later means updating one row, nothing already printed; a 403 means invites are admin-only.
API reference for this stepConfirm membership over the webhook
Each join or leave arrives as its own event with the participant and the action. Key your own records on that event rather than on the create response, because people who join by link never appear in it.
Arrives on your webhook as
groups.participants.In PHP the Slim route reads (string) $request->getBody() once, verifies the signature against those bytes, and then json_decode()s them into $payload; check $payload['event'] === 'groups.participants' and array_key_exists('participant', $payload['data']) before touching a field. Return the 200 response immediately and queue the database write, keyed on the participant id so a redelivered event is harmless.
The whole script
Every step above in one runnable file. Save it as create-group.php, put your token in the environment, and run it.
<?php
// Create Group with the Wapito WhatsApp API.
//
// Create the group, read it back by id, publish the invite link, and let the participants webhook keep your database in step.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php create-group.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Create the group ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/groups', [
'body' => '{"subject":"Acme Launch Team","participants":["+15551234567","+15559876543"],"description":"Coordination for the Q3 launch. Keep it on topic."}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Read the group back ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/groups/120363041234567890@g.us', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Share the invite link ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/groups/120363041234567890@g.us/invite', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
Receive the webhook
Slim 4 receiver for groups, 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', '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
- getenv('WAPITO_TOKEN') returns false when the variable is not exported to the PHP process, and 'Bearer ' . false is just 'Bearer ', so the first call fails with 401. Under php-fpm the variable has to be in the pool config, not only in your shell, and a CLI run under cron needs it in the crontab line or a sourced env file.
- Guzzle's default is to throw on 4xx, which means a participant with restrictive privacy settings can abort the script before the read-back runs. Catch ClientException around the create call, log $e->getResponse()->getBody(), and decide whether to continue with the participants who were accepted.
- Building the participants array from a comma-separated config string with explode leaves a trailing space on every number after the first, and the API answers 400 invalid_recipient for the second one; array_map('trim', ...) before the encode, and validate each entry with a regex for digits and a leading plus so the failure names the row.
Frequently asked questions
How many groups can I create in a day?
WhatsApp publishes no number, and anyone who quotes you one is guessing. What is observable is that new numbers get restricted far sooner than established ones. Start with a handful a day on a warmed-up number, watch for timelocks, and treat the first restriction as a signal to slow down rather than as a quota to probe.
Can I add people to the group as I create it?
Yes, the create call takes a participant list, but it is the riskiest way to fill a group. Many people have privacy settings that stop strangers adding them, so they will silently not appear, and those who do appear may report the number. Publishing the invite link is slower and much safer.
Does the linked number stay in the group forever?
It stays until it leaves or is removed. Because the creator holds the superadmin role, leaving a group you created hands nothing over automatically, so promote a human admin before the automation departs. Otherwise the group is left without anyone who can change its settings or membership.
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.