Search by

multiwa / multiwa-php

ribato22

Official PHP SDK for MultiWA, the self-hosted WhatsApp API gateway with a built-in team inbox.

v2.1.0 2026-11-06 15:10 UTC

This package is auto-updated.

Last update: 2026-11-07 11:50:02 UTC


README

Official PHP SDK for MultiWA, the self-hosted WhatsApp API gateway with a built-in team inbox.

Requirements

  • PHP 8.1 or higher
  • Guzzle 7.5 or higher (installed with the SDK)

Installation

composer require multiwa/multiwa-php

To try a change that is not released yet, install from a clone of the MultiWA repository with a Composer path repository:

{
  "repositories": [{ "type": "path", "url": "../MultiWA/packages/sdk-php" }],
  "require": { "multiwa/multiwa-php": "*" }
}

Quick start

<?php

use MultiWA\MultiWA;

// baseUrl must include the API prefix /api/v1:
//   Local dev: http://localhost:3000/api/v1
//   Docker:    http://localhost:3333/api/v1
$client = new MultiWA(
    baseUrl: 'https://your-multiwa-instance.com/api/v1',
    apiKey: 'your-api-key',
);

$result = $client->messages()->sendText(
    profileId: 'your-profile-id',
    to: '6281234567890',
    text: 'Hello from the MultiWA PHP SDK!',
);

echo $result->messageId;

The constructor also takes timeout (seconds, default 30) and handler, a Guzzle handler for tests (see Testing your code).

Messages

to is a phone number with country code (6281234567890; +, spaces and dashes are fine) or a WhatsApp JID (…@s.whatsapp.net, …@c.us, a group …@g.us). Media goes by public url or by base64.

$messages = $client->messages();

$messages->sendText($profileId, $to, 'Hello!');
$messages->sendText($profileId, $to, 'Yes', quotedMessageId: $whatsappMessageId); // reply with a quote
$messages->sendImage($profileId, $to, url: 'https://example.com/a.jpg', caption: 'Look');
$messages->sendVideo($profileId, $to, url: 'https://example.com/a.mp4', caption: 'Watch');
$messages->sendAudio($profileId, $to, url: 'https://example.com/a.ogg', ptt: true); // ptt: voice note
$messages->sendDocument($profileId, $to, 'report.pdf', url: 'https://example.com/report.pdf', caption: 'Q3');
$messages->sendLocation($profileId, $to, -6.2088, 106.8456, name: 'Monas', address: 'Jakarta');
$messages->sendContact($profileId, $to, 'Budi', '6281111111111', email: 'budi@example.com');
$messages->sendPoll($profileId, $to, 'Lunch?', ['Rice', 'Noodles'], allowMultipleAnswers: false);
$messages->sendReaction($profileId, $result->messageId, '👍'); // MultiWA's message id; '' removes it

// Stored messages, newest first (rows as the API returns them)
$rows = $messages->list($profileId, limit: 50, offset: 0, type: 'text', direction: 'incoming');

Every send returns a MultiWA\Models\MessageResult:

Property
success false when sending failed; error says why
messageId MultiWA's id of the stored message, the one sendReaction() takes
conversationId
status sent, queued (a worker sends it) or failed
waMessageId WhatsApp's id of the message once WhatsApp accepted it (null while queued); WebSocket message:ack events use this id
error

Contacts

$contacts = $client->contacts();

// Creates the contact, or merges tags and metadata into the one with this phone
$contact = $contacts->create($profileId, '6281234567890', name: 'Budi', tags: ['vip'], metadata: ['source' => 'web']);
$contact = $contacts->get($contact->id);

$page = $contacts->list($profileId, search: 'bud', tags: ['vip', 'new'], limit: 50, offset: 0);
// ['contacts' => Contact[], 'total' => 1, 'limit' => 50, 'offset' => 0]

$contacts->update($contact->id, name: 'Budi S', tags: ['vip']); // tags and metadata replace the old ones
$contacts->delete($contact->id); // ['success' => true]

Broadcasts

A broadcast is created as a draft and sends only when you start or schedule it. The profile's send limits pace it.

$broadcasts = $client->broadcasts();

$broadcast = $broadcasts->create(
    profileId: $profileId,
    name: 'October promo',
    message: 'Hello! Our promo starts today.',            // a text, or a message array (below)
    recipients: ['type' => 'tags', 'value' => ['vip']],
    settings: ['delayMin' => 5000, 'delayMax' => 15000],   // optional
);

