Search by

rmyndharis / openwa

rmyndharis

Official PHP SDK for OpenWA, the open-source WhatsApp API Gateway (not affiliated with WhatsApp or Meta)

Package info

github.com/rmyndharis/openwa-php

pkg:composer/rmyndharis/openwa

Statistics

Installs: 1 347

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

0.11.0 2026-11-10 10:52 UTC

This package is auto-updated.

Last update: 2026-11-10 11:38:53 UTC


README

This document describes SDK 0.11.0. messages->receipts requires OpenWA 0.28.0 or later. contacts->lid requires OpenWA 0.27.1 or later. groups->setMemberLabel and the match search key require OpenWA 0.27.0 or later. 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.

Responses are buffered even when an injected client sets stream to true, so JSON, binary and error bodies share the same read-failure handling.

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' => true to sendAudio to send a real WhatsApp voice note (PTT). Supply audio/ogg; codecs=opus audio for reliable playback; the server defaults the mimetype to that when ptt is 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, though the gateway may still complete the request: retry a send only with the same Idempotency-Key. Both extend OpenWAException, which covers API responses and timeouts only: a transport failure (a refused or reset connection, a DNS failure, a cut body) is a GuzzleHttp\Exception\TransferException, and an invalid argument (an empty or dot id, a bad raw path or idempotency key) an \InvalidArgumentException. 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 was turned away before it ran, so nothing was sent or applied; a send also sent nothing when its recipient lookup failed, its connection closed before the message was written, or its media or preview image URL could not be fetched through the session's proxy. Each of these frees a keyed send's 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.

A body that ends before the Content-Length it declares, such as a media download the gateway cut off when its store failed mid-read, is a failed transfer raised as a GuzzleHttp\Exception\TransferException: cURL and Guzzle 8 raise their own, and on Guzzle 7's stream handler, which returns such a body as if it were whole, the SDK raises a RequestException naming the request and the byte counts. That handler still returns two cut bodies without an error, because neither carries a length to compare with: a chunked body (the gateway sends a Content-Length with media whenever the store reports a size) and a compressed body the application asked for with its own Accept-Encoding default header.

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.

The signature covers the body only and carries no timestamp, so a captured delivery still verifies when it is sent again. Dedupe on $delivery['idempotencyKey'] (also the X-OpenWA-Idempotency-Key header): the gateway's own retries and an operator's redrive of a failed delivery reuse it, so a key you have already handled is the same event arriving again.

Notes

  • Use HTTPS in production — the API key is sent as X-API-Key and is bearer-equivalent. Over plaintext http:// to a non-localhost host the client writes a warning with error_log(); pass 'allowInsecureHttp' => true to skip it (for example on a private Docker network).
  • A list you build into a body (mentions, poll options, status recipients) must be a PHP list: json_encode writes an array whose keys have gaps, as array_unique and array_filter leave them, as an object, which the gateway answers with 400. Pass it through array_values(); the SDK does that itself for participant lists and the bulk messages list.
  • 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). A path with a . or .. segment (also after an encoded slash, as in ..%2F), or one that does not begin with /, throws InvalidArgumentException before anything is sent. An id with a . or .. part between slashes, such as ../x, is refused the same way.

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.yml syncs the mirror's main on every push to main that touches sdk/php/**, which is what Packagist's dev-main follows. A run started from another branch mirrors nothing, and a run whose sdk/php content is no longer that of the current main leaves the mirror to the newer run.
  • php-sdk-release.yml cuts 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:

  1. If the minor line changes, update extra.branch-alias.dev-main in composer.json (e.g. 0.1.x-dev → 0.2.x-dev) and land it on main. The release workflow refuses to publish when the alias does not match the tag.
  2. Tag that commit php-sdk-v<version> (e.g. php-sdk-v0.11.0) and push the tag. The SDK has its own version line — the monorepo's v* tags are the app version and never trigger an SDK release.
  3. The workflow runs the test suite, then tags the mirror <version> (no v prefix, 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.