rmyndharis / openwa
Official PHP SDK for OpenWA, the open-source WhatsApp API Gateway (not affiliated with WhatsApp or Meta)
Requires
- php: >=8.1
- guzzlehttp/guzzle: ^7.9
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
This document describes SDK 0.7.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the event.response webhook event name require OpenWA 0.25.0 or later.
Official PHP SDK for OpenWA, the open-source WhatsApp API Gateway. OpenWA is an independent project, not affiliated with or endorsed by WhatsApp or Meta.
A synchronous client built on Guzzle, PSR-4 autoloaded.
Install
composer require rmyndharis/openwa
Requires PHP 8.1+ and Guzzle 7.9+ or 8.x. The namespace is OpenWA\.
Usage
<?php require 'vendor/autoload.php'; use OpenWA\Client; $client = new Client([ 'baseUrl' => 'https://your-gateway.example.com', 'apiKey' => 'owa_k1_…', ]); // Sessions are addressed by the UUID that create() returns, not by name. Create a session once; // afterwards, find its id with $client->sessions->list(['name' => 'my-session']). $session = $client->sessions->create(['name' => 'my-session']); $client->sessions->start($session['id']); // Link the account before sending: scan sessions->getQrCode (or use sessions->requestPairingCode for a number with no linked session), // then wait for status 'ready'. An unlinked session answers the send with 409. $result = $client->messages->sendText($session['id'], [ 'chatId' => '628123456789@c.us', 'text' => 'Hello from the OpenWA PHP SDK!', ]); echo $result['messageId'];
For tests, inject a Guzzle client whose handler is a MockHandler — no network, no global state:
$client = new Client([ 'baseUrl' => 'http://localhost', 'apiKey' => 'k', 'httpClient' => $mockGuzzleClient, ]);
The SDK timeout (30 seconds by default) applies to every request, including those sent through an injected client, and overrides that client's own timeout. On Guzzle 8 it is also sent as read_timeout, the idle bound of Guzzle's stream handler (the one in use when ext-curl is missing, or when you inject a StreamHandler), so it replaces a read_timeout set on the injected client, and 0 removes that bound. The SDK has no separate idle-timeout setting: a stricter idle bound means a lower timeout, which also limits the whole call except on Guzzle 7's stream handler, where it bounds each wait instead (see Errors). cURL handlers ignore read_timeout, and Guzzle 7 leaves the injected client's value alone.
The optional defaultHeaders map accepts strings, scalar values, null, and non-empty lists of those values. Numbers, booleans, and null are converted to text. An empty list is rejected by header name before any request is sent because Guzzle 8 cannot represent a header with zero values. Use '' for an empty header value.
Messaging
Voice notes: pass
'ptt' => truetosendAudioto send a real WhatsApp voice note (PTT). Supplyaudio/ogg; codecs=opusaudio for reliable playback; the server defaults the mimetype to that whenpttis set without one.
Errors
A non-2xx response throws a typed OpenWA\Exceptions\OpenWAApiException subclass —
OpenWAAuthException (401), OpenWAForbiddenException (403), OpenWANotFoundException (404),
OpenWAConflictException (409), OpenWARateLimitException (429), OpenWANotImplementedException
(501), OpenWAServiceUnavailableException (503) — each exposing getStatus() and the parsed
getBody(). A timeout throws OpenWATimeoutException. 503 means the gateway could not complete the
request right now: the path to WhatsApp failed, the gateway is draining or short of a dependency,
or, on Baileys, WhatsApp throttled (code 429) or timed out (code 408) the request. Back off and
retry later instead of at once, because repeating a throttled request straight away prolongs the
throttle; a catalog 503 can persist because WhatsApp may never answer that query, so bound any
retry. A 429 from the global rate limiter lifts when its window expires (seconds for the per-second
tier, up to an hour for the hourly tier by default), and getRetryAfterSeconds() carries its
Retry-After header. A 429 whose getErrorCode() is "SEND_PACING_LIMITED" is usually not
transient: do not retry it before getRetryAfterSeconds(), which then comes from the body: a few
seconds when only sends still in flight caused it, the rest of the failure breaker's cooldown
(SEND_PACING_BREAKER_COOLDOWN_MS, 15 minutes by default) after a run of send failures, otherwise
up to the next UTC day. getHeaders() returns the response headers. A 503 does not always prove a
write was never carried out: when WhatsApp timed out, the change may still have been applied, so
re-read the state before repeating it. A throttle on a send or a single write is the exception: it
was turned away before it ran, so nothing was sent or applied, and a keyed send frees its
Idempotency-Key. A group settings update with several fields applies them one at a time, so a 503
on a later field, a throttle included, leaves the earlier ones applied; its message lists them after
already applied:, so re-read the settings before retrying, or send one field per request. In a
routed deployment a forward that fails before reaching the owner node answers 503, one that fails
after the request reached it answers 502 or 504, and a 503 from the owner itself is relayed
unchanged.
Guzzle's stream handler, which Guzzle uses on a host without ext-curl, throws a plain Guzzle exception
when a gateway stays silent past the timeout, before the response headers on either Guzzle major and, on
Guzzle 7, in the middle of the body, where a silence can last up to about twice the timeout before it
is cut off. On Guzzle 7 that handler bounds each wait, not the whole call, so a body that keeps trickling
bytes is not cut off and arrives however long it takes; cURL, and Guzzle 8's stream handler, bound the
whole call. The SDK turns such a failure into OpenWATimeoutException when it has no
response and lasted at least the whole timeout, so a failure of that length from another cause, such as a
slow name-resolution error, is reported as a timeout too. A timeout of 0 has no bound to compare with, and
on Guzzle 7 the wait for the response headers is still ended by PHP's default_socket_timeout (60 seconds by
default), which surfaces as a GuzzleHttp\Exception\ConnectException: catch
GuzzleHttp\Exception\TransferException as well as OpenWATimeoutException if the application sets
timeout to 0 on such a host.
use OpenWA\Exceptions\OpenWANotFoundException; try { $client->sessions->get('00000000-0000-0000-0000-000000000000'); } catch (OpenWANotFoundException $e) { echo $e->getStatus(); // 404 }
Receiving webhooks
A webhook configured with a secret signs each delivery in its X-OpenWA-Signature header. Check it
with WebhookSignature::verify against the raw request body, exactly as received, and decode the
JSON only after the check passes: a re-encoded body can differ byte for byte and will not verify.
The helper returns false for a missing, malformed or non-matching signature.
use OpenWA\WebhookSignature; $raw = file_get_contents('php://input'); if (!WebhookSignature::verify($raw, $_SERVER['HTTP_X_OPENWA_SIGNATURE'] ?? null, $secret)) { http_response_code(401); exit; } $delivery = json_decode($raw, true); // Process $delivery['event'] and $delivery['data'] here.
Notes
- Use HTTPS in production — the API key is sent as
X-API-Keyand is bearer-equivalent. Over plaintexthttp://to a non-localhost host the client writes a warning witherror_log(); pass'allowInsecureHttp' => trueto skip it (for example on a private Docker network). - The SDK does not retry, and never follows redirects (so the key is never re-sent to a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a reverse proxy) is preserved.
- Escape hatch for endpoints the SDK does not wrap:
$client->request($method, $path, $query, $body).
Releasing
Packagist installs this SDK from the mirror repository
rmyndharis/openwa-php, not from the
monorepo — Composer needs composer.json at a repository root and does not
support subdirectories. Two workflows keep that mirror correct:
split-php-sdk.ymlsyncs the mirror'smainon every push that touchessdk/php/**, which is what Packagist'sdev-mainfollows.php-sdk-release.ymlcuts a versioned release by pushing a tag to the mirror. Packagist derives versions from that repository's tags, so a tag in this monorepo alone publishes nothing.
There is no version field in composer.json, and there should not be —
Composer takes the version from the tag, and a hardcoded field drifts. The tag
is the version.
Cutting a release:
- If the minor line changes, update
extra.branch-alias.dev-mainincomposer.json(e.g.0.1.x-dev→0.2.x-dev) and land it onmain. The release workflow refuses to publish when the alias does not match the tag. - Tag that commit
php-sdk-v<version>(e.g.php-sdk-v0.7.0) and push the tag. The SDK has its own version line — the monorepo'sv*tags are the app version and never trigger an SDK release. - The workflow runs the test suite, then tags the mirror
<version>(novprefix, matching the existing tags there). Packagist picks it up from its GitHub hook within a minute or two.
A released version is effectively immutable. Packagist caches a tag's contents, so moving one does not reliably reach anyone who already resolved it. The workflow refuses to overwrite an existing mirror tag — bump the version instead of re-tagging.
Requires the PHP_SDK_SPLIT_TOKEN secret, the same one the mirror workflow
uses. Unlike the mirror workflow, which skips with a notice when the token is
absent, the release fails: a release that silently publishes nothing looks
exactly like one that worked.
License
MIT
Send idempotency
The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.
Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically.
$client->messages->sendText($sessionId, ['chatId' => '628123@c.us', 'text' => 'Hello'], 'order-123');
The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 whose getErrorCode() is IDEMPOTENCY_KEY_IN_PROGRESS means the first request is still running, so retry with the same key later; a 409 IDEMPOTENCY_OUTCOME_UNKNOWN means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (getErrorCode() tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.