How to follow a channel in PHP with the Wapito WhatsApp API

Search or resolve a link, verify it is the right channel, page back through the history, then handle new posts from the webhook.

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.

With PHP the follow-and-bridge job is a set of Guzzle GETs from a CLI script plus a Slim route for new posts. The paged history read is a do-while over offsets, the processed ids live in a table rather than in a PHP array that dies with the process, and the resolution of an invite code is the step that keeps a customer from following a lookalike.

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. Search the directory

    Search returns public channels matching a term. Treat the results as candidates rather than as matches: names are not unique and a lookalike channel is a real risk when you are following on a customer's behalf.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/newsletters/find?q=release%20notes&country=GB&count=50', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP search with $client->request('GET', $url, ['query' => ['q' => $term]]) so Guzzle encodes the term, and treat the decoded list under 'newsletters' as candidates; names repeat, so pick by an explicit rule on subscribers_count, the verified flag or a known id. Log every candidate you rejected and why, because the rule will need tuning.

    API reference for this step
  2. Resolve an invite link

    If you already have a channel link, resolve its code to get the name, description and follower count before doing anything else. This is how you confirm you have the official channel and not an imitation.

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

    In PHP resolve a link with a GET on /newsletters/invite/ . rawurlencode($code) and compare $data['name'] and $data['subscribers_count'] with the values you expect before following. Catch GuzzleHttp\Exception\ClientException for a 404: an expired link is an ordinary answer, and the invite code is the part after the last slash of the public link.

    API reference for this step
  3. Read what has been published

    Page back through the channel's posts to seed your own archive or to catch up after an outage. Store the message ids so a re-run does not process the same post twice.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('GET', 'https://api.wapito.com/v1/newsletters/120363099887766554@newsletter/messages?count=50&offset=0', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getBody();

    In PHP page the history with a do-while that adds count to the offset until it reaches $data['total'], inserting each message id into a table with INSERT IGNORE or an ON CONFLICT clause so a re-run skips what it already stored. Keep the newest id in a settings row and page forward from it next time rather than from zero.

    API reference for this step
  4. React to new posts

    New posts arrive as message events from the channel id. Route them by that id so a bridge to another system knows which feed a post belongs to.

    Arrives on your webhook as messages.

    In PHP the Slim handler decodes the messages event and reads the sender id from $payload['data']; look it up in an array from id to destination and forward from a queued job after returning the 200. The handler itself must not call the destination system, and the job checks the ids table before posting so a redelivery is a no-op.

The whole script

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

<?php
// Follow Channels with the Wapito WhatsApp API.
//
// Search or resolve a link, verify it is the right channel, page back through the history, then handle new posts from the webhook.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php follow-channels.php
require __DIR__ . '/vendor/autoload.php';

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

// --- Search the directory ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/newsletters/find?q=release%20notes&country=GB&count=50', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Resolve an invite link ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/newsletters/invite/0029VaAbCdEfGhIjKlMnOp', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Read what has been published ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/newsletters/120363099887766554@newsletter/messages?count=50&offset=0', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

Receive the webhook

Slim 4 receiver for 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 = ['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();
Every event, its payload and the retry rules

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 search URL with string concatenation and no rawurlencode sends a term with spaces that the API rejects; pass the query option to Guzzle instead, which also encodes the ampersand a channel name may contain.
  • A processed-ids array kept in a PHP session or a static variable is gone at the end of the request, and the bridge reposts the archive; use a table with a unique index on the message id and let the database refuse the duplicate.
  • A do-while that reads $data['count'] as the page size and stops when it is smaller than the request's count ends one page early when the API caps the page below what you asked for; loop on the offset against total instead, and treat a missing total as one more page to fetch.

Frequently asked questions

Can I follow a private channel?

Only if you have its invite link, which is how private channels are shared in the first place. There is no way to discover a private channel through search, and resolving a code you were not given will simply fail. Treat a channel link like any other credential.

Do I get every post as a webhook?

New posts from channels the linked number follows arrive as message events keyed to the channel id, so yes for anything published after you follow. History is a separate problem: page back through the messages endpoint once, then rely on the webhook for everything after that.

Is it legal to republish what I read?

That is a copyright and terms question rather than an API one, and the answer depends on the publisher and your jurisdiction. Reading a public channel to trigger your own workflow is uncontroversial; republishing someone else's posts wholesale is a decision to take with your own legal advice.

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.