Skip to content

Repository files navigation

Silon PHP SDK

Packagist VersionPHP VersionLicense

PHP client for the Silon messaging platform API — send messages on any channel (WhatsApp, SMS, email, push, web push, voice), manage CRM contacts and groups, run bulk campaigns, consume events, and verify webhooks. PHP 8.1+, zero runtime dependencies (native curl), PSR-4.

Installation

composer require silon/silon-sdk

Requires PHP >= 8.1 with the curl and json extensions. The SDK pulls in no runtime packages; the default transport is native curl. You can swap in your own HTTP client (Guzzle, a PSR-18 adapter, a mock) — see Custom HTTP client.

Quickstart

<?phpuseSilon\Client;
$client = newClient([
'apiKey' => 'sk_live_...', // Settings → API keys; or set SILON_API_KEY'workspace' => 'acme', // => https://acme.silon.tech; or SILON_WORKSPACE / SILON_BASE_URL
]);
$sent = $client->messages->send([
'channel' => 'whatsapp',
'to' => ['client_id' => 'cust_001'],
'content' => ['body' => 'Your order has shipped 📦'],
]);
echo$sent->id, '', $sent->status; // e.g. "9f3e..." "queued"

Configuration resolves in this order and fails fast at construction (a Silon\Exception\SilonException) if a required value is missing:

  • API keyapiKey, else SILON_API_KEY. Required.
  • Base URLbaseUrl, else SILON_BASE_URL, else workspacehttps://<workspace>.silon.tech, else SILON_WORKSPACE. Required; a trailing slash is stripped.
  • timeout (seconds, default 30), maxRetries (default 6), headers (added to every request).

Every operation takes an associative array of the API's own field names (snake_case) and returns a typed model whose properties mirror the API. Unknown response fields are preserved and readable via $model->new_field, so an older SDK keeps working as the API grows.

Sending

Three entry points, every channel: messages->send targets a single recipient via to; messages->sendBatch sends many independent, personalised messages in one call — inline rows via messages (max 500) or an uploaded CSV via file; broadcasts->create fans one piece of content out to an audience (a client group, explicit client ids, or an inline recipients list). On send, exactly one of to / audience is required, and on sendBatch exactly one of messages / file — the SDK validates client-side and throws SilonException before any network call.

// Approved WhatsApp template to a raw number$client->messages->send([
'channel' => 'whatsapp',
'to' => ['phone_number' => '+12025550123'],
'whatsapp_template' => [
'name' => 'order_confirmed',
'language' => 'en',
'variables' => ['body_1' => 'Sara', 'body_2' => 'ORD-42'],
],
'provider' => 'meta_cloud',
]);
// Personalised batch — every row is its own message (same shape as a send()// body minus `audience`); the top-level channel is the default, a row's own// channel wins. Max 500 rows, all-or-nothing: any invalid row 422s the whole// batch (`messages[3].to.phone_number`) and nothing is queued.$batch = $client->messages->sendBatch([
'channel' => 'sms',
'messages' => [
['to' => ['phone_number' => '+96550001234'], 'content' => ['body' => 'Sara, table for 2 at 7pm.']],
['to' => ['phone_number' => '+96550001235'], 'content' => ['body' => 'Omar, table for 4 at 9pm.']],
[
'channel' => 'email',
'to' => ['email' => 'lulu@example.com'],
'content' => ['subject' => 'Booking confirmed', 'body' => 'Lulu, table for 6 at 8pm.'],
],
],
]);
// Per-row envelopes come back in request order; poll each id individually.// Inline batches have no GET endpoint — the per-row ids are the tracking key.foreach ($batch->messages ?? [] as$row) {
$status = $client->messages->retrieve($row->id);
echo$row->id, '', $status->status, PHP_EOL;
}
// Batch from an uploaded CSV — request-level fields are row defaults, CSV// columns override per row ({{name}} renders from the row). The rows expand// asynchronously: the 202 is an aggregate (no per-row `messages`) and the// returned id IS the bulk batch id.$uploaded = $client->bulk->files->upload("phone_number,name\n+96550001234,Sara\n");
$fileBatch = $client->messages->sendBatch([
'file' => $uploaded->name,
'channel' => 'sms',
'content' => ['body' => 'Hello {{name}}'],
]);
echo$fileBatch->status, '', $fileBatch->row_count; // "queued" 1$detail = $client->bulk->retrieve((int) $fileBatch->id); // per-row status// Email broadcast to a client group$result = $client->broadcasts->create([
'channel' => 'email',
'audience' => ['type' => 'client_group', 'slug' => 'vip'],
'content' => ['subject' => 'We saved you a seat', 'body' => '<h1>Hello</h1>'],
]);
echo$result->target_count, '', $result->skipped_count;
// Why rows were skipped, itemised (skipped_count stays the sum):echo$result->skipped?->suppressed; // e.g. 2// Track it$broadcast = $client->broadcasts->retrieve($result->id);
foreach ($client->broadcasts->deliveries($result->id, ['limit' => 100])->autoPaging() as$delivery) {
echo$delivery->client_id, '', $delivery->status, PHP_EOL;
}

