PHP code example of sendly / sendly-php

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"

$client = new Sendly('sk_live_v1_xxx', [
    'baseUrl' => 'https://sendly.live/api/v1',
    'timeout' => 60,
    'maxRetries' => 5,
    'organization_id' => 'org_xxx',
]);

// 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';

$message = $client->messages()->get('4a7c1e2f-9b3d-4c8a-91f2-7d5e6a0b3c19');

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. 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}";
}

$verification = $event->objectAs(WebhookVerificationData::class);
// or, for verification.* events:
$verification = $event->verification();

echo $verification?->phone;


// 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']);

$file = $client->media()->upload('/path/to/photo.jpg');
echo $file->id;
echo $file->url;
echo $file->contentType;
echo $file->sizeBytes;

$client->messages()->send([
    'to' => '+12125550123',
    'text' => 'Here it is!',
    'mediaUrls' => [$file->url],
]);

// 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();

use Sendly\Sendly;

$client = new Sendly('sk_live_v1_your_master_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'];

$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');

$client->enterprise->webhooks->set(['url' => 'https://hooks.acme.example/enterprise']);
$overview = $client->enterprise->analytics->overview();
$messages = $client->enterprise->analytics->messages(['period' => '30d']);
$delivery = $client->enterprise->analytics->delivery();
$credits = $client->enterprise->analytics->credits(['period' => '30d']);
bash
composer