OTP done right
Send one-time codes from your own number: Mursilat generates and hashes the code, uses a fixed safe text, and limits attempts and recipients.
POST /v2/otp sends a verification code; POST /v2/otp/{otpId}/verify checks what the person typed. You never handle or store the code: Mursilat generates it with a secure random generator and keeps only a keyed hash.
curl -X POST "https://mursilat.com/api/v2/otp" \
-H "Authorization: Bearer $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-confirm" \
-H "Content-Type: application/json" \
-d '{
"numberId": "cm1abcd2345efgh6789ijkl",
"to": "201001234567",
"length": 6,
"ttlSeconds": 300,
"maxAttempts": 5,
"app": "متجر النور",
"copyButton": true
}'const res = await fetch('https://mursilat.com/api/v2/otp', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WASLA_API_KEY}`,
'Idempotency-Key': 'order-1042-confirm',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"numberId": "cm1abcd2345efgh6789ijkl",
"to": "201001234567",
"length": 6,
"ttlSeconds": 300,
"maxAttempts": 5,
"app": "متجر النور",
"copyButton": true
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data);import os
import requests
headers = {"Authorization": f"Bearer {os.environ['WASLA_API_KEY']}", "Idempotency-Key": "order-1042-confirm"}
res = requests.post(
"https://mursilat.com/api/v2/otp",
headers=headers,
json={
"numberId": "cm1abcd2345efgh6789ijkl",
"to": "201001234567",
"length": 6,
"ttlSeconds": 300,
"maxAttempts": 5,
"app": "متجر النور",
"copyButton": True
},
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data)<?php
$ch = curl_init('https://mursilat.com/api/v2/otp');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-confirm',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'numberId' => 'cm1abcd2345efgh6789ijkl',
'to' => '201001234567',
'length' => 6,
'ttlSeconds' => 300,
'maxAttempts' => 5,
'app' => 'متجر النور',
'copyButton' => true
], JSON_UNESCAPED_UNICODE),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status >= 400) { throw new Exception($data['error']['code'] . ': ' . $data['error']['message']); }
print_r($data);Fixed templates
The text is fixed and localized, so the OTP endpoint can never relay arbitrary messages or phishing links. Only the code, the validity in minutes and your app name are filled in:
locale | Text |
|---|---|
ar (default) | Text«كود التحقق لـ {app}: {code} — صالح لمدة {minutes} دقيقة. متشاركهوش مع أي حد.» |
en | Text“Your verification code for {app}: {code} — Valid for {minutes} minutes. Do not share it with anyone.” |
app accepts letters, digits and spaces (≤ 30) — no links, no domain endings such as com or sa, no link services, no runs of 6+ digits. copyButton: true adds a “Copy code” button.
Limits
| Limit | Value |
|---|---|
| Code length / validity / attempts | Value4–8 digits · 60–900 s · 1–10 attempts |
| Codes per recipient | Value5 per hour |
| Different recipients per account | Value200 per hour (OTP_ACCOUNT_RATE_LIMITED) |
| Verify calls | Value60 per key per minute |
| Number Shield OTP class | Value30 per hour and 300 per day per number, 3–5 s pacing |
| Quota | Value1 message unit, refunded if the send fails |
Opted-out recipients are refused. OTP rows are deleted after 7 days; the recipient is stored masked.
Verifying
Send the code as a string (leading zeros matter). Each call spends one attempt before the constant-time comparison; at maxAttempts the OTP locks. valid: false comes with a reason: INVALID_CODE, EXPIRED, TOO_MANY_ATTEMPTS or ALREADY_VERIFIED. The otp.verified webhook (without the code) fires on success.
curl -X POST "https://mursilat.com/api/v2/otp/otp_8f2c1d9a7b3e4f56a1b2c3d4/verify" \
-H "Authorization: Bearer $WASLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "048213"
}'const res = await fetch('https://mursilat.com/api/v2/otp/otp_8f2c1d9a7b3e4f56a1b2c3d4/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WASLA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"code": "048213"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data);import os
import requests
headers = {"Authorization": f"Bearer {os.environ['WASLA_API_KEY']}"}
res = requests.post(
"https://mursilat.com/api/v2/otp/otp_8f2c1d9a7b3e4f56a1b2c3d4/verify",
headers=headers,
json={
"code": "048213"
},
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data)<?php
$ch = curl_init('https://mursilat.com/api/v2/otp/otp_8f2c1d9a7b3e4f56a1b2c3d4/verify');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WASLA_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'code' => '048213'
], JSON_UNESCAPED_UNICODE),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status >= 400) { throw new Exception($data['error']['code'] . ': ' . $data['error']['message']); }
print_r($data);Deliverability
Send OTPs from a number people already know — ideally a dedicated one, not the number you use for campaigns, so marketing reports never block your logins. See Using a dedicated business number.