Twilio Status Callbacks: What 'delivered' Actually Means
Twilio will tell you a message was delivered. That doesn't mean anyone read it. Here's how I build a real audit trail in Laravel.
Twilio's message status callbacks look simple until a client asks you to prove that a patient received their appointment reminder and you realize 'delivered' just means the carrier accepted the message, not that any human saw it. I learned this distinction the hard way on a healthcare project, and it changed how I instrument every SMS integration I build.
What the Status Callback Actually Tells You
Twilio's outbound SMS lifecycle moves through a sequence of statuses: queued → sending → sent → delivered (sometimes) → failed or undelivered. The one that trips people up is delivered.
delivered means the carrier sent a delivery receipt (DLR) back to Twilio confirming the message hit the handset. But DLRs are carrier-dependent, optional, and in the US, major carriers like Verizon and AT&T don't reliably return them for standard A2P 10DLC traffic. You'll see sent as the terminal status far more often than you'd expect. And when you do get delivered, it's a network-level confirmation — the OS received the SMS. Whether the user's phone was on, whether they saw it, whether they're even the one holding that phone — none of that is in scope.
For a billing notification? sent is probably fine. For a medication reminder to a patient with a complex care plan? You need to understand exactly what you're attesting to when you show a checkmark in your UI.
The Problem This Actually Solves
What status callbacks do give you that's genuinely useful: a reliable, asynchronous audit trail of what Twilio attempted and what the network reported back. Without callbacks, you'd have to poll the Messages API repeatedly — expensive, slow, and easy to get wrong. With a properly wired callback endpoint, Twilio posts to you every time a message transitions state. You capture that, persist it, and now you have evidence.
I use this for three things:
- Alerting on failures. If a message hits
failedorundelivered, I want to know immediately, especially on healthcare or payment notification flows. - Retry logic. Some failure codes are retryable (30003 is unreachable/unavailable, often temporary), others aren't (30006 is landline, don't bother).
- Audit exports. Clients periodically need to demonstrate that notifications were sent. A database of status transitions with timestamps beats a screenshot of the Twilio console every time.
The Laravel Implementation
Here's how I wire this up. I keep it simple: a dedicated route, a controller that validates the Twilio signature, and a model that stores every status transition.
First, the migration:
Schema::create('sms_message_log', function (Blueprint $table) {
$table->id();
$table->string('twilio_sid', 34)->index();
$table->string('to_number', 20);
$table->string('from_number', 20);
$table->string('status', 20);
$table->string('error_code', 10)->nullable();
$table->text('error_message')->nullable();
$table->string('direction', 20)->default('outbound-api');
$table->json('raw_payload');
$table->timestamps();
});
I store raw_payload every time. Storage is cheap. Not having the original payload when debugging a weird status six months later is not cheap.
The route, in routes/api.php, skipped from CSRF middleware:
Route::post('/webhooks/twilio/status', [TwilioStatusController::class, 'handle'])
->name('twilio.status');
And in bootstrap/app.php (Laravel 11) or the VerifyCsrfToken middleware (older), make sure this route is excluded from CSRF. Twilio posts form-encoded data and has no concept of your CSRF token.
The controller:
<?php
namespace App\Http\Controllers\Webhooks;
use App\Models\SmsMessageLog;
use App\Notifications\SmsFailureAlert;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Twilio\Security\RequestValidator;
class TwilioStatusController
{
public function handle(Request $request)
{
if (!$this->isValidTwilioRequest($request)) {
Log::warning('Twilio signature validation failed', [
'ip' => $request->ip(),
'url' => $request->fullUrl(),
]);
abort(403, 'Invalid signature');
}
$sid = $request->input('MessageSid');
$status = $request->input('MessageStatus');
SmsMessageLog::create([
'twilio_sid' => $sid,
'to_number' => $request->input('To'),
'from_number' => $request->input('From'),
'status' => $status,
'error_code' => $request->input('ErrorCode'),
'error_message' => $request->input('ErrorMessage'),
'direction' => $request->input('Direction', 'outbound-api'),
'raw_payload' => $request->all(),
]);
if (in_array($status, ['failed', 'undelivered'])) {
$this->handleFailure($sid, $request->input('ErrorCode'), $request->all());
}
return response('', 204);
}
private function isValidTwilioRequest(Request $request): bool
{
$validator = new RequestValidator(config('services.twilio.auth_token'));
return $validator->validate(
$request->header('X-Twilio-Signature', ''),
$request->fullUrl(),
$request->all()
);
}
private function handleFailure(string $sid, ?string $errorCode, array $payload): void
{
$retryable = in_array($errorCode, ['30003', '30005', '30022']);
Log::error('SMS delivery failure', [
'sid' => $sid,
'error_code' => $errorCode,
'retryable' => $retryable,
'to' => $payload['To'] ?? null,
]);
// Only page on non-retryable failures or if this is a critical flow
if (!$retryable) {
Notification::route('slack', config('services.slack.webhook_url'))
->notify(new SmsFailureAlert($sid, $errorCode, $payload));
}
}
}
When you send the message, tell Twilio where to POST status updates:
$client = new Client(
config('services.twilio.account_sid'),
config('services.twilio.auth_token')
);
$message = $client->messages->create($toNumber, [
'from' => config('services.twilio.from_number'),
'body' => $body,
'statusCallback' => route('twilio.status'),
]);
You get multiple POSTs per message — one for each state transition. That's intentional. A single message might generate queued, sending, sent, and then delivered as four separate webhook calls. Your log table will have multiple rows per SID, and that's fine. That's the audit trail.
Gotchas That Will Bite You
Signature validation breaks behind a load balancer. If you're on a stack where the proxy terminates TLS and forwards on HTTP, $request->fullUrl() might return an http:// URL while Twilio signed against https://. Twilio's SDK validates the full URL including scheme. Fix it with FORCE_HTTPS=true in your env or by ensuring your APP_URL is correct and your TrustProxies middleware is configured. I've debugged this twice and it's always the same root cause.
Callbacks can arrive out of order. Twilio documents this. A sent callback might arrive after a delivered callback. Don't build logic that assumes monotonic state progression. If you're maintaining a "current status" column somewhere, use updated_at timestamps to decide which status wins, not insertion order.
You don't always get a terminal callback. If Twilio can't reach your callback URL (your server is down, returns a 5xx, etc.), it retries with exponential backoff — but it eventually gives up. That message could be in an indeterminate state in your database forever. I keep a cleanup job that looks for SIDs older than 48 hours without a terminal status (delivered, failed, undelivered) and reconciles them via the API:
$twilioMessage = $client->messages($sid)->fetch();
// update your log with $twilioMessage->status
delivered is not available for all countries. International SMS delivery receipts are a patchwork. India, Brazil, many parts of Southeast Asia — carriers either don't support DLRs or Twilio can't surface them. Plan for sent being your terminal status on international sends.
The ErrorCode field is only present on failure. Don't check for its absence to infer success. Check the MessageStatus field explicitly.
When I'd Reach for This
I wire up status callbacks on every Twilio integration I build now. The overhead is a single route and a log table. The payoff is being able to answer questions like "did that notification go out?" without logging into the Twilio console and hunting around.
It's especially worth the effort when:
- You're in a regulated industry (healthcare, finance) and need demonstrable notification audit trails
- You're sending transactional messages where failures need immediate human attention
- You want to build retry logic on top of Twilio rather than blindly hoping messages land
- Your client will eventually ask for a report and you want to already have the data
I wouldn't overthink the architecture here. I've seen people reach for queues and event sourcing for this. A simple log table and a Slack alert for hard failures covers 95% of real-world needs without the operational complexity.
Where I'd be more cautious: if you're presenting delivered status to end-users as proof that a person received something, make sure you're labeling it accurately. "Message delivered to carrier" is defensible. "Message received by patient" is not — and the difference matters when you're in a room with a compliance officer.
Bottom Line
Twilio's status callbacks are one of the more reliable webhook implementations I've worked with — good documentation, signature validation that actually works, and retry behavior that's predictable. The footgun isn't in the API; it's in misreading what the statuses mean and then building product decisions on top of that misreading. Capture everything, be honest about what delivered attests to, and you'll have an audit trail worth having.
Need help shipping something like this? Get in touch.