Integrations
Send SMS with PHP and Laravel
There is no Sendozi PHP package. Plain PHP posts JSON with cURL; Laravel uses Http::withToken. Encode each body once, retry network errors, 429 and 5xx with the same Idempotency-Key, keep the key in config, and queue anything larger than a single OTP.
Plain PHP
<?php
declare(strict_types=1);
final class SendoziException extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly string $errorCode,
string $message,
public readonly string $resolution,
public readonly string $requestId,
) {
parent::__construct("{$errorCode}: {$message}");
}
}
final class Sendozi
{
private const BASE_URL = 'https://api.sendozi.com/v1';
private const RETRYABLE = [429, 500, 502, 503, 504];
public function __construct(private readonly string $apiKey)
{
}
/** One number or many, on the promotional route. */
public function sendPromotional(string|array $to, string $message, string $sender, string $idempotencyKey): array
{
$many = is_array($to);
$body = ['route' => 'promotional', 'sender' => $sender, 'message' => $message]
+ ($many ? ['recipients' => $to] : ['recipient' => $to]);
return $this->call('POST', $many ? '/sms/bulk' : '/sms/send', $body, $idempotencyKey, $many ? 300 : 30);
}
/** One transactional SMS, in your own words. The key keeps a retry from sending twice. */
public function sendTransactional(string $to, string $message, string $sender, string $idempotencyKey): array
{
return $this->call('POST', '/sms/send', [
'route' => 'transactional',
'sender' => $sender,
'recipient' => $to,
'message' => $message,
], $idempotencyKey);
}
public function getMessage(string $id): array
{
return $this->call('GET', '/messages/' . rawurlencode($id));
}
public function getBatch(string $id): array
{
return $this->call('GET', '/message-batches/' . rawurlencode($id));
}
private function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null,
int $timeout = 30, int $attempts = 4): array
{
// Encoded once, so every retry sends identical bytes.
$payload = $body === null ? null : json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$headers = ['Authorization: Bearer ' . $this->apiKey];
if ($payload !== null) $headers[] = 'Content-Type: application/json';
if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
$canRetry = $method === 'GET' || $idempotencyKey !== null;
for ($attempt = 1; ; $attempt++) {
$retryAfter = 0;
$curl = curl_init(self::BASE_URL . $path);
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HEADERFUNCTION => function ($c, string $line) use (&$retryAfter) {
if (stripos($line, 'retry-after:') === 0) $retryAfter = (int) trim(substr($line, 12));
return strlen($line);
},
] + ($payload !== null ? [CURLOPT_POSTFIELDS => $payload] : []));
$raw = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($curl);
curl_close($curl);
if ($raw === false) {
if (! $canRetry || $attempt >= $attempts) throw new RuntimeException("Sendozi request failed: {$curlError}");
usleep((2 ** $attempt) * 500_000);
continue;
}
$envelope = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);
if ($envelope['success'] ?? false) return $envelope['data'];
$error = $envelope['error'] ?? [];
$exception = new SendoziException($status, $error['code'] ?? 'unknown', $error['message'] ?? '',
$error['resolution'] ?? '', $envelope['request_id'] ?? '');
if (! $canRetry || ! in_array($status, self::RETRYABLE, true) || $attempt >= $attempts) throw $exception;
$retryAfter > 0 ? sleep($retryAfter) : usleep((2 ** $attempt) * 500_000 + random_int(0, 250_000));
}
}
}<?php
$sendozi = new Sendozi(getenv('SENDOZI_API_KEY'));
try {
$result = $sendozi->sendTransactional(
to: '08012345678',
message: "Your Acme code is {$code}. It expires in 10 minutes.",
sender: getenv('SENDOZI_TRANSACTIONAL_SENDER'),
idempotencyKey: "login-{$attemptId}",
);
echo $result['batch_id'], ' ', $result['message_id'] ?? '', PHP_EOL;
} catch (SendoziException $e) {
// Branch on errorCode, never on the message text.
error_log("sendozi {$e->errorCode} request={$e->requestId}");
}Numbers can be sent as 08012345678, 8012345678, 2348012345678 or +2348012345678; Sendozi normalises them. Invalid and repeated numbers are dropped and counted in invalid_count and duplicates_removed rather than failing the request, unless none is valid.
Laravel
<?php
return [
// ...
'sendozi' => [
'key' => env('SENDOZI_API_KEY'),
'sender' => env('SENDOZI_SENDER'),
'transactional_sender' => env('SENDOZI_TRANSACTIONAL_SENDER'),
'webhook_secret' => env('SENDOZI_WEBHOOK_SECRET'),
],
];<?php
namespace App\Services;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class SendoziClient
{
private const BASE_URL = 'https://api.sendozi.com/v1';
public function send(array $body, string $idempotencyKey, bool $bulk = false): array
{
// Encoded once and sent with withBody, so every retry is byte-identical.
$json = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$response = Http::withToken(config('services.sendozi.key'))
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->acceptJson()
->timeout($bulk ? 300 : 30)
->retry(4, fn (int $attempt, \Throwable $e) => $this->delayFor($attempt, $e), function (\Throwable $e) {
// Network errors, 429 and 5xx only.
return ! $e instanceof RequestException
|| in_array($e->response->status(), [429, 500, 502, 503, 504], true);
}, throw: false)
->withBody($json, 'application/json')
->post(self::BASE_URL . ($bulk ? '/sms/bulk' : '/sms/send'));
$envelope = $response->json();
if (! ($envelope['success'] ?? false)) {
throw new \RuntimeException(($envelope['error']['code'] ?? 'unknown') . ': ' . ($envelope['error']['message'] ?? ''));
}
return $envelope['data'];
}
private function delayFor(int $attempt, \Throwable $e): int
{
$after = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;
return $after > 0 ? $after * 1000 : (2 ** $attempt) * 500;
}
}<?php
namespace App\Jobs;
use App\Services\SendoziClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class SendCampaign implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 3;
public int $timeout = 600;
public function __construct(public string $campaignId, public array $numbers, public string $message)
{
}
public function handle(SendoziClient $sendozi): void
{
// The same key on every retry of this job: it cannot queue the send twice.
$batch = $sendozi->send([
'route' => 'promotional',
'sender' => config('services.sendozi.sender'),
'recipients' => $this->numbers,
'message' => $this->message,
], "campaign-{$this->campaignId}", bulk: true);
logger()->info('sendozi batch queued', ['batch_id' => $batch['batch_id'], 'accepted' => $batch['accepted_count']]);
}
}The webhook route
<?php
use App\Jobs\RecordSendoziEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/sendozi', function (Request $request) {
$raw = $request->getContent();
$timestamp = (string) $request->header('x-sendozi-timestamp');
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, config('services.sendozi.webhook_secret'));
abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300, 401);
abort_unless(hash_equals($expected, (string) $request->header('x-sendozi-signature')), 401);
RecordSendoziEvent::dispatch(json_decode($raw, true)); // dedupe on the event id
return response()->noContent();
});Frequently asked questions
- Is there a Sendozi package for Laravel or Composer?
- No. Sendozi publishes no PHP package. Laravel's HTTP client or cURL is enough, and the classes on this page are a complete integration.
- How do I send bulk SMS from Laravel?
- Post an array of numbers as recipients to /v1/sms/bulk in one request, from a queued job, with route promotional and an Idempotency-Key. It becomes one batch and counts as one request against the rate limit.
- Why does my Laravel webhook return 419?
- CSRF protection. Register the route in routes/api.php, or add its path to the CSRF exception list, and verify the Sendozi signature instead.
Related reading
Getting started
Quickstart: send your first SMS
Create a sandbox key, send your first SMS, read the queued batch it returns, follow the message to delivery, and switch to production. A complete first integration.
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Sending
Transactional SMS: OTPs, alerts and receipts
What a transactional send needs: a Transactional Sender ID, one recipient, your own message and an Idempotency-Key. No templates to register. With a working OTP example.
Reliability
Webhooks
Register a webhook endpoint, the eight events and their payloads, verifying the signature over the raw body, how retries and pausing work, and a receiver that survives repeats and reordering.
Integrations
Send SMS with Node.js
A complete Node.js integration for Sendozi: a dependency-free client, promotional and transactional sends, safe retries, batch tracking, and a verified webhook receiver for Express and Next.js.