1. Go to this page and download the library: Download sendly/sendly-php library. Choose the download type require.
2. Extract the ZIP file and open the index.php.
3. Add this code to the index.php.
<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
sendly / sendly-php example snippets
use Sendly\Sendly;
$client = new Sendly('sk_live_v1_your_api_key');
// Send an SMS
$message = $client->messages()->send(
'+12125550123',
'Hello from Sendly!'
);
echo $message->id; // "4a7c1e2f-9b3d-4c8a-91f2-7d5e6a0b3c19"
echo $message->status; // "queued"
// Marketing message (default)
$message = $client->messages()->send(
'+12125550123',
'Check out our new features!'
);
// Transactional message (bypasses quiet hours)
$message = $client->messages()->send(
'+12125550123',
'Your verification code is: 123456',
'transactional'
);
// With custom metadata (max 4KB)
$message = $client->messages()->send(
'+12125550123',
'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' => '+12125550123',
'text' => 'Hello from our team!',
'from' => '+447700900123',
]);
echo $message->id;
echo $message->status;
echo $message->creditsUsed;
// A test key, a sandbox destination, or a live key the account is not yet
// set up to send with gives a simulated send: nothing reached a handset.
if ($message->isSimulated()) {
echo $message->simulatedReason ?? 'simulated'; // the reason, for a live key
}
// Basic listing
$messages = $client->messages()->list(['limit' => 50]);
foreach ($messages as $msg) {
echo $msg->to;
}
// With filters
$messages = $client->messages()->list([
'status' => 'delivered',
'to' => '+12125550123',
'limit' => 20,
'offset' => 0,
]);
// Pagination info: total counts every matching message, not just this page
echo $messages->total;
echo $messages->hasMore ? 'more pages' : 'last page';
// Schedule a message for future delivery. Returns a plain array, not an object.
$scheduled = $client->messages()->schedule(
'+12125550123',
'Your appointment is tomorrow!',
gmdate('Y-m-d\TH:i:s\Z', strtotime('+1 hour')) // 5 minutes to 5 days ahead
);
echo $scheduled['id'];
echo $scheduled['scheduledAt'];
echo $scheduled['status']; // "scheduled"
echo $scheduled['creditsReserved']; // reserved now, refunded if you cancel
// List scheduled messages: rows under 'data', how many under 'count'
$result = $client->messages()->listScheduled(['limit' => 20]);
foreach ($result['data'] as $msg) {
echo "{$msg['id']}: {$msg['scheduledAt']}\n";
}
// Get a specific scheduled message
$msg = $client->messages()->getScheduled('schd_xxx');
// Cancel a scheduled message (refunds the reserved credits). Cancelling is
// refused less than a minute before the send time.
$result = $client->messages()->cancelScheduled('schd_xxx');
echo "Refunded: {$result['creditsRefunded']} credits";
// Send up to 10,000 messages in one API call. Returns a plain array, not an object.
$batch = $client->messages()->sendBatch([
['to' => '+12125550123', 'text' => 'Hello User 1!'],
['to' => '+12125550147', 'text' => 'Hello User 2!'],
['to' => '+12125550155', 'text' => 'Hello User 3!'],
]);
echo $batch['batchId'];
echo "Status: {$batch['status']}"; // "processing" while it runs
echo "Total: {$batch['total']}";
echo "Sent: {$batch['sent']}, Failed: {$batch['failed']}";
echo "Credits used: {$batch['creditsUsed']}";
// Also present: 'optedOutSkipped', 'invalidSkipped', 'creditsRefunded' and
// 'messages' (per-recipient results, empty while 'processing'). 'retrying' is
// only there when the batch finished inside the request (test keys).
// Get batch status
$status = $client->messages()->getBatch($batch['batchId']);
echo "{$status['sent']}/{$status['total']} sent";
// List all batches
$batches = $client->messages()->listBatches();
// Preview batch (dry run) - validates without sending
$preview = $client->messages()->previewBatch([
['to' => '+12125550123', 'text' => 'Hello User 1!'],
['to' => '+447700900123', 'text' => 'Hello UK!'],
]);
echo "Credits needed: {$preview['creditsNeeded']} (balance {$preview['creditBalance']})";
echo $preview['hasSufficientCredits'] ? 'enough credits' : 'top up first';
echo "Sendable: {$preview['sendable']}, blocked: {$preview['blocked']}, duplicates: {$preview['duplicates']}";
foreach ($preview['blockedMessages'] as $blocked) {
echo "{$blocked['to']}: {$blocked['reason']}\n";
}
// 'byCountry' breaks the run down per country with its credits and tier.
// 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";
}
$group = $client->messages()->sendGroup([
'to' => ['+14155550123', '+14155550178'],
'text' => 'Hey team - quick sync at noon?',
// 'from' => '+15125550100', // optional; omit to use your default sender
// 'mediaUrls' => ['https://.../a.jpg'], // optional; text or mediaUrls ated)
echo $group['group_message_id']; // "grp_xxx" (present on live sends)
// Recipients: on a live send each entry is an array with the per-recipient
// status (['phoneNumber' => '+14155550123', 'status' => 'queued']); on a
// simulated send each entry is the phone number string.
foreach ($group['to'] as $r) {
echo is_array($r) ? "{$r['phoneNumber']} {$r['status']}\n" : "{$r}\n";
}
// On a test key (or while verification is pending) the send is simulated and
// the response carries 'simulated' => true instead of a real group id.
$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 available
$message = $client->messages()->send(
'+12125550123',
'Your order has shipped!',
idempotencyKey: 'order-4821-shipped'
);
// Countries Sendly can provision in, and the number types available in each
$countries = $client->numbers()->listCountries();
foreach ($countries['countries'] as $c) {
echo "{$c['code']} {$c['name']}: " . implode(', ', $c['numberTypes']) . "\n";
}
// Search live availability. Monthly costs come back already customer-priced.
$available = $client->numbers()->listAvailable([
'country' => 'GB',
'type' => 'mobile',
// 'contains' => '207',
]);
foreach ($available['numbers'] as $n) {
echo "{$n['phoneNumber']} {$n['monthlyCost']} {$n['currency']}\n";
}
// Buy one. Provisioning is asynchronous — check `status`.
$order = $client->numbers()->buy([
'phoneNumber' => $available['numbers'][0]['phoneNumber'],
'countryCode' => 'GB',
'phoneNumberType' => 'mobile',
'monthlyCost' => $available['numbers'][0]['monthlyCost'],
]);
if ($order['status'] === 'provisioning') {
// Poll list() until the number reports as active.
} else {
// 'documents_riod end;
// anything else is released immediately.
$result = $client->numbers()->release('num_abc123');
if ($result['scheduled'] ?? false) {
echo "Releases at {$result['scheduledReleaseAt']}";
} else {
echo 'Released';
}
// Shorten a URL
$link = $client->links()->create('https://acme.example/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');
// 1. Connect a number ($9.99 one-time). A human must finish the connect URL.
$signup = $client->whatsapp()->signup->create('+12125550147');
echo $signup['connectUrl']; // hand this to a person to complete in a browser
// Poll until active. After the Facebook step the signup stays "registering"
// while WhatsApp activates the number. Activation usually takes a few minutes
// but can take hours. If it hasn't finished about 6 hours after the session
// began, the session fails with registration_timeout and the fee is refunded.
$status = $client->whatsapp()->signup->get($signup['id']);
echo $status['status']; // "initiated" -> "registering" -> "active" (or "failed")
if ($status['status'] === 'failed') {
print_r($status['failureReasons']); // e.g. ['waba_mismatch']; the fee is refunded
}
// List connected senders
$result = $client->whatsapp()->senders->list();
foreach ($result['senders'] as $s) {
echo "{$s['phoneNumber']} ({$s['displayName']}) — {$s['status']}\n";
// $s['businessAccountId'], $s['businessName'], $s['callingEnabled'],
// $s['outboundCallingAllowed']
}
// Add another number to a connected account ($9.99 one-time, no Facebook step).
// Take the account id from an active sender: the list is newest first, and a
// pending sender's businessAccountId is null. A null id starts a Facebook
// connection instead, and a blank one is refused.
// WhatsApp sends the number a 6-digit code by text ('sms') or call ('voice').
$active = array_values(array_filter(
$result['senders'],
fn($s) => $s['status'] === 'active' && $s['businessAccountId'] !== null
));
$added = $client->whatsapp()->signup->create('+12125550148', [
'businessAccountId' => $active[0]['businessAccountId'],
'verificationMethod' => 'sms',
]);
echo $added['status']; // "verifying"
// While verifying, get() returns the code once WhatsApp's text reaches the
// number (else null), so you can submit it without a person reading it out
$code = null;
for ($i = 0; $i < 10 && $code === null; $i++) {
sleep(3);
$code = $client->whatsapp()->signup->get($added['id'])['verificationCode'] ?? null;
}
if ($code !== null) {
try {
$client->whatsapp()->signup->verify($added['id'], $code); // "active"
} catch (\Sendly\Exceptions\ValidationException $e) {
// 422 whatsapp_verification_code_invalid. The fifth wrong code throws
// SendlyException (409 whatsapp_verification_failed) instead: the
// signup fails and the fee is refunded.
echo $e->getResponseBody()['attemptsRemaining'];
}
} else {
// No code after 30 seconds? Ask for another, by voice call this time.
// A resend sooner than 30 seconds after the signup last changed throws
// RateLimitException (429 whatsapp_verification_resend_too_soon).
$client->whatsapp()->signup->resend($added['id'], 'voice');
}
// Read and update a sender's business profile (what recipients see when
// they open your details in WhatsApp)
$profile = $client->whatsapp()->senders->getProfile('+12125550147');
echo $profile['displayName'];
echo $profile['about'];
$client->whatsapp()->senders->updateProfile('+12125550147', [
'about' => 'Fresh roasted coffee, delivered.', // max 139 chars
'description' => 'Small-batch roaster shipping nationwide.', // max 512
'website' => 'https://acme.example',
]);
// Profile photo: a square JPEG or PNG up to 5 MB, at least 192 px wide
$profile = $client->whatsapp()->senders->uploadProfilePhoto('+12125550147', 'logo.png');
echo $profile['profilePhotoUrl'];
$client->whatsapp()->senders->deleteProfilePhoto('+12125550147');
// Ice breakers (up to 4, shown when someone first opens the chat) and
// commands (up to 30, shown when they type "/"). Each list you pass replaces
// the stored one; [] clears it; a list you leave out is kept.
$client->whatsapp()->senders->updateConversationalComponents('+12125550147', [
'iceBreakers' => ['Where is my order?', 'Opening hours'],
'commands' => [['command' => 'track', 'description' => 'Track an order']],
]);
$components = $client->whatsapp()->senders->getConversationalComponents('+12125550147');
// WhatsApp calling: WhatsApp users can call the number, and it rings like a
// phone call. Needs voice on for the number first (409 voice_not_enabled).
$calling = $client->whatsapp()->senders->setCalling('+12125550147', true);
echo $calling['callingEnabled'] ? 'on' : 'off';
// 2. Create a template (Meta reviews it, usually 24-48h). Variables go in the
// body and buttons only: a header containing {{1}} is refused with
// template_header_variable_unsupported.
$template = $client->whatsapp()->templates->create([
'sender' => '+12125550147',
'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. The response is exactly
// ['open' => ..., 'expiresAt' => ...]: no window on record gives open false
// and expiresAt null; an expired one gives open false and the past expiresAt.
$window = $client->whatsapp()->window('+12125550147', '+12125550123');
if ($window['open']) {
// Free-form text inside the window
$message = $client->messages()->send([
'channel' => 'whatsapp',
'to' => '+12125550123',
'from' => '+12125550147',
'text' => 'Your table is ready!',
]);
} else {
// An approved template works regardless of the window
$message = $client->messages()->send([
'channel' => 'whatsapp',
'to' => '+12125550123',
'from' => '+12125550147',
'template' => [
'name' => 'order_shipped',
'language' => 'en_US',
'variables' => ['1' => 'Acme Inc', '2' => '#4821'],
],
]);
}
echo $message['id'];
echo $message['whatsapp']['kind']; // "text" or "template"
// Priced as described above: in-window replies use the per-number monthly
// allowance of 1,000 at 1 credit each, templates by category and country.
echo $message['creditsUsed'];
// Media with a caption (inside the window; exactly one media URL per message)
$client->messages()->send([
'channel' => 'whatsapp',
'to' => '+12125550123',
'from' => '+12125550147',
'mediaUrls' => ['https://acme.example/receipt.pdf'],
'text' => 'Here is your receipt.',
]);
use Sendly\Exceptions\SendlyException;
use Sendly\Exceptions\ValidationException;
use Sendly\WhatsAppSmsFallback;
try {
$message = $client->messages()->send([
'channel' => 'whatsapp',
'to' => '+12125550123',
'from' => '+12125550147',
'text' => 'Your table is ready!',
'fallbackToSms' => true,
]);
$fallback = new WhatsAppSmsFallback($message['fallback']);
echo $fallback->status; // "pending", "sent", "failed", "unavailable" or "skipped"
echo $fallback->reason; // "window_closed", "whatsapp_refused" or "whatsapp_failed"
if ($fallback->isSent()) {
// The SMS went instead ($message['status'] is "failed")
$sms = $client->messages()->get($fallback->messageId);
echo $sms->fallbackOf; // the WhatsApp message's id
}
} catch (ValidationException $e) {
$body = $e->getResponseBody();
if ($e->getApiErrorCode() === 'sms_fallback_not_supported') {
// 422: marketing_template, authentication_template or media_without_caption
echo $body['reason'];
} elseif (isset($body['fallback'])) {
// 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
// either. A closed window also carries the WhatsApp message's id.
$fallback = new WhatsAppSmsFallback($body['fallback']);
echo "{$e->getApiErrorCode()}: {$fallback->status} {$fallback->error}";
} else {
throw $e;
}
} catch (SendlyException $e) {
if ($e->getApiErrorCode() !== 'sms_fallback_unavailable') {
throw $e;
}
// 403: this number can't send SMS to the recipient; nothing was sent
}
use Sendly\Resources\RcsCustomerStage;
// Prefill from what Sendly already holds (your 10DLC brand or toll-free verification)
$dossier = $client->rcs()->dossier->get();
$brand = $client->rcs()->brands->create(array_merge($dossier['brand'], [
'displayName' => 'Acme Coffee',
'legalEntityType' => 'LIMITED_LIABILITY_COMPANY',
'organizationType' => 'PRIVATE_PROFIT',
'websiteUrl' => 'https://acme.example',
'ein' => '12-3456789',
'address' => ['line1' => '1 Market St', 'city' => 'San Francisco', 'state' => 'CA', 'postalCode' => '94105', 'countryCode' => 'US'],
'contact' => ['firstName' => 'Jane', 'lastName' => 'Doe', 'email' => '[email protected]', 'phoneNumber' => '+12125550123'],
]))['brand'];
$agent = $client->rcs()->agents->create([
'brandId' => $brand['id'],
'displayName' => 'Acme Coffee',
'useCase' => 'MULTI_USE',
'basics' => [
'description' => 'Order updates and offers from Acme Coffee.',
'logoUrl' => 'https://acme.example/rcs/logo.png',
'heroUrl' => 'https://acme.example/rcs/hero.png',
'brandColor' => '#6B4F3A',
'privacyPolicyUrl' => 'https://acme.example/privacy',
'termsAndConditionsUrl' => 'https://acme.example/terms',
'phoneNumber' => ['number' => '+12125550123', 'label' => 'Call us'],
'website' => ['url' => 'https://acme.example', 'label' => 'Visit us'],
],
])['agent'];
// Submit both for review (pass your own idempotency key so a retried job
// does not file the request twice), then watch the stage
$client->rcs()->agents->submit($agent['id'], 'acme-rcs-submit-1');
$registration = $client->rcs()->registration->get();
echo $registration['stage']; // in_review, changes_requested, brand_verification, agent_review, testing, ...
if ($registration['stage'] === RcsCustomerStage::CHANGES_REQUESTED) {
echo $registration['agent']['reviewNote'];
$client->rcs()->agents->update($agent['id'], ['basics' => ['description' => 'Order updates from Acme Coffee.']]);
$client->rcs()->agents->submit($agent['id']);
}
// In testing: invite handsets (the list replaces the previous one), send to
// them, then file the campaign details and request launch
$client->rcs()->agents->setTestDevices($agent['id'], [
'+12125550123',
['phoneNumber' => '+12125550147', 'label' => 'QA phone'],
]);
$client->rcs()->agents->update($agent['id'], [
'campaign' => [
'companyOverview' => 'Acme Coffee roasts small-batch coffee and runs cafes with pickup and delivery.',
'agentOverview' => 'Sends order confirmations, pickup alerts and occasional offers to opted-in customers.',
'interactions' => [
['interactionType' => 'TRANSACTIONAL_UPDATES', 'description' => 'Order and pickup status'],
],
'messageExamples' => [
'Your order #4821 is ready for pickup.',
'Thanks for your order! We will text you when it is ready.',
'Reply STOP to opt out at any time.',
],
'consentSettings' => [
'optInMethods' => [['methodType' => 'WEBSITE', 'description' => 'Checkbox at checkout']],
'callToAction' => 'Tick the box at checkout to get order updates from Acme Coffee. Reply STOP to opt out, HELP for help.',
'callToActionUrl' => 'https://acme.example/checkout',
'callToActionMediaUrl' => 'https://acme.example/rcs/checkout-opt-in.png',
'doubleOptIn' => false,
'optInMessage' => 'Acme Coffee: you are opted in to order updates. Reply STOP to opt out.',
'helpResponse' => 'Acme Coffee: reply STOP to opt out or call +1 212 555 0123.',
'optOutResponse' => 'Acme Coffee: you are opted out and will receive no more messages.',
],
],
]);
$client->rcs()->agents->requestLaunch($agent['id'], ['testUrl' => 'https://acme.example/rcs-test-notes']);
// Field-level problems come back as ValidationException with the API's
// errors list; review locks and not-ready states as SendlyException
try {
$client->rcs()->agents->submit($agent['id']);
} catch (\Sendly\Exceptions\ValidationException $e) {
foreach ($e->getDetails() ?? [] as $issue) {
echo "{$issue['path']}: {$issue['message']}\n"; // e.g. brand.ein: Enter a 9-digit EIN
}
} catch (\Sendly\Exceptions\SendlyException $e) {
echo $e->getApiErrorCode(); // rcs_field_locked, rcs_brand_not_verified, ...
}
// 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('+12125550123');
echo $cap['capable'] ? 'RCS' : 'would fall back to SMS';
// Text with suggested replies and actions
$message = $client->messages()->send([
'channel' => 'rcs',
'to' => '+12125550123',
'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' => '+12125550123',
'card' => [
'title' => 'Spring collection',
'description' => 'New arrivals are in - take a look.',
'mediaUrl' => 'https://acme.example/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' => '+12125550123',
'text' => 'RCS or nothing',
'fallbackToSms' => false,
]);
use Sendly\Resources\CallStatus;
use Sendly\Resources\CallRecordingStatus;
use Sendly\Exceptions\InsufficientCreditsException;
use Sendly\Exceptions\SendlyException;
// Place a call. The agent speaks first; `context` steers what it says on
// this call only, and `metadata` comes back on every read and webhook.
try {
$call = $client->calls()->create([
'to' => '+15125550123',
'agentId' => '3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
'from' => '+15125550100', // optional when only one number is voice-enabled
'context' => 'You are calling Jordan to confirm the 3pm appointment on Tuesday.',
'metadata' => ['crmId' => 'lead_8812'],
]);
echo $call['id']; // "6f1c2d3e-..."
echo $call['status']; // "ringing"
} catch (InsufficientCreditsException $e) {
// Top up: the balance cannot cover one minute at the agent rate
} catch (SendlyException $e) {
// 428 e911_\n";
}
if ($result['pagination']['hasMore']) {
// fetch the next page with 'offset' => $result['pagination']['offset'] + $result['pagination']['limit']
}
// End a call early. Ringing -> cancelled, active -> completed; a call that
// has already ended is returned unchanged.
$client->calls()->hangup($call['id']);
// Fetch the recording. The URL is signed and valid for five minutes.
// Agent-handled calls are stereo: the agent on the left channel, the other party on the right.
$recording = $client->calls()->recording($call['id']);
if ($recording['status'] === CallRecordingStatus::READY) {
file_put_contents('call.ogg', file_get_contents($recording['url']));
}
use Sendly\Resources\VoiceMode;
use Sendly\Resources\CallErrorCode;
use Sendly\Exceptions\SendlyException;
use Sendly\Exceptions\ValidationException;
// Pick a voice, then create an agent
foreach ($client->voice->voices->list()['data'] as $voice) {
echo "{$voice['id']}: {$voice['label']}\n"; // "ashley: Ashley (US, warm)"
}
$agent = $client->voice->agents->create([
'name' => 'Front desk',
'voice' => 'ashley',
'greeting' => 'Thanks for calling Acme, how can I help?',
'instructions' => 'Answer questions about opening hours and take a message for anything else.',
'tools' => ['sendSms' => true],
]);
echo $agent['id']; // "3c4d5e6f-..."
var_dump($agent['canSendSms']); // true
// Change only what you pass; tools keys you leave out keep their values
$client->voice->agents->update($agent['id'], [
'greeting' => 'Thanks for calling Acme. How can I help today?',
]);
// Register the emergency address: ad, or switch voice off
$client->voice->numbers->update($number['id'], ['voiceMode' => VoiceMode::RING_DASHBOARD]);
$client->voice->numbers->update($number['id'], ['voiceEnabled' => false]);
// Every number and agent in the workspace
foreach ($client->voice->numbers->list()['data'] as $n) {
echo "{$n['phoneNumber']} {$n['voiceMode']}\n";
}
$agents = $client->voice->agents->list()['data'];
// Delete an agent once no number answers with it; its sending key is revoked
try {
$client->voice->agents->delete($agent['id']);
} catch (SendlyException $e) {
if ($e->getApiErrorCode() === CallErrorCode::AGENT_IN_USE) {
print_r($e->getResponseBody()['numbers']); // ['+15125550100']
}
}
// Create a webhook endpoint. create() returns a WebhookCreatedResponse: the
// webhook itself is nested under ->webhook, and ->secret is shown only here.
$created = $client->webhooks()->create(
'https://acme.example/webhooks/sendly',
['message.delivered', 'message.failed']
);
echo $created->webhook->id; // "whk_..."
echo $created->secret; // "whsec_..." — store securely, shown once!
// List all webhooks (array of Webhook objects)
$webhooks = $client->webhooks()->list();
foreach ($webhooks as $wh) {
echo "{$wh->url} {$wh->successRate}% " . ($wh->isHealthy() ? 'healthy' : 'degraded') . "\n";
}
// Get a specific webhook. Delivery stats (successRate, totalDeliveries) are
// filled in by list() only; get() returns them as 0.
$wh = $client->webhooks()->get('whk_xxx');
echo $wh->circuitState; // closed, open, half_open
// Update a webhook
$client->webhooks()->update('whk_xxx', [
'url' => 'https://new-endpoint.acme.example/webhook',
'events' => ['message.delivered', 'message.failed', 'message.sent'],
// 'isActive' => false, 'mode' => 'live', 'description' => '...'
]);
// Test a webhook. A delivered test returns a WebhookTestResult; a test your
// endpoint fails throws ValidationException with the API's message, and
// getResponseBody()['success'] is false. An unknown webhook id also throws
// ValidationException here, not the NotFoundException get() throws.
try {
$result = $client->webhooks()->test('whk_xxx');
echo "{$result->statusCode} in {$result->responseTimeMs}ms ({$result->deliveryId})";
} catch (\Sendly\Exceptions\ValidationException $e) {
echo $e->getMessage();
}
// Rotate webhook secret. Deliveries are signed with the new secret from the
// moment it is issued, so switch your endpoint over straight away.
$rotation = $client->webhooks()->rotateSecret('whk_xxx');
echo $rotation->secret; // shown only once
// Inspect and retry deliveries
$deliveries = $client->webhooks()->listDeliveries('whk_xxx', ['limit' => 20]);
foreach ($deliveries as $d) {
echo "{$d->eventType} -> {$d->httpStatus}\n";
}
$one = $client->webhooks()->getDelivery('whk_xxx', 'del_xxx');
$client->webhooks()->retryDelivery('whk_xxx', 'del_xxx');
// Recover after an outage. Both are refused with HTTP 409 while the circuit is
// open, so reset it first.
$client->webhooks()->resetCircuit('whk_xxx');
// The window runs from 'since' to 'until' (default: now) and is at most 7 days.
$since = gmdate('Y-m-d\TH:i:s\Z', strtotime('-2 days'));
// Re-fire deliveries we recorded but could not deliver:
$client->webhooks()->redeliver('whk_xxx', ['since' => $since]);
// Synthesize events that never got an audit row. A synthesized event reuses
// the id the original dispatch used, so dedupe on $event->id, not on
// data.object.id: a message's sent and delivered events share it.
$client->webhooks()->backfill('whk_xxx', ['since' => $since]);
// 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";
}
use Sendly\Webhooks;
use Sendly\WebhookVerificationData;
use Sendly\Exceptions\WebhookSignatureException;
try {
$event = Webhooks::parseEvent(
$rawRequestBody,
$_SERVER['HTTP_X_SENDLY_SIGNATURE'],
$webhookSecret,
$_SERVER['HTTP_X_SENDLY_TIMESTAMP'] ?? null
);
} catch (WebhookSignatureException $e) {
http_response_code(401);
return;
}
match ($event->type) {
'rcs_agent.live' => activateAgent($event->object['agent_id']),
'whatsapp_template.approved' => touchTemplate($event->object['id'], $event->object['updatedAt']),
// call.* numbers are legitimately null for in-app calls
'call.completed' => logCall($event->object['from'], $event->object['to']),
// the contact is `id`; the message that flagged it is `message_id`
'contact.auto_flagged' => quarantine($event->object['id'], $event->object['message_id']),
default => null,
};
if ($event->data !== null) {
echo "{$event->data->id} is {$event->data->status}";
}
// public/sendly-webhook.php
ceptions\WebhookSignatureException;
$raw = file_get_contents('php://input') ?: '';
try {
$event = Webhooks::parseEvent(
$raw,
$_SERVER['HTTP_X_SENDLY_SIGNATURE'] ?? '',
getenv('SENDLY_WEBHOOK_SECRET') ?: '',
$_SERVER['HTTP_X_SENDLY_TIMESTAMP'] ?? null
);
} catch (WebhookSignatureException $e) {
http_response_code(401);
exit;
}
switch ($event->type) {
// Lifecycle: data.object is an RCS agent.
// {"agent_id": "...", "name": "...", "stage": "live", "organization_id": "..."}
case 'rcs_agent.live':
$agentId = $event->object['agent_id']; // verbatim, nothing defaulted
$stage = $event->get('stage'); // null when the key is absent
error_log("RCS agent {$agentId} reached {$stage}");
// $event->data is null here. An agent is not a message.
break;
// Lifecycle: a call. from/to are legitimately null for in-app calls.
case 'call.completed':
error_log(sprintf(
'call %s ran %d s',
$event->object['id'],
$event->object['duration_secs'] ?? 0
));
break;
// Message event: the typed view is populated, so use it.
case 'message.delivered':
if ($event->data !== null) {
error_log("{$event->data->id} delivered to {$event->data->to}");
}
break;
}
http_response_code(200);
// Send a code
$verification = $client->verify()->send('+12125550123', [
'app_name' => 'Acme',
'code_length' => 6,
'timeout_secs' => 300,
// 'template_id' => 'tpl_xxx', 'profile_id' => 'vp_xxx',
]);
echo $verification['id'];
echo $verification['status'];
echo $verification['expires_at'];
// On a test key the response carries the code so you can assert on it
if ($verification['sandbox']) {
echo $verification['sandbox_code'];
}
// Check it. A wrong code throws ValidationException (400 invalid_code) and
// the error body carries remaining_attempts.
try {
$result = $client->verify()->check($verification['id'], '123456');
echo $result['status']; // "verified"
} catch (\Sendly\Exceptions\ValidationException $e) {
echo $e->getResponseBody()['remaining_attempts'] ?? '';
}
// Resend, fetch, list
$client->verify()->resend($verification['id']);
$one = $client->verify()->get($verification['id']);
$all = $client->verify()->list(['limit' => 20]);
$verified = $client->verify()->list(['status' => 'verified', 'limit' => 20]);
foreach ($verified['verifications'] as $v) {
echo "{$v['phone']}: {$v['status']}\n";
}
$session = $client->verify()->sessions->create([
'success_url' => 'https://acme.example/verified',
'cancel_url' => 'https://acme.example/cancel',
'brand_name' => 'Acme',
'brand_color' => '#6B4F3A',
'metadata' => ['userId' => 'u_123'],
]);
// Send the user to the session's URL, then validate the token they come back with.
// A returned result is always valid; a bad, used or unverified token throws.
try {
$check = $client->verify()->sessions->validate($tokenFromCallback);
echo $check['phone'];
} catch (\Sendly\Exceptions\ValidationException $e) {
echo $e->getApiErrorCode(); // invalid_token, token_already_used or not_verified
}
$all = $client->templates()->list();
$presets = $client->templates()->presets();
$template = $client->templates()->create('Order shipped', 'Hi {{name}}, order {{id}} shipped!');
$client->templates()->update($template['id'], ['text' => 'Hi {{name}}, {{id}} is on its way!']);
$client->templates()->publish($template['id']);
$preview = $client->templates()->preview($template['id'], ['name' => 'Sam', 'id' => '#4821']);
$copy = $client->templates()->clone($template['id'], 'Order shipped v2');
$client->templates()->delete($template['id']);
// Draft one with AI
$generated = $client->templates()->generate('A sign-in code message for the Acme app', 'transactional');
echo $generated['text'];
// Contacts
$contacts = $client->contacts()->list(['limit' => 50, 'search' => 'sam']);
$contact = $client->contacts()->create('+12125550123', [
'name' => 'Sam Rivera',
'email' => '[email protected]',
'metadata' => ['plan' => 'pro'],
]);
$client->contacts()->update($contact['id'], ['name' => 'Sam R.']);
// Auto-pagination
foreach ($client->contacts()->each(['listId' => 'lst_xxx']) as $c) {
echo $c['phone_number'] . "\n";
}
// Bulk import
$result = $client->contacts()->import([
['phone' => '+12125550123', 'name' => 'Sam'],
['phone' => '+12125550147', 'name' => 'Alex'],
], ['listId' => 'lst_xxx']);
echo "{$result['imported']} imported, {$result['skippedDuplicates']} duplicates";
// Contacts are auto-flagged invalid after a terminal bad-number failure or a
// carrier lookup. Clear the flag when the call was wrong:
$client->contacts()->markValid($contact['id']);
$client->contacts()->bulkMarkValid(['listId' => 'lst_xxx']); // or ['ids' => [...]]
// Ask for a background carrier lookup (runs 1-5 minutes)
$client->contacts()->checkNumbers(['listId' => 'lst_xxx']);
// Lists - reached through the lists() method
$lists = $client->contacts()->lists();
$list = $lists->create('VIP customers', 'Top accounts');
$lists->addContacts($list['id'], [$contact['id']]);
$lists->removeContact($list['id'], $contact['id']);
$lists->update($list['id'], ['name' => 'VIPs']);
// The API returns every list in one response and ignores limit and offset,
// so each() makes a single request
foreach ($lists->each() as $l) {
echo $l['name'] . "\n";
}
// Deletes return ['success' => true]; an error response throws
$lists->delete($list['id']);
$client->contacts()->delete($contact['id']);
use Sendly\Resources\Campaigns;
$campaign = $client->campaigns()->create('Spring sale', 'Our spring sale is live!', [
'contactListId' => 'lst_xxx',
]);
// Cost and audience before you commit
$preview = $client->campaigns()->preview($campaign['id']);
// The v1 preview answers with recipientCount, totalRecipients, estimatedCredits,
// currentBalance, hasEnoughCredits, blockedCount, sendableCount, byCountry and
// warnings. There is no estimatedSegments and no id.
echo "{$preview['recipientCount']} recipients, {$preview['estimatedCredits']} credits";
// Send now: the result is the batch the campaign went out as
$result = $client->campaigns()->send($campaign['id']);
echo "{$result['batchId']}: {$result['sent']}/{$result['total']} sent";
// or
$client->campaigns()->schedule($campaign['id'], gmdate('Y-m-d\TH:i:s\Z', strtotime('+1 day')));
$client->campaigns()->cancel($campaign['id']);
$client->campaigns()->clone($campaign['id'], 'Spring sale round 2');
// list() returns 'campaigns' with 'total', 'limit' and 'offset'
foreach ($client->campaigns()->each(['status' => Campaigns::STATUS_COMPLETED]) as $c) {
echo $c['name'] . "\n";
}
$client->campaigns()->delete($campaign['id']); // ['success' => true]
$threads = $client->conversations()->list(['status' => 'active', 'limit' => 20]);
foreach ($threads['data'] as $t) {
echo $t['id'] . "\n";
}
$thread = $client->conversations()->get('conv_xxx', [
'onversations()->close('conv_xxx');
$client->conversations()->reopen('conv_xxx');
$client->conversations()->update('conv_xxx', ['tags' => ['vip']]);
// Labels on a thread
$client->conversations()->addLabels('conv_xxx', ['lbl_a', 'lbl_b']);
$client->conversations()->removeLabel('conv_xxx', 'lbl_a');
// Feed a thread to your own model, or use Sendly's suggestions
$context = $client->conversations()->getContext('conv_xxx', 20);
echo $context['context'];
$suggestions = $client->conversations()->suggestReplies('conv_xxx');
// Labels
$label = $client->labels()->create('Billing', ['color' => '#6B4F3A']);
$all = $client->labels()->list();
// Drafts - a reply staged for approval before it sends
$draft = $client->drafts()->create('conv_xxx', 'Proposed reply text');
$client->drafts()->update($draft['id'], ['text' => 'Revised reply text']);
$client->drafts()->approve($draft['id']); // sends it
// or turn a pending draft down instead of approving it:
// $client->drafts()->reject($draft['id'], 'Wrong tone');
$pending = $client->drafts()->list(['status' => 'pending']);
// Rules - label threads by the AI classification of inbound messages.
// Conditions and actions are each one associative array; a list of arrays
// is merged into one before it is sent.
$rule = $client->rules()->create(
'Tag complaints',
['intent' => 'complaint', 'sentiment' => 'negative', 'intentConfidenceMin' => 0.8],
['addLabels' => [$label['id']]], // or 'closeConversation' => true
['priority' => 10]
);
$client->rules()->update($rule['id'], ['priority' => 1]);
$client->rules()->delete($rule['id']);
$client->labels()->delete($label['id']);
// 1. Brand - starts 'pending', poll until 'verified' (or 'failed')
$brand = $client->tenDlc()->createBrand([
'legalName' => 'Acme Coffee LLC',
'ein' => '12-3456789',
'entityType' => 'PRIVATE_PROFIT',
'website' => 'https://acme.example',
'email' => '[email protected]',
])['data'];
$state = $client->tenDlc()->getBrand($brand['id'])['data'];
echo $state['status'];
print_r($state['failureReasons']);
// Pre-check a use case before spending a campaign on it
$check = $client->tenDlc()->qualify($brand['id'], 'MIXED')['data'];
echo $check['qualified'] ? 'ok' : $check['reason'];
// 2. Campaign - starts 'pending', poll until 'active'
$campaign = $client->tenDlc()->createCampaign([
'brandId' => $brand['id'],
'useCase' => 'MIXED',
'description' => 'Order updates and occasional offers.',
'messageFlow' => 'Customers opt in at checkout with a checkbox.',
'sampleMessages' => ['Your order #4821 is ready.', 'Reply STOP to opt out.'],
])['data'];
// 3. Assign a number you own to the active campaign
$assignment = $client->tenDlc()->assignNumber($campaign['id'], '+12125550123')['data'];
echo $assignment['status']; // "Active" once the number can send
$client->tenDlc()->listBrands();
$client->tenDlc()->listCampaigns();
$client->tenDlc()->listAssignments();
// Your workspace's short codes
$codes = $client->get('/short_codes');
foreach ($codes['shortCodes'] as $c) {
echo "{$c['shortCode']} {$c['status']} / {$c['reviewStatus']}\n";
}
// Start an application (one open application per workspace)
$request = $client->post('/short_codes/requests', [
'useCase' => 'Order notifications and delivery alerts',
'optInFlow' => 'Customers tick a consent box at checkout.',
'sampleMessages' => ['Your order #4821 has shipped.'],
'expectedMonthlyVolume' => '50000',
'helpResponse' => 'Acme: reply STOP to opt out.',
'stopConfirmation' => 'Acme: you are opted out and will get no more messages.',
]);
echo $request['id'];
// The application itself
$application = $client->get('/short_codes/application');
$client->put('/short_codes/application', ['useCase' => 'Updated use case']);
$client->post('/short_codes/application/preflight');
$client->post('/short_codes/application/submit');
// Check the payload before you file it - advisory only, no writes
$check = $client->businessUpgrade()->preflight(['businessName' => 'Acme Coffee LLC']);
echo $check['verdict'];
print_r($check['issues']);
// Best-known values across the caller's verified workspaces
$prefill = $client->businessUpgrade()->bestPrefill();
// File it, with the IRS letter (CP-575 / 147C) as multipart
$upgrade = $client->businessUpgrade()->start('ws_xxx', [
'businessName' => 'Acme Coffee LLC',
'brn' => '12-3456789',
'brnType' => 'EIN',
'brnCountry' => 'US',
'entityType' => 'PRIVATE_PROFIT',
'website' => 'https://acme.example',
], ['einDocPath' => '/path/to/cp575.pdf']);
echo $upgrade['status']; // "provisioning"
// The pending upgrade carries the new toll-free number once one is reserved
$status = $client->businessUpgrade()->status('ws_xxx');
echo $status['pending']['tollFreeNumber'] ?? '';
$client->businessUpgrade()->resubmit('ws_xxx', ['website' => 'https://acme.example/about']);
$client->businessUpgrade()->cancel('ws_xxx');
// After approval, decide what happens to the old number
$client->businessUpgrade()->setDisposition('ws_xxx', [
'disposition' => 'moved', // or 'released'
'targetWorkspaceId' => 'ws_yyy', //
// Get account information
$account = $client->account()->get();
echo $account->email;
echo $account->organization['name'] ?? ''; // the workspace the key belongs to
echo $account->apiKey['type']; // the calling key: "test" or "live"
// Limits and business verification live on the account object
echo $account->limits->messagesPerMinute;
echo $account->verification->status ?? 'none'; // "verified", "pending", "rejected", ...
echo $account->verification->isVerified() ? 'verified' : 'not verified';
// Check credit balance
$credits = $client->account()->credits();
echo "Available: {$credits->availableBalance} credits"; // what you can spend now
echo "Total: {$credits->balance} credits";
echo "Reserved: {$credits->reservedBalance} credits"; // held for messages still sending
echo $credits->billingMode; // "prepaid", or "pooled" for a workspace on an enterprise credit pool
// View credit transaction history (CreditTransaction::TYPE_PURCHASE, TYPE_USAGE,
// TYPE_REFUND, TYPE_BONUS, TYPE_TRANSFER, TYPE_ADMIN_GRANT, TYPE_ADMIN_SEED)
$transactions = $client->account()->transactions(['limit' => 50]);
foreach ($transactions as $tx) {
echo "{$tx->type}: {$tx->amount} credits - {$tx->description}\n";
}
// Move credits to another workspace you own
$client->account()->transferCredits('org_xxx', 500);
// List API keys
$keys = $client->account()->apiKeys();
foreach ($keys as $key) {
echo "{$key->name} ({$key->type}): {$key->prefix} " . ($key->isActive ? 'active' : 'revoked') . "\n";
echo implode(', ', $key->scopes) . "\n";
}
// Get a specific API key ($key->revokedAt is set once it is revoked)
$key = $client->account()->getApiKey('key_xxx');
// Get API key usage stats (returned as a plain array)
$usage = $client->account()->getApiKeyUsage('key_xxx');
print_r($usage);
// Create a new API key. Without 'type' the API creates a test key.
$created = $client->account()->createApiKey('Production Key', [
'type' => 'live', // 'test' or 'live'; anything else throws before sending
'scopes' => ['sms:send', 'sms:read'], // optional; only scopes the calling key has
'expiresAt' => '2027-01-01T00:00:00Z', // optional
]);
echo "New key: {$created['key']}"; // Only shown once!
echo $created['apiKey']->isLive() ? 'live' : 'test';
// 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\RateLimitException;
try {
$client->messages()->send('+12125550123', 'Hello!', idempotencyKey: 'order-4821-hello');
} catch (RateLimitException $e) {
if ($e->getApiErrorCode() === 'too_many_failed_key_attempts') {
// Repeated wrong API keys from this address locked it out. Do not
// retry: fix the key, then wait getRetryAfter() seconds, since until
// the lockout ends the right key can be refused too.
throw $e;
}
sleep(max(1, $e->getRetryAfter()));
// then retry with the same idempotencyKey so the retry cannot double-send
}
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('+12125550123', 'Hello!');
} catch (AuthenticationException $e) {
// Invalid API key (401)
} catch (RateLimitException $e) {
// 429. too_many_failed_key_attempts is a lockout for wrong API keys:
// fix the key rather than retrying. Otherwise wait and retry.
echo $e->getApiErrorCode() . ', retry after ' . $e->getRetryAfter() . ' seconds';
} catch (InsufficientCreditsException $e) {
// Add more credits (402)
} catch (ValidationException $e) {
// Invalid request: getCode() is 400 or 422, the status the API sent
print_r($e->getDetails());
} catch (NotFoundException $e) {
// Resource not found (404) - also how a channel that is not enabled
// for your account reads: the endpoints are absent
} catch (NetworkException $e) {
// Connection failed or timed out, after maxRetries attempts
} catch (SendlyException $e) {
// Other error
echo $e->getMessage();
echo $e->getCode(); // HTTP status
echo $e->getErrorCode(); // the SDK's class code, e.g. "VALIDATION_ERROR"
echo $e->getApiErrorCode(); // the API's error code, e.g. "rcs_field_locked"
print_r($e->getResponseBody()); // the decoded error body, when there was one
}
$message->id; // Unique identifier
$message->to; // Recipient phone number
$message->from; // string|null
$message->text; // Message content
$message->status; // queued, sent, delivered, failed, bounced, retrying, read, received
$message->direction; // outbound, inbound
$message->segments; // int
$message->creditsUsed; // Credits consumed
$message->isSandbox; // bool
$message->createdAt; // DateTimeImmutable
$message->deliveredAt; // DateTimeImmutable|null
$message->updatedAt; // deprecated: deliveredAt, or createdAt before delivery
$message->errorCode; // string|null
$message->errorMessage; // string|null, a failed message's reason
$message->retryCount; // int
$message->metadata; // array|null
$message->aiMetadata; // array|null (AI classification on inbound messages)
$message->messageFormat; // "sms", "mms", "whatsapp" or "rcs"
$message->mediaUrls; // array|null: MMS media, WhatsApp media or an RCS card image
$message->batchId; // string|null, set on messages sent in a batch
$message->simulated; // bool, true when nothing reached a handset
$message->simulatedReason; // string|null, why a live key's send was simulated
// Helper methods
$message->isSimulated(); // bool
$message->isDelivered(); // bool
$message->isFailed(); // bool
$message->isBounced(); // bool - carrier rejected
$message->isPending(); // bool - queued or sent
// Convert to array
$message->toArray();
$ws = $client->enterprise->workspaces->create(['name' => 'Acme Insurance']);
$list = $client->enterprise->workspaces->list();
$detail = $client->enterprise->workspaces->get('ws_xxx');
$client->enterprise->workspaces->delete('ws_xxx');
// Provision up to 100 workspaces in one call. Each takes name and optionally
// sourceWorkspaceId, creditAmount and creditSourceWorkspaceId; for
// verification, inheritWithNewNumber, keys, opt-in pages or webhooks, use
// provision() one workspace at a time.
$bulk = $client->enterprise->workspaces->provisionBulk([
['name' => 'Acme Insurance - Dallas', 'sourceWorkspaceId' => 'ws_verified'],
['name' => 'Acme Insurance - Houston', 'sourceWorkspaceId' => 'ws_verified'],
]);
// Reuse a verified workspace's verification. With purchaseNewNumber the
// workspace is ordered its own toll-free number instead of sharing the
// source's; the result then has newNumber true, and tollFreeNumber is null
// when no number could be ordered.
$inherited = $client->enterprise->workspaces->inheritVerification('ws_xxx', [
'sourceWorkspaceId' => 'ws_verified',
'purchaseNewNumber' => true,
]);
echo $inherited['tollFreeNumber'] ?? 'no number ordered yet';
$client->enterprise->workspaces->transferCredits('ws_dest', [
'sourceWorkspaceId' => 'ws_source',
'amount' => 5000,
]);
// name defaults to "API key" and type to 'test'. A live key needs the
// workspace to be verified and to have credits (403 / 402 otherwise).
$key = $client->enterprise->workspaces->createKey('ws_xxx', [
'name' => 'Production',
'type' => 'live',
]);
echo $key['key']; // shown only once
$client->enterprise->workspaces->revokeKey('ws_xxx', 'key_abc');