send via .chat

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

Today

Your send via .chat login code is

482931

This code expires in 10 minutes.

15:54

Thanks, I am in.

15:55 ✓✓
Message

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.