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

List what exists, create the channel with an honest description, store its id, and delete only when you have archived the history.

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 channel lifecycle is four Guzzle requests to the /newsletters paths, best run from a CLI script that lists before it creates. The protocol calls the broadcast object a newsletter, so that is the word in every decoded array, while channel in the rest of the API means your linked number; keep the two apart in variable names and in your table names too.

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. List the channels this number owns

    Start from the live list so a create job does not make a second channel with the same name. Channels are called newsletters in the protocol, which is the vocabulary the API uses.

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

    In PHP fetch the list with a GET and build a lookup with array_column($data['newsletters'], 'id', 'name') so the create step is an isset() on the name; the list is short for one number and needs no paging. Normalise the name with mb_strtolower before the lookup so a differently cased title is not treated as new; pass role=owner in the query.

    API reference for this step
  2. Create the channel

    Give it a name and a description that say plainly what will be published and how often. Followers cannot reply, so the description is the only place to set expectations before someone follows.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('POST', 'https://api.wapito.com/v1/newsletters', [
      'body' => '{"name":"Acme Release Notes","description":"Every shipped change, once a week.","picture":"https://acme.example/brand/channel-cover.png"}',
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
        'Content-Type' => 'application/json',
      ],
    ]);
    
    echo $response->getBody();

    In PHP create with a POST whose body is json_encode(['name' => $name, 'description' => $description], JSON_UNESCAPED_UNICODE); a description over the limit raises a ClientException with a 400 you can read from $e->getResponse()->getBody(). The decoded reply carries the id and invite_link; write both to your table, and add a picture key with a media id when you have a logo.

    API reference for this step
  3. Read it back and store the id

    The channel id ends in its own suffix and is the recipient you post to later. Store it as a string alongside the invite link, which is what you actually publish.

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

    In PHP read the channel back with a GET on /newsletters/ . $id and keep $data['id'] as a string in a varchar column; the id ends in its own suffix and is the recipient for later posts. The invite link is the public value and belongs in a separate column your site reads; subscribers_count deserves its own column.

    API reference for this step
  4. Delete a channel you no longer run

    Deletion removes the channel for its followers too, so archive the posts you care about first. A channel that is finished but worth keeping is better left in place with a final post.

    <?php
    
    $client = new \GuzzleHttp\Client();
    
    $response = $client->request('DELETE', 'https://api.wapito.com/v1/newsletters/120363099887766554@newsletter', [
      'headers' => [
        'Authorization' => 'Bearer wpt_YOUR_TOKEN',
      ],
    ]);
    
    echo $response->getStatusCode();

    In PHP delete with a DELETE request only after archiving the posts, because followers lose the channel too. Catch GuzzleHttp\Exception\ServerException for the 501 engine_unsupported_feature some engines answer with, and treat it as a reported limitation rather than retrying; a 403 means the linked number is not the owner and no retry will change that.

    API reference for this step

The whole script

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

<?php
// Create Channel with the Wapito WhatsApp API.
//
// List what exists, create the channel with an honest description, store its id, and delete only when you have archived the history.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   php create-channel.php
require __DIR__ . '/vendor/autoload.php';

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

// --- List the channels this number owns ---
$client = new \GuzzleHttp\Client();

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

echo $response->getBody();

// --- Create the channel ---
$client = new \GuzzleHttp\Client();

$response = $client->request('POST', $baseUrl . '/newsletters', [
  'body' => '{"name":"Acme Release Notes","description":"Every shipped change, once a week.","picture":"https://acme.example/brand/channel-cover.png"}',
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();

// --- Read it back and store the id ---
$client = new \GuzzleHttp\Client();

$response = $client->request('GET', $baseUrl . '/newsletters/120363099887766554@newsletter', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getBody();

// --- Delete a channel you no longer run ---
$client = new \GuzzleHttp\Client();

$response = $client->request('DELETE', $baseUrl . '/newsletters/120363099887766554@newsletter', [
  'headers' => [
    'Authorization' => 'Bearer ' . $token,
  ],
]);

echo $response->getStatusCode();

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

  • A retry loop around the create request after a Guzzle ConnectException can make the channel twice; list again inside the catch and only resend when the name is still absent, because the first request may have completed after the socket dropped.
  • json_encode without JSON_UNESCAPED_UNICODE turns an accented channel name into \u escapes, and while the API decodes them, the diff in your logs is unreadable; set the flag once in a helper and use it for every body the script builds.
  • Passing the description through nl2br or htmlspecialchars because the same string is also shown on your website sends literal br tags and entity codes to WhatsApp, where they appear as text under the channel name; keep the stored value plain and escape only at the point where HTML is rendered.

Frequently asked questions

Why does the API call it a newsletter?

Because that is the object's name in the protocol and in every engine library. WhatsApp marketed the feature to users as Channels, but the wire format never changed. Wapito keeps the protocol name in the API so the field you see matches what the engine returns, and uses channel for your linked number.

Can I see who follows my channel?

No. Follower identities are hidden from the owner by design - you see a count, not a list. That is a real difference from a group, and it means a channel is a publishing tool rather than a contact-collection tool. Put a link in your posts if you need people to identify themselves.

How many channels can one number own?

WhatsApp does not publish a figure, and creating them in bulk is exactly the pattern that draws attention. Create the channels you will actually publish to, on a warmed-up number, and space the creations out rather than provisioning a batch in one afternoon.

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.