Send OTP codes over WhatsApp with one API call
Flat monthly rate, no per-message fees. By default up to 60 new recipients a day per number — built for small and mid-size apps.
Limits
Limits at a glance
There is no cap on how many messages you send. What gets numbers restricted is contacting many people who never wrote to you — so send protection is on by default for every new number.
Most one-time codes go to people who have never written to your number, so each one is a new conversation. Codes to users who write to you first don’t count at all — see way B below.
What counts as a new conversation: a person who has never written to your number and whom you have not messaged in the last 7 days. Replies to people who wrote to you first and messages to groups and channels never count, and a follow-up within those 7 days doesn’t count twice.
Need more? Spread new contacts across several numbers, or switch send protection off for a number after confirming the warning — at your own risk.
Two ways
Two ways to verify a user
Both run on the same two building blocks: the send-message action and your webhook. The code itself is generated, stored and checked in your backend — WaAPI only carries the message.
Send a code
- The user enters their phone number on your login page.
- Your backend creates a code and sends it with one API call.
- The user types the code back into your page.
Counts against the limit when the user has never written to your number: that is a new conversation.
The user writes first
- Your login page shows a one-time key, for example LOGIN-4821, and your number.
- The user sends that key to your number; it reaches your backend by webhook.
- Your backend matches the key and signs the user in — or answers with a code.
Doesn’t count against the limit: a reply to someone who wrote to you first is not a new conversation. The one-time key ties the message to the login attempt.
Way A
Send the code
Generate a random code in your backend, store it with an expiry, then send it. When the user types it back, compare and delete it.
curl --request POST \
--url https://waapi.app/api/v1/instances/123/client/action/send-message \
--header 'authorization: Bearer your-api-token' \
--header 'content-type: application/json' \
--data '{
"chatId": "[email protected]",
"message": "Your verification code is 482193. It expires in 5 minutes."
}'
<?php
$instanceId = 123;
$apiToken = 'your-api-token';
$ch = curl_init("https://waapi.app/api/v1/instances/{$instanceId}/client/action/send-message");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'authorization: Bearer ' . $apiToken,
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'chatId' => '[email protected]',
'message' => 'Your verification code is 482193. It expires in 5 minutes.',
]),
]);
echo curl_exec($ch);
import requests
instance_id = 123
api_token = "your-api-token"
response = requests.post(
f"https://waapi.app/api/v1/instances/{instance_id}/client/action/send-message",
headers={"authorization": f"Bearer {api_token}"},
json={
"chatId": "[email protected]",
"message": "Your verification code is 482193. It expires in 5 minutes.",
},
)
print(response.json())
const instanceId = 123;
const apiToken = 'your-api-token';
fetch(`https://waapi.app/api/v1/instances/${instanceId}/client/action/send-message`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
"chatId": "[email protected]",
"message": "Your verification code is 482193. It expires in 5 minutes.",
}),
})
.then((res) => res.json())
.then(console.log);
Way B
Let the user write first
Your webhook receives every incoming message. Match the one-time key from your login page, confirm the login and answer — the answer goes to someone who wrote first, so it doesn’t use the new-conversation budget. On Laravel, the package receives the webhook for you and fires a MessageEvent.
<?php
// Your webhook URL. Verify the signature first (see the webhook tutorial).
$event = json_decode(file_get_contents('php://input'), true);
if (($event['event'] ?? '') === 'message') {
$message = $event['data']['message']['_data'];
$from = $message['from']; // chat id of the sender
$key = trim($message['body'] ?? ''); // e.g. "LOGIN-4821" from your login page
if ($login = findPendingLogin($key)) { // your storage
confirmLogin($login, $from);
sendMessage($from, 'You are signed in. Not you? Reply STOP.');
}
}
<?php
// app/Listeners/ConfirmLoginByMessage.php — the Laravel package receives the webhook
// and dispatches MessageEvent for every incoming message.
namespace App\Listeners;
use App\Models\PendingLogin;
use WaAPI\WaAPI\Events\MessageEvent;
use WaAPI\WaAPI\WaAPI;
class ConfirmLoginByMessage
{
public function handle(MessageEvent $event): void
{
if ($event->isFromMe()) {
return;
}
$key = trim($event->getMessage() ?? ''); // e.g. "LOGIN-4821"
$login = PendingLogin::where('key', $key)->first();
if ($login) {
$login->confirm($event->getFrom());
app(WaAPI::class)->sendMessage($event->getFrom(), 'You are signed in. Not you? Reply STOP.');
}
}
}
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhook")
def webhook():
# Verify the signature first (see the webhook tutorial).
event = request.get_json()
if event.get("event") == "message":
message = event["data"]["message"]["_data"]
sender = message["from"] # chat id of the sender
key = message.get("body", "").strip() # e.g. "LOGIN-4821" from your login page
login = find_pending_login(key) # your storage
if login:
confirm_login(login, sender)
send_message(sender, "You are signed in. Not you? Reply STOP.")
return "", 204
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhook', async (req, res) => {
// Verify the signature first (see the webhook tutorial).
const event = req.body;
if (event.event === 'message') {
const message = event.data.message._data;
const from = message.from; // chat id of the sender
const key = (message.body ?? '').trim(); // e.g. "LOGIN-4821" from your login page
const login = await findPendingLogin(key); // your storage
if (login) {
await confirmLogin(login, from);
await sendMessage(from, 'You are signed in. Not you? Reply STOP.');
}
}
res.sendStatus(204);
});
app.listen(3000);
# Local test: post a sample incoming message to your own handler.
# Unsigned, so only while the signature check is not in place yet.
curl --request POST \
--url http://localhost:8000/webhook \
--header 'content-type: application/json' \
--data '{
"event": "message",
"instanceId": "123",
"data": {
"message": {
"_data": {
"from": "[email protected]",
"body": "LOGIN-4821",
"fromMe": false
}
}
}
}'
Honest answer
When the official API is the better choice
Many sign-ups a day
If hundreds of people who never wrote to you need a code every day, the new-conversation budget runs out. Meta’s Cloud API bills authentication messages per message instead, with sending tiers that rise with your volume.
Login can never fail
WaAPI connects your own number as an independent service, and a number that automates can be restricted. If a missing code locks users out of something critical, use an official channel — and keep a fallback either way.
The full comparison: Meta’s Cloud API vs. WaAPI.
Fallback
Always keep a second channel
Not every user has the app on the number they typed, and any channel can be down. Offer SMS or email as a fallback: if your webhook hasn’t reported the message as delivered within a few seconds, or the user taps “send another way”, switch.
Check up front whether a number is registered with the is-registered-user action, and skip straight to the fallback if it isn’t.
Flat rate, simple
Unlimited messages. Media, groups, channels, SDKs and MCP included. Cancel anytime.
- No per-message fees
- No per-user fees
- No setup fee
- Cancel anytime
OTP FAQ
Rules, numbers and what happens when something goes wrong.
-
Sending a code that someone asked for is a transactional message. You are responsible for following the platform’s terms and the law where you operate: only send codes to people who requested one, and don’t use the channel for marketing they didn’t agree to.
-
To people who never wrote to your number: by default up to 20 an hour and 60 a day per number. Codes to users who write to you first are not limited this way. For more, spread new users across several numbers, or switch send protection off for a number at your own risk.
-
Someone who has never written to your number and whom you have not messaged in the last 7 days. A second code to the same user within that window doesn’t count again.
-
Then it can’t send until the restriction is lifted, which is why a fallback channel matters. Our guide on restrictions explains how they happen and how to get a number unlocked.
-
As long as your backend decides. WaAPI delivers the message; creating, storing and expiring the code is your application’s job. Five to ten minutes is common.
Try a code flow on your own number
3-day free trial · No credit card · Cancel anytime.