Complete reference for integrating WhatsApp OTP authentication, messaging, devices, broadcast, and webhooks into your application.
https://api.loginwa.com
Auth: Bearer <API_KEY>
Pricing is in USD. Base URL is https://api.loginwa.com (also reachable at https://loginwa.com/api). Versioned endpoints live under /api/v1/…; the OTP product endpoints are /api/auth/start & /api/auth/verify.
POST /api/auth/start with the user's phone number.POST /api/auth/verify with the session_id and OTP code.# Send OTP
curl -X POST https://api.loginwa.com/api/auth/start \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"6281234567890"}'
# Verify OTP
curl -X POST https://api.loginwa.com/api/auth/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"session_id":"SESSION_ID","otp_code":"123456"}'
Authorization: Bearer <API_KEY> header.X-Api-Key: <API_KEY> header.application/json.For mobile/SPA apps, call LoginWA from your backend server to keep the API key secure.
// Example header
Authorization: Bearer sk_live_abc123xyz789
// or
X-Api-Key: sk_live_abc123xyz789
The WhatsApp OTP product. These endpoints now require a valid API key and an active subscription (rate-limited to 60 requests/min). Note the base path is /api/auth/… (not /api/v1/auth/…).
Send an OTP to a phone number via WhatsApp.
| Parameter | Type | Required | Description |
|---|---|---|---|
| phone | string | Yes | Phone number with country code (e.g., 6281234567890) |
| country_code | string | No | Default country code if not in phone |
| otp_length | int | No | OTP length, 4–8 (default: 6) |
| message_template | string | No | Custom message with {code} placeholder |
| meta | object | No | Custom metadata to track |
{
"session_id": "3bbaaf0b-3c11-44a2-8a7e-4edc426c5fcd",
"expires_in": 300,
"debug_code": null
}
debug_code is always null in production; the real code is only delivered over WhatsApp.
Verify the OTP code entered by the user.
| Parameter | Type | Required | Description |
|---|---|---|---|
| session_id | string | Yes | Session ID from /api/auth/start |
| otp_code | string | Yes | OTP code entered by user |
{
"status": "verified",
"phone_number": "6281234567890",
"verified_at": "2026-06-01T12:00:00Z"
}
A failed check returns 422 with status invalid_code, expired, or max_attempts.
Send custom WhatsApp messages beyond OTP.
Send a WhatsApp text message to a phone number. Each successful send deducts one message from your quota.
| Parameter | Type | Required | Description |
|---|---|---|---|
| phone | string | Yes | Recipient phone with country code (10–20 chars) |
| message | string | Yes | Message content, 1–4096 chars |
| device_id | string | No | Specific device to send from (auto-selected if omitted) |
| meta | object | No | Custom metadata attached to the message log |
curl -X POST https://api.loginwa.com/api/v1/messages/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"6281234567890","message":"Hello from LoginWA!"}'
{
"success": true,
"message_id": "msg_abc123",
"trace_id": "9f1c2e7a-1b3d-4f5a-9c8e-2a1b3c4d5e6f",
"device_id": "dev_abc123",
"phone": "6281234567890",
"quota_remaining": 4987
}
Errors: 422 validation_failed, 429 quota_exceeded, 502 send_failed, 503 no_device_connected / no_device_available. See Error Codes.
Calling POST /api/v1/messages/send in four languages. Keep your API key on the server side.
curl -X POST https://api.loginwa.com/api/v1/messages/send \
-H "Authorization: Bearer $LOGINWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "6281234567890",
"message": "Hello from LoginWA!"
}'
$client = new \GuzzleHttp\Client();
$res = $client->post('https://api.loginwa.com/api/v1/messages/send', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('LOGINWA_API_KEY'),
'Content-Type' => 'application/json',
],
'json' => [
'phone' => '6281234567890',
'message' => 'Hello from LoginWA!',
],
]);
$data = json_decode((string) $res->getBody(), true);
echo $data['message_id'];
const res = await fetch(
'https://api.loginwa.com/api/v1/messages/send',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LOGINWA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: '6281234567890',
message: 'Hello from LoginWA!',
}),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
console.log(data.message_id);
import os, requests
res = requests.post(
'https://api.loginwa.com/api/v1/messages/send',
headers={
'Authorization': f"Bearer {os.environ['LOGINWA_API_KEY']}",
'Content-Type': 'application/json',
},
json={
'phone': '6281234567890',
'message': 'Hello from LoginWA!',
},
)
res.raise_for_status()
print(res.json()['message_id'])
Manage WhatsApp devices connected to your app. Device IDs use the engine_device_id (e.g. dev_abc123). Pairing is asynchronous: creating or refreshing a device returns 202 and you poll GET /api/v1/devices/{id} for the QR token.
List all devices for your app.
// Response 200
{
"devices": [
{
"id": "dev_abc123",
"label": "My WhatsApp",
"status": "online",
"phone_number": "6281234567890",
"is_online": true,
"last_seen_at": "2026-06-01T10:00:00+00:00",
"created_at": "2026-01-15T10:00:00+00:00"
}
],
"count": 1
}
Register a new device and start QR pairing.
| Parameter | Type | Required |
|---|---|---|
| label | string | No |
// Response 202
{
"device_id": "dev_abc123",
"engine_device_id": "dev_abc123",
"label": "My WhatsApp",
"status": "qr_waiting",
"qr_code": "2@A1b2C3...",
"job_id": "job_xyz",
"qr_status_url": "https://api.loginwa.com/api/v1/devices/dev_abc123",
"message": "Scan QR code with WhatsApp to connect device"
}
Get device status, including the QR token while status is qr_waiting.
Request a fresh QR code (only when qr_waiting / offline / registering). Returns 202.
Disconnect and remove a device.
Send bulk WhatsApp messages to multiple recipients with smart delay, scheduling, and progress tracking. All responses are wrapped in { "success": true, "data": … }.
Hello {name}!| Status | Description |
|---|---|
draft | Campaign created, not started |
queued | Waiting to be processed |
sending | Currently sending messages |
paused | Temporarily stopped |
completed | All messages sent |
failed | Campaign failed |
List all your broadcast campaigns (paginated, 20/page).
Create a new broadcast campaign. Returns 201; returns 402 if contacts exceed remaining quota.
| Parameter | Type | Required |
|---|---|---|
| name | string | Yes |
| message | string | Yes |
| contacts | array (1–10000) | Yes |
| contacts.*.phone | string | Yes |
| contacts.*.name | string | No |
| contacts.*.variables | object | No |
| media_url | string | No |
| media_type | string | No (image/video/document/audio) |
| delay_seconds | integer | No (1–60, default: 5) |
| schedule_at | datetime | No (must be in the future) |
Get campaign details and progress.
Start sending the campaign (requires an online device).
Pause a campaign that is currently sending.
Resume a paused campaign.
List contacts with delivery status. Filter with ?status=sent|failed|pending
Delete a campaign (cannot delete while sending — pause first).
// 1. Create campaign
POST /api/v1/broadcast/campaigns
{
"name": "Summer Sale 2026",
"message": "Hello {name}!\n\nSpecial offer for you:\n- 50% discount\n- Free shipping\n\nClick: https://shop.com/promo",
"contacts": [
{"phone": "081234567890", "name": "Budi"},
{"phone": "081234567891", "name": "Ani", "variables": {"city": "Jakarta"}},
{"phone": "081234567892", "name": "Citra"}
],
"delay_seconds": 5,
"media_url": "https://example.com/promo.jpg",
"media_type": "image"
}
// Response
{
"success": true,
"data": {
"id": "camp_abc123xyz",
"name": "Summer Sale 2026",
"status": "draft",
"total_contacts": 3,
"estimated_duration": "15 seconds"
}
}
// 2. Start sending
POST /api/v1/broadcast/campaigns/camp_abc123xyz/send
// 3. Check progress
GET /api/v1/broadcast/campaigns/camp_abc123xyz
{
"success": true,
"data": {
"id": "camp_abc123xyz",
"status": "sending",
"progress": {
"total": 3,
"sent": 2,
"delivered": 1,
"failed": 0,
"pending": 1,
"percentage": 66.7
}
}
}
Configure webhooks to receive real-time events (incoming messages, delivery status, device and OTP events). Each app may register up to 5 webhooks. The signing secret is shown only once at creation and via regenerate-secret.
| Event | Description |
|---|---|
message.incoming | New incoming WhatsApp message |
message.sent | Message successfully sent |
message.delivered | Message delivered to recipient |
message.read | Message read by recipient |
message.failed | Message delivery failed |
device.connected | Device paired successfully |
device.disconnected | Device disconnected |
device.qr_ready | A QR code is ready to scan |
otp.verified | OTP verification successful |
otp.expired | OTP session expired |
Omit events (or send an empty array) to subscribe to all events.
LoginWA POSTs JSON to your URL with headers X-LoginWA-Event and X-LoginWA-Signature (HMAC-SHA256 of the raw body using your secret).
{
"event": "message.incoming",
"timestamp": "2026-06-01T12:00:00+00:00",
"data": {
"message_id": "msg_abc123",
"from": "6281234567890",
"body": "Hello!",
"device_id": "dev_xyz"
}
}
List all webhooks (also returns available_events).
Create a new webhook. Returns 201 with the secret (shown once).
| Parameter | Type | Required |
|---|---|---|
| url | string (public https) | Yes |
| events | array | No |
| secret | string | No (auto-generated) |
| retry_count | integer | No (0–10, default: 3) |
Get webhook details and delivery health.
Update url, events, active, secret, or retry_count.
Delete a webhook.
Send a signed webhook.test event to the URL.
Generate a new signing secret.
Each delivery includes a signature in the X-LoginWA-Signature header (HMAC-SHA256 of the raw request body). Verify it to ensure authenticity:
// PHP/Laravel example
$signature = $request->header('X-LoginWA-Signature');
$payload = $request->getContent();
$secret = 'your_webhook_secret';
$expected = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, (string) $signature)) {
abort(403, 'Invalid signature');
}
Restrict an API key to specific source IP addresses. When restriction is enabled, requests from non-whitelisted IPs are rejected with 403 ip_not_allowed. These endpoints are exempt from the IP check so you can always manage your list.
List whitelisted IPs and whether restriction is enabled.
Add an IP. Body: ip_address (required), label (optional). Returns 409 if it already exists.
Remove an IP from the whitelist.
Enable/disable restriction. Body: enabled (boolean). Returns 422 if enabling with no IPs.
Automatic replies and chatbot flows are configured per app in the Dashboard — there is no separate API to manage them. When an inbound message arrives, LoginWA evaluates your rules and may reply automatically, then still delivers the message.incoming event to your webhooks so your own logic can run too.
Manage auto-reply in the Dashboard →Pricing is in USD. Plans and quotas are managed in the Dashboard, not via the API. Payments — including QRIS for Indonesian customers and card/PayPal globally — are handled entirely in the billing UI; there is no public billing API. An inactive, suspended, or past-due subscription causes API requests to return 402 subscription_suspended.
Manage Billing →Sending an OTP via POST /api/auth/start. Verify with POST /api/auth/verify using the returned session_id.
$client = new \GuzzleHttp\Client();
// Send OTP
$res = $client->post('https://api.loginwa.com/api/auth/start', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('LOGINWA_API_KEY'),
'Content-Type' => 'application/json',
],
'json' => [
'phone' => '6281234567890',
'otp_length' => 6,
],
]);
$data = json_decode((string) $res->getBody(), true);
$sessionId = $data['session_id'];
const BASE_URL = 'https://api.loginwa.com/api';
const API_KEY = process.env.LOGINWA_API_KEY;
async function sendOtp(phone) {
const res = await fetch(`${BASE_URL}/auth/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone }),
});
if (!res.ok) throw new Error('Failed to send OTP');
return res.json();
}
import requests
import os
BASE_URL = 'https://api.loginwa.com/api'
API_KEY = os.environ.get('LOGINWA_API_KEY')
def send_otp(phone):
res = requests.post(
f'{BASE_URL}/auth/start',
headers={
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json',
},
json={'phone': phone}
)
res.raise_for_status()
return res.json()
# Send OTP
curl -X POST https://api.loginwa.com/api/auth/start \
-H "Authorization: Bearer $LOGINWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"6281234567890"}'
# Verify OTP
curl -X POST https://api.loginwa.com/api/auth/verify \
-H "Authorization: Bearer $LOGINWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"session_id":"xxx","otp_code":"123456"}'
Start free, then scale as you grow. Prices are in USD. All plans include full API access, Widget, SDK, Dashboard, Webhooks, and unlimited projects.
| Limit Type | Value |
|---|---|
| Rate limit — /api/v1/* | 120 requests/minute per API key |
| Rate limit — /api/auth/* | 60 requests/minute per API key |
| OTP Length | 6 digits (4–8 configurable) |
| OTP TTL | 5 minutes (300 seconds) |
| Max Verify Attempts | 5 attempts |
| Monthly Quota | Based on your plan (see pricing) |
Per-key limits can be customized. Exceeding the rate limit returns 429 rate_limited with a Retry-After header. Exceeding quota returns 429 quota_exceeded.
| Code | HTTP | Description |
|---|---|---|
unauthorized | 401 | Invalid or missing API key |
subscription_suspended | 402 | Subscription inactive, suspended, or past due |
ip_not_allowed | 403 | Source IP not in the API key whitelist |
validation_failed | 422 | Request body failed validation (see errors) |
invalid_code | 422 | OTP code is incorrect |
expired | 422 | OTP session has expired |
max_attempts | 422 | Too many verification attempts |
quota_exceeded | 429 | Monthly message quota exceeded |
rate_limited | 429 | Too many requests per minute |
send_failed | 502 | WhatsApp engine failed to send the message |
no_device_available | 503 | No connected WhatsApp device available |
// 422 validation_failed
{
"error": "validation_failed",
"message": "Invalid request parameters",
"errors": { "phone": ["The phone field is required."] }
}
// 429 quota_exceeded
{ "error": "quota_exceeded", "message": "Monthly message quota exceeded", "quota_remaining": 0 }
// 403 ip_not_allowed
{ "error": "ip_not_allowed", "message": "Your IP address is not whitelisted for this API key.", "ip": "203.0.113.5" }
Having trouble with integration? We're here to help.