Send Free WhatsApp OTP Messages
Learn how to send unlimited OTP verification codes without paying a per-message Meta fee, by using the free Service conversation category and a webhook.
Understanding WhatsApp Message Categories
WhatsApp Business messaging has 4 conversation categories for different use cases, each billed differently by Meta in Pakistan:
Utility
Per message · available in Pakistan
Service
When the user initiates · available in Pakistan
Authentication
Per OTP message · too expensive for most use cases
Marketing
Per message · not intended for OTP
How Does This Work?
When a customer sends the first message, WhatsApp opens a 24-hour Service-category window during which your replies are free:
- 1. Customer clicks your WhatsApp link — they're redirected to WhatsApp with pre-filled text.
- 2. Customer sends the "otp" message — they simply press send (the text is pre-filled).
- 3. Your webhook receives the message — your system detects the OTP request keyword.
- 4. Generate and send the OTP for free — reply with the OTP code using the Service category.
Step 1 · Create a WhatsApp Redirect Link
When your customer fills out a form (name, email, mobile), create a WhatsApp link that opens the app, pre-fills the message with the "otp" keyword, and points to your WhatsApp Business number:
https://wa.me/92313XXXXXXX?text=otp
92313XXXXXXX is your WhatsApp Business number (with country code); ?text=otp is the pre-filled message text.Step 2 · Set Up a Webhook to Receive Messages
Configure your webhook to receive incoming WhatsApp messages:
- Log in to your dashboard
- Navigate to
Settings → Webhook Configuration - Add your webhook URL, e.g.
https://yourdomain.com/webhook.php
Step 3 · Understand the Webhook Payload
When a customer sends a message, WhatsApp sends a JSON payload to your webhook:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "40752625244XXX",
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "92313XXXXX",
"phone_number_id": "43001968685XXX"
},
"contacts": [{
"profile": { "name": "Mubashar Shahzad" },
"wa_id": "92300XXXXX"
}],
"messages": [{
"from": "92300XXXX",
"id": "wamid.HBgMOTIzMDAxNjU0MzIxwA==",
"timestamp": "1769151710",
"text": { "body": "otp" },
"type": "text"
}]
},
"field": "messages"
}]
}]
}
from is the customer's WhatsApp number, text.body is the message content ("otp"), timestamp is when it was sent, and contacts.profile.name is the customer's name.Step 4 · Create the Webhook Handler (PHP)
Create a webhook.php file to process incoming messages and send the OTP:
<?php
// STEP 1: Read incoming WhatsApp webhook data
$input = file_get_contents("php://input");
$data = json_decode($input, true);
// STEP 2: Extract message from webhook data
if (isset($data['entry'][0]['changes'][0]['value']['messages'][0])) {
$message = $data['entry'][0]['changes'][0]['value']['messages'][0];
if ($message['type'] === 'text') {
$from = $message['from']; // e.g. "92300XXXXXXX"
$text = trim($message['text']['body']); // e.g. "otp"
// STEP 3: Check if the message is an OTP request
if (strtolower($text) === 'otp') {
// STEP 4: Generate a random 6-digit OTP code
$otp_code = rand(100000, 999999);
// STEP 5: Store the OTP in your database with an expiry (see Step 5)
// STEP 6: Send the OTP via WhatsApp (free, Service category)
$payload = [[
"mobile" => $from,
"type" => "text",
"text" => ["body" => "Your OTP code is: $otp_code\n\nThis code will expire in 5 minutes.\n\nDo not share this code with anyone."]
]];
$api_url = "https://wa.sendpk.com/api/send.php";
$api_key = "YOUR_API_KEY_HERE";
$url = $api_url."?api_key=".$api_key."&free_form=".urlencode(json_encode($payload));
$response = file_get_contents($url);
}
}
}
// STEP 7: Always respond with 200 OK
http_response_code(200);
echo "OK";
?>
Step 5 · Store OTPs for Later Verification
Store generated OTPs in your database so you can verify them when the user submits a code:
CREATE TABLE otp_codes (
id INT AUTO_INCREMENT PRIMARY KEY,
mobile VARCHAR(20) NOT NULL,
otp_code VARCHAR(6) NOT NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
verified TINYINT(1) DEFAULT 0,
INDEX idx_mobile (mobile),
INDEX idx_otp (otp_code)
);
<?php
$conn = mysqli_connect("localhost", "username", "password", "database");
$mobile = $from; // From webhook
$otp_code = rand(100000, 999999);
$expires_at = date('Y-m-d H:i:s', strtotime('+5 minutes'));
$sql = "INSERT INTO otp_codes (mobile, otp_code, created_at, expires_at) VALUES (?, ?, NOW(), ?)";
$stmt = mysqli_prepare($conn, $sql);
mysqli_stmt_bind_param($stmt, "sss", $mobile, $otp_code, $expires_at);
mysqli_stmt_execute($stmt);
?>
Step 6 · Verify the OTP When the User Submits It
When the user enters the OTP code, verify it against your database:
<?php
$conn = mysqli_connect("localhost", "username", "password", "database");
$mobile = $_POST['mobile']; // e.g. "92300XXXXXXX"
$submitted_otp = $_POST['otp']; // e.g. "123456"
$sql = "SELECT * FROM otp_codes
WHERE mobile = ? AND otp_code = ? AND verified = 0 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1";
$stmt = mysqli_prepare($conn, $sql);
mysqli_stmt_bind_param($stmt, "ss", $mobile, $submitted_otp);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
if (mysqli_num_rows($result) > 0) {
$sql_update = "UPDATE otp_codes SET verified = 1 WHERE mobile = ? AND otp_code = ?";
$stmt_update = mysqli_prepare($conn, $sql_update);
mysqli_stmt_bind_param($stmt_update, "ss", $mobile, $submitted_otp);
mysqli_stmt_execute($stmt_update);
echo json_encode(["success" => true, "message" => "Mobile number verified successfully!"]);
} else {
echo json_encode(["success" => false, "message" => "Invalid or expired OTP code"]);
}
?>
Complete Real-World Example
Here's how everything works together in a user registration form:
<form id="registrationForm">
<label>Name:</label>
<input type="text" name="name" required>
<label>Email:</label>
<input type="email" name="email" required>
<label>Mobile (with country code):</label>
<input type="text" name="mobile" id="mobile" placeholder="92300XXXXXXX" required>
<button type="button" onclick="requestOTP()">Verify via WhatsApp</button>
</form>
<div id="otpSection" style="display:none">
<input type="text" id="otpCode" placeholder="Enter 6-digit code" maxlength="6">
<button onclick="verifyOTP()">Verify OTP</button>
</div>
<script>
function requestOTP() {
var mobile = document.getElementById('mobile').value;
if (!mobile) { alert('Please enter your mobile number'); return; }
window.open('https://wa.me/92313XXXXXXX?text=otp', '_blank');
document.getElementById('otpSection').style.display = 'block';
}
function verifyOTP() {
var mobile = document.getElementById('mobile').value;
var otp = document.getElementById('otpCode').value;
fetch('verify_otp.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'mobile=' + mobile + '&otp=' + otp
})
.then(response => response.json())
.then(data => { alert(data.success ? data.message : 'Error: ' + data.message); });
}
</script>
Key Benefits
Cost Savings
Save PKR 25 per OTP compared to the Authentication category
Unlimited
Send unlimited OTP messages without worrying about per-message cost
Compliant
100% compliant with WhatsApp's terms of service
Fast
Instant delivery through WhatsApp's infrastructure
Important Considerations
- Always set an OTP expiry time (recommended: 5 minutes)
- Store OTPs securely in your database, and never log them in plain text
- Limit OTP generation attempts to prevent abuse (e.g. max 3 per number per hour)
- Mark an OTP as used immediately after successful verification
- Include your brand name in the OTP message
- Provide clear expiry information to users
- Test with multiple numbers before going live
- Monitor webhook logs regularly
Troubleshooting Common Issues
- Verify the webhook URL is publicly accessible (test with a tool like webhook.site)
- Check the URL is correctly configured in your dashboard
- Ensure your server accepts POST requests
- Verify your API key is correct
- Check the WhatsApp number is properly connected
- Ensure the customer initiated the conversation first
- Check whether the OTP has expired (default: 5 minutes)
- Verify the mobile number format matches exactly
- Ensure the OTP hasn't already been used