Developer Portal

Connect your ERP, CRM, website or app to WhatsApp SaaS with an API key.

Not logged in ← Back to Dashboard
Base URL: https://your-domain/api/v1

Getting Started

Six steps from a new account to your first WhatsApp message sent through your own code.

1

Create/log in to your tenant account

Register from the registration page or log in on the dashboard.

2

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.

3

Verify the connection is active

The WhatsApp page shows a Connected status once done. You can also confirm this programmatically — see Authentication below.

4

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.

5

Copy the API key

The full key is shown exactly once, immediately after creation. Store it somewhere secure now.

Keep your API key secret. Never expose it in frontend/browser or mobile client code, embed it in a public repository, or send it to anyone. Treat it like a password — use it only from a secure backend/server you control. Anyone with the key can send messages and read data as your tenant.
6

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
You never need a Meta access token, App Secret, or WhatsApp access token. Your API key alone identifies your tenant. The platform resolves your tenant → your connected WhatsApp account → your phone number ID → the Meta Cloud API call, entirely server-side. Your application only ever talks to our API.
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.

GET/ping

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.

MethodPathAuthScopeDescription
GET/pingAPI key—Verify key + connection status
POST/messages/textAPI keymessages.sendSend a text message
POST/messages/documentAPI keymessages.sendSend a document
POST/messages/imageAPI keymessages.sendSend an image
POST/messages/videoAPI keymessages.sendSend a video
POST/messages/audioAPI keymessages.sendSend an audio clip
POST/messages/templateAPI keymessages.sendSend an approved template
GET/messagesKey or sessionmessages.viewList messages, paginated + filtered
GET/messages/{id}Key or sessionmessages.viewSingle message status
GET/templatesKey or sessiontemplates.viewList templates
POST/templatesKey or sessiontemplates.createCreate a draft template
GET/templates/{id}Key or sessiontemplates.viewSingle template detail
GET/contactsKey or sessioncontacts.viewList/search contacts
POST/contactsKey or sessioncontacts.createCreate a contact
GET/contacts/{id}Key or sessioncontacts.viewSingle contact
PUT/contacts/{id}Key or sessioncontacts.editUpdate a contact
DELETE/contacts/{id}Key or sessioncontacts.deleteDelete a contact
POST/contacts/{id}/opt-inKey or sessioncontacts.editOpt a contact in
POST/contacts/{id}/opt-outKey or sessioncontacts.editOpt a contact out
GET/campaignsKey or sessioncampaigns.viewList campaigns
POST/campaignsKey or sessioncampaigns.createCreate a campaign
GET/campaigns/{id}Key or sessioncampaigns.viewCampaign detail + progress
POST/campaigns/{id}/startKey or sessioncampaigns.sendStart a draft/scheduled campaign
POST/campaigns/{id}/pauseKey or sessioncampaigns.managePause a processing campaign
POST/campaigns/{id}/cancelKey or sessioncampaigns.manageCancel a campaign
GET/usageKey or sessionusage.viewCurrent-period usage summary
GET/usage/rangeKey or sessionusage.viewUsage 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

POST/messages/textmessages.send

Sends a free-form text message. Recipients use international format without a leading + (e.g. 91XXXXXXXXXX).

Request body

FieldTypeRequiredNotes
recipientstringYesWhatsApp number, international format
messagestringYesUp 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.

GET/templates?status=approvedtemplates.view

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.

GET/templates/{id}templates.view
POST/templatestemplates.create

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

POST/messages/templatemessages.send
FieldTypeRequiredNotes
tostringYesRecipient, international format
templatestringYesTemplate name, must exist for your tenant
languagestringYesMust match the template's approved language
variablesobjectNoBody 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.

POST/messages/imagemessages.send
{ "recipient": "919XXXXXXXXX", "media_url": "https://.../product.jpg", "caption": "Check this out" }
POST/messages/videomessages.send
{ "recipient": "919XXXXXXXXX", "media_url": "https://.../demo.mp4", "caption": "See it in action" }
POST/messages/audiomessages.send
{ "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.

POST/messages/documentmessages.send
{ "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

GET/messages/{message_id}messages.view

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.

StatusMeaning
pendingLogged, not yet sent to Meta
sentMeta has accepted and sent it (confirmed by Meta's own status webhook)
deliveredDelivered to the recipient's device
readRead by the recipient
failedRejected 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

GET/messagesmessages.view
Query paramNotes
typetext / document / image / video / audio / template
statuspending / sent / delivered / read / failed
directionoutbound / inbound
date_from, date_toISO dates
per_pageDefault 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.

MethodPathNotes
GET/contacts?search=, ?tag_id=, ?group_id=, ?opted_in=, pagination
POST/contactsBody: 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

MethodPathNotes
GET/campaignsIncludes send progress counts
POST/campaignsBody: name, template_id (must be approved), audience_type, variable_mapping
GET/campaigns/{id}Detail + recipient counts
POST/campaigns/{id}/startDraft/scheduled → processing
POST/campaigns/{id}/pauseProcessing → paused
POST/campaigns/{id}/cancelAny 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

EventFires when
message.sentMeta confirms an outbound message was sent
message.deliveredDelivered to the recipient's device
message.readRead by the recipient
message.failedA send is rejected, or fails delivery
message.receivedA customer sends you a WhatsApp message
campaign.completedEvery recipient in a campaign reaches a final status
campaign.failedA campaign's send job fails unexpectedly
template.updatedA 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." }
}
StatusCodeMeaning
401INVALID_API_KEYMissing or unrecognized API key
403REVOKED_API_KEYKey exists but has been revoked
403API_KEY_EXPIREDKey's expiry date has passed
403TENANT_INACTIVEYour tenant account is deactivated
403PERMISSION_DENIEDKey lacks the scope for this action
404NOT_FOUND / TEMPLATE_NOT_FOUND / MESSAGE_NOT_FOUNDRecord doesn't exist, or isn't yours
409WHATSAPP_DISCONNECTEDConnect WhatsApp before sending
402PLAN_LIMIT_EXCEEDEDYour plan's usage limit was reached
422VALIDATION_ERROR / TEMPLATE_NOT_APPROVEDBad input — see errors for field detail
429RATE_LIMIT_EXCEEDEDToo many requests — see Rate Limits
502META_API_ERRORMeta rejected the send (bad number, etc.)
500INTERNAL_SERVER_ERRORUnexpected 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()