WhatsApp OTP API for developers 2026 — send and verify a code, with PHP, Laravel, Node.js and Python examples
The full contract of the WhatsApp verification-code API: send, verify, resend and status, the public key and HMAC signing, every error code and limit, and the verification callback to your server — with ready code in PHP, Laravel, Node.js, Python and curl.
On this page 11
- Before the first line of code: an OTP app and two keys
- Five endpoints — the whole contract
- The quickest try: curl
- PHP — one function for every call
- Laravel — through Http
- Node.js — fetch, no libraries
- Python — requests
- Errors and limits — and what to do about each
- The verification callback to your server
- What you pay
- Developer questions
The short answer
With two calls: POST /wa_otp_api/send with the user's number returns a request_id, then POST /wa_otp_api/verify with that request_id and the code they typed. Authentication is a public key in the X-Public-Key header, with optional HMAC-SHA256 signing using the secret. The code goes out from your connected number at Meta's rate for the recipient's country, or from the shared number at a flat price per code with no Meta account. It is the only public developer API open today.
Also asked as: «WhatsApp OTP PHP» · «WhatsApp OTP Laravel» · «WhatsApp OTP Node.js» · «OTP API» · «كيف ارسل كود تحقق على الواتساب من الموقع» · «كود OTP لا يصل» · «خدمة OTP للشركات» · «كيف أرسل OTP من API؟» · «WhatsApp OTP integration» · «WhatsApp API example» · «WhatsApp API error» · «OTP واتساب لا يصل»
Before the first line of code: an OTP app and two keys
- Create an OTP app in the dashboard
From OTP in the ArabyBot dashboard: the app name, the name shown in the message, then the sender — the ready shared number, or a WhatsApp number connected to your account. You also set the code length, how long it stays valid and how many verify attempts it gets.
- Take the public key and the secret
The public key starts with
ab_otp_and travels in theX-Public-Keyheader on every call. The secret starts withsk_otp_and is shown once at creation: keep it in your server's environment variables — it is what you sign requests with and how you check our signature on callbacks. - Choose what you lock down
Three optional settings on the app: request signing (any call without a valid X-Signature is refused), allowed domains for browser calls, and your servers' IP addresses. Switch on the first and third if the call goes through your server.
Five endpoints — the whole contract
| Endpoint | Body | Returns |
|---|---|---|
POST /wa_otp_api/send | phone, optional lang, length (4–8), expiry_seconds (60–900), metadata | request_id, expires_in, expires_at |
POST /wa_otp_api/verify | request_id, code | request_id, phone, verified_at |
POST /wa_otp_api/resend | request_id | A fresh code, same shape as send |
GET /wa_otp_api/status/{request_id} | — | status, attempts, expires_at, verified_at |
GET /wa_otp_api/ping | — | The app's name and settings — to check the key works |
The phone is in international format, digits only or with a leading +: 966501234567. Every error comes back in one shape: {"success":false,"error_code":"…","message":"…"}, so build your logic on error_code, not on the message text.
The quickest try: curl
# 1) send the code
curl -X POST https://app.arabybot.com/wa_otp_api/send \
-H "Content-Type: application/json" \
-H "X-Public-Key: ab_otp_…" \
-d '{"phone":"966501234567","lang":"ar"}'
{"success":true,"request_id":"3f6c2a9e-41b7-4c0d-9a51-7e2b8d4f1c60",
"expires_in":300,"expires_at":"2026-10-01 14:05:00"}
# 2) verify what the user typed
curl -X POST https://app.arabybot.com/wa_otp_api/verify \
-H "Content-Type: application/json" \
-H "X-Public-Key: ab_otp_…" \
-d '{"request_id":"3f6c2a9e-41b7-4c0d-9a51-7e2b8d4f1c60","code":"482913"}'
{"success":true,"request_id":"3f6c2a9e-…","phone":"966501234567",
"verified_at":"2026-10-01 14:01:12"}
PHP — one function for every call
PHP's built-in cURL is enough, no libraries. Keep the request_id in the user's server-side session, not in the page.
<?php
// One helper for every call. Keys come from the environment, never from the page.
function arabybot_otp(string $action, array $body): array
{
$raw = json_encode($body);
$headers = [
'Content-Type: application/json',
'X-Public-Key: ' . getenv('ARABYBOT_OTP_PUBLIC_KEY'),
];
if ($secret = getenv('ARABYBOT_OTP_SECRET')) { // only if request signing is on
$headers[] = 'X-Signature: sha256=' . hash_hmac('sha256', $raw, $secret);
}
$ch = curl_init('https://app.arabybot.com/wa_otp_api/' . $action);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $raw, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
]);
$res = json_decode((string) curl_exec($ch), true);
curl_close($ch);
return is_array($res) ? $res : ['success' => false, 'error_code' => 'network'];
}
session_start();
$sent = arabybot_otp('send', ['phone' => '201001234567', 'lang' => 'ar']);
$_SESSION['otp_request'] = $sent['request_id'] ?? null;
// …later, when the user submits the code
$check = arabybot_otp('verify', ['request_id' => $_SESSION['otp_request'], 'code' => $_POST['code']]);
if (! empty($check['success'])) {
// the number is verified
}
Laravel — through Http
Laravel's Http client writes the request in a few lines. If you switch request signing on, sign the very bytes you send: build the JSON yourself and send it with withBody, because ->post() re-encodes the array.
use Illuminate\Support\Facades\Http;
// config/services.php → 'arabybot' => ['otp_key' => env('ARABYBOT_OTP_PUBLIC_KEY')]
$otp = Http::withHeaders(['X-Public-Key' => config('services.arabybot.otp_key')])
->baseUrl('https://app.arabybot.com/wa_otp_api')
->timeout(15);
$sent = $otp->post('/send', ['phone' => $request->phone, 'lang' => 'ar'])->json();
session(['otp_request' => $sent['request_id'] ?? null]);
$check = $otp->post('/verify', [
'request_id' => session('otp_request'),
'code' => $request->code,
])->json();
if ($check['success'] ?? false) {
// verified
}
// With request signing on, sign the exact bytes you send:
// $raw = json_encode($body);
// Http::withHeaders([... 'X-Signature' => 'sha256='.hash_hmac('sha256', $raw, $secret)])
// ->withBody($raw, 'application/json')->post('https://app.arabybot.com/wa_otp_api/send');
Node.js — fetch, no libraries
import crypto from 'node:crypto';
// Node 18+ (built-in fetch). Keys from the environment.
async function otp(action, body) {
const raw = JSON.stringify(body);
const headers = {
'Content-Type': 'application/json',
'X-Public-Key': process.env.ARABYBOT_OTP_PUBLIC_KEY,
};
if (process.env.ARABYBOT_OTP_SECRET) { // only if request signing is on
headers['X-Signature'] = 'sha256=' + crypto
.createHmac('sha256', process.env.ARABYBOT_OTP_SECRET).update(raw).digest('hex');
}
const res = await fetch(`https://app.arabybot.com/wa_otp_api/${action}`,
{ method: 'POST', headers, body: raw });
return res.json();
}
const sent = await otp('send', { phone: '971501234567', lang: 'ar' });
const check = await otp('verify', { request_id: sent.request_id, code: '482913' });
console.log(check.success ? 'verified' : check.error_code);
Python — requests
import hashlib, hmac, json, os
import requests
def otp(action, body):
raw = json.dumps(body)
headers = {"Content-Type": "application/json",
"X-Public-Key": os.environ["ARABYBOT_OTP_PUBLIC_KEY"]}
secret = os.environ.get("ARABYBOT_OTP_SECRET")
if secret: # only if request signing is on
headers["X-Signature"] = "sha256=" + hmac.new(
secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
return requests.post(f"https://app.arabybot.com/wa_otp_api/{action}",
data=raw, headers=headers, timeout=15).json()
sent = otp("send", {"phone": "9647701234567", "lang": "ar"})
check = otp("verify", {"request_id": sent["request_id"], "code": "482913"})
print("verified" if check.get("success") else check.get("error_code"))
Errors and limits — and what to do about each
| error_code | HTTP | Meaning and what to do |
|---|---|---|
missing_key · invalid_key | 401 | The public key is missing, wrong, or the app is paused. |
missing_signature · bad_signature | 401 | Signing is on and the request is unsigned, or the signature covers a different body from the one sent. |
origin_blocked · ip_blocked | 403 | The call came from a domain or IP not allowed in the app's settings. |
invalid_phone · missing_phone | 422 | Fix the number to international format before sending. |
cooldown · rate_limit_phone | 429 | Too many requests for one number. Read retry_after in seconds and show a countdown. |
rate_limit_app · rate_limit_app_day | 429 | The app's limit: 60 sends a minute and 20,000 a day by default. |
invalid_code | 401 | Wrong code; attempts_remaining comes with it. |
too_many_attempts | 429 | Attempts used up; request a new code. |
expired · already_resolved | 410 | The code expired, or was already used. |
quota_exceeded · insufficient_balance · package_expired | 402 | Your plan or wallet does not cover the send. |
wa_send_failed | 502 | WhatsApp refused the send; a shared-number charge is refunded. |
The verification callback to your server
If you set a callback URL on the app, an otp.verified event reaches you the moment a code verifies, signed with your secret. Compute HMAC-SHA256 of the body as received and compare it with the header before you trust it.
// The callback your server receives when a code is verified (if you set a callback URL)
POST https://your-site.example/otp-callback
X-ArabyBot-Event: otp.verified
X-ArabyBot-Signature: sha256=<HMAC-SHA256 of the raw body, keyed with your sk_otp_ secret>
{"event":"otp.verified","app_id":"12","request_id":"3f6c2a9e-…",
"phone":"966501234567","verified_at":"2026-10-01T14:01:12+00:00"}
What you pay
On your own number the code goes out as an approved authentication template and Meta bills the recipient country's rate: $0.0036 to an Egyptian number and $0.0107 to a Saudi one, measured on 1,147 codes. On the shared number it is a flat price per code from your wallet ($0.0300), with no Meta account. The table for every Arab country is in the verification-code price in every Arab country.
Developer questions
Do I need my own WhatsApp API number to send the code?
No. When you create an OTP app you choose the sender: the shared number the platform runs, working within minutes with no Meta account at a flat price per code taken from your wallet, or your own number connected to the platform, sending through an approved authentication template on your number, with Meta billing you the recipient country's rate directly.
Do I verify by phone number or by request_id?
By request_id, always. The send call returns it; keep it in the user's session on your server, then post it with the code they typed to the verify call. A code is accepted once only, and is refused after it expires or after the attempt limit you set for the app.
What does one code cost?
On your own number: Meta's authentication rate for the recipient's country — $0.0036 to an Egyptian number and $0.0107 to a Saudi one; the full table is in the code price in every Arab country. On the shared number: a flat $0.0300 per code. Every resend is a new billed message.
What if the send fails?
The call returns wa_send_failed with HTTP 502 and WhatsApp's reason, and on the shared number you are not charged, because the deduction is refunded automatically. Show the user a "try again" button after a pause; do not resend in a loop.
Can I call it straight from the browser?
Technically yes — the API accepts browser calls from the domains you allow in the app's settings. But the right design routes the call through your server: that is where you keep the request_id away from the user, switch on request signing with the secret, and restrict calls to your servers' IP addresses.
Is there an API to send WhatsApp messages other than the code?
Not open yet. The OTP API is the only public developer API available today, alongside the order-intake and cart-event webhooks and the AI agent's tools — detailed in the developer guide.
Read next
Create an OTP app and send the first code
On the shared number the first code arrives within minutes of signing up, with no Meta account.