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.
Plain 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'];
}
}<?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.
<?php
return [
// ...
'sendozi' => [
'key' => env('SENDOZI_API_KEY'),
'sender_id' => env('SENDOZI_SENDER_ID', 'Sendozi'),
'webhook_secret' => env('SENDOZI_WEBHOOK_SECRET'),
],
];<?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;
}
}<?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
<?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.
Related reading
Getting started
Quickstart: send your first SMS
Get an API key, send a sandbox SMS, read the response envelope, register a delivery webhook and move to production. A complete first integration in one page.
Channels
SMS API reference
Endpoints, request fields, routing, batching, sender ID management and page-based cost for sending SMS to Nigerian numbers through the Sendozi API.
Reliability
Delivery webhooks
Register a webhook endpoint, receive message.delivered and message.failed events, verify the signature over the raw body, and build a receiver that survives retries and reordering.
Reliability
Idempotency
How Idempotency-Key works on Sendozi send endpoints, what happens on a repeat with the same or a different body, how long keys are retained, and how to choose a key.
Integrations
Send SMS with Node.js
A complete Node.js integration for sending SMS to Nigerian numbers: a typed client, phone normalisation, idempotent retries, error handling and an Express webhook receiver.