Skip to content
Oxtro
Documentation menu

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

PHP
<?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'];
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.

PHP
<?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']);
}
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.

PHP
<?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);