Every messages->send / messages->sendBatch / broadcasts->create / otp->send call carries an Idempotency-Key header (auto-generated UUIDv4 unless you pass idempotency_key), so automatic retries can never double-send. The key is a request header, never part of the JSON body. Channel-specific fields not covered by the documented keys can go in extra_body, which is merged into the JSON body last.

Scheduling and cancellation

Pass send_at — a DateTimeInterfacewith a UTC offset, or an ISO-8601 string (e.g. "2026-07-15T09:00:00+03:00") — on messages->send, broadcasts->create, or the file form of messages->sendBatch to schedule the send. It must be strictly in the future and at most 90 days ahead; naive date-times are rejected — otherwise the server answers 422 send-at-invalid. The response is the normal 202 envelope with status: "scheduled", and its id is stable across the lifecycle: messages->retrieve / broadcasts-> retrieve resolve it before dispatch ("scheduled") and after (the normal queued/sent lifecycle).

$scheduled = $client->messages->send([
'channel' => 'sms',
'to' => ['phone_number' => '+96550001234'],
'content' => ['body' => 'Doors open in an hour'],
'send_at' => newDateTimeImmutable('+1 hour'),
]);
echo$scheduled->status; // "scheduled"// Changed your mind? Allowed while still "scheduled":$canceled = $client->messages->cancel($scheduled->id);
echo$canceled->status; // "canceled" — never dispatches

messages->cancel($id) and broadcasts->cancel($id) return the same envelope types as their create calls (now showing status: "canceled") and emit a message.canceled / broadcast.canceled event. Cancel is idempotent by nature — repeating it answers 200 with the canceled envelope again (no Idempotency-Key is sent, and none is needed). Once the send has dispatched (or for an immediate send's id) the server answers 409 not-cancellable (ConflictException); an unknown id is a 404. send_at on sendBatch works with the file form only; with inline messages it is rejected 422 batch-invalid (schedule those individually via messages->send).

Suppressions

$client->suppressions manages the workspace do-not-contact list. A row is an address (E.164 phone or email, stored normalized so any formatting matches) optionally scoped to one channel — omit channel to suppress the address everywhere. It is enforced automatically on every send path:

  • Single-recipient sends (messages->send with to, otp->send) to a suppressed address reject 422 recipient-suppressed (UnprocessableEntityException).
  • Fan-outs (broadcasts->create, messages->sendBatch, legacy bulk) silently skip suppressed recipients — never an error. The 202 envelope itemises why rows were skipped in an additive skipped object (suppressed / wrong_channel / duplicate), with skipped_count staying the sum. On broadcasts skipped is null exactly when target_count is null (a scheduled audience resolving at dispatch).
$row = $client->suppressions->create([
'address' => '+96550001234',
'reason' => 'stop', // "manual" (default) | "unsubscribe" | "hard_bounce" | "stop"
]); // omit `channel` => suppressed everywhereforeach ($client->suppressions->list(['channel' => 'sms'])->autoPaging() as$s) {
echo$s->address, '', $s->reason, PHP_EOL;
}
$client->suppressions->delete($row->id);

Transactional/legal sends may bypass suppression with 'override_suppression' => true — single-recipient sends only, and the key must carry the suppressions:override scope (else 403 missing-scope).

CRM: clients and groups

$client->clients->create([
'client_id' => 'cust_001',
'first_name' => 'Sara',
'phone_number' => '+96512345678',
'default_channel' => 'whatsapp',
]);
// Lists are cursor-paginated (newest first); iterate one page or drain all:foreach ($client->clients->list(['limit' => 100])->autoPaging() as$contact) {
echo$contact->client_id, PHP_EOL;
}
$client->clients->update('cust_001', ['notes' => 'VIP']); // PATCH (partial)$client->clients->delete('cust_001');
$group = $client->clientGroups->create([
'name' => 'VIP',
'slug' => 'vip',
'client_ids' => ['cust_001', 'cust_002'], // membership (write-only)
]);

Templates

Slug-keyed message templates with an immutable version spine — the same rows sends render for 'template' => ['slug' => ...].

$tmpl = $client->templates->create([
'slug' => 'order-shipped',
'subject' => 'Your order is on its way',
'body_md' => 'Hi {{ client_name }}, order **{{ order_id }}** has shipped.',
]); // version 1$client->templates->update('order-shipped', ['body_md' => '...new copy...']); // mints v2// Pin a revision on a send (omit `version` for the latest):$client->messages->send([
'channel' => 'email',
'to' => ['email' => 'sara@example.com'],
'template' => ['slug' => 'order-shipped', 'version' => 1, 'variables' => ['client_name' => 'Sara']],
]);

Webhooks

Verify the Silon-Signature header on incoming deliveries — no HTTP client needed. Always verify against the raw request body, not re-encoded JSON.

useSilon\Webhooks;
useSilon\Exception\WebhookSignatureVerificationException;
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_SILON_SIGNATURE'] ?? '';
try {
$event = Webhooks::constructEvent($payload, $signature, $_ENV['SILON_WEBHOOK_SECRET']);
} catch (WebhookSignatureVerificationException$e) {
http_response_code(400);
exit;
}
if ($event->type === 'message.failed') {
error_log($event->data->recipient . ' failed: ' . $event->data->error);
}

