laravel maintained by nando
Nando Laravel SDK
Official Laravel package for the Nando SMS public API. Send messages from your app and verify signed delivery webhooks — with a typed client, a fluent builder, and drop-in webhook middleware.
- ✅ Fluent SMS builder and a typed client
- ✅ First-class error handling (
NandoApiException) - ✅ Ed25519 webhook signature verification + middleware
- ✅ Config publishing and package auto-discovery
Requirements
- PHP 8.1+
- Laravel 10, 11, or 12
ext-sodium(bundled with PHP 7.2+)
Installation
composer require nando/laravel
The service provider and Nando facade are auto-discovered. Publish the config if you want to tweak it:
php artisan vendor:publish --tag=nando-config
Set your credentials in .env:
NANDO_API_KEY=sk_nando_live0123456789abcdef...
NANDO_WEBHOOK_PUBLIC_KEY=base64-encoded-ed25519-public-key
# optional
NANDO_BASE_URL=https://nando.ge/api
NANDO_TIMEOUT=15
Generate the API key and copy the webhook public key from the Nando platform under API.
Sending SMS
Fluent builder
use Nando\Laravel\Facades\Nando;
Nando::sms()
->transactional() // ->otp() / ->regular()
->to('+995555123456') // or ->to(['+99555...', '+99557...'])
->body('Your verification code is 123456')
->send();
Pick the sender brand (defaults to your account's default brand when omitted) and schedule for later:
Nando::sms()
->brand(1)
->regular()
->to(['+995555111111', '+995555222222'])
->body('Big sale this weekend!')
->scheduledAt(now()->addHours(3))
->send();
Data object or raw payload
sendSms() returns a Nando\Laravel\Data\Sms DTO and accepts either a SendSmsData DTO or a raw array:
use Nando\Laravel\Data\SendSmsData;
use Nando\Laravel\Enums\SmsSubType;
$sms = Nando::sendSms(new SendSmsData(
body: 'Your verification code is 123456',
recipients: ['+995555123456'],
subType: SmsSubType::Transactional,
));
$sms->id; // queued SMS id
$sms->raw; // full server payload
// ...or a plain array:
Nando::sendSms([
'sms_subtype' => 'transactional',
'sms_type' => 'one_time',
'body' => 'Your verification code is 123456',
'raw_phone_numbers' => ['+995555123456'],
]);
Account info
selfInfo() returns a typed Nando\Laravel\Data\AccountInfo:
$info = Nando::selfInfo();
$info->smsBalance; // int — remaining credits
$info->brandNames; // list<BrandName>
$info->defaultBrand(); // ?BrandName — used when a request omits brand_name_id
$info->company; // array — company details
Nando::test(); // bool — is the API key valid?
Architecture
The package is organised the way a Laravel developer would expect:
| Layer | Class | Responsibility |
|---|---|---|
| Contract | Contracts\NandoClientInterface |
Bind/type-hint this; swap or fake in tests |
| Client | NandoClient |
Composes repositories, exposes shortcuts |
| Transport | Http\Connector |
Auth, base URL, JSON, error handling |
| Repositories | Repositories\MessageRepository, AccountRepository |
Per-resource operations |
| DTOs | Data\SendSmsData, Sms, AccountInfo, BrandName |
Immutable, typed data |
| Webhooks | Webhook\SignatureVerifier, WebhookEvent |
Verification + parsed events |
use Nando\Laravel\Contracts\NandoClientInterface;
public function __construct(private NandoClientInterface $nando) {}
// Resource repositories are available directly:
$this->nando->messages()->send($data);
$this->nando->account()->info();
Error handling
Any non-2xx response throws a NandoApiException carrying the HTTP status and the parsed error envelope ({ "error": ... } or a { "errors": { field: message } } validation map):
use Nando\Laravel\Exceptions\NandoApiException;
try {
Nando::sms()->otp()->to('+995555123456')->body('123456')->send();
} catch (NandoApiException $e) {
report($e);
$e->status; // e.g. 400, 401, 403
$e->errors; // ['body' => 'is required'] or ['error' => '...']
$e->getMessage();
}
Webhooks
Nando POSTs a signed JSON event to your configured webhook URL whenever an API-originated SMS job changes status. Delivery is asynchronous: the send call returns immediately, and final statuses arrive later on your webhook.
Verify automatically with middleware
The package registers a nando.webhook middleware alias that rejects any request with a missing or invalid X-Nando-Signature (HTTP 403) before your controller runs:
use App\Http\Controllers\NandoWebhookController;
Route::post('/webhooks/nando', NandoWebhookController::class)
->middleware('nando.webhook');
use Illuminate\Http\Request;
use Nando\Laravel\Webhook\WebhookEvent;
class NandoWebhookController
{
public function __invoke(Request $request)
{
$event = WebhookEvent::fromJson($request->getContent());
// Deduplicate — retries can deliver the same event more than once.
if (Cache::add("nando:{$event->eventId}", true, now()->addDay())) {
if ($event->hasFailures()) {
// inspect $event->failedReceivers
}
// ...react to $event->currentStatus
}
return response()->noContent(); // 200 OK acknowledges delivery
}
}
Make sure no middleware mutates the raw body before verification. Signature checks run against the exact bytes Nando sent.
Verify manually
use Nando\Laravel\Webhook\SignatureVerifier;
public function handle(Request $request, SignatureVerifier $verifier)
{
$ok = $verifier->verify(
$request->getContent(), // raw body bytes
$request->header('X-Nando-Signature'), // base64 signature
);
abort_unless($ok, 403);
}
Event payload
{
"event_id": "sms_job:123:processing:completed",
"event": "sms_job.status_changed",
"occurred_at": "2026-06-24T12:00:00Z",
"previous_status": "processing",
"current_status": "completed",
"sms_job": { "id": 123, "status": "completed", "brand_name": "Nando" },
"summary": { "total_receivers": 1, "delivered_count": 1, "failed_count": 0 },
"failed_receivers": []
}
Return 200 OK to acknowledge. Any other status, a timeout, or a network error triggers up to 5 retries with exponential backoff (1s, 2s, 4s, 8s, 16s).
Testing
composer install
vendor/bin/phpunit
License
MIT. See LICENSE.