Skip to content
Sendozi

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.

By SendoziUpdated 1 min read

Plain PHP

PHP 8.1+, ext-curl and ext-json
Sendozi.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));
        }
    }
}
Usage
<?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

Configuration, a client and a queued campaign job
config/services.php
<?php

return [
    // ...
    'sendozi' => [
        'key' => env('SENDOZI_API_KEY'),
        'sender' => env('SENDOZI_SENDER'),
        'transactional_sender' => env('SENDOZI_TRANSACTIONAL_SENDER'),
        'webhook_secret' => env('SENDOZI_WEBHOOK_SECRET'),
    ],
];
app/Services/SendoziClient.php
<?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;
    }
}
app/Jobs/SendCampaign.php
<?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

routes/api.php: no CSRF, verify the timestamped signature over the raw body
routes/api.php
<?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.

Run it against sandbox

A sk_test_ key runs the same validation without sending or charging.