Webhooks::verifySignature($payload, $header, $secret, $tolerance = 300) returns a bool (constant-time compare; $tolerance <= 0 skips the freshness check), and Webhooks::sign($secret, $payload, $timestamp = null) produces a valid header for tests. You can also read the same event stream over HTTP with $client->events->list() / $client->events->retrieve($id), and manage subscriptions with $client->webhookEndpoints (create returns a one-time secret; ->test($id) sends a signed ping; ->listAttempts($id) is the delivery ledger).

Pagination

Cursor endpoints return a Silon\CursorPage, which is iterable, count()-able, and indexable for the current page, and offers hasNextPage(), nextPage(), and a lazy autoPaging() generator that walks every page. nextPage() never follows the opaque next URL directly — it extracts only its query params and re-requests the original path against your configured base URL (proxy safety).

$page = $client->events->list(['type' => 'message.delivered', 'limit' => 50]);
foreach ($pageas$event) { /* this page only */ }
foreach ($page->autoPaging() as$event) { /* every page, fetched lazily */ }

Errors

Every non-2xx response raises a typed exception under Silon\Exception\, selected by status code, all extending ApiStatusException (itself a SilonException):

StatusException
400BadRequestException
401AuthenticationException
403PermissionDeniedException
404NotFoundException
409ConflictException
410GoneException
422UnprocessableEntityException
429RateLimitException (adds ->retryAfter seconds)
≥500InternalServerException

Each carries ->statusCode, ->requestId (from X-Request-Id), ->errorType, ->errors (a list of ErrorDetail with code / detail / attr), ->retryable (the body's verbatim retryable flag, null when absent — true iff retrying the same request could ever succeed), and ->body (the parsed JSON, useful for shapes like the OTP-verify failure's remaining_attempts). Transport failures raise ApiConnectionException (timeouts: ApiTimeoutException, a subtype).

useSilon\Exception\UnprocessableEntityException;
useSilon\Exception\RateLimitException;
useSilon\Exception\ApiStatusException;
try {
$client->messages->send([...]);
} catch (UnprocessableEntityException$e) {
echo$e->errors[0]->attr, ': ', $e->errors[0]->detail;
} catch (RateLimitException$e) {
sleep((int) ceil($e->retryAfter ?? 1));
} catch (ApiStatusException$e) {
error_log("HTTP {$e->statusCode} ({$e->requestId}): {$e->getMessage()}");
}

Retries

The SDK retries automatically (default maxRetries = 6) with exponential backoff and jitter, honouring the server's Retry-After / RateLimit-Reset hint. A request is retried only when it is safe: the method is GET/HEAD/OPTIONS/PUT/DELETE or it carries an Idempotency-Key (so every send / sendBatch / broadcasts->create / otp->send is retry-safe), and the failure is a connection error/timeout or HTTP 429/500/502/503/504. Other POST/PATCH requests are never retried. The same Idempotency-Key is replayed on every attempt, so a retried send cannot double-fire.

Test mode

sk_test_ API keys traverse the full pipeline (validation, scopes, throttles, idempotency, delivery rows, events) but never reach a provider and never bill; every affected envelope carries livemode: false. Delivery status is simulated a few seconds after the 202, so polling and webhooks behave realistically.

Magic recipients (test mode only, deterministic):

RecipientResult
+15005550001 / delivered@silon.testdelivered
+15005550002 / bounce@silon.testfailed (simulated provider error)
+15005550009 / suppressed@silon.testalways suppressed (single send → 422 recipient-suppressed; fan-out → skipped into skipped.suppressed)
any otherdelivered

In live mode a magic recipient is rejected 422 test-recipient-in-live. Test-mode OTPs are never dispatched; the magic code 000000 always verifies (and only it). Webhook endpoints carry a create-time livemode flag (default true): test events deliver only to livemode: false endpoints, live events only to livemode: true ones.

Custom HTTP client

The default transport is native curl. Inject any Silon\Http\HttpClientInterface to control TLS (private CAs, client certs), route through a proxy, or drive the SDK against a mock in tests:

useSilon\Client;
useSilon\Http\HttpClientInterface;
useSilon\Http\Request;
useSilon\Http\Response;
$client = newClient([
'apiKey' => 'sk_live_...',
'workspace' => 'acme',
'httpClient' => newclassimplements HttpClientInterface {
publicfunctionsend(Request$request, float$timeout): Response
{
// ... adapt to Guzzle, a PSR-18 client, an on-prem CA, etc.// Return a Response for any HTTP status; throw a// Silon\Http\TransportException only when no response is produced.
}
},
]);

Async

This SDK is synchronous, matching the platform idiom for PHP. Every call blocks until the response is ready and returns a typed model. (The Python SDK additionally ships an async client.)

Development

composer install
composer test# or: ./vendor/bin/phpunit

License

MIT

About

Official PHP SDK for the Silon messaging platform API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages