Official WhatsApp Business API Partner
wa.sendpk.com logo
Integration Tutorial

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.

100% WhatsApp Compliant No Per-Message Meta Fee PHP & MySQL

Understanding WhatsApp Message Categories

WhatsApp Business messaging has 4 conversation categories for different use cases, each billed differently by Meta in Pakistan:

Utility

PKR 2.8

Per message · available in Pakistan

Service

FREE

When the user initiates · available in Pakistan

Authentication

PKR 25

Per OTP message · too expensive for most use cases

Marketing

Varies

Per message · not intended for OTP

The Challenge: In Pakistan, the Authentication category (designed for OTP) costs PKR 25 per message — unaffordable for most businesses at scale. The Utility category at PKR 2.8 is cheaper, but cannot be used for authentication messages.
The Solution: Use the Service category, which is completely free when the customer initiates the conversation. This is 100% legal and compliant with WhatsApp's policies — the same method used by banks and other regulated services.

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. 1. Customer clicks your WhatsApp link — they're redirected to WhatsApp with pre-filled text.
  2. 2. Customer sends the "otp" message — they simply press send (the text is pre-filled).
  3. 3. Your webhook receives the message — your system detects the OTP request keyword.
  4. 4. Generate and send the OTP for free — reply with the OTP code using the Service category.
When a customer initiates the conversation, WhatsApp allows you to respond within a 24-hour window using the Service category at no cost — exactly how banks and other services already operate.

Step 2 · Set Up a Webhook to Receive Messages

Configure your webhook to receive incoming WhatsApp messages:

  1. Log in to your dashboard
  2. Navigate to Settings → Webhook Configuration
  3. 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:

Webhook JSON payload
{
  "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"
    }]
  }]
}
Key fields: 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:

webhook.php
<?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:

otp_codes table
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)
);
Store OTP (PHP)
<?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:

verify_otp.php
<?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:

registration-form.html
<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

Security best practices:
  • 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
Pro tips:
  • 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
Remember that the free Service-category window lasts 24 hours from when the customer sends their first message. You can send multiple messages within this window at no cost.

Troubleshooting Common Issues

Webhook not receiving messages
  • 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
OTP not sending
  • Verify your API key is correct
  • Check the WhatsApp number is properly connected
  • Ensure the customer initiated the conversation first
OTP verification fails
  • Check whether the OTP has expired (default: 5 minutes)
  • Verify the mobile number format matches exactly
  • Ensure the OTP hasn't already been used
Get Started NowLog in to your dashboard and connect your webhook