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.
An SMS OTP flow has two requests from the user and one from your server:
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.
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.
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.
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.
| Response | Meaning | What your code should do |
|---|---|---|
| 201 | Accepted and charged | Show the code-entry screen |
402 insufficient_balance | Account is out of credit | Alert yourself; tell the user to try later |
422 no_valid_recipients | Not a Malaysian mobile number | Ask the user to check the number |
429 rate_limit_exceeded | Over your key's send limit | Wait for Retry-After; ask us to raise the limit if it is your real peak |
| Timeout or 5xx | Result unknown | Retry 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.
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.
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.
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.
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.
The code does, but HantarSMS only delivers to Malaysian mobile numbers, which is why the examples reject anything that is not 601…
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.