Send OTP SMS from PHP
plain PHP, Laravel and WordPress

Generate the code, store it safely, send it to a Malaysian number and check what the user types — complete code you can paste in, with the limits that stop bots running up your bill.

How the pieces fit

An SMS OTP flow has two requests from the user and one from your server:

  1. The user enters a phone number. Your server checks the limits, creates a random code, saves a hash of it with an expiry, and sends the SMS through the API.
  2. The user types the code. Your server compares it with the saved hash, counts the attempt, and marks the code used if it matches.

HantarSMS only does the sending. The code never leaves your system except inside the SMS text, so you can switch providers later without touching the verification logic. All examples below send to Malaysian numbers only; the API accepts 0123456789, 60123456789 or +60123456789.

Plain PHP

PHP 8 with cURL and PDO (MySQL). Put your key in an environment variable, never in the code. First, one table:

CREATE TABLE otp_codes (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  phone      VARCHAR(15)  NOT NULL,
  code_hash  VARCHAR(255) NOT NULL,
  expires_at DATETIME     NOT NULL,
  attempts   TINYINT      NOT NULL DEFAULT 0,
  created_at DATETIME     NOT NULL,
  INDEX (phone, created_at)
);

The sending function. The Idempotency-Key is what makes a retry safe:

<?php
function hantarsms_send(string $to, string $text, string $idemKey): array {
    $ch = curl_init('https://hantarsms.com/api/v1/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'X-API-Key: ' . getenv('HANTARSMS_KEY'),
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idemKey,
            'User-Agent: myshop/1.0',
        ],
        CURLOPT_POSTFIELDS => json_encode(['to' => $to, 'text' => $text, 'reference' => 'otp']),
    ]);
    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);   // 0 = timeout / no connection
    curl_close($ch);
    return ['status' => $status, 'body' => $body === false ? null : json_decode($body, true)];
}

// Normalise to 60XXXXXXXXX so rate limits are counted per real number.
function my_phone(string $raw): ?string {
    $n = preg_replace('/\D/', '', $raw);
    if (str_starts_with($n, '0')) $n = '6' . $n;
    return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}

Issuing a code — limits first, then the SMS:

