https://your-domain/api/v1
Getting Started
Six steps from a new account to your first WhatsApp message sent through your own code.
Create/log in to your tenant account
Register from the registration page or log in on the dashboard.
Connect WhatsApp through Embedded Signup
From the dashboard's WhatsApp page, click Connect and complete Meta's Embedded Signup flow. This links your WhatsApp Business Account (WABA) to your tenant — the platform handles the Meta OAuth exchange for you.
Verify the connection is active
The WhatsApp page shows a Connected status once done. You can also confirm this programmatically — see Authentication below.
Go to API Keys → Create API Key
From the dashboard sidebar, open API Keys and click Create API Key. Optionally restrict it to specific scopes and/or set an expiry date.
Copy the API key
The full key is shown exactly once, immediately after creation. Store it somewhere secure now.
Make your first API request
Verify the key works and see your connection status:
curl BASE_URL/ping \ -H "Authorization: Bearer YOUR_API_KEY"
A successful response looks like:
{
"success": true,
"message": "API key is valid.",
"data": {
"tenant_id": 12,
"business_name": "Acme Co",
"whatsapp": { "connected": true, "status": "connected", "account": { "waba_id": "...", "phone_number": "+1..." } }
}
}Authentication
Every request to the endpoints below needs your API key as a bearer token:
Authorization: Bearer wsaas_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Your app ↓ Authorization: Bearer <api key> Our API ↓ resolves tenant from the key Your connected WhatsApp account ↓ resolves phone number ID + access token (server-side, never returned to you) Meta WhatsApp Cloud API ↓ Customer's WhatsApp
If useful for debugging or support, your WhatsApp Business Account ID (waba_id) is available read-only via GET /ping or the dashboard's WhatsApp page — never a Meta access token or app secret.
Verifies your key and returns which tenant/WhatsApp account it resolves to. No scope required.
Try it
API Reference
Every endpoint below is live and documented from the actual implementation — not a mock. permission shows the API-key scope required, when the route is scope-gated.
| Method | Path | Auth | Scope | Description |
|---|---|---|---|---|
| GET | /ping | API key | — | Verify key + connection status |
| POST | /messages/text | API key | messages.send | Send a text message |
| POST | /messages/document | API key | messages.send | Send a document |
| POST | /messages/image | API key | messages.send | Send an image |
| POST | /messages/video | API key | messages.send | Send a video |
| POST | /messages/audio | API key | messages.send | Send an audio clip |
| POST | /messages/template | API key | messages.send | Send an approved template |
| GET | /messages | Key or session | messages.view | List messages, paginated + filtered |
| GET | /messages/{id} | Key or session | messages.view | Single message status |
| GET | /templates | Key or session | templates.view | List templates |
| POST | /templates | Key or session | templates.create | Create a draft template |
| GET | /templates/{id} | Key or session | templates.view | Single template detail |
| GET | /contacts | Key or session | contacts.view | List/search contacts |
| POST | /contacts | Key or session | contacts.create | Create a contact |
| GET | /contacts/{id} | Key or session | contacts.view | Single contact |
| PUT | /contacts/{id} | Key or session | contacts.edit | Update a contact |
| DELETE | /contacts/{id} | Key or session | contacts.delete | Delete a contact |
| POST | /contacts/{id}/opt-in | Key or session | contacts.edit | Opt a contact in |
| POST | /contacts/{id}/opt-out | Key or session | contacts.edit | Opt a contact out |
| GET | /campaigns | Key or session | campaigns.view | List campaigns |
| POST | /campaigns | Key or session | campaigns.create | Create a campaign |
| GET | /campaigns/{id} | Key or session | campaigns.view | Campaign detail + progress |
| POST | /campaigns/{id}/start | Key or session | campaigns.send | Start a draft/scheduled campaign |
| POST | /campaigns/{id}/pause | Key or session | campaigns.manage | Pause a processing campaign |
| POST | /campaigns/{id}/cancel | Key or session | campaigns.manage | Cancel a campaign |
| GET | /usage | Key or session | usage.view | Current-period usage summary |
| GET | /usage/range | Key or session | usage.view | Usage for a date range |
"Key or session" means the exact same URL works whether you call it with your API key (a server-to-server integration) or you're logged into the dashboard (a browser session) — see Authentication.
Send Messages
Sends a free-form text message. Recipients use international format without a leading + (e.g. 91XXXXXXXXXX).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
recipient | string | Yes | WhatsApp number, international format |
message | string | Yes | Up to 4096 characters |
{
"recipient": "919XXXXXXXXX",
"message": "Hello, this is a test message."
}Response
{
"success": true,
"message": "Message sent successfully",
"data": { "id": 501, "message_id": "wamid.HBg...", "status": "sent" }
}A rejected send (bad number, Meta error) responds 502 with error.code = "META_API_ERROR" — see Errors.
Try it
Templates
WhatsApp template messages must be pre-approved by Meta before they can be sent to a customer who hasn't messaged you in the last 24 hours. Approval status is controlled entirely by Meta — this platform only mirrors it. A template is never treated as approved until Meta's own status confirms it.
Lists your templates. Called with an API key (rather than a dashboard session), only approved templates are returned by default, since those are the only ones Meta will actually deliver.
Creates a local draft. It is not submitted to Meta (and cannot be sent) until submitted for approval from the dashboard's Templates page.
Sending a template message
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | Yes | Recipient, international format |
template | string | Yes | Template name, must exist for your tenant |
language | string | Yes | Must match the template's approved language |
variables | object | No | Body placeholders keyed by position: {"1": "...", "2": "..."} fills {{1}}, {{2}} in order |
{
"to": "919XXXXXXXXX",
"template": "order_confirmation",
"language": "en",
"variables": {
"1": "Sagar",
"2": "ORD-1001"
}
}Validation and error handling:
- Unknown template name/language for your tenant →
404,TEMPLATE_NOT_FOUND - Template exists but Meta hasn't approved it yet →
422,TEMPLATE_NOT_APPROVED - WhatsApp not connected for your account →
409,WHATSAPP_DISCONNECTED
Media Messages
Send an image, video, or audio clip by public URL — the platform never stores or proxies the file; Meta downloads it directly from the link you provide, so it must be reachable over the internet.
{ "recipient": "919XXXXXXXXX", "media_url": "https://.../product.jpg", "caption": "Check this out" }{ "recipient": "919XXXXXXXXX", "media_url": "https://.../demo.mp4", "caption": "See it in action" }{ "recipient": "919XXXXXXXXX", "media_url": "https://.../voice-note.ogg" }Audio messages have no caption field — that's a Meta Cloud API constraint, not a platform limitation.
{ "recipient": "919XXXXXXXXX", "document_url": "https://.../invoice.pdf", "filename": "invoice.pdf", "caption": "Your invoice" }All four accept the same response shape as Send Messages ({ id, message_id, status }).
Message Status
Returns the current status of a single message you own. Requesting another tenant's message id returns a plain 404 — never a hint that the id belongs to someone else.
| Status | Meaning |
|---|---|
pending | Logged, not yet sent to Meta |
sent | Meta has accepted and sent it (confirmed by Meta's own status webhook) |
delivered | Delivered to the recipient's device |
read | Read by the recipient |
failed | Rejected at send time, or failed delivery — see error_message |
{
"success": true,
"message": "Message retrieved successfully.",
"data": {
"id": 501, "recipient": "919XXXXXXXXX", "type": "text", "direction": "outbound",
"status": "delivered", "error_message": null, "sent_at": "2026-08-19T10:05:50Z", "created_at": "2026-08-19T10:05:49Z"
}
}List messages
| Query param | Notes |
|---|---|
type | text / document / image / video / audio / template |
status | pending / sent / delivered / read / failed |
direction | outbound / inbound |
date_from, date_to | ISO dates |
per_page | Default 20, max 100 |
Response is paginated: { data: { items: [...], meta: { current_page, per_page, total, last_page } } }. Real-time status changes (and inbound replies) are also delivered as webhooks — see Webhooks, rather than requiring you to poll this endpoint.
Contacts
Full CRUD, tenant-isolated — a request can only ever see or modify your own tenant's contacts.
| Method | Path | Notes |
|---|---|---|
| GET | /contacts | ?search=, ?tag_id=, ?group_id=, ?opted_in=, pagination |
| POST | /contacts | Body: name, phone (required), email, custom_fields |
| GET | /contacts/{id} | Includes tags/groups |
| PUT | /contacts/{id} | Partial update |
| DELETE | /contacts/{id} | — |
| POST | /contacts/{id}/opt-in | — |
| POST | /contacts/{id}/opt-out | — |
{ "name": "Priya Sharma", "phone": "919XXXXXXXXX", "email": "priya@example.com", "custom_fields": {"plan": "pro"} }Tags and groups are managed via the dashboard's Contacts page today, not yet via this API.
Campaigns
| Method | Path | Notes |
|---|---|---|
| GET | /campaigns | Includes send progress counts |
| POST | /campaigns | Body: name, template_id (must be approved), audience_type, variable_mapping |
| GET | /campaigns/{id} | Detail + recipient counts |
| POST | /campaigns/{id}/start | Draft/scheduled → processing |
| POST | /campaigns/{id}/pause | Processing → paused |
| POST | /campaigns/{id}/cancel | Any active/draft campaign → cancelled |
Resuming a paused campaign, retrying failed recipients, and sending a test message are dashboard-only actions today, not yet exposed to API keys.
Campaign completion is delivered as a webhook (campaign.completed / campaign.failed) rather than requiring you to poll — see Webhooks.
Webhooks
Configure a URL of yours that this platform calls whenever something happens on your account — a message status changes, a customer replies, a campaign finishes. This is separate from (and unrelated to) Meta's own webhook to us; you never touch that.
Supported events
| Event | Fires when |
|---|---|
message.sent | Meta confirms an outbound message was sent |
message.delivered | Delivered to the recipient's device |
message.read | Read by the recipient |
message.failed | A send is rejected, or fails delivery |
message.received | A customer sends you a WhatsApp message |
campaign.completed | Every recipient in a campaign reaches a final status |
campaign.failed | A campaign's send job fails unexpectedly |
template.updated | A template's Meta approval status changes |
Payload & signing
Every delivery is a POST with a JSON body and a signature header computed from your webhook secret:
POST https://your-app.example.com/webhooks/wsaas
X-Webhook-Signature: sha256=<hex-encoded HMAC-SHA256>
Content-Type: application/json
{
"event": "message.delivered",
"data": { "message_id": 501, "recipient": "919XXXXXXXXX", "status": "delivered" },
"timestamp": "2026-08-19T10:05:50+00:00"
}Verifying the signature
$payload = file_get_contents('php://input');
$expected = hash_hmac('sha256', $payload, $webhookSecret);
$given = str_replace('sha256=', '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '');
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit('Invalid signature');
}const crypto = require('crypto');
function verify(rawBody, header, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const given = (header || '').replace('sha256=', '');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}Never send your API key in a webhook request — only the signature header, computed from your separate webhook secret.
Configure your endpoint
Log into the dashboard in this browser to configure your webhook endpoint here.
Errors
Every error response follows the same envelope. error.code is a stable, machine-readable string safe to branch on in your integration; message/errors are for humans/logs.
{
"success": false,
"message": "The requested WhatsApp template is not approved.",
"errors": [],
"error": { "code": "TEMPLATE_NOT_APPROVED", "message": "The requested WhatsApp template is not approved." }
}| Status | Code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or unrecognized API key |
| 403 | REVOKED_API_KEY | Key exists but has been revoked |
| 403 | API_KEY_EXPIRED | Key's expiry date has passed |
| 403 | TENANT_INACTIVE | Your tenant account is deactivated |
| 403 | PERMISSION_DENIED | Key lacks the scope for this action |
| 404 | NOT_FOUND / TEMPLATE_NOT_FOUND / MESSAGE_NOT_FOUND | Record doesn't exist, or isn't yours |
| 409 | WHATSAPP_DISCONNECTED | Connect WhatsApp before sending |
| 402 | PLAN_LIMIT_EXCEEDED | Your plan's usage limit was reached |
| 422 | VALIDATION_ERROR / TEMPLATE_NOT_APPROVED | Bad input — see errors for field detail |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests — see Rate Limits |
| 502 | META_API_ERROR | Meta rejected the send (bad number, etc.) |
| 500 | INTERNAL_SERVER_ERROR | Unexpected error on our side — never includes a stack trace |
Rate Limits
API-key requests are limited to 120 requests per minute, per key. This is the real, currently configured limit — it can change, so treat the response headers as the source of truth rather than hardcoding this number.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
{
"success": false,
"message": "Too many requests. Please slow down and try again shortly.",
"error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Please slow down and try again shortly." }
}Use Retry-After (seconds) to back off before your next request.
Code Examples
Sending an approved template message, in five languages. Swap the endpoint/body for any other request in this reference.
curl -X POST BASE_URL/messages/template \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "919XXXXXXXXX",
"template": "order_confirmation",
"language": "en",
"variables": { "1": "Sagar", "2": "ORD-1001" }
}'$ch = curl_init('BASE_URL/messages/template');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => '919XXXXXXXXX',
'template' => 'order_confirmation',
'language' => 'en',
'variables' => ['1' => 'Sagar', '2' => 'ORD-1001'],
]),
]);
$response = json_decode(curl_exec($ch), true);$response = Http::withToken('YOUR_API_KEY')
->post('BASE_URL/messages/template', [
'to' => '919XXXXXXXXX',
'template' => 'order_confirmation',
'language' => 'en',
'variables' => [
'1' => 'Sagar',
'2' => 'ORD-1001',
],
]);
$response->json();const response = await fetch('BASE_URL/messages/template', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: '919XXXXXXXXX',
template: 'order_confirmation',
language: 'en',
variables: { '1': 'Sagar', '2': 'ORD-1001' },
}),
});
const data = await response.json();import requests
response = requests.post(
"BASE_URL/messages/template",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"to": "919XXXXXXXXX",
"template": "order_confirmation",
"language": "en",
"variables": {"1": "Sagar", "2": "ORD-1001"},
},
)
data = response.json()