$broadcasts->update($broadcast->id, name: 'October promo v2');   // drafts only
$broadcasts->schedule($broadcast->id, new DateTimeImmutable('2026-11-01 09:00', new DateTimeZone('Asia/Jakarta')));
$broadcasts->start($broadcast->id);   // ['success' => true, 'message' => 'Broadcast started', 'recipientCount' => 42]
$broadcasts->pause($broadcast->id);   // the Broadcast, status paused
$broadcasts->resume($broadcast->id);  // ['success' => true, 'message' => 'Broadcast resumed']
$broadcasts->cancel($broadcast->id);  // the Broadcast, status failed
$broadcasts->delete($broadcast->id);  // ['success' => true]; not while it is running

$broadcasts->get($broadcast->id);
$broadcasts->list($profileId, status: 'running'); // Broadcast[]
$broadcasts->stats($broadcast->id);
// ['id' => …, 'status' => 'completed', 'stats' => ['total' => 10, 'sent' => 9, 'delivered' => 8,
//  'read' => 5, 'failed' => 1, 'pending' => 0, 'successRate' => 80, 'progress' => 100], 'duration' => 600, …]
  • message: a string is sent as text. For media pass an array: ['type' => 'image', 'url' => 'https://…', 'text' => 'caption', 'mimetype' => 'image/jpeg']; the types are text, image, video, audio and document (a document also takes filename).
  • recipients: ['type' => 'tags', 'value' => [tag, …]] (contacts with any of the tags), ['type' => 'contacts', 'value' => [contact id or phone, …]], or ['type' => 'all'].
  • settings: delayMin and delayMax (ms between messages), batchSize, retryFailed and retryAttempts.
  • A Broadcast has id, profileId, name, status (draft, scheduled, running, paused, completed, failed), message, recipients, settings, stats (total, pending, sent, delivered, read, failed) and the scheduledAt, startedAt, completedAt, createdAt and updatedAt dates.

Profiles

A profile is one WhatsApp number.

$profiles = $client->profiles();

$profile = $profiles->create($workspaceId, 'Sales', engine: 'whatsapp-web-js'); // engine is optional
$profile = $profiles->get($profile->id);           // $profile->displayName, ->phoneNumber, ->status, …
$all = $profiles->list($workspaceId);              // Profile[] with messageCount; workspaceId is optional
$profiles->update($profile->id, name: 'Sales ID'); // also engine, webhookUrl, webhookSecret
$profiles->status($profile->id);
// ['id' => …, 'name' => 'Sales', 'status' => 'connected', 'phone' => '628…', 'lastConnectedAt' => …, 'engineConnected' => true]
$profiles->disconnect($profile->id);               // ['status' => 'disconnected']
$profiles->delete($profile->id);                   // ['success' => true]; also deletes its messages and contacts

Linking a number (QR code)

$profiles->connect($profileId); // ['status' => 'connecting', 'message' => 'Scan QR code to connect']

The QR code is not part of the REST API: it is pushed as the qr:update event of the WebSocket API. A Socket.IO client connects to https://your-multiwa-instance.com/ws with the API key, emits join with the profile id, and shows each event's qrCode (a data:image/png;base64,… URL) until it is scanned. The event reaches owners and admins only: a dashboard sign-in of one qualifies, and so does an API key of one with profiles:write (or no scope list). You can also scan it in the admin dashboard. Afterwards status() reports connected. A profile that is already linked answers ['status' => 'already_connected', …].

Webhooks

use MultiWA\Clients\WebhooksClient;

$webhooks = $client->webhooks();

$webhook = $webhooks->create($profileId, 'https://example.com/hook', ['message.received', 'message.sent']);
$secret = $webhook->secret; // generated by the server; keep it to verify deliveries

$webhooks->get($webhook->id);
$webhooks->list($profileId);                                      // Webhook[]
$webhooks->update($webhook->id, events: ['message.received'], enabled: false);
$webhooks->test($webhook->id);   // sends a signed `test` event: ['success' => true, 'message' => …]
$webhooks->delete($webhook->id); // ['success' => true]

// In your webhook endpoint: verify the signature over the RAW body
$ok = WebhooksClient::verifySignature(
    file_get_contents('php://input'),
    $_SERVER['HTTP_X_MULTIWA_SIGNATURE'] ?? null,
    $secret,
);

create() and update() also take headers, extra headers sent with every delivery (['X-Tenant' => 'acme']).

