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. Normalise recipients to +234 first, put the key in config rather than in code, and queue anything larger than a single OTP.

By SendoziUpdated 1 min read

Plain PHP

No dependencies beyond ext-curl and ext-json
Sendozi.php
<?php

declare(strict_types=1);

final class SendoziException extends RuntimeException
{
    public function __construct(
        public readonly string $errorCode,
        string $message,
        public readonly string $resolution,
        public readonly string $requestId,
        public readonly int $status,
    ) {
        parent::__construct($message);
    }
}

final class Sendozi
{
    private const BASE_URL = 'https://api.sendozi.com';

    public function __construct(
        private readonly string $apiKey,
        private readonly string $senderId = 'Sendozi',
    ) {
    }

    /** 08012345678 and 2348012345678 both become +2348012345678. */
    public static function normalisePhone(string $raw): ?string
    {
        $value = preg_replace('/[\s()\-.]/', '', trim($raw)) ?? '';

        if (str_starts_with($value, '00')) {
            $value = '+' . substr($value, 2);
        }

        if (preg_match('/^0\d{10}$/', $value)) {
            $value = '+234' . substr($value, 1);
        } elseif (preg_match('/^234\d{10}$/', $value)) {
            $value = '+' . $value;
        } elseif (preg_match('/^[789][01]\d{8}$/', $value)) {
            $value = '+234' . $value;
        }

        return preg_match('/^\+234[789][01]\d{8}$/', $value) ? $value : null;
    }

    public function sendSms(
        string $to,
        string $message,
        string $smsType = 'transactional',
        ?string $idempotencyKey = null,
    ): array {
        $recipient = self::normalisePhone($to);
        if ($recipient === null) {
            throw new InvalidArgumentException("Not a Nigerian mobile number: {$to}");
        }

        return $this->post('/v1/sms/send', [
            'sender' => $this->senderId,
            'recipient' => $recipient,
            'message' => $message,
            'sms_type' => $smsType,
        ], $idempotencyKey);
    }

    private function post(string $path, array $payload, ?string $idempotencyKey): array
    {
        $headers = [
            'Authorization: Bearer ' . $this->apiKey,
            'Content-Type: application/json',
        ];
        if ($idempotencyKey !== null) {
            $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
        }

        $curl = curl_init(self::BASE_URL . $path);
        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 15,
        ]);

        $raw = curl_exec($curl);
        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $error = curl_error($curl);
        curl_close($curl);

        if ($raw === false) {
            throw new RuntimeException("Sendozi request failed: {$error}");
        }

        $envelope = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);

        if (! ($envelope['success'] ?? false)) {
            throw new SendoziException(
                $envelope['error']['code'] ?? 'request_failed',
                $envelope['error']['message'] ?? 'The request could not be completed.',
                $envelope['error']['resolution'] ?? '',
                $envelope['request_id'] ?? '',
                $status,
            );
        }

        return $envelope['data'];
    }
}
Usage
<?php

$sendozi = new Sendozi(getenv('SENDOZI_API_KEY'), getenv('SENDOZI_SENDER_ID'));

try {
    $message = $sendozi->sendSms(
        to: '08012345678',
        message: 'Your Acme code is 492811. It expires in 10 minutes.',
        smsType: 'transactional',
        idempotencyKey: 'otp-' . $attemptId,
    );

    echo $message['id'], ' ', $message['status'], PHP_EOL;
} catch (SendoziException $exception) {
    // Branch on errorCode, never on the message text.
    error_log("sendozi {$exception->errorCode} request={$exception->requestId}");
}

Laravel

In Laravel, put the credentials in config/services.php so they come from the environment and are cacheable, then wrap the calls in a service the rest of the application can resolve.

Configuration, service and queued job
config/services.php
<?php

return [
    // ...
    'sendozi' => [
        'key' => env('SENDOZI_API_KEY'),
        'sender_id' => env('SENDOZI_SENDER_ID', 'Sendozi'),
        'webhook_secret' => env('SENDOZI_WEBHOOK_SECRET'),
    ],
];
app/Services/SendoziClient.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class SendoziClient
{
    private const BASE_URL = 'https://api.sendozi.com';

    public function sendSms(
        string $to,
        string $message,
        string $smsType = 'transactional',
        ?string $idempotencyKey = null,
    ): array {
        $recipient = $this->normalise($to);

        $request = Http::withToken(config('services.sendozi.key'))
            ->timeout(15)
            ->acceptJson();

        if ($idempotencyKey !== null) {
            $request = $request->withHeaders(['Idempotency-Key' => $idempotencyKey]);
        }

        $envelope = $request->post(self::BASE_URL . '/v1/sms/send', [
            'sender' => config('services.sendozi.sender_id'),
            'recipient' => $recipient,
            'message' => $message,
            'sms_type' => $smsType,
        ])->json();

        if (! ($envelope['success'] ?? false)) {
            throw new RuntimeException(
                sprintf('%s: %s', $envelope['error']['code'] ?? 'request_failed', $envelope['error']['message'] ?? ''),
            );
        }

        return $envelope['data'];
    }

    private function normalise(string $raw): string
    {
        $value = preg_replace('/[\s()\-.]/', '', trim($raw)) ?? '';

        if (preg_match('/^0\d{10}$/', $value)) {
            $value = '+234' . substr($value, 1);
        } elseif (preg_match('/^234\d{10}$/', $value)) {
            $value = '+' . $value;
        }

        abort_unless(preg_match('/^\+234[789][01]\d{8}$/', $value), 422, 'Invalid Nigerian mobile number.');

        return $value;
    }
}
app/Jobs/SendSms.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;
use Illuminate\Queue\SerializesModels;

class SendSms implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 4;

    /** Exponential backoff between attempts, in seconds. */
    public array $backoff = [5, 15, 60];

    public function __construct(
        public string $phone,
        public string $message,
        public string $eventId,
    ) {
    }

    public function handle(SendoziClient $sendozi): void
    {
        // The event id keeps the key stable across every retry of this job.
        $sendozi->sendSms(
            to: $this->phone,
            message: $this->message,
            idempotencyKey: "event-{$this->eventId}",
        );
    }
}

The webhook route

Exclude it from CSRF, verify over the raw body, queue the work
routes/web.php
<?php

use App\Jobs\RecordSendoziDelivery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// Add '/hooks/sendozi' to the CSRF exception list, or register this in routes/api.php.
Route::post('/hooks/sendozi', function (Request $request) {
    $raw = $request->getContent();
    $expected = hash_hmac('sha256', $raw, config('services.sendozi.webhook_secret'));

    abort_unless(
        hash_equals($expected, (string) $request->header('x-sendozi-signature')),
        401,
    );

    RecordSendoziDelivery::dispatch(json_decode($raw, true));

    return response()->noContent();
});

Frequently asked questions

Is there a Sendozi package for Laravel or Composer?
No. Sendozi publishes no PHP package. Laravel's built-in HTTP client is enough, and the service class on this page is a complete integration.
How do I send bulk SMS from Laravel?
Post an array of normalised +234 recipients to /v1/sms/bulk in one request, from a queued job. One request carries the whole batch 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, then verify the Sendozi signature instead.

Run it against sandbox

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