Official PHP SDK for the Sendly SMS API.
- PHP 8.1+
- Composer
composer require sendly/sendly-php<?php
use Sendly\Sendly;
$client = new Sendly('sk_live_v1_your_api_key');
// Send an SMS
$message = $client->messages()->send(
'+15551234567',
'Hello from Sendly!'
);
echo $message->id; // "msg_abc123"
echo $message->status; // "queued"Before sending live SMS messages, you need:
-
Business Verification - Complete verification in the Sendly dashboard
- International: Instant approval (just provide Sender ID)
- US/Canada: Requires carrier approval (3-7 business days)
-
Credits - Add credits to your account
- Test keys (
sk_test_*) work without credits (sandbox mode) - Live keys (
sk_live_*) require credits for each message
- Test keys (
-
Live API Key - Generate after verification + credits
- Dashboard → API Keys → Create Live Key
| Key Type | Prefix | Credits Required | Verification Required | Use Case |
|---|---|---|---|---|
| Test | sk_test_v1_* |
No | No | Development, testing |
| Live | sk_live_v1_* |
Yes | Yes | Production messaging |
Note: You can start development immediately with a test key. Messages to sandbox test numbers are free and don't require verification.
$client = new Sendly('sk_live_v1_xxx', [
'baseUrl' => 'https://sendly.live/api/v1',
'timeout' => 60,
'maxRetries' => 5,
]);// Marketing message (default)
$message = $client->messages()->send(
'+15551234567',
'Check out our new features!'
);
// Transactional message (bypasses quiet hours)
$message = $client->messages()->send(
'+15551234567',
'Your verification code is: 123456',
'transactional'
);
// With custom metadata (max 4KB)
$message = $client->messages()->send(
'+15551234567',
'Your order #12345 has shipped!',
null, // messageType
['order_id' => '12345', 'customer_id' => 'cust_abc']
);
// Send from one of your owned numbers (or an alphanumeric sender ID).
// Omit `from` to use your default sender.
$message = $client->messages()->send([
'to' => '+15551234567',
'text' => 'Hello from our team!',
'from' => '+447111111111',
]);
echo $message->id;
echo $message->status;
echo $message->creditsUsed;// Basic listing
$messages = $client->messages()->list(['limit' => 50]);
foreach ($messages as $msg) {
echo $msg->to;
}
// With filters
$messages = $client->messages()->list([
'status' => 'delivered',
'to' => '+15551234567',
'limit' => 20,
'offset' => 0,
]);
// Pagination info
echo $messages->total;
echo $messages->hasMore;$message = $client->messages()->get('msg_abc123');
echo $message->to;
echo $message->text;
echo $message->status;
echo $message->deliveredAt?->format('Y-m-d H:i:s');// Schedule a message for future delivery
$scheduled = $client->messages()->schedule(
'+15551234567',
'Your appointment is tomorrow!',
'2025-01-15T10:00:00Z'
);
echo $scheduled->id;
echo $scheduled->scheduledAt;
// List scheduled messages
$result = $client->messages()->listScheduled();
foreach ($result as $msg) {
echo "{$msg->id}: {$msg->scheduledAt}\n";
}
// Get a specific scheduled message
$msg = $client->messages()->getScheduled('sched_xxx');
// Cancel a scheduled message (refunds credits)
$result = $client->messages()->cancelScheduled('sched_xxx');
echo "Refunded: {$result->creditsRefunded} credits";// Send multiple messages in one API call (up to 1000)
$batch = $client->messages()->sendBatch([
['to' => '+15551234567', 'text' => 'Hello User 1!'],
['to' => '+15559876543', 'text' => 'Hello User 2!'],
['to' => '+15551112222', 'text' => 'Hello User 3!'],
]);
echo $batch->batchId;
echo "Queued: {$batch->queued}";
echo "Failed: {$batch->failed}";
echo "Credits used: {$batch->creditsUsed}";
// Get batch status
$status = $client->messages()->getBatch('batch_xxx');
// List all batches
$batches = $client->messages()->listBatches();
// Preview batch (dry run) - validates without sending
$preview = $client->messages()->previewBatch([
['to' => '+15551234567', 'text' => 'Hello User 1!'],
['to' => '+447700900123', 'text' => 'Hello UK!'],
]);
echo "Total credits needed: {$preview->totalCredits}";
echo "Valid: {$preview->valid}, Invalid: {$preview->invalid}";// Auto-pagination with generator
foreach ($client->messages()->each() as $message) {
echo "{$message->id}: {$message->to}\n";
}
// With filters
foreach ($client->messages()->each(['status' => 'delivered']) as $message) {
echo "Delivered: {$message->id}\n";
}Send one MMS to 2-8 recipients (US/Canada only). Everyone shares a single
thread and replies fan out to all participants. Group messaging is an A2P
10DLC capability — the sending number must be an MMS-enabled, 10DLC-registered
number you own. Requires the group_mms feature (and enable_mms for media)
to be enabled for your account.
$group = $client->messages()->sendGroup([
'to' => ['+14155551234', '+14155555678'],
'text' => 'Hey team - quick sync at noon?',
// 'from' => '+15125550100', // optional; omit to use your default sender
// 'mediaUrls' => ['https://.../a.jpg'], // optional; text or mediaUrls required
// 'messageType' => 'transactional', // default; use 'marketing' for quiet hours
]);
echo $group['id']; // "msg_xxx"
echo $group['status']; // "sent" (or "delivered" when simulated)
echo $group['group_message_id']; // "grp_xxx" (present on live sends)Rewrite a draft into a single, polished SMS segment (≤160 characters) and get a
short explanation of what changed. Pass messageType to steer the rewrite; with
no text it generates a suitable message for that type instead. At least one of
text or messageType is required. Requires the ai_classification feature;
when AI is unavailable the original text is returned with an empty explanation.
$result = $client->messages()->enhance(
'hey come check out our sale this weekend',
'marketing'
);
echo $result['enhanced']; // polished, ≤160-char rewrite
echo $result['explanation']; // what changed and why
echo $result['model'] ?? ''; // model used, when availableList the phone numbers on your account, inspect one, make a number your default sender or keep a number scheduled for release, and release a number.
// List your numbers
$result = $client->numbers()->list();
foreach ($result['numbers'] as $n) {
echo "{$n['phoneNumber']} — {$n['status']} ({$n['phoneNumberType']})\n";
}
// Get a single number (includes `isDefault`)
$number = $client->numbers()->get('num_abc123');
echo $number['phoneNumber']; // "+15551234567"
echo $number['isDefault'] ? 'default sender' : 'not default';
// Make a number your workspace's default sender (must be active)
$client->numbers()->update('num_abc123', ['isDefault' => true]);
// "Keep this number": undo a scheduled period-end release
$client->numbers()->update('num_abc123', ['pendingCancellation' => false]);
// Release a number. A live paid purchase is cancelled at period end;
// anything else is released immediately.
$result = $client->numbers()->release('num_abc123');
if ($result['scheduled'] ?? false) {
echo "Releases at {$result['scheduledReleaseAt']}";
} else {
echo 'Released';
}Mint branded short links for a destination URL, list them with click analytics, and disable (kill) an individual link. Branded, owned-domain short links improve deliverability — carriers filter public shorteners — and give you click data.
Note: URL shortening is gated behind the
url_shortenerrollout flag and is not yet generally available; until the flag is enabled for your account the endpoints read as absent and calls throwNotFoundException(HTTP 404).
// Shorten a URL
$link = $client->links()->create('https://example.com/spring-sale?utm_source=sms');
echo $link['code']; // "Ab3xY7"
echo $link['shortUrl']; // "https://sendly.live/l/Ab3xY7"
// List your links with click counts
$result = $client->links()->list(['limit' => 20]);
foreach ($result['links'] as $l) {
echo "{$l['shortUrl']} -> {$l['destinationUrl']} ({$l['clickCount']} clicks)\n";
}
// Kill a link (its redirect returns 404 until re-enabled)
$client->links()->disable('Ab3xY7');
// Re-enable it
$client->links()->enable('Ab3xY7');Connect a number you own to WhatsApp, create Meta-reviewed message templates,
check 24-hour conversation windows, and send WhatsApp messages by passing
'channel' => 'whatsapp' to messages()->send().
Connecting a number is a one-time $19 setup (no monthly fee) and always ends
with a human step: the signup returns a connectUrl that a person must open in
a browser and log in with Facebook to link their WhatsApp Business Account.
Free-form text and media only deliver inside an open 24-hour window (the
recipient messaged you in the last 24h); an approved template works anytime.
Note: The WhatsApp channel is being rolled out gradually and is not yet generally available; until it is enabled for your account the endpoints read as absent and calls throw
NotFoundException(HTTP 404). WhatsApp writes (signup, templates, sends) require a live API key.
// 1. Connect a number ($19 one-time). A human must finish the connect URL.
$signup = $client->whatsapp()->signup->create('+15559876543');
echo $signup['connectUrl']; // hand this to a person to complete in a browser
// Poll until active
$status = $client->whatsapp()->signup->get($signup['id']);
echo $status['status']; // "initiated" -> "registering" -> "active"
// List connected senders
$result = $client->whatsapp()->senders->list();
foreach ($result['senders'] as $s) {
echo "{$s['phoneNumber']} ({$s['displayName']}) — {$s['status']}\n";
}
// Read and update a sender's business profile (what recipients see when
// they open your details in WhatsApp)
$profile = $client->whatsapp()->senders->getProfile('+15559876543');
echo $profile['displayName'];
echo $profile['about'];
$client->whatsapp()->senders->updateProfile('+15559876543', [
'about' => 'Fresh roasted coffee, delivered.', // max 139 chars
'description' => 'Small-batch roaster shipping nationwide.', // max 512
'website' => 'https://acme.example',
]);
// 2. Create a template (Meta reviews it, usually 24-48h)
$template = $client->whatsapp()->templates->create([
'sender' => '+15559876543',
'name' => 'order_shipped',
'language' => 'en_US',
'category' => 'UTILITY',
'body' => 'Hi {{1}}, your order {{2}} has shipped!',
'examples' => ['1' => 'Sam', '2' => '#4821'],
]);
echo $template['status']; // "PENDING"
// Fix a rejected template by editing it (template names are locked for
// ~30 days after deletion, so edit instead of delete + re-create)
$client->whatsapp()->templates->update($template['id'], [
'body' => 'Hi {{1}}, your order {{2}} is on its way!',
'examples' => ['1' => 'Sam', '2' => '#4821'],
]);
// List and delete templates
$result = $client->whatsapp()->templates->list();
$client->whatsapp()->templates->delete('wat_xxx');
// 3. Check the 24-hour window, then send
$window = $client->whatsapp()->window('+15559876543', '+15551234567');
if ($window['open']) {
// Free-form text inside the window
$message = $client->messages()->send([
'channel' => 'whatsapp',
'to' => '+15551234567',
'from' => '+15559876543',
'text' => 'Your table is ready!',
]);
} else {
// An approved template works regardless of the window
$message = $client->messages()->send([
'channel' => 'whatsapp',
'to' => '+15551234567',
'from' => '+15559876543',
'template' => [
'name' => 'order_shipped',
'language' => 'en_US',
'variables' => ['1' => 'Acme Inc', '2' => '#4821'],
],
]);
}
echo $message['id'];
echo $message['whatsapp']['kind']; // "text" or "template"
echo $message['creditsUsed']; // priced by destination country + category
// Media with a caption (inside the window; exactly one media URL per message)
$client->messages()->send([
'channel' => 'whatsapp',
'to' => '+15551234567',
'from' => '+15559876543',
'mediaUrls' => ['https://example.com/receipt.pdf'],
'text' => 'Here is your receipt.',
]);Send branded rich messages — text with suggested replies and actions, or rich
cards with an image and buttons — through your workspace's RCS agent by
passing 'channel' => 'rcs' to messages()->send(). Delivery is
per-recipient: not every device or network supports RCS. Text sends fall back
to plain SMS automatically (billed as SMS) unless you disable the fallback;
rich cards have no SMS form and only deliver to RCS-capable recipients.
Note: The RCS channel is being rolled out gradually and is not yet generally available; until it is enabled for your account the endpoints read as absent and calls throw
NotFoundException(HTTP 404). RCS sends and capability checks require a live API key. RCS agents are registered by Sendly for your brand — contact support to set one up.
// Discover your RCS agents ('testing' reaches invited test devices only;
// 'approved' reaches everyone). Pass 'agentId' on sends and capability
// checks when your workspace has more than one agent.
$result = $client->rcs()->agents->list();
foreach ($result['agents'] as $a) {
echo "{$a['name']} — {$a['status']}" . ($a['sendable'] ? ' (sendable)' : '') . "\n";
}
// Pre-flight: can this recipient receive RCS?
$cap = $client->rcs()->capability('+15551234567');
echo $cap['capable'] ? 'RCS' : 'would fall back to SMS';
// Text with suggested replies and actions
$message = $client->messages()->send([
'channel' => 'rcs',
'to' => '+15551234567',
'text' => 'Your order has shipped! Want live updates?',
'suggestions' => [
['reply' => ['text' => 'Yes, notify me', 'postbackData' => 'notify_yes']],
['action' => ['text' => 'Track order', 'postbackData' => 'track', 'url' => 'https://acme.example/orders/4821']],
],
]);
// The response discloses which channel delivered
echo $message['channel']; // "rcs", or "sms" when it fell back
if (($message['fellBackTo'] ?? null) === 'sms') {
// Delivered as plain SMS (billed as SMS). Suggestions have no SMS form
// and were dropped ($message['rcs']['suggestionsDropped'] is true).
} else {
echo $message['rcs']['kind']; // "text" or "card"
echo $message['rcs']['agentName']; // the brand name recipients see
}
// Rich card (RCS-capable recipients only — cards have no SMS form)
$client->messages()->send([
'channel' => 'rcs',
'to' => '+15551234567',
'card' => [
'title' => 'Spring collection',
'description' => 'New arrivals are in - take a look.',
'mediaUrl' => 'https://example.com/spring.jpg', // public JPEG/PNG/GIF
'orientation' => 'vertical', // or 'horizontal'
'suggestions' => [
['action' => ['text' => 'Shop now', 'postbackData' => 'shop', 'url' => 'https://acme.example/spring']],
],
],
]);
// Opt out of the SMS fallback — the send fails with a 422
// (rcs_not_supported_for_recipient) when the recipient can't receive RCS
$client->messages()->send([
'channel' => 'rcs',
'to' => '+15551234567',
'text' => 'RCS or nothing',
'fallbackToSms' => false,
]);// Create a webhook endpoint
$webhook = $client->webhooks()->create(
'https://example.com/webhooks/sendly',
['message.delivered', 'message.failed']
);
echo $webhook->id;
echo $webhook->secret; // Store securely!
// List all webhooks
$webhooks = $client->webhooks()->list();
// Get a specific webhook
$wh = $client->webhooks()->get('whk_xxx');
// Update a webhook
$client->webhooks()->update('whk_xxx', [
'url' => 'https://new-endpoint.example.com/webhook',
'events' => ['message.delivered', 'message.failed', 'message.sent']
]);
// Test a webhook
$result = $client->webhooks()->test('whk_xxx');
// Rotate webhook secret
$rotation = $client->webhooks()->rotateSecret('whk_xxx');
// Delete a webhook
$client->webhooks()->delete('whk_xxx');
// List available webhook event types
$eventTypes = $client->webhooks()->listEventTypes();
foreach ($eventTypes as $eventType) {
echo "Event: {$eventType}\n";
}// Get account information
$account = $client->account()->get();
echo $account->email;
// Check credit balance
$credits = $client->account()->getCredits();
echo "Available: {$credits->availableBalance} credits";
echo "Reserved: {$credits->reservedBalance} credits";
echo "Total: {$credits->balance} credits";
// View credit transaction history
$transactions = $client->account()->getCreditTransactions();
foreach ($transactions as $tx) {
echo "{$tx->type}: {$tx->amount} credits - {$tx->description}\n";
}
// List API keys
$keys = $client->account()->listApiKeys();
foreach ($keys as $key) {
echo "{$key->name}: {$key->prefix}*** ({$key->type})\n";
}
// Get a specific API key
$key = $client->account()->getApiKey('key_xxx');
// Get API key usage stats
$usage = $client->account()->getApiKeyUsage('key_xxx');
echo "Messages sent: {$usage->messagesSent}";
// Create a new API key
$newKey = $client->account()->createApiKey('Production Key', [
'expiresAt' => '2027-01-01T00:00:00Z', // optional
]);
echo "New key: {$newKey['key']}"; // Only shown once!
// Rotate an API key (old key stays valid for a grace period, default 24h)
$rotation = $client->account()->rotateApiKey('key_xxx', [
'gracePeriodHours' => 48, // optional; 24-168 inclusive
]);
echo "New key: {$rotation['newKey']['key']}"; // Only shown once!
echo $rotation['message']; // "Old key will expire in 48 hours"
// Revoke an API key
$client->account()->revokeApiKey('key_xxx');use Sendly\Exceptions\AuthenticationException;
use Sendly\Exceptions\RateLimitException;
use Sendly\Exceptions\InsufficientCreditsException;
use Sendly\Exceptions\ValidationException;
use Sendly\Exceptions\NotFoundException;
use Sendly\Exceptions\NetworkException;
use Sendly\Exceptions\SendlyException;
try {
$message = $client->messages()->send('+15551234567', 'Hello!');
} catch (AuthenticationException $e) {
// Invalid API key
} catch (RateLimitException $e) {
// Rate limit exceeded
echo "Retry after: " . $e->getRetryAfter() . " seconds";
} catch (InsufficientCreditsException $e) {
// Add more credits
} catch (ValidationException $e) {
// Invalid request
print_r($e->getDetails());
} catch (NotFoundException $e) {
// Resource not found
} catch (NetworkException $e) {
// Network error
} catch (SendlyException $e) {
// Other error
echo $e->getMessage();
echo $e->getErrorCode();
}$message->id; // Unique identifier
$message->to; // Recipient phone number
$message->text; // Message content
$message->status; // queued, sending, sent, delivered, failed
$message->creditsUsed; // Credits consumed
$message->createdAt; // DateTimeImmutable
$message->updatedAt; // DateTimeImmutable
$message->deliveredAt; // DateTimeImmutable|null
$message->errorCode; // string|null
$message->errorMessage; // string|null
// Helper methods
$message->isDelivered(); // bool
$message->isFailed(); // bool
$message->isPending(); // bool
// Convert to array
$message->toArray();| Status | Description |
|---|---|
queued |
Message is queued for delivery |
sending |
Message is being sent |
sent |
Message was sent to carrier |
delivered |
Message was delivered |
failed |
Message delivery failed |
| Tier | Countries | Credits per SMS |
|---|---|---|
| Domestic | US, CA | 2 |
| Tier 1 | GB, PL, IN, etc. | 8 |
| Tier 2 | FR, JP, AU, etc. | 12 |
| Tier 3 | DE, IT, MX, etc. | 16 |
Use test API keys (sk_test_v1_xxx) with these test numbers:
| Number | Behavior |
|---|---|
| +15005550000 | Success (instant) |
| +15005550001 | Fails: invalid_number |
| +15005550002 | Fails: unroutable_destination |
| +15005550003 | Fails: queue_full |
| +15005550004 | Fails: rate_limit_exceeded |
| +15005550006 | Fails: carrier_violation |
The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (sk_live_v1_master_*).
Create a fully configured workspace in a single call:
use Sendly\Sendly;
$client = new Sendly('sk_live_v1_master_YOUR_KEY');
$result = $client->enterprise->provision([
'name' => 'Acme Insurance - Austin',
'sourceWorkspaceId' => 'ws_verified',
'creditAmount' => 5000,
'creditSourceWorkspaceId' => 'SOURCE_WORKSPACE_ID',
'keyName' => 'Production',
'keyType' => 'live',
'generateOptInPage' => true,
]);
echo $result['workspace']['id'];
echo $result['key']['key'];Three provisioning modes:
| Mode | Params | Description |
|---|---|---|
| Inherit | sourceWorkspaceId |
Shares toll-free number from verified workspace |
| Inherit + New Number | sourceWorkspaceId + inheritWithNewNumber => true |
Copies business info, purchases new number |
| Fresh | verification => [...] |
Full business details, new number + carrier approval |
$ws = $client->enterprise->workspaces->create('Acme Insurance');
$list = $client->enterprise->workspaces->list();
$detail = $client->enterprise->workspaces->get('ws_xxx');
$client->enterprise->workspaces->delete('ws_xxx');$client->enterprise->workspaces->transferCredits('ws_dest', [
'sourceWorkspaceId' => 'ws_source',
'amount' => 5000,
]);
$key = $client->enterprise->workspaces->createKey('ws_xxx', [
'name' => 'Production',
'type' => 'live',
]);
echo $key['key'];
$client->enterprise->workspaces->revokeKey('ws_xxx', 'key_abc');$client->enterprise->webhooks->set('https://yourapp.com/webhooks');
$overview = $client->enterprise->analytics->overview();
$messages = $client->enterprise->analytics->messages('30d');
$delivery = $client->enterprise->analytics->delivery();Full enterprise docs: sendly.live/docs/enterprise
MIT