Calls

$client->calls() is in SDK 1.4.0 and later.

Incoming WhatsApp calls: the follow-up queue, the readiness of answering them in the dashboard, the recordings and the announcement (Calls answered in the dashboard).

$calls = $client->calls();

$page = $calls->list($profileId, outcome: 'answered');    // CallPage: ->calls, ->nextCursor
$calls->update($callId, followUp: 'done');                // unassign() removes the assignee
$calls->reply($callId, 'We will call you back');

// Owners and admins
$file = fopen('notice.mp3', 'rb');                        // recording needs the announcement first
$calls->uploadAnnouncement($profileId, $file);            // the SDK leaves your stream open
fclose($file);
$calls->updateSettings($profileId, answerInDashboard: true, recording: true);
$audio = $calls->getRecording($callId);                   // AudioFile: ->content, ->contentType
$calls->deleteRecording($callId);

Every calls method is in the PHP SDK guide. An API key acts with its user's role, and a key with a scope list needs the scopes in API keys. With Guzzle's cURL handler, the default, the client's timeout covers a whole download: a long recording may need a larger one. A refused setting throws an ApiException (409); getResponseBody()['reasons'] says why. Answering is for people signed in to the dashboard: follow calls with the call.* webhooks, or the WebSocket call:update event.

Other routes

request() calls any route of the REST API with the same authentication and error handling, and returns the decoded JSON:

$result = $client->request('POST', 'messages/otp', [
    'json' => ['profileId' => $profileId, 'to' => $to, 'text' => 'Your code is 563842', 'code' => '563842'],
]);

Errors

Everything the SDK throws extends MultiWA\Exceptions\MultiWAException:

Exception Thrown for Adds
MultiWAException No response (connection failed, timeout) or a success that is not JSON
ApiException Any 4xx or 5xx, e.g. 409 PROFILE_DISCONNECTED (sent while the profile is offline) getStatusCode(), getErrorCode(), getResponseBody()
ValidationException 400, 422 getErrors(): the validation messages
AuthenticationException 401 (bad or missing API key), 403 (not allowed, or not in your organization)
NotFoundException 404
RateLimitException 429 getRetryAt()

The four specific ones extend ApiException. getErrorCode() returns the API's machine-readable code when it sends one (PROFILE_DISCONNECTED, DAILY_LIMIT_REACHED, OTP_DELIVERY_FAILED, …), else null. A resource of another organization, or one that does not exist, usually answers 403: the API does not reveal which.

A 429 is the send gate protecting the number. Its code says which limit:

getErrorCode() Meaning getRetryAt()
DAILY_LIMIT_REACHED The profile's daily cap When the counter resets
COLD_DAILY_LIMIT_REACHED The cap for messages to chats that have not written to the number within the service window (24 hours by default) When the counter resets
COLD_CIRCUIT_OPEN Those messages are paused after repeated failed deliveries When they resume
REPEAT_SUPPRESSED The same text to the same chat moments ago When it may be sent again
null Too many API requests per minute null
use MultiWA\Exceptions\ApiException;
use MultiWA\Exceptions\MultiWAException;
use MultiWA\Exceptions\RateLimitException;
use MultiWA\Exceptions\ValidationException;

try {
    $result = $client->messages()->sendText($profileId, $to, 'Hello!');
    if (!$result->success) {
        echo "WhatsApp refused it: {$result->error}";
    }
} catch (RateLimitException $e) {
    echo "{$e->getErrorCode()}, retry after {$e->getRetryAt()?->format(DATE_ATOM)}";
} catch (ValidationException $e) {
    echo implode("\n", $e->getErrors());
} catch (ApiException $e) {
    echo "HTTP {$e->getStatusCode()} {$e->getErrorCode()}: {$e->getMessage()}";
} catch (MultiWAException $e) {
    echo "MultiWA unreachable: {$e->getMessage()}";
}

Testing your code

Pass a Guzzle handler as the last constructor argument to answer requests without a server:

use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;

$client = new MultiWA('https://wa.example.com/api/v1', 'test-key', handler: new MockHandler([
    new Response(201, [], '{"success":true,"messageId":"m1","conversationId":"c1","status":"sent"}'),
]));

Development

composer install
composer test     # PHPUnit
composer analyse  # PHPStan

The tests check every call against the API's route list (scripts/api-routes.snapshot.json), so run them inside the MultiWA repository; elsewhere that check is skipped.

License

MIT, see LICENSE.