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: 949

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

0.6.0 2026-10-15 11:42 UTC

This package is auto-updated.

Last update: 2026-10-25 01:24:56 UTC


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' => 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. 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-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).
  • 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.yml syncs the mirror's main on every push that touches sdk/php/**, which is what Packagist's dev-main follows.
  • 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.7.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.