How to link a number 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.
Connecting a number from PHP splits into a CLI step that reads state and requests a pairing code, and a Slim route that receives channel events; the QR fallback needs a browser stream, which PHP serves best through a small endpoint the page polls. Guzzle throws on the 409 that a wrong state produces, so the catch is where the state is re-read.
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
Check the channel state
Read the state before you ask for anything. A code can only be issued while the session is waiting to be linked, so polling for one against a connected channel just produces errors.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/channel', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP read the state with a GET on /channel and branch on $data['state']; a code is only issued in the waiting state. Poll from the CLI with a for loop and sleep(2) and a fixed attempt count, because a script that loops forever under supervisor is restarted into the same loop.
API reference for this stepRequest a pairing code
Ask for a code for the number you intend to link, and have the person type it into the linked-devices screen on that phone. Codes expire quickly, so request one while they are already looking at the phone.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('POST', 'https://api.wapito.com/v1/channel/pairing-code', [ 'body' => '{"phone":"+15557654321"}', 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', 'Content-Type' => 'application/json', ], ]); echo $response->getBody();In PHP request a code with a POST whose body is json_encode(['phone' => $e164]) and print it at once for the person to type; the code is short-lived. Catch GuzzleHttp\Exception\ClientException and check getStatusCode() for the 409 channel_not_in_qr_state, then read the state again before retrying.
API reference for this stepFall back to a QR if you need to
The QR refreshes every few seconds, so stream it to the browser rather than emailing a screenshot. New-device QR linking has been unreliable across engines since mid-2026, which is why the code is the primary path.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/channel/qr', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP serve the QR through a small endpoint that GETs /channel/qr on each request and returns the image, and let the browser poll it every few seconds with JavaScript; do not cache it and never email it. A 409 raises ClientException, meaning the channel left the waiting state.
API reference for this stepWatch the connection over the webhook
Every state change arrives as an event carrying the new state, the previous one and a reason. A ban needs a human and a restart does not, so branch on the reason rather than treating every disconnect the same.
Arrives on your webhook as
channel.In PHP the Slim handler decodes the channel event and switches on $payload['data']['reason']; a ban needs a person and a restart does not, so the branch decides between an alert and a log line. Write the state transition to your table and return the 200 first.
The whole script
Every step above in one runnable file. Save it as connect-number-qr-pairing.php, put your token in the environment, and run it.
<?php
// Connect a Number with the Wapito WhatsApp API.
//
// Read the state, request a pairing code, use the QR only as a fallback, and drive everything else from the channel webhook.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php connect-number-qr-pairing.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Check the channel state ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/channel', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Request a pairing code ---
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $baseUrl . '/channel/pairing-code', [
'body' => '{"phone":"+15557654321"}',
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
// --- Fall back to a QR if you need to ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/channel/qr', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
Receive the webhook
Slim 4 receiver for channel — 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 = ['channel'];
/** 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
- A web request that polls the state in a loop hits max_execution_time before the person finishes typing; run the poll from the CLI or return to the browser and let it poll.
- A QR endpoint behind a page cache or a CDN serves a stale code that never scans; send no-cache headers and bypass any caching layer for that route.
- sleep() inside a Slim route blocks a php-fpm worker for the whole poll and starves other requests; keep polling out of the web process.
Frequently asked questions
Should I use a pairing code or a QR?
A pairing code, in almost every case. It can be read aloud, pasted into a chat or typed from a support ticket, and it does not depend on a camera pointed at a refreshing image. QR linking of new devices has also been unreliable across engine libraries since mid-2026, so treat it as the fallback.
Can I keep using WhatsApp on the phone afterwards?
Yes - that is the central difference from the official platform. Wapito links a companion device, exactly like WhatsApp Web, so the human keeps their app, their chats and their history while your automation works alongside them on the same number.
What happens if the session drops?
The channel webhook fires with the new state, the previous state and a reason. A transient engine restart reconnects by itself; a logout by the phone's owner or a ban does not, and both need a person. Branch on the reason rather than retrying blindly into a session that is gone.
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.