function issue_otp(PDO $db, string $phone): bool {
    // One code per 60 seconds and five per hour for each number.
    $q = $db->prepare("SELECT SUM(created_at > NOW() - INTERVAL 60 SECOND) AS last_min, COUNT(*) AS last_hour
                       FROM otp_codes WHERE phone = ? AND created_at > NOW() - INTERVAL 1 HOUR");
    $q->execute([$phone]);
    $r = $q->fetch(PDO::FETCH_ASSOC);
    if ($r['last_min'] > 0 || $r['last_hour'] >= 5) return false;

    $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    $db->prepare("INSERT INTO otp_codes (phone, code_hash, expires_at, created_at)
                  VALUES (?, ?, NOW() + INTERVAL 5 MINUTE, NOW())")
       ->execute([$phone, password_hash($code, PASSWORD_DEFAULT)]);
    $key  = 'otp-' . $db->lastInsertId();
    $text = "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.";

    $res = hantarsms_send($phone, $text, $key);
    if ($res['status'] === 0 || $res['status'] >= 500) {
        // Timed out or server error: retry once with the SAME key.
        // If the first request actually went through, this returns it instead of sending again.
        $res = hantarsms_send($phone, $text, $key);
    }
    return $res['status'] === 201;
}

Checking what the user typed:

function check_otp(PDO $db, string $phone, string $input): bool {
    $q = $db->prepare("SELECT id, code_hash, attempts FROM otp_codes
                       WHERE phone = ? AND expires_at > NOW() ORDER BY id DESC LIMIT 1");
    $q->execute([$phone]);
    $row = $q->fetch(PDO::FETCH_ASSOC);
    if (!$row || $row['attempts'] >= 5) return false;          // no live code, or too many guesses

    $db->prepare("UPDATE otp_codes SET attempts = attempts + 1 WHERE id = ?")->execute([$row['id']]);
    if (!password_verify($input, $row['code_hash'])) return false;

    $db->prepare("UPDATE otp_codes SET expires_at = NOW() WHERE id = ?")->execute([$row['id']]);  // single use
    return true;
}

Why hash the code: if your database leaks, live codes do not. Why count attempts: without it, a script can try all million six-digit codes against one number.

Laravel

The same flow with Laravel's HTTP client. Put the key in .env as HANTARSMS_KEY and expose it through config/services.php. Retry only on connection errors — retrying a 402 (no balance) or 422 (bad number) will not change the answer.

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

$res = Http::withHeaders([
        'X-API-Key'       => config('services.hantarsms.key'),
        'Idempotency-Key' => "otp-{$otp->id}",
    ])
    ->timeout(15)
    ->retry(2, 500, fn ($e) => $e instanceof ConnectionException, throw: false)
    ->post('https://hantarsms.com/api/v1/messages', [
        'to'   => $otp->phone,
        'text' => "MyShop: your verification code is {$code}. Valid for 5 minutes. Do not share it.",
    ]);

if ($res->status() !== 201) {
    Log::warning('OTP SMS not sent', ['error' => $res->json('error.code')]);
}

Store the code with Hash::make() and check it with Hash::check(), and use Laravel's RateLimiter for the per-number and per-IP limits.

WordPress

Prefer a ready-made plugin? The HantarSMS WordPress plugin does OTP login, WooCommerce order SMS and marketing campaigns. To write it yourself instead, save this as wp-content/plugins/myshop-otp/myshop-otp.php, add define('HANTARSMS_KEY', '...'); to wp-config.php, and activate it. It adds a REST endpoint your sign-up or checkout form can call, and a function to check the code when the form is submitted.

<?php
/**
 * Plugin Name: MyShop SMS OTP
 */

function myshop_phone(string $raw): ?string {
    $n = preg_replace('/\D/', '', $raw);
    if (str_starts_with($n, '0')) $n = '6' . $n;
    return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}

add_action('rest_api_init', function () {
    register_rest_route('myshop/v1', '/otp', [
        'methods'             => 'POST',
        'permission_callback' => '__return_true',
        'callback'            => 'myshop_send_otp',
    ]);
});

function myshop_send_otp(WP_REST_Request $req) {
    $phone = myshop_phone((string) $req->get_param('phone'));
    if (!$phone) {
        return new WP_Error('bad_phone', 'Enter a Malaysian mobile number.', ['status' => 400]);
    }
    $ipKey = 'otp_ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
    if (get_transient("otp_wait_$phone") || (int) get_transient($ipKey) >= 10) {
        return new WP_Error('too_many', 'Please wait before requesting another code.', ['status' => 429]);
    }

    $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    set_transient("otp_code_$phone", [
        'hash'  => wp_hash_password($code),
        'exp'   => time() + 5 * MINUTE_IN_SECONDS,
        'tries' => 0,
    ], 5 * MINUTE_IN_SECONDS);
    set_transient("otp_wait_$phone", 1, MINUTE_IN_SECONDS);
    set_transient($ipKey, (int) get_transient($ipKey) + 1, HOUR_IN_SECONDS);

    $res = wp_remote_post('https://hantarsms.com/api/v1/messages', [
        'timeout' => 15,
        'headers' => [
            'X-API-Key'       => HANTARSMS_KEY,
            'Content-Type'    => 'application/json',
            'Idempotency-Key' => 'otp-' . wp_generate_uuid4(),
        ],
        'body' => wp_json_encode([
            'to'   => $phone,
            'text' => "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.",
        ]),
    ]);
    if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 201) {
        return new WP_Error('send_failed', 'Could not send the code. Try again in a minute.', ['status' => 502]);
    }
    return ['sent' => true];
}

// Call this when the form is submitted.
function myshop_check_otp(string $rawPhone, string $input): bool {
    $phone = myshop_phone($rawPhone);
    $row   = $phone ? get_transient("otp_code_$phone") : false;
    if (!$row || $row['tries'] >= 5 || time() > $row['exp']) return false;

    $row['tries']++;
    set_transient("otp_code_$phone", $row, max(1, $row['exp'] - time()));   // keep the original expiry
    if (!wp_check_password($input, $row['hash'])) return false;

    delete_transient("otp_code_$phone");                                    // single use
    return true;
}

Two WordPress-specific notes. If the site is behind Cloudflare or another proxy, REMOTE_ADDR is the proxy's address, so the per-IP limit needs the real visitor IP from the proxy's header. And add a CAPTCHA to the form that calls this endpoint — it is public, and every call that passes the limits costs an SMS.

Handling the API's answers

ResponseMeaningWhat your code should do
201Accepted and chargedShow the code-entry screen
402 insufficient_balanceAccount is out of creditAlert yourself; tell the user to try later
422 no_valid_recipientsNot a Malaysian mobile numberAsk the user to check the number
429 rate_limit_exceededOver your key's send limitWait for Retry-After; ask us to raise the limit if it is your real peak
Timeout or 5xxResult unknownRetry with the same Idempotency-Key

The full list of error codes, delivery-status lookups and Python, Node.js and Java examples are in the API documentation. Ready-made message wording is in OTP SMS templates.

PHP and WordPress OTP questions

Is there a WordPress or WooCommerce plugin?

Yes: the free HantarSMS WordPress plugin adds SMS-code login, WooCommerce order SMS and SMS marketing campaigns, and gives you hantarsms_otp_send() and hantarsms_otp_verify() for your own forms. If you would rather own every line, the short plugin on this page covers the OTP part with code you fully control.

Should I store the OTP in plain text?

No. Store a hash (password_hash in PHP, Hash::make in Laravel, wp_hash_password in WordPress) and compare with the matching verify function. A leaked database then does not leak live codes.

What if the API request times out?

Retry with the same Idempotency-Key. If the first request did go through, the API returns that result instead of sending a second SMS, so the user gets one code and you pay once.

How do I stop bots from sending thousands of codes?

Limit codes per phone number and per IP address, add a CAPTCHA to the public form, and keep only the credit you need. Each API key also has a send-rate limit of 60 requests a minute by default.

Do these examples work outside Malaysia?

The code does, but HantarSMS only delivers to Malaysian mobile numbers, which is why the examples reject anything that is not 601…

Get an API key

Sign up, create a key in the dashboard and claim the RM1 trial credit on WhatsApp — enough to test these examples on your own phone.