How to resolve a LID 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.
For PHP the identity work is two Guzzle GETs and a schema with two nullable-aware columns: the linked identity as the key, the phone as a nullable varchar. Guzzle's exception on a 404 has to be caught and treated as the normal case of an identity with no number, which is a different reflex from most PHP API code, where an exception means the request itself went wrong.
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
Look up the LID for a number you know
Given a phone number, fetch the linked identity it maps to. Doing this for your own known contacts up front means later group events resolve from cache instead of needing a lookup each time.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/contacts/+15551234567/lid', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP look up an identity for a known number with a GET on /contacts/ . rawurlencode($e164) . '/lid' and write $data['lid'] beside the number in your contacts table; run it once over your list from the CLI so events later resolve from your own table. rawurlencode turns the leading plus into %2B, which the API decodes on its side.
API reference for this stepResolve a LID that arrived in an event
Group and channel events increasingly carry a linked identity instead of a number. Resolve it once, store both, and key your own records on the linked identity because that is the value that will keep arriving.
<?php $client = new \GuzzleHttp\Client(); $response = $client->request('GET', 'https://api.wapito.com/v1/contacts/lid/187264518273645@lid', [ 'headers' => [ 'Authorization' => 'Bearer wpt_YOUR_TOKEN', ], ]); echo $response->getBody();In PHP resolve an identity from an event with a GET on /contacts/lid/ . rawurlencode($lid), catching GuzzleHttp\Exception\ClientException for the 404 that means no mapping exists. Read the phone with $data['phone'] ?? null and store the identity either way; both results are complete rows. The array also carries name, push_name and is_business, worth saving in the same statement.
API reference for this stepHandle the case where it cannot be resolved
Sometimes there is no mapping to be had, and the event arrives with a null phone number. Design for that: a participant you can address and count but cannot match to a CRM row is still a participant.
Arrives on your webhook as
groups.participants.In PHP the Slim handler reads $payload['data']['participant'] and takes the phone with the null coalescing operator, then upserts by identity using INSERT ... ON DUPLICATE KEY UPDATE or the equivalent for your database. A participant without a number is still counted and still addressable by identity; write the UPDATE half with COALESCE so a later null never erases a number.
The whole script
Every step above in one runnable file. Save it as lid-to-phone.php, put your token in the environment, and run it.
<?php
// LID to Phone with the Wapito WhatsApp API.
//
// Resolve the identities you already know, resolve the ones that arrive in events, and design for the case where no phone number comes back.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// php lid-to-phone.php
require __DIR__ . '/vendor/autoload.php';
$baseUrl = 'https://api.wapito.com/v1';
$token = getenv('WAPITO_TOKEN');
// --- Look up the LID for a number you know ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/contacts/+15551234567/lid', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
// --- Resolve a LID that arrived in an event ---
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $baseUrl . '/contacts/lid/187264518273645@lid', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
]);
echo $response->getBody();
Receive the webhook
Slim 4 receiver for groups.participants, messages — 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', 'messages'];
/** 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();
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 NOT NULL constraint on the phone column turns the ordinary null-phone case into a database exception in the webhook handler and a retry storm from the API; make the column nullable, and return the 200 before the insert so a schema mistake shows up in your logs rather than in redelivery counts.
- Casting an identity with (int) to use it as an array key or an integer primary key silently changes it; keep it as a string and use a varchar primary key. PHP also converts a numeric string key to an integer inside a plain array, so a lookup map keyed by identity should be prefixed or held in an object.
- PDO::FETCH_ASSOC returns the phone column as null only when the driver is configured for it; older MySQL drivers with emulated prepares hand back an empty string, and a strict comparison against null then treats every unresolved participant as resolved with a blank number. Set ATTR_EMULATE_PREPARES to false or test with empty().
Frequently asked questions
Why did WhatsApp introduce linked identities?
To stop group and channel participation from exposing everybody's phone number to everybody else. It is a privacy improvement for users, and a real migration cost for anyone whose automation assumed the sender of a group message is always a number they can look up.
Is a LID stable over time?
It is stable for a given account, which is what makes it useful as a key. Treat it the way you would treat any external identifier: store it, index on it, and do not try to parse meaning out of it. If the account itself goes away, so does the identity.
Can I message someone using only their LID?
In contexts where the identity is the participant - inside a group, for instance - yes. Starting a fresh one-to-one conversation from an identity alone is not something to rely on, so resolve to a phone number when you need to open a new thread, and expect that to sometimes be impossible.
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.