Examples
Send WhatsApp messages and OTP codes from Laravel
The Laravel Http facade is all you need. No package to install.
You need an API key and the ID of a connected number. The quickstart covers both.
Put the credentials in config/services.php and your .env file:
// config/services.php
'oxtro' => [
'url' => env('OXTRO_API_URL', 'https://api.getoxtro.com'),
'key' => env('OXTRO_API_KEY'),
'connection' => env('OXTRO_CONNECTION_ID'),
'webhook_secret' => env('OXTRO_WEBHOOK_SECRET'),
],Send a message
The API answers 202 with status: "queued". The Idempotency-Key header makes a retry safe. Full reference: Messages API.
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.oxtro.key'))
->withHeaders(['Idempotency-Key' => "order-{$order->id}-confirmation"])
->post(config('services.oxtro.url') . '/v1/messages', [
'connection_id' => config('services.oxtro.connection'),
'to' => $order->phone,
'template' => [
'name' => 'order_confirmed',
'variables' => [
'customer_name' => $order->customer_name,
'order_number' => $order->number,
],
],
])
->throw();
$messageId = $response->json('id'); // msg_123, status "queued"{
"id": "msg_123",
"status": "queued",
"queued_at": "2026-10-10T09:41:07.000Z"
}Send a one-time code
Oxtro generates the code and sends it on WhatsApp. You only keep the request_id. Full reference: OTP API.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
public function send(Request $request)
{
$request->validate(['phone' => ['required', 'string']]);
$response = Http::withToken(config('services.oxtro.key'))
->post(config('services.oxtro.url') . '/v1/otp/send', [
'connection_id' => config('services.oxtro.connection'),
'to' => $request->input('phone'),
]);
if ($response->status() === 429) {
// OTP_COOLDOWN_ACTIVE or RATE_LIMITED
return back()->withErrors(['phone' => $response->json('error.message')]);
}
$response->throw();
// Keep request_id with the session. The code itself never reaches your server.
$request->session()->put('otp_request_id', $response->json('request_id'));
return redirect()->route('otp.form');
}{
"request_id": "otp_123",
"expires_in": 300,
"status": "sent"
}Verify the code
A correct code returns 200. A wrong, expired or locked code returns 400 with the code OTP_INVALID, OTP_EXPIRED or OTP_MAX_ATTEMPTS.
public function verify(Request $request)
{
$request->validate(['code' => ['required', 'string']]);
$response = Http::withToken(config('services.oxtro.key'))
->post(config('services.oxtro.url') . '/v1/otp/verify', [
'request_id' => $request->session()->get('otp_request_id'),
'code' => $request->input('code'),
]);
if ($response->successful()) {
// $response->json('phone_number') is the verified number in E.164 format.
$request->session()->forget('otp_request_id');
return redirect()->intended('/');
}
// A wrong, expired or locked code is an HTTP 400 with one of these codes.
$rejected = ['OTP_INVALID', 'OTP_EXPIRED', 'OTP_MAX_ATTEMPTS'];
if (in_array($response->json('error.code'), $rejected, true)) {
return back()->withErrors(['code' => $response->json('error.message')]);
}
$response->throw();
}{
"verified": true,
"request_id": "otp_123",
"phone_number": "+213555123456",
"verified_at": "2026-10-10T09:42:31.000Z"
}Verify a webhook signature
Oxtro signs every webhook with HMAC-SHA256 over <timestamp>.<raw body>. Check it before you trust the event. Full reference: Webhooks.
// routes/api.php (no CSRF check on this route group)
use App\Http\Controllers\OxtroWebhookController;
Route::post('/webhooks/oxtro', OxtroWebhookController::class);
// app/Http/Controllers/OxtroWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class OxtroWebhookController extends Controller
{
public function __invoke(Request $request)
{
// getContent() is the raw body, the exact bytes Oxtro signed.
abort_unless($this->signatureIsValid(
$request->getContent(),
(string) $request->header('X-Webhook-Signature'),
config('services.oxtro.webhook_secret'),
), 401);
if ($request->json('event') === 'message.received') {
logger()->info('WhatsApp reply', $request->json('data'));
}
return response()->noContent(200);
}
private function signatureIsValid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[trim($key)] = $value;
}
if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
return hash_equals(hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret), $parts['v1']);
}
}