Skip to content
Oxtro
Documentation menu

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
// 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.

Laravel
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"
202 Accepted
{
  "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.

Laravel
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');
}
200 OK
{
  "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.

Laravel
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();
}
200 OK
{
  "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.

Laravel
// 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']);
    }
}