Examples
Send WhatsApp messages and OTP codes from PHP
Plain PHP with the curl extension. No Composer package needed.
You need an API key and the ID of a connected number. The quickstart covers both.
Send a message
The API answers 202 with status: "queued". The Idempotency-Key header makes a retry safe. Full reference: Messages API.
<?php
function oxtro_post(string $path, array $payload, array $headers = []): array
{
$ch = curl_init('https://api.getoxtro.com' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array_merge([
'Authorization: Bearer ' . getenv('OXTRO_API_KEY'),
'Content-Type: application/json',
], $headers),
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = json_decode((string) curl_exec($ch), true) ?? [];
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$status, $body];
}
[$status, $body] = oxtro_post('/v1/messages', [
'connection_id' => 'conn_123',
'to' => '+213555123456',
'text' => 'Your order ORD-4821 has been confirmed.',
], ['Idempotency-Key: order-4821-confirmation']);
if ($status !== 202) {
throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']);
}
echo $body['id']; // msg_123{
"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.
<?php
// oxtro_post() is defined in the "Send a message" example above.
[$status, $body] = oxtro_post('/v1/otp/send', [
'connection_id' => 'conn_123',
'to' => '+213555123456',
]);
if ($status !== 200) {
throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']);
}
// Keep request_id with the user's session. The code itself never reaches your server.
$_SESSION['otp_request_id'] = $body['request_id'];{
"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.
<?php
[$status, $body] = oxtro_post('/v1/otp/verify', [
'request_id' => $_SESSION['otp_request_id'],
'code' => $_POST['code'],
]);
if ($status === 200) {
// $body['verified'] is true and $body['phone_number'] is the verified number.
} elseif (in_array($body['error']['code'], ['OTP_INVALID', 'OTP_EXPIRED', 'OTP_MAX_ATTEMPTS'], true)) {
// Wrong, expired or locked code. Ask the user to retry or request a new one.
} else {
throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']);
}{
"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.
<?php
function oxtro_signature_is_valid(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;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Read the raw body. Do not re-encode the decoded JSON: the bytes must match what Oxtro signed.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!oxtro_signature_is_valid($rawBody, $header, getenv('OXTRO_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
if ($event['event'] === 'message.received') {
error_log($event['data']['from'] . ': ' . $event['data']['content']);
}
http_response_code(200);