sendTemplate('+919876543210', 'order_update', 'en', ['variables' => ['Rahul', 'ORD-1']]); */ declare(strict_types=1); namespace Fliok; /** A refusal from the API — or, with status 0, a request that never got an answer. */ class FliokException extends \RuntimeException { private int $status; private ?string $errorCode; private ?string $requestId; private ?int $retryAfter; /** @var mixed */ private $body; /** * @param mixed $body The parsed error body, as sent. */ public function __construct(int $status, ?string $errorCode, string $message, ?string $requestId = null, ?int $retryAfter = null, $body = null, ?\Throwable $previous = null) { parent::__construct($message, $status, $previous); $this->status = $status; $this->errorCode = $errorCode; $this->requestId = $requestId; $this->retryAfter = $retryAfter; $this->body = $body; } /** The HTTP status; 0 when no answer arrived (the code is then "network_error" or "timeout"). */ public function getStatus(): int { return $this->status; } /** * The API's machine code — the `error` field of the body ("rate_limited", "outside_24h_window", * "insufficient_scope"…). null when the body had none, or carried validation details instead * (they are in getBody()). */ public function getErrorCode(): ?string { return $this->errorCode; } /** A reference for support, when the answer carried one. */ public function getRequestId(): ?string { return $this->requestId; } /** Seconds the API asked us to wait (Retry-After), on a 429 or 503. */ public function getRetryAfter(): ?int { return $this->retryAfter; } /** @return mixed */ public function getBody() { return $this->body; } } final class Fliok { public const VERSION = '0.1.0'; public const DEFAULT_BASE_URL = 'https://fliok.com/api/v1'; private const RETRY_BASE_SECONDS = 0.5; private const RETRY_CAP_SECONDS = 8.0; /** curl's number for "timed out" (CURLE_OPERATION_TIMEDOUT); a transport throws it as the code. */ private const TIMEOUT_CODE = 28; private string $apiKey; private string $baseUrl; private int $maxRetries; private int $timeout; private int $maxRetryAfter; /** @var callable */ private $transport; /** @var callable */ private $sleep; /** @var array{status:int,requestId:?string,replayed:bool,rateLimit:?array}|null */ private ?array $last = null; /** * @param string $apiKey An API key (wa_…) from Settings → API keys. * @param array{baseUrl?:string,maxRetries?:int,timeout?:int,maxRetryAfter?:int,transport?:callable,sleep?:callable} $options * baseUrl default https://fliok.com/api/v1 * maxRetries retries after the first attempt, default 3 (0 turns retrying off) * timeout seconds per attempt, default 60 (a 16 MB media upload takes a while) * maxRetryAfter the longest Retry-After (seconds) worth sitting out, default 60; a longer one is * thrown as the 429 at once, so a script is never parked for an hour unawares * transport function (string $method, string $url, array $headers, ?string $body, int $timeout): array * returning ['status' => int, 'headers' => [lowercase name => value], 'body' => string], * throwing a \RuntimeException when no answer came (code 28 = timed out). Default: curl. * sleep function (float $seconds): void — how retries wait (override to log, or in tests) */ public function __construct(string $apiKey, array $options = []) { if (trim($apiKey) === '') { throw new \InvalidArgumentException("Fliok: pass your API key (wa_…)."); } $this->apiKey = trim($apiKey); $this->baseUrl = rtrim($options['baseUrl'] ?? self::DEFAULT_BASE_URL, '/'); $this->maxRetries = max(0, (int) ($options['maxRetries'] ?? 3)); $this->timeout = (int) ($options['timeout'] ?? 60); $this->maxRetryAfter = (int) ($options['maxRetryAfter'] ?? 60); if (isset($options['transport'])) { $this->transport = $options['transport']; } else { if (!function_exists('curl_init')) { throw new \RuntimeException('Fliok: the PHP curl extension is needed (or pass a transport).'); } $this->transport = [self::class, 'curlTransport']; } $this->sleep = $options['sleep'] ?? static function (float $seconds): void { usleep((int) round($seconds * 1000000)); }; } /** The RateLimit-* headers of the last answer — ['limit', 'remaining', 'reset' (seconds)] — or null. */ public function rateLimit(): ?array { return $this->last['rateLimit'] ?? null; } /** Status, request id, replay flag and rate limit of the last answer. */ public function lastResponse(): ?array { return $this->last; } /** * Call any endpoint: $fliok->request('GET', '/contacts', ['query' => ['limit' => 50]]). * The path is relative to the base URL. Returns the decoded JSON body (arrays); throws a * FliokException on a refusal. * * @param array{query?:array,body?:mixed,idempotencyKey?:string|false|null} $options * idempotencyKey: your own key for a write (1–255 characters, remembered for 24 hours); left * out, one is made. false sends none — the request is then never retried, since a retry could * do the write twice. * @return mixed */ public function request(string $method, string $path, array $options = []) { $method = strtoupper($method); $url = $this->url($path, $options['query'] ?? []); $write = $method !== 'GET'; // One key for the whole call — made once, before the first attempt, and sent unchanged on // every retry. That is what lets the API answer a retry with the first result. $given = $options['idempotencyKey'] ?? null; $key = $given === false ? null : ($given ?? ($write ? self::uuid4() : null)); // A write without a key is sent exactly once: if the answer is lost we cannot know whether it ran. $retryable = !$write || $key !== null; $body = array_key_exists('body', $options) && $options['body'] !== null ? self::encodeBody($options['body']) : null; $headers = self::buildHeaders($this->apiKey, $key, $body !== null); for ($attempt = 0; ; $attempt++) { try { $res = ($this->transport)($method, $url, $headers, $body, $this->timeout); } catch (\RuntimeException $e) { if ($retryable && $attempt < $this->maxRetries) { ($this->sleep)(self::backoff($attempt)); continue; } $timeout = $e->getCode() === self::TIMEOUT_CODE; throw new FliokException( 0, $timeout ? 'timeout' : 'network_error', $timeout ? "No answer within {$this->timeout} seconds." : 'Could not reach Fliok: ' . $e->getMessage(), null, null, null, $e ); } $status = (int) $res['status']; $resHeaders = array_change_key_case($res['headers'] ?? [], CASE_LOWER); $parsed = self::decode((string) ($res['body'] ?? '')); $this->last = [ 'status' => $status, 'requestId' => self::requestIdOf($resHeaders, $parsed), 'replayed' => ($resHeaders['idempotent-replayed'] ?? '') === 'true', 'rateLimit' => self::rateLimitOf($resHeaders), ]; if ($status >= 200 && $status < 300) { return $parsed; } $error = self::errorFrom($status, $resHeaders, $parsed, $this->last['requestId']); $wait = $retryable && $attempt < $this->maxRetries ? $this->retryDelay($error, $attempt) : null; if ($wait === null) { throw $error; } ($this->sleep)($wait); } } /** request() for the convenience methods: always an array, even for an empty answer. */ private function call(string $method, string $path, array $options = []): array { $r = $this->request($method, $path, $options); return is_array($r) ? $r : []; } /** Seconds to wait before retrying this refusal — or null for "don't". */ private function retryDelay(FliokException $e, int $attempt): ?float { $asked = $e->getRetryAfter(); if ($e->getStatus() === 429) { // The API says when the bucket has room again; waiting less only earns another 429. $wait = $asked ?? ($this->last['rateLimit']['reset'] ?? self::backoff($attempt)); return $wait > $this->maxRetryAfter ? null : (float) $wait; } if ($e->getStatus() >= 500) { if ($asked !== null) { return $asked > $this->maxRetryAfter ? null : (float) $asked; } return self::backoff($attempt); } // The first attempt with this key is still running on the server (its answer was lost on // the way back): wait, and the retry gets that attempt's answer. if ($e->getStatus() === 409 && $e->getErrorCode() === 'idempotency_key_in_progress') { return self::backoff($attempt); } return null; } private function url(string $path, array $query): string { $p = $path !== '' && $path[0] === '/' ? $path : '/' . $path; $qs = self::encodeQuery($query); return $this->baseUrl . $p . ($qs !== '' ? (strpos($p, '?') === false ? '?' : '&') . $qs : ''); } // ── Convenience methods: one per common endpoint, the body exactly as the API takes it ──────── // $to is a phone number (E.164, "+" optional), or ['recipient' => ''] for a // customer who shares no number. $idempotencyKey: null = make one, false = send none. /** Who is calling: the workspace, and the key's label, role and expiry. A cheap connection test. */ public function me(): array { return $this->call('GET', '/me'); } /** POST /send with a body you built: to|recipient, type, text|payload, phoneNumberId, replyTo. */ public function send(array $body, $idempotencyKey = null): array { return $this->call('POST', '/send', ['body' => $body, 'idempotencyKey' => $idempotencyKey]); } /** * A free-form text. Needs an open 24-hour window (the customer wrote in the last 24 hours). * * @param string|array $to * @param array{phoneNumberId?:string,replyTo?:string,idempotencyKey?:string|false} $options */ public function sendText($to, string $text, array $options = []): array { return $this->send(self::recipient($to) + ['type' => 'text', 'text' => $text] + self::sendExtras($options), $options['idempotencyKey'] ?? null); } /** * An approved template — the only way to open a new conversation. * * @param string|array $to * @param string $language Its language code ("en", "en_US"); sent as { code } the way a send expects it. * @param array $template Shortcuts — variables (the body's {{1}}, {{2}}… in order), headerText, * headerMedia (['id' => …] or ['link' => …, 'filename' => …]), couponCode, offerExpiresAt — or * Meta's own `components`. * @param array{phoneNumberId?:string,replyTo?:string,idempotencyKey?:string|false} $options */ public function sendTemplate($to, string $name, string $language, array $template = [], array $options = []): array { $t = ['name' => $name] + $template + ['language' => ['code' => $language]]; return $this->send( self::recipient($to) + ['type' => 'template', 'payload' => ['type' => 'template', 'template' => $t]] + self::sendExtras($options), $options['idempotencyKey'] ?? null ); } /** * An image, video, audio, document or sticker. Needs an open 24-hour window. * * @param string|array $to * @param string $type image | video | audio | document | sticker * @param array $media ['id' => ''] or ['link' => 'https://…'], plus caption, and filename for a document. * @param array{phoneNumberId?:string,replyTo?:string,idempotencyKey?:string|false} $options */ public function sendMedia($to, string $type, array $media, array $options = []): array { return $this->send( self::recipient($to) + ['type' => $type, 'payload' => ['type' => $type, $type => $media]] + self::sendExtras($options), $options['idempotencyKey'] ?? null ); } /** GET /messages — filters such as conversationId and status, plus paging. */ public function listMessages(array $query = []): array { return $this->call('GET', '/messages', ['query' => $query]); } /** GET /messages/{id} — by Fliok's id or by the wamid webhooks carry. */ public function getMessage(string $id): array { return $this->call('GET', '/messages/' . rawurlencode($id)); } /** * POST /media — upload up to 16 MB and get ['id' => …], reusable for about 30 days. * * @param string $bytes The file's raw bytes (file_get_contents()); base64-encoded here. * @param array{mimeType?:string,filename?:string,phoneNumberId?:string,idempotencyKey?:string|false} $options * The API's defaults are application/pdf and document.pdf. */ public function uploadMedia(string $bytes, array $options = []): array { $body = ['fileBase64' => base64_encode($bytes)]; foreach (['mimeType', 'filename', 'phoneNumberId'] as $k) { if (isset($options[$k])) { $body[$k] = $options[$k]; } } return $this->call('POST', '/media', ['body' => $body, 'idempotencyKey' => $options['idempotencyKey'] ?? null]); } /** GET /contacts — newest first. Filters: search, phone, channel; paging: limit, cursor. */ public function listContacts(array $query = []): array { return $this->call('GET', '/contacts', ['query' => $query]); } public function getContact(string $id): array { return $this->call('GET', '/contacts/' . rawurlencode($id)); } /** POST /contacts — phone (or e164), displayName, tags, attributes, optInStatus, optInSource. */ public function createContact(array $contact, $idempotencyKey = null): array { return $this->call('POST', '/contacts', ['body' => $contact, 'idempotencyKey' => $idempotencyKey]); } /** PATCH /contacts/{id} — displayName, tags, attributes, optInStatus, optInSource. */ public function updateContact(string $id, array $changes, $idempotencyKey = null): array { return $this->call('PATCH', '/contacts/' . rawurlencode($id), ['body' => $changes, 'idempotencyKey' => $idempotencyKey]); } public function deleteContact(string $id, $idempotencyKey = null): array { return $this->call('DELETE', '/contacts/' . rawurlencode($id), ['idempotencyKey' => $idempotencyKey]); } public function listConversations(array $query = []): array { return $this->call('GET', '/conversations', ['query' => $query]); } public function getConversation(string $id): array { return $this->call('GET', '/conversations/' . rawurlencode($id)); } /** POST /conversations — contactId or to, phoneNumberId, lookup, payload (the opening template). */ public function startConversation(array $body, $idempotencyKey = null): array { return $this->call('POST', '/conversations', ['body' => $body, 'idempotencyKey' => $idempotencyKey]); } /** PATCH /conversations/{id} — state, priority, isArchived, assignedAgentId. */ public function updateConversation(string $id, array $changes, $idempotencyKey = null): array { return $this->call('PATCH', '/conversations/' . rawurlencode($id), ['body' => $changes, 'idempotencyKey' => $idempotencyKey]); } public function markConversationRead(string $id, $idempotencyKey = null): array { return $this->call('POST', '/conversations/' . rawurlencode($id) . '/read', ['body' => new \stdClass(), 'idempotencyKey' => $idempotencyKey]); } public function addConversationNote(string $id, string $note, $idempotencyKey = null): array { return $this->call('POST', '/conversations/' . rawurlencode($id) . '/notes', ['body' => ['body' => $note], 'idempotencyKey' => $idempotencyKey]); } public function listTemplates(array $query = []): array { return $this->call('GET', '/templates', ['query' => $query]); } public function getTemplate(string $id): array { return $this->call('GET', '/templates/' . rawurlencode($id)); } /** POST /templates — name, language (a plain code), category, components; wabaConnectionId optional. */ public function createTemplate(array $template, $idempotencyKey = null): array { return $this->call('POST', '/templates', ['body' => $template, 'idempotencyKey' => $idempotencyKey]); } /** POST /templates/sync — fetch the latest statuses from Meta. */ public function syncTemplates($idempotencyKey = null): array { return $this->call('POST', '/templates/sync', ['body' => new \stdClass(), 'idempotencyKey' => $idempotencyKey]); } public function listBroadcasts(array $query = []): array { return $this->call('GET', '/broadcasts', ['query' => $query]); } public function getBroadcast(string $id): array { return $this->call('GET', '/broadcasts/' . rawurlencode($id)); } /** * POST /broadcasts — name, templateId, phoneNumberId, and segment or segmentId; variables, * scheduledAt, sendNow, confirmNewRecipients, knownOnly, followUpOf optional. */ public function createBroadcast(array $broadcast, $idempotencyKey = null): array { return $this->call('POST', '/broadcasts', ['body' => $broadcast, 'idempotencyKey' => $idempotencyKey]); } /** DELETE /broadcasts/{id} — cancel one that has not gone out. */ public function cancelBroadcast(string $id, $idempotencyKey = null): array { return $this->call('DELETE', '/broadcasts/' . rawurlencode($id), ['idempotencyKey' => $idempotencyKey]); } public function listWebhooks(): array { return $this->call('GET', '/webhooks'); } /** GET /webhooks/events — every event you can subscribe to. */ public function webhookEvents(): array { return $this->call('GET', '/webhooks/events'); } /** POST /webhooks — url, events (or one event). The answer carries the signing secret, shown only this once. */ public function createWebhook(array $webhook, $idempotencyKey = null): array { return $this->call('POST', '/webhooks', ['body' => $webhook, 'idempotencyKey' => $idempotencyKey]); } /** PATCH /webhooks/{id} — active, events, url. */ public function updateWebhook(string $id, array $changes, $idempotencyKey = null): array { return $this->call('PATCH', '/webhooks/' . rawurlencode($id), ['body' => $changes, 'idempotencyKey' => $idempotencyKey]); } /** POST /webhooks/{id}/rotate — a new secret; deliveries carry both signatures for 24 hours. */ public function rotateWebhook(string $id, $idempotencyKey = null): array { return $this->call('POST', '/webhooks/' . rawurlencode($id) . '/rotate', ['body' => new \stdClass(), 'idempotencyKey' => $idempotencyKey]); } public function deleteWebhook(string $id, $idempotencyKey = null): array { return $this->call('DELETE', '/webhooks/' . rawurlencode($id), ['idempotencyKey' => $idempotencyKey]); } public function listLeads(array $query = []): array { return $this->call('GET', '/leads', ['query' => $query]); } public function getLead(string $id): array { return $this->call('GET', '/leads/' . rawurlencode($id)); } /** POST /leads — phone, and name, email, source, stage, owner, rating, dealValue, tags, attributes, note… */ public function createLead(array $lead, $idempotencyKey = null): array { return $this->call('POST', '/leads', ['body' => $lead, 'idempotencyKey' => $idempotencyKey]); } public function updateLead(string $id, array $changes, $idempotencyKey = null): array { return $this->call('PATCH', '/leads/' . rawurlencode($id), ['body' => $changes, 'idempotencyKey' => $idempotencyKey]); } public function addLeadNote(string $id, string $note, $idempotencyKey = null): array { return $this->call('POST', '/leads/' . rawurlencode($id) . '/notes', ['body' => ['body' => $note], 'idempotencyKey' => $idempotencyKey]); } /** Invoices, proformas and quotations you send your customers (Fliok's own bills are GET /invoices). */ public function listSalesInvoices(array $query = []): array { return $this->call('GET', '/sales-invoices', ['query' => $query]); } public function getSalesInvoice(string $id): array { return $this->call('GET', '/sales-invoices/' . rawurlencode($id)); } /** POST /sales-invoices — ['document' => […], 'issue' => ['send' => true]] (leave issue out for a draft). */ public function createSalesInvoice(array $body, $idempotencyKey = null): array { return $this->call('POST', '/sales-invoices', ['body' => $body, 'idempotencyKey' => $idempotencyKey]); } /** Give a draft its number (and, with send, send it). */ public function issueSalesInvoice(string $id, bool $send, ?array $paid = null, $idempotencyKey = null): array { $body = ['send' => $send] + ($paid !== null ? ['paid' => $paid] : []); return $this->call('POST', '/sales-invoices/' . rawurlencode($id) . '/issue', ['body' => $body, 'idempotencyKey' => $idempotencyKey]); } /** Send an issued document again; $channels ⊆ ['whatsapp', 'email'] (default: whichever the customer has). */ public function sendSalesInvoice(string $id, ?array $channels = null, $idempotencyKey = null): array { $body = $channels !== null ? ['channels' => $channels] : new \stdClass(); return $this->call('POST', '/sales-invoices/' . rawurlencode($id) . '/send', ['body' => $body, 'idempotencyKey' => $idempotencyKey]); } /** * POST /payment-requests (WhatsApp Pay, India) — conversationId or to, items * ([['name' => …, 'quantity' => 1, 'unitPaise' => 49900]]), referenceId, taxPaise, shippingPaise, * discountPaise, body, footer, expiresInMinutes. */ public function createPaymentRequest(array $request, $idempotencyKey = null): array { return $this->call('POST', '/payment-requests', ['body' => $request, 'idempotencyKey' => $idempotencyKey]); } public function getPaymentRequest(string $id): array { return $this->call('GET', '/payment-requests/' . rawurlencode($id)); } // ── Webhooks ────────────────────────────────────────────────────────────────────────────────── /** * Is this webhook delivery really from Fliok? Pass the RAW body (file_get_contents('php://input') * — never re-encoded JSON, which will not match), the request headers (getallheaders(), or * $_SERVER) and the endpoint's secret. * * Fliok signs "{x-webhook-timestamp}.{body}" with HMAC-SHA256 and sends it as * "x-webhook-signature: sha256=". For 24 hours after a secret is rotated the header carries * two, comma-separated (new, then old); one match is enough. A timestamp more than five minutes * from now is refused, so a captured delivery cannot be replayed later. * * @param array $headers Any case: ['X-Webhook-Signature' => …], or $_SERVER's HTTP_X_WEBHOOK_SIGNATURE. * @param int|null $now "Now" in unix seconds — for tests. */ public static function verifyWebhook(string $rawBody, array $headers, string $secret, int $toleranceSeconds = 300, ?int $now = null): bool { if ($secret === '') { return false; } $ts = self::headerOf($headers, 'x-webhook-timestamp'); $sigHeader = self::headerOf($headers, 'x-webhook-signature'); if ($ts === null || $sigHeader === null || !preg_match('/^\d{1,12}$/', $ts)) { return false; } $now = $now ?? time(); if (abs($now - (int) $ts) > $toleranceSeconds) { return false; } $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret); $ok = false; foreach (explode(',', $sigHeader) as $part) { if (preg_match('/^sha256=([0-9a-fA-F]{64})$/', trim($part), $m)) { // Every candidate is compared (no early exit), each in constant time. $ok = hash_equals($expected, strtolower($m[1])) || $ok; } } return $ok; } // ── Helpers (public where the tests, or a custom transport, use them) ─────────────────────── /** The headers every request carries. */ public static function buildHeaders(string $apiKey, ?string $idempotencyKey, bool $hasBody): array { $h = [ 'Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json', 'User-Agent' => 'fliok-php/' . self::VERSION, ]; if ($hasBody) { $h['Content-Type'] = 'application/json'; } if ($idempotencyKey !== null) { $h['Idempotency-Key'] = $idempotencyKey; } return $h; } /** null is left out, booleans become "true"/"false", a list repeats its key (tag=a&tag=b). */ public static function encodeQuery(array $query): string { $parts = []; foreach ($query as $k => $v) { foreach (is_array($v) ? $v : [$v] as $item) { if ($item === null) { continue; } $s = is_bool($item) ? ($item ? 'true' : 'false') : (string) $item; $parts[] = rawurlencode((string) $k) . '=' . rawurlencode($s); } } return implode('&', $parts); } /** A random version-4 UUID — the default Idempotency-Key. */ public static function uuid4(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } /** * The default transport: one HTTP request with curl, no redirects followed. * * @return array{status:int,headers:array,body:string} */ public static function curlTransport(string $method, string $url, array $headers, ?string $body, int $timeout): array { $lines = ['Expect:']; // no "100-continue" round trip before a large upload foreach ($headers as $name => $value) { $lines[] = $name . ': ' . $value; } $resHeaders = []; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$resHeaders): int { if (strncasecmp($line, 'HTTP/', 5) === 0) { $resHeaders = []; // a new status line: keep only the final answer's headers } elseif (strpos($line, ':') !== false) { [$name, $value] = explode(':', $line, 2); $resHeaders[strtolower(trim($name))] = trim($value); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $out = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if (PHP_VERSION_ID < 80000) { curl_close($ch); // a no-op since PHP 8, where the handle is freed with the object } if ($out === false || $errno !== 0) { throw new \RuntimeException($error !== '' ? $error : 'curl error ' . $errno, $errno); } return ['status' => $status, 'headers' => $resHeaders, 'body' => (string) $out]; } /** @param mixed $body */ private static function encodeBody($body): string { // The API takes JSON objects; PHP's empty array would go out as "[]". if ($body === []) { return '{}'; } return json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } /** @return mixed */ private static function decode(string $text) { if ($text === '') { return null; } $v = json_decode($text, true); return json_last_error() === JSON_ERROR_NONE ? $v : $text; // a proxy's HTML error page, kept as text } /** @param string|array $to */ private static function recipient($to): array { return is_array($to) ? ['recipient' => (string) $to['recipient']] : ['to' => $to]; } private static function sendExtras(array $options): array { return array_intersect_key($options, ['phoneNumberId' => true, 'replyTo' => true]); } /** Exponential with jitter: 0.5 s, 1 s, 2 s… capped at 8 s, each scaled by a random 50–100%. */ private static function backoff(int $attempt): float { $base = min(self::RETRY_CAP_SECONDS, self::RETRY_BASE_SECONDS * (2 ** $attempt)); return $base / 2 + (mt_rand() / mt_getrandmax()) * ($base / 2); } /** @param mixed $body */ private static function errorFrom(int $status, array $headers, $body, ?string $requestId): FliokException { $b = is_array($body) ? $body : []; $code = isset($b['error']) && is_string($b['error']) ? $b['error'] : null; $message = (isset($b['message']) && is_string($b['message']) && $b['message'] !== '') ? $b['message'] : ((isset($b['detail']) && is_string($b['detail']) && $b['detail'] !== '') ? $b['detail'] : ($code ?? "Fliok answered HTTP {$status}.")); return new FliokException($status, $code, $message, $requestId, self::retryAfterOf($headers), $body); } private static function retryAfterOf(array $headers): ?int { $v = isset($headers['retry-after']) ? trim((string) $headers['retry-after']) : ''; if ($v === '') { return null; } if (preg_match('/^\d+$/', $v)) { return (int) $v; } $at = strtotime($v); // an HTTP date is allowed too return $at === false ? null : max(0, $at - time()); } private static function rateLimitOf(array $headers): ?array { foreach (['ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset'] as $h) { if (!isset($headers[$h]) || !is_numeric($headers[$h])) { return null; } } return [ 'limit' => (int) $headers['ratelimit-limit'], 'remaining' => (int) $headers['ratelimit-remaining'], 'reset' => (int) $headers['ratelimit-reset'], ]; } /** @param mixed $body */ private static function requestIdOf(array $headers, $body): ?string { if (is_array($body)) { foreach (['requestId', 'reference'] as $k) { if (isset($body[$k]) && is_string($body[$k])) { return $body[$k]; } } } return $headers['x-request-id'] ?? $headers['cf-ray'] ?? null; } private static function headerOf(array $headers, string $name): ?string { $server = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); foreach ($headers as $k => $v) { if (strcasecmp((string) $k, $name) === 0 || $k === $server) { return is_array($v) ? implode(',', $v) : (string) $v; } } return null; } }