How to check a number in PHP with the Wapito WhatsApp API

Normalise to international format, check in modest batches, and store every result with its date so you never check the same number twice.

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 number check is a CLI script that normalises with libphonenumber-for-php, posts chunks with Guzzle, writes every result with its date, and reads the usage endpoint between chunks. It must not run under a web request: a long list is many sequential calls, and the execution limit would end it before the allowance does. Run it from cron or a Symfony console command instead.

Before you start

  • composer require guzzlehttp/guzzle
  • getenv('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

  1. Normalise the numbers first

    Convert to international format with a real phone-number library before you check anything. A number with a national trunk prefix left on is a different number, and checking it wastes an allowance and teaches you nothing.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/contacts/+15551234567/exists', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP a single lookup is a GET on /contacts/ . rawurlencode($e164) . '/exists' after PhoneNumberUtil::getInstance()->format($number, PhoneNumberFormat::E164); rawurlencode keeps the plus sign as %2B, which the API decodes. The decoded array carries exists as a boolean and the jid the number maps to. Use the batch endpoint for anything longer than a handful of numbers.

    API reference for this step
  2. Check a batch

    Send a modest batch rather than one request per number. The response tells you, per number, whether an account exists and what identity it maps to, which is the part worth storing.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/contacts/check', [
      'body' => '{"phones":["+15551234567","+15559876543","+15550000000"]}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP post a chunk with json_encode(['phones' => $chunk]) as the body and foreach over $data['results'], which has one row per number with phone, the exists flag and the jid; insert each row with the date before the next chunk, and compare $data['checked'] with count($chunk) to spot dropped rows. Catch ClientException for a 429 and stop rather than retry.

    API reference for this step
  3. Store the result and watch the allowance

    Write each answer back to your own database with the date you checked, then read the usage endpoint before the next batch. Re-checking numbers you already know about is the easiest way to burn an allowance and attract attention at the same time.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/usage?from=2026-09-01&to=2026-09-15', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP read /usage before each chunk and compute the headroom as $usage['limits']['number_checks_per_day'] minus $usage['totals']['number_checks']; when it is smaller than count($chunk), exit non-zero with the shortfall in the message so cron's mail tells you to run again tomorrow. Posting a batch anyway only produces quota_exceeded, and that response still counts as an API request.

    API reference for this step

The whole script

Every step above in one runnable file. Save it as check-number.php, put your token in the environment, and run it.

<?php
// Check Number with the Wapito WhatsApp API.
//
// Normalise to international format, check in modest batches, and store every result with its date so you never check the same number twice.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php check-number.php
require __DIR__ . '/vendor/autoload.php';

$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');

// --- Normalise the numbers first ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/contacts/+15551234567/exists', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Check a batch ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/contacts/check', [
  'body' => '{"phones":["+15551234567","+15559876543","+15550000000"]}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Store the result and watch the allowance ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/usage?from=2026-09-01&to=2026-09-15', [
  '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();
Every event, its payload and the retry rules

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

  • PhoneNumberUtil::parse throws NumberParseException on a malformed row; a foreach without a try block dies on the first one, so wrap the parse and write the failures to a separate list with the line number, then review that list by hand before the next run.
  • Storing numbers in an integer column drops the plus sign and overflows on 32-bit builds; use a varchar column and keep the E.164 string. The same applies to the jid the response carries, which has a suffix and is never numeric, and to the date column, which should be a real DATE so the re-check query can compare it.
  • A retry loop around the batch request that catches every ClientException re-sends a quota-limited batch and wastes the reset window; only retry on ConnectException, and let a 429 end the run so the next cron entry starts against a fresh allowance.

Frequently asked questions

How many numbers can I check per day?

Your plan sets an explicit daily allowance, and the API tells you how much is left through the usage endpoint. The harder limit is behavioural: even inside your allowance, checking a large list in a short burst is the pattern that draws attention, so spread it out.

Why is this riskier than sending a message?

Because a check involves no relationship at all. Sending a message to someone who wrote to you is normal behaviour; asking the network about thousands of numbers you have never contacted is what a scraper does. The abuse systems weight that difference heavily, and so does Wapito's metering.

Does a check tell me the person's name?

No. It tells you whether an account exists and gives you the identity you would address, nothing more. Profile details are a separate call with their own privacy rules, and a contact who has restricted their profile will not reveal a name or a photo to a stranger's number.

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.