PHP client for the Sendly SMS API (https://api.sendly.link).
- Zero Composer runtime dependencies (transport is based solely on
ext-curl), strict types,readonlyresult classes. - PHP 8.2+.
- No automatic retries: a retried request could send an SMS twice, so the client never retries requests on its own. If you retry sending on the application side, make sure first that the previous attempt definitely failed before the request was accepted.
- PHP >= 8.2 with the
curlandjsonextensions.
composer require sendly/php-clientThe package is not yet published on Packagist – until then you can install it
directly from the repository by adding this to your project's composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/sendly-link/php-client"
}
],
"require": {
"sendly/php-client": "^2.0"
}
}| Setting | Constructor argument | Environment variable | Default value |
|---|---|---|---|
| API token | $token |
SENDLY_TOKEN |
– (required) |
| Base URL | $baseUrl |
SENDLY_BASE_URL |
https://api.sendly.link |
| HTTP timeout (s) | $timeout |
– | 30.0 |
Arguments passed explicitly to the constructor take precedence over
environment variables. Every SENDLY_* variable has a legacy fallback: when
SENDLY_TOKEN / SENDLY_BASE_URL is not set, ACTIO_TOKEN /
ACTIO_BASE_URL is read instead; when both are set, SENDLY_* wins. A
missing token (empty argument and no environment variables) raises
SendlyValidationException. The token is generated in the Sendly client
panel (or provided by customer support).
use Sendly\Client\SendlyClient;
$client = new SendlyClient('your-api-token'); // or set SENDLY_TOKEN
$result = $client->sendSms(from: '48732129000', to: '48732129001', body: 'Test Sendly');
echo $result->messageId; // unique message id, e.g. "a906cff7719bd889"Equivalent in curl:
curl -X POST https://api.sendly.link/api/sms \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": "48732129001", "body": "Test Sendly"}'The message_id field (snake_case in the API) is exposed throughout the
client as messageId (single send, sendSmsMulti result entries, and
NOTIFICATION webhook events).
$results = $client->sendSmsMulti(
from: '48732129000',
to: ['48732129001', '48732129002'],
body: 'Test Sendly',
);
foreach ($results as $item) {
echo $item->number . ' ' . $item->messageId . PHP_EOL;
}Equivalent in curl:
curl -X POST https://api.sendly.link/api/sms-multi \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": ["48732129001", "48732129002"], "body": "Test Sendly"}'to must contain 1–100 unique numbers. Server-side validation works on an
"all or nothing" basis: every number must be a valid Polish mobile number,
and the numbers must be unique – otherwise the entire request is rejected.
This API method must additionally be enabled for your token by Sendly
customer support.
The Sendly SMS API sends incoming messages via POST, as well as (once
enabled) delivery notifications, to the webhook address configured in the
client panel. Delivery is a single attempt, redirects are not followed, and
no authentication is performed against your webhook; requests originate from
the current IP address of the api.sendly.link host.
Webhook::parse accepts the raw request body as a string or decoded JSON
as an array, and returns a typed model:
use Sendly\Client\DeliveryNotification;
use Sendly\Client\IncomingMessage;
use Sendly\Client\Webhook;
$event = Webhook::parse(file_get_contents('php://input'));
if ($event instanceof IncomingMessage) {
// {"type": "MESSAGE", "from": ..., "to": ..., "body": ...}
printf("SMS from %s to %s: %s\n", $event->from, $event->to, $event->body);
} elseif ($event instanceof DeliveryNotification) {
// {"type": "NOTIFICATION", "message_id": ..., "status": "DELIVERED" | "ERROR"}
printf("Message %s is %s\n", $event->messageId, $event->status->value);
}Example payloads:
{"type": "MESSAGE", "from": "48732129000", "to": "48732129001", "body": "Test sms"}
{"type": "NOTIFICATION", "message_id": "a906cff7719bd889", "status": "DELIVERED"}Invalid payloads and unknown type/status values raise
SendlyValidationException. Note that IncomingMessage::$from can be an
alphanumeric sender name (a sender field override), not just a string of
digits. The delivery status is the DeliveryStatus enum (DELIVERED /
ERROR).
All library errors extend SendlyException (which extends
RuntimeException):
| Exception | When |
|---|---|
SendlyValidationException |
Invalid input detected client-side, before any HTTP request is sent; also invalid webhook payloads. |
SendlyApiException |
The API responded with an error status (403 authorization problem, 422 validation problem). |
SendlyException |
Network errors (DNS, connection, timeout – with curl error info) and unexpected 200 responses with an invalid structure. |
Client-side validation: from/to must be digit strings 9–11 characters
long, body must not be empty, to in a multi-send must contain 1–100
unique numbers.
SendlyApiException carries statusCode, a parsed errors map (optional
keys token, from, to, body, each a list of messages), and rawBody
with the raw response body text – a diagnostic fallback that is always
available, regardless of the shape of the decoded JSON (or when the response
is not JSON at all).
use Sendly\Client\SendlyApiException;
use Sendly\Client\SendlyClient;
use Sendly\Client\SendlyValidationException;
$client = new SendlyClient('your-api-token');
try {
$client->sendSms(from: '48732129000', to: '48732129001', body: 'Test Sendly');
} catch (SendlyValidationException $exception) {
echo 'Bad input, nothing was sent: ' . $exception->getMessage();
} catch (SendlyApiException $exception) {
if ($exception->statusCode === 403) {
echo 'Authorization problem: ' . implode('; ', $exception->errors['token'] ?? [(string) $exception->rawBody]);
} elseif ($exception->statusCode === 422) {
foreach ($exception->errors as $field => $messages) {
echo $field . ': ' . implode('; ', $messages) . PHP_EOL;
}
} else {
echo $exception->getMessage();
}
}Every outgoing request to the API carries, alongside Authorization and
Content-Type:
User-Agent: sendly-php-client/<version> (PHP/<major>.<minor>), e.g.sendly-php-client/2.0.0 (PHP/8.2)– the version comes from theSendlyClient::VERSIONconstant;X-Request-Id: <uuid>– a fresh UUID v4 for every request (generator based onrandom_bytes, no external dependencies), a correlation identifier for contacting customer support and investigating suspected double sends (complementing the no-retry policy).
Outgoing messages are encoded as UCS2 and split every 60 characters. GSM7 encoding can optionally be enabled (via the client panel / customer support) with a single-message limit of up to 160 characters.
The client never retries failed requests – a retry could send an SMS twice. If you need retries, implement them yourself with idempotency in mind. HTTP redirects are not followed either.
3CX SMS API mode (/api/tcx) is mutually exclusive with the REST SMS API and
is not implemented in this client.
# Composer (if not installed globally):
curl -sS https://getcomposer.org/installer | php -- --install-dir=. --filename=composer.phar
php composer.phar install
php composer.phar test # or: vendor/bin/phpunitTests run against a local HTTP stub server (php -S 127.0.0.1:<port>) and
never call the real API; the real curl transport is fully exercised in the
process.
MIT – Copyright (c) 2026 ACTIO. See LICENSE.