Send an SMS to Cameroon from Laravel
Laravel already has everything it needs: an HTTP client, notifications, a queue. What is missing is a channel that talks to a Cameroonian carrier. This page builds that channel end to end — a service, an OTP notification, a verified webhook, and tests that send no SMS at all. No package to install.
Prerequisites
- Laravel 10 or 11, on PHP 8.1 or newer.
- A G-SMS API key starting with
gsk_, created in the console for a given application. - A working queue if you use the
ShouldQueuenotification shown here — otherwise the network call happens inside your user's request. - A shared cache (Redis or database) to deduplicate webhooks. The
arraydriver will not do: it forgets everything between requests.
Configuration
The key stays in the environment, never in the repository. Going through config/services.php keeps config:cache working in production.
# .env
GSMS_URL="https://api.sms.gworldship.com"
GSMS_API_KEY="gsk_votre_cle_api"
GSMS_WEBHOOK_SECRET="whsec_your_secret"config/services.php
<?php
// config/services.php — add this entry to the returned array.
return [
// …
'gsms' => [
'url' => env('GSMS_URL', 'https://api.sms.gworldship.com'),
'key' => env('GSMS_API_KEY'),
'webhook_secret' => env('GSMS_WEBHOOK_SECRET'),
],
];The service
Deliberately without ->throw(): on this API, two error codes are ordinary answers. The 409 says the message already exists, the 402 says credit has run out. Turning them into a generic HTTP exception throws away the most useful information you have.
app/Services/GSms.php
<?php
// app/Services/GSms.php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class GSms
{
private function client(): PendingRequest
{
return Http::baseUrl(config('services.gsms.url'))
->withToken(config('services.gsms.key')) // Authorization: Bearer gsk_…
->acceptJson()
->timeout(15);
}
/**
* Sends an SMS and returns the raw response, deliberately without ->throw().
* A 409 is not a failure, and a 402 will not fix itself on retry.
* The caller decides.
*/
public function send(string $to, string $text, ?string $clientReference = null): Response
{
$payload = ['to' => $to, 'text' => $text];
if ($clientReference !== null) {
$payload['clientReference'] = $clientReference;
}
return $this->client()->post('/v1/sms', $payload);
}
/** Account balance, in CFA francs. */
public function balanceXaf(): int
{
return (int) $this->client()->get('/v1/balance')->json('balanceXaf');
}
}A notification channel
Going through Laravel's notification system rather than calling the service directly gives you the queue, retries, logging and testability for free. The channel turns each response code into a decision: record it, ignore it, or try again.
app/Notifications/Channels/GSmsChannel.php
<?php
// app/Notifications/Channels/GSmsChannel.php
namespace App\Notifications\Channels;
use App\Exceptions\GSmsOutOfCredit;
use App\Services\GSms;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
class GSmsChannel
{
public function __construct(private GSms $gsms)
{
}
public function send(object $notifiable, Notification $notification): void
{
$to = $notifiable->routeNotificationFor('gsms', $notification);
if (! $to) {
return;
}
$message = $notification->toGSms($notifiable);
$response = $this->gsms->send($to, $message['text'], $message['reference'] ?? null);
if ($response->status() === 202) {
Log::info('SMS accepted', [
'messageId' => $response->json('messageId'),
'priceXaf' => $response->json('priceXaf'),
'balanceAfterXaf' => $response->json('balanceAfterXaf'),
]);
return;
}
if ($response->status() === 409) {
// The body is NOT an error: it is the message already sent under
// this reference. Typically, this job had already run.
Log::info('SMS already sent', ['messageId' => $response->json('messageId')]);
return;
}
if ($response->status() === 402) {
// Nothing was charged. Retrying right now would be pointless, so we
// raise a business exception rather than a technical failure.
throw new GSmsOutOfCredit('G-SMS credit exhausted.');
}
// 429 (too fast, or daily quota), 503 (carrier paused), 5xx: those do
// deserve another attempt, so let the exception bubble up.
Log::warning('G-SMS failure', [
'status' => $response->status(),
'code' => $response->json('code'),
'retryAfter' => $response->header('Retry-After'),
]);
$response->throw();
}
}GSmsOutOfCredit is yours to write: an empty class extending RuntimeException is enough. It exists so that "out of credit" reads differently from "network down" in your logs and your alerting.
The OTP notification
A login code is the most common use case, and the one where idempotency matters most: if the worker replays the job after a network hiccup, the user must not get two messages nor be charged twice. The reference embeds the code itself, so a replay of the same job produces the same reference and the API answers 409 without charging anything.
app/Notifications/SendLoginCode.php
<?php
// app/Notifications/SendLoginCode.php
namespace App\Notifications;
use App\Notifications\Channels\GSmsChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class SendLoginCode extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private string $code)
{
}
public function via(object $notifiable): array
{
return [GSmsChannel::class];
}
/** @return array{text: string, reference: string} */
public function toGSms(object $notifiable): array
{
return [
'text' => "Your login code is {$this->code}. It expires in 5 minutes.",
// Same job replayed, same reference: the API answers 409 instead of
// charging a second SMS. 64 characters maximum.
'reference' => 'login-' . $notifiable->getKey() . '-' . $this->code,
];
}
}The channel needs to know where to write. On the notifiable model:
<?php
// app/Models/User.php — this is how the channel finds the number:
// routeNotificationFor('gsms') calls routeNotificationForGsms().
class User extends Authenticatable
{
use Notifiable;
// …
/** Expected format: +2376XXXXXXXX, 2376XXXXXXXX or 6XXXXXXXX. */
public function routeNotificationForGsms(): ?string
{
return $this->phone;
}
}And in use:
<?php
// From a controller, a service, a console command…
$code = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
cache()->put("login-code:{$user->id}", $code, now()->addMinutes(5));
$user->notify(new SendLoginCode($code));
// The notification implements ShouldQueue: the user's HTTP request returns
// immediately, and the call to G-SMS happens in a worker.The responses you have to handle
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 202 | — | Accepted, charged, queued. | Store messageId to match the webhook later. |
| 402 | INSUFFICIENT_BALANCE | Credit exhausted. Nothing charged. | Do not loop the job: alert, top up, replay. |
| 409 | — | This clientReference was already used. | Treat it as sent. The body carries the original message. |
| 429 | RATE_LIMITED | More than 10 requests per second on the key. | Release the job back after Retry-After (1 s). |
| 429 | QUOTA_EXCEEDED | Daily quota reached (disabled by default). | Postpone to tomorrow or ask for a higher quota. |
| 503 | OPERATOR_DISABLED | Sending to this carrier is paused. Nothing charged. | Reschedule after Retry-After (one hour). |
{ code, message } object, it is the message already accepted, shaped like the 202 and without balanceAfterXaf. A $response->throw() applied to every non-success therefore turns a successful send into an exception, and your job will keep coming back forever. Receiving delivery receipts
A 202 says "accepted", not "received". The rest arrives as a push to a URL you declare in the console. There is a single event, sms.sent, emitted on every status change: SENT, then DELIVERED or FAILED, the last two being final. A FAILED message is refunded automatically, once.
<?php
// routes/api.php — outside the web group, therefore no CSRF token.
use App\Http\Controllers\GSmsWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/gsms', GSmsWebhookController::class);app/Http/Controllers/GSmsWebhookController.php
<?php
// app/Http/Controllers/GSmsWebhookController.php
namespace App\Http\Controllers;
use App\Models\SmsMessage;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
class GSmsWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
// The RAW body: the signature covers the bytes received, not an array
// re-serialised by Laravel.
$raw = $request->getContent();
$timestamp = (string) $request->header('X-GSms-Timestamp');
$signature = (string) $request->header('X-GSms-Signature');
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return response('stale timestamp', 400);
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$raw . '.' . $timestamp,
config('services.gsms.webhook_secret')
);
// hash_equals: constant-time comparison, non-negotiable.
if (! hash_equals($expected, $signature)) {
return response('bad signature', 401);
}
$event = $request->json();
// At-least-once delivery. Cache::add only writes if the key is absent:
// it needs a shared store (Redis, database), not the 'array' driver.
if (! Cache::add('gsms-event:' . $event->get('eventId'), true, now()->addDays(2))) {
return response()->noContent();
}
$data = $event->get('data');
SmsMessage::where('message_id', $data['messageId'])->update([
'status' => $data['status'], // SENT, DELIVERED or FAILED
'failure_reason' => $data['failureReason'], // null until something fails
]);
// 204: any 2xx counts as an acknowledgement. Answer within 10 s.
return response()->noContent();
}
}- The signature is
HMAC_SHA256(secret, raw_body + "." + timestamp), lowercase hexadecimal, prefixed withsha256=. - Reject a timestamp more than five minutes off: that is the replay protection.
applicationIdholds the application slug, not its technical identifier, despite the name.- Deduplicate on
eventId.X-GSms-Deliverychanges on every attempt and is not suitable. - Unlike API responses, the webhook body keeps its null fields:
clientReferenceandfailureReasonare always present, possibly asnull. - On failure, attempts space out: 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h, stopping after 48 h. The delivery becomes
EXHAUSTEDand stays replayable from the console.
Testing without sending a single SMS
Http::fake() intercepts the call: your tests check the message body and the idempotency reference without spending anything. Do this early — it is the only way to prove that a replayed job produces the same clientReference.
tests/Feature/SendLoginCodeTest.php — Pest syntax; the body is identical in PHPUnit
<?php
// tests/Feature/SendLoginCodeTest.php
use App\Models\User;
use App\Notifications\SendLoginCode;
use Illuminate\Support\Facades\Http;
it('sends the login code by SMS', function () {
Http::fake([
'api.sms.gworldship.com/v1/sms' => Http::response([
'messageId' => '8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21',
'to' => '+237690000000',
'operator' => 'ORANGE',
'segments' => 1,
'priceXaf' => 5,
'status' => 'QUEUED',
'clientReference' => 'login-1-4821',
'balanceAfterXaf' => 95,
'createdAt' => '2026-09-12T08:41:07Z',
], 202),
]);
$user = User::factory()->create(['phone' => '+237690000000']);
$user->notify(new SendLoginCode('4821'));
Http::assertSent(fn ($request) => $request['to'] === '+237690000000'
&& str_contains($request['text'], '4821')
&& $request['clientReference'] === 'login-' . $user->id . '-4821');
});What the example on this page costs
| Text sent | "Your login code is 4821. It expires in 5 minutes." — 49 characters |
| Encoding | GSM-7: every character is in the standard alphabet |
| Segments | 1 (up to 160 characters in GSM-7) |
| Recipient | +237690000000 — the 69 prefix, so Orange |
| Cost | 5 XAF |
5 XAF to MTN as well, 7 XAF to Camtel and Nexttel, 10 XAF on the default route, used for prefixes outside the ART numbering plan. A failed message is refunded.
What will surprise you
- An apostrophe can double the bill. As long as the text fits the GSM-7 alphabet, a segment holds 160 characters; otherwise it drops to 70. The typographic apostrophe "’", lowercase "ç", "ê" and the guillemets "«»" are not part of that alphabet. A Blade view that applies smart quotes is enough to flip all your messages. Beyond one segment, capacity falls to 153 characters in GSM-7 and 67 in UCS-2; these sizes are administrable, so trust the
segmentsvalue returned. - The recipient sees a number, not a name. There is no custom sender ID: G-SMS carries transactional messages from a Cameroonian SIM, and that number is what shows up. Name your service in the text.
- The limits. 10 requests per second per key, 1,600 characters per message, 64 characters for
clientReference. The daily quota is disabled by default. - A message belongs to one application only.
GET /v1/sms/{messageId}returns 404, not 403, if the key belongs to another application.
Ready to send your first SMS?
Create an account, get 100 FCFA of credit and an API key straight away.
Create account