Knowing whether an SMS was delivered, failed, or is still in transit is essential for transactional messaging. SMS providers send this information via webhooks — HTTP callbacks that notify your application of status changes. This guide shows you how to handle delivery reports from Twilio, Vonage, and other providers in Laravel.
SMS delivery follows a lifecycle. Here are the standard statuses:
| Status | Meaning |
|---|---|
accepted | Provider has accepted the message for delivery |
queued | Message is in the delivery queue |
sent | Message was sent to the carrier |
delivered | Message reached the recipient’s device |
undelivered | Carrier could not deliver (invalid number, etc.) |
failed | Message could not be sent |
expired | Message expired before delivery |
Store these in a database to track your delivery performance:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sms_messages', function (Blueprint $table) {
$table->id();
$table->morphs('sendable'); // The notifiable model
$table->string('provider', 32); // twilio, vonage, etc.
$table->string('provider_message_id', 128)->unique();
$table->string('from', 20);
$table->string('to', 20);
$table->text('body');
$table->string('status', 32)->default('pending');
$table->json('status_history')->nullable();
$table->text('error_message')->nullable();
$table->string('error_code', 64)->nullable();
$table->unsignedSmallInteger('segments')->default(1);
$table->decimal('cost', 8, 6)->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->timestamps();
});
}
};
---
## Setting Up Webhook Routes in Laravel
Create a dedicated webhook controller. Webhooks must return a 2xx response quickly — providers will retry if they get timeouts or 5xx errors.
```php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Webhooks\TwilioWebhookController;
use App\Http\Controllers\Webhooks\VonageWebhookController;
Route::post('/webhooks/sms/twilio', TwilioWebhookController::class)
->name('webhooks.sms.twilio');
Route::post('/webhooks/sms/vonage', VonageWebhookController::class)
->name('webhooks.sms.vonage');
---
## Twilio Status Callback Webhook
Twilio sends delivery status updates to a callback URL you specify when sending the message. Here's how to configure it in your notification:
```php
<?php
namespace App\Notifications;
use App\Models\SmsMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\TwilioMessage;
class OrderConfirmation extends Notification
{
use Queueable;
public function toTwilio(object $notifiable): TwilioMessage
{
return (new TwilioMessage)
->content("Your order has been confirmed!")
->from(config('services.twilio.from'))
->statusCallback(route('webhooks.sms.twilio'));
}
}
---
Handle the callback:
```php
<?php
namespace App\Http\Controllers\Webhooks;
use App\Models\SmsMessage;
use Illuminate\Http\Request;
class TwilioWebhookController extends Controller
{
public function __invoke(Request $request)
{
$validated = $request->validate([
'MessageSid' => 'required|string',
'MessageStatus' => 'required|string',
'To' => 'required|string',
'From' => 'required|string',
'ErrorCode' => 'nullable|string',
'ErrorMessage' => 'nullable|string',
]);
$message = SmsMessage::where(
'provider_message_id',
$validated['MessageSid']
)->first();
if (!$message) {
// Unknown message — provider might be testing or it's old
logger()->warning('Unknown Twilio webhook received', $validated);
return response('OK');
}
$newStatus = match ($validated['MessageStatus']) {
'delivered' => 'delivered',
'undelivered', 'failed' => 'failed',
'sent' => 'sent',
'queued' => 'queued',
default => $message->status,
};
$history = $message->status_history ?? [];
$history[] = [
'status' => $newStatus,
'timestamp' => now()->toIso8601String(),
'provider' => 'twilio',
'error_code' => $validated['ErrorCode'] ?? null,
];
$message->update([
'status' => $newStatus,
'status_history' => $history,
'error_code' => $validated['ErrorCode'] ?? $message->error_code,
'error_message' => $validated['ErrorMessage'] ?? $message->error_message,
'delivered_at' => $newStatus === 'delivered' ? now() : $message->delivered_at,
'failed_at' => $newStatus === 'failed' ? now() : $message->failed_at,
]);
return response('OK');
}
}
---
## Vonage Delivery Receipt Webhook
Vonage (formerly Nexmo) sends delivery receipts to a configured webhook URL. Handle it similarly:
```php
<?php
namespace App\Http\Controllers\Webhooks;
use App\Models\SmsMessage;
use Illuminate\Http\Request;
class VonageWebhookController extends Controller
{
public function __invoke(Request $request)
{
$validated = $request->validate([
'messageId' => 'required|string',
'status' => 'required|string',
'to' => 'required|string',
'msisdn' => 'required|string',
'err-code' => 'nullable|string',
'err-text' => 'nullable|string',
'price' => 'nullable|numeric',
]);
$message = SmsMessage::where(
'provider_message_id',
$validated['messageId']
)->first();
if (!$message) {
logger()->warning('Unknown Vonage webhook received', $validated);
return response('OK');
}
$statusMap = [
'delivered' => 'delivered',
'expired' => 'expired',
'failed' => 'failed',
'rejected' => 'failed',
'accepted' => 'accepted',
'buffered' => 'queued',
'unknown' => 'unknown',
];
$newStatus = $statusMap[$validated['status']] ?? $message->status;
$history = $message->status_history ?? [];
$history[] = [
'status' => $newStatus,
'timestamp' => now()->toIso8601String(),
'provider' => 'vonage',
'error_code' => $validated['err-code'] ?? null,
];
$updates = [
'status' => $newStatus,
'status_history' => $history,
'error_code' => $validated['err-code'] ?? $message->error_code,
'error_message' => $validated['err-text'] ?? $message->error_message,
'delivered_at' => $newStatus === 'delivered' ? now() : $message->delivered_at,
'failed_at' => in_array($newStatus, ['failed', 'expired', 'rejected']) ? now() : $message->failed_at,
];
if (isset($validated['price'])) {
$updates['cost'] = $validated['price'];
}
$message->update($updates);
return response('OK');
}
}
---
## CSRF Exclusion for Webhooks
Webhook requests from external services won't have a CSRF token. Exclude your webhook routes from CSRF protection:
```php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'/webhooks/sms/*',
]);
})
// ...
->create();
---
For API routes using `api` middleware group, CSRF isn't applied, but you should still authenticate webhooks through signature verification.
## Signature Verification
Never trust incoming webhooks blindly. Verify signatures to ensure requests are genuinely from your provider.
### Twilio Signature Verification
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Twilio\Security\RequestValidator;
class VerifyTwilioWebhook
{
public function handle(Request $request, Closure $next): Response
{
$validator = new RequestValidator(
config('services.twilio.auth_token')
);
$signature = $request->header('X-Twilio-Signature');
$url = $request->fullUrl();
$params = $request->post();
if (!$signature || !$validator->validate($signature, $url, $params)) {
logger()->warning('Invalid Twilio webhook signature', [
'url' => $url,
'ip' => $request->ip(),
]);
return response('Invalid signature.', 403);
}
return $next($request);
}
}
---
Apply middleware to the route:
```php
Route::post('/webhooks/sms/twilio', TwilioWebhookController::class)
->middleware(VerifyTwilioWebhook::class)
->name('webhooks.sms.twilio');
---
### Vonage Signature Verification
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyVonageWebhook
{
public function handle(Request $request, Closure $next): Response
{
$params = $request->collect()->toArray();
$signature = $request->input('sig');
if (!$signature) {
return response('Missing signature.', 403);
}
$expected = $this->calculateSignature(
$params,
config('services.vonage.api_secret')
);
if (!hash_equals($expected, $signature)) {
logger()->warning('Invalid Vonage webhook signature');
return response('Invalid signature.', 403);
}
return $next($request);
}
protected function calculateSignature(array $params, string $secret): string
{
// Vonage signed request algorithm
unset($params['sig']);
ksort($params);
$string = '';
foreach ($params as $key => $value) {
$string .= "&{$key}={$value}";
}
return md5(substr($string, 1) . $secret);
}
}
---
## Storing Delivery Status in Database
Build a comprehensive delivery log with status transitions:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SmsMessage extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'status_history' => 'array',
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'failed_at' => 'datetime',
];
}
public function sendable()
{
return $this->morphTo();
}
public function recordStatus(string $status, ?string $errorCode = null, ?string $errorMessage = null): void
{
$history = $this->status_history ?? [];
$history[] = [
'status' => $status,
'timestamp' => now()->toIso8601String(),
];
$updates = [
'status' => $status,
'status_history' => $history,
];
if ($errorCode) {
$updates['error_code'] = $errorCode;
}
if ($errorMessage) {
$updates['error_message'] = $errorMessage;
}
match ($status) {
'sent' => $updates['sent_at'] ??= now(),
'delivered' => $updates['delivered_at'] ??= now(),
'failed', 'undelivered', 'expired', 'rejected' => $updates['failed_at'] ??= now(),
default => null,
};
$this->update($updates);
}
public function scopeDelivered($query)
{
return $query->where('status', 'delivered');
}
public function scopeFailed($query)
{
return $query->whereIn('status', ['failed', 'undelivered', 'expired', 'rejected']);
}
public function scopePending($query)
{
return $query->whereIn('status', ['pending', 'queued', 'accepted', 'sent']);
}
public function isDelivered(): bool
{
return $this->status === 'delivered';
}
public function isFailed(): bool
{
return in_array($this->status, ['failed', 'undelivered', 'expired', 'rejected']);
}
}
---
## Retry Logic for Failed Deliveries
Implement automatic retries for transient failures:
```php
<?php
namespace App\Console\Commands;
use App\Models\SmsMessage;
use App\Services\SmsService;
use Illuminate\Console\Command;
class RetryFailedSms extends Command
{
protected $signature = 'sms:retry-failed
{--max-attempts=3}
{--hours=24}';
protected $description = 'Retry failed SMS deliveries';
public function handle(SmsService $smsService): int
{
$failed = SmsMessage::failed()
->where('failed_at', '>=', now()->subHours($this->option('hours')))
->where('updated_at', '<=', now()->subMinutes(5)) // avoid hot retries
->get();
if ($failed->isEmpty()) {
$this->info('No failed messages to retry.');
return 0;
}
$retried = 0;
foreach ($failed as $message) {
$attempts = count(
array_filter($message->status_history ?? [],
fn($h) => in_array($h['status'], ['failed', 'undelivered'])
)
);
if ($attempts >= (int) $this->option('max-attempts')) {
$this->warn("Skipping message #{$message->id}: max attempts reached");
continue;
}
try {
$smsService->sendFromMessage($message);
$retried++;
$this->info("Retried message #{$message->id}");
} catch (\Exception $e) {
$this->error("Failed to retry message #{$message->id}: {$e->getMessage()}");
}
}
$this->info("Retried {$retried} messages.");
return 0;
}
}
---
## Testing Webhook Handling
```php
<?php
namespace Tests\Feature\Webhooks;
use App\Models\SmsMessage;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class TwilioWebhookTest extends TestCase
{
public function test_handles_delivery_status()
{
$message = SmsMessage::factory()->create([
'provider_message_id' => 'SM1234567890',
'status' => 'sent',
]);
$this->postJson('/webhooks/sms/twilio', [
'MessageSid' => 'SM1234567890',
'MessageStatus' => 'delivered',
'To' => '+1234567890',
'From' => '+1098765432',
])->assertOk();
$this->assertEquals('delivered', $message->fresh()->status);
$this->assertNotNull($message->fresh()->delivered_at);
}
public function test_rejects_invalid_signature()
{
$this->withMiddleware(\App\Http\Middleware\VerifyTwilioWebhook::class);
$this->postJson('/webhooks/sms/twilio', [
'MessageSid' => 'SM1234567890',
'MessageStatus' => 'delivered',
], ['X-Twilio-Signature' => 'invalid'])
->assertStatus(403);
}
}
---
## Monitoring Webhook Health
Set up a simple health dashboard:
```php
<?php
namespace App\Console\Commands;
use App\Models\SmsMessage;
use Illuminate\Console\Command;
class SmsDeliveryReport extends Command
{
protected $signature = 'sms:delivery-report {--hours=24}';
protected $description = 'Show SMS delivery statistics';
public function handle()
{
$since = now()->subHours($this->option('hours'));
$stats = [
'total' => SmsMessage::where('created_at', '>=', $since)->count(),
'delivered' => SmsMessage::delivered()->where('created_at', '>=', $since)->count(),
'failed' => SmsMessage::failed()->where('created_at', '>=', $since)->count(),
'pending' => SmsMessage::pending()->where('created_at', '>=', $since)->count(),
];
$this->table(
['Status', 'Count', 'Rate'],
[
['Total', $stats['total'], '100%'],
['Delivered', $stats['delivered'],
$stats['total'] > 0
? round(($stats['delivered'] / $stats['total']) * 100, 1) . '%'
: 'N/A'],
['Failed', $stats['failed'],
$stats['total'] > 0
? round(($stats['failed'] / $stats['total']) * 100, 1) . '%'
: 'N/A'],
['Pending', $stats['pending'],
$stats['total'] > 0
? round(($stats['pending'] / $stats['total']) * 100, 1) . '%'
: 'N/A'],
]
);
}
}
---
## Conclusion
Handling SMS webhooks in Laravel is straightforward with properly structured controllers, middleware for signature verification, and a robust database model for tracking message statuses. Delivery reporting gives you visibility into your SMS infrastructure and lets you react to failures in real time.
Need reliable SMS delivery? Use our [Laravel SMS API](/services/laravel-sms-api) for seamless integration. For high-volume needs, explore our [enterprise SMS solution](/services/enterprise-sms-solution-laravel). And if you need help with custom webhook integrations, check out our [SMS gateway integration service](/services/laravel-sms-gateway-integration-service).