Laravel OTP authentication
Add WhatsApp OTP codes to your Laravel app
Send login codes or magic links through WhatsApp or Telegram, with auth-only SMS fallback available when chat delivery cannot complete.
- Endpoint
- /api/send/auth
- API scope
- auth_messages
send via .chat
Login code
Thanks, I am in.
01
Use Laravel's built-in HTTP client, no extra dependency required.
02
Save the local challenge first, then store the accepted response message_id.
03
Listen for delivery webhooks to mark the OTP as sent or failed.
The complete OTP flow
Users already open WhatsApp and Telegram quickly. Sending OTP codes through a connected chat channel gives your login flow a familiar, high-attention delivery path, but the important security rule stays the same: your Laravel app owns the OTP.
Generate the code in Laravel, store only a hash, send the plaintext code once through sendvia.chat, then save the accepted response message_id. When sendvia.chat later posts a delivery webhook, match it by message_id and update the local delivery status.
Do not use the delivery webhook to decide whether the user typed the correct code. The webhook tells you whether the message was sent or failed. The login check should compare the user-entered code against the hashed code you saved locally.
1. Store OTP challenges locally
Create a table that can hold the recipient, the hashed code, the sendvia.chat message_id, delivery state, expiry, and consume time. This gives you one row to use for verification and one row to update when the webhook arrives.
Migration
Schema::create('otp_challenges', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('phone', 32)->index();
$table->string('code_hash');
$table->string('sendvia_message_id')->nullable()->unique();
$table->string('delivery_status')->default('created'); // created, accepted, sent, failed
$table->text('delivery_reason')->nullable();
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
$table->timestamps();
});
Model
final class OtpChallenge extends Model
{
protected $fillable = [
'user_id',
'phone',
'code_hash',
'sendvia_message_id',
'delivery_status',
'delivery_reason',
'expires_at',
'consumed_at',
];
protected $casts = [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
];
}
2. Send the code and save message_id
Put the API key in config/services.php, then use Laravel's HTTP client to call the auth endpoint. A successful API response means the message was accepted for asynchronous delivery, not that it has already arrived.
config/services.php
'sendvia' => [
'key' => env('SENDVIA_API_KEY'),
'webhook_token' => env('SENDVIA_WEBHOOK_TOKEN'),
],
Otp service
use App\Models\OtpChallenge;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
final class SendViaOtp
{
public function send(string $phone, ?int $userId = null): OtpChallenge
{
$code = (string) random_int(100000, 999999);
$challenge = OtpChallenge::create([
'user_id' => $userId,
'phone' => $phone,
'code_hash' => Hash::make($code),
'delivery_status' => 'created',
'expires_at' => now()->addMinutes(10),
]);
$response = Http::withToken(config('services.sendvia.key'))
->acceptJson()
->post('https://sendvia.chat/api/send/auth', [
'phone' => $phone,
'code' => $code,
'language' => app()->getLocale(),
]);
if ($response->failed()) {
$challenge->update([
'delivery_status' => 'failed',
'delivery_reason' => $response->json('detail') ?? 'sendvia request failed',
]);
throw ValidationException::withMessages([
'phone' => 'We could not send the login code. Please try again.',
]);
}
$challenge->update([
'sendvia_message_id' => $response->json('message_id'),
'delivery_status' => $response->json('status', 'accepted'),
]);
return $challenge;
}
}
3. Verify the user-entered code
When the user submits the code, look up the latest unused challenge for that phone or user, reject expired rows, and compare with Hash::check. This part does not need to wait for the webhook.
Verify controller
use App\Models\OtpChallenge;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
public function verify(Request $request)
{
$data = $request->validate([
'phone' => ['required', 'string'],
'code' => ['required', 'digits:6'],
]);
$challenge = OtpChallenge::query()
->where('phone', $data['phone'])
->whereNull('consumed_at')
->where('expires_at', '>', now())
->latest()
->first();
if (! $challenge || ! Hash::check($data['code'], $challenge->code_hash)) {
throw ValidationException::withMessages([
'code' => 'The code is invalid or expired.',
]);
}
$challenge->update(['consumed_at' => now()]);
// Continue your login flow here: mark session as verified, issue token, etc.
}
4. Listen for sendvia.chat delivery webhooks
Configure your API key webhook URL in sendvia.chat, for example https://example.com/api/sendvia/webhook. If you configure a webhook bearer token, verify the inbound
Authorization: Bearer ...
header before trusting the event.
Webhooks are sent only when an accepted message reaches a terminal state:
sent
or failed. Update the row by
sendvia_message_id
so the webhook is idempotent.
routes/api.php
Route::post('/sendvia/webhook', SendViaWebhookController::class);
Webhook controller
use App\Models\OtpChallenge;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class SendViaWebhookController
{
public function __invoke(Request $request)
{
$expected = 'Bearer ' . config('services.sendvia.webhook_token');
if (! hash_equals($expected, $request->header('Authorization', ''))) {
abort(Response::HTTP_UNAUTHORIZED);
}
$payload = $request->validate([
'message_id' => ['required', 'string'],
'type' => ['required', 'string'],
'status' => ['required', 'in:sent,failed'],
'reason_text' => ['nullable', 'string'],
'sent_at' => ['nullable', 'date'],
'failed_at' => ['nullable', 'date'],
]);
OtpChallenge::where('sendvia_message_id', $payload['message_id'])
->update([
'delivery_status' => $payload['status'],
'delivery_reason' => $payload['reason_text'] ?? null,
]);
return response()->json(['ok' => true]);
}
}
5. Match the accepted response and webhook
The first API response is synchronous and should be saved immediately. The webhook arrives later and uses the same message_id.
202 Accepted response
{
"ok": true,
"message_id": "msg_Qp8kR7RrrlNQ7CwR",
"status": "accepted"
}
Delivery webhook
{
"message_id": "msg_Qp8kR7RrrlNQ7CwR",
"type": "auth",
"status": "sent",
"reason_text": null,
"channel": "whatsapp",
"destination_kind": "fixed_number",
"provider_message_id": "wamid.HBg...",
"attempt_count": 1,
"sms_fallback_attempt_count": 0,
"accepted_at": "2026-05-24T10:30:00Z",
"sent_at": "2026-05-24T10:30:02Z",
"failed_at": null
}
Validation and fallback notes
Use E.164 phone numbers, such as +39333111222. Codes can contain letters, numbers, dots, underscores, and dashes, and they should expire quickly in your own application.
For auth messages, sendvia.chat can use configured SMS fallback when the primary chat delivery cannot complete and fallback quota allows it. Contact alerts, booking messages, and appointment reminders intentionally do not use SMS fallback.
If a delivery webhook says failed, keep the challenge unconsumed and let the user request a new code. If the webhook says sent but the user types the wrong code, the message delivered correctly and your normal OTP verification should reject the attempt.
FAQ
Laravel OTP delivery questions
Can Laravel use WhatsApp for two-factor codes?
Yes. Generate and hash the code in Laravel, send only the delivery request through sendvia.chat, then verify the submitted code inside your own application.
When should SMS fallback run?
SMS fallback should stay auth-only and run when chat delivery cannot complete, the destination number is valid, and your account has fallback quota available.