Developers
WhatsApp API
Replay your source system's commitments — bookings, appointments, follow-ups — as scheduled WhatsApp reminders with idempotent create, reschedule and cancel. Also send one-off template and session messages and receive signed delivery webhooks. Everything is JSON over HTTPS — no SDK required.
Base URL
https://waani.xyzQuickstart
Reminders are the fastest way to start. The next section walks the full commitment lifecycle; this one sends a one-off template message.
- Create a key in Settings → API keys. It is shown once.
- Send a pre-approved template to a phone number.
- Poll the message, or point a webhook at your server for status updates.
curl -X POST https://waani.xyz/api/v1/messages \
-H "Authorization: Bearer wsk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"to": "919876543210",
"template": "order_confirmation",
"language": "en_US",
"params": ["Gagan", "#1042"],
"header_image_url": "https://cdn.example.com/orders/1042.jpg"
}'{ "id": "48213", "status": "queued" }Commitment reminders
A commitment is an event in your system — an appointment, a class, a payment follow-up — with a stable external_id and a schedule of reminders leading up to it. Replaying the same event never duplicates a reminder; changing its material supersedes the old generation. When a booking is cancelled, one call stops every pending reminder.
/api/v1/reminders| Field | Type | Notes |
|---|---|---|
schedule_id | uuid | Required. The reminder schedule that defines when messages go out. |
external_id | string | Required. Your stable ID for this commitment. Replay with the same value upserts. |
event_type | string | Required. e.g. appointment. |
to | string | Required. Recipient phone, digits plus optional +. |
title | string | Required. Shown in message parameters. |
scheduled_at | string | Required. Future ISO instant with Z or numeric offset. |
timezone | string | Required IANA zone (e.g. Asia/Kolkata). Controls local wall-clock rules; never infer it from a numeric offset. |
metadata | object | Optional. Free-form fields for message parameters. |
curl -X POST https://waani.xyz/api/v1/reminders \
-H "Authorization: Bearer wsk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"schedule_id": "df75125c-2138-47c8-bc85-34080305dc59",
"external_id": "booking-1042",
"event_type": "appointment",
"to": "+919876543210",
"title": "Product consultation",
"scheduled_at": "2030-09-10T15:30:00+05:30",
"timezone": "Asia/Kolkata",
"metadata": { "company_name": "Example Ltd" }
}'A new commitment returns 201 with the generated jobs. Replaying the same external_id and material returns 200 with created: false and no new jobs; sending changed material creates one superseding generation.
{
"id": "7282ea21-f012-4db3-a9f3-11cb14cb2491",
"external_id": "booking-1042",
"status": "scheduled",
"generation": 1,
"created": true,
"jobs": [
{
"id": "0df7b128-c3cf-4cea-a74c-343486f0315a",
"label": "9 AM one day before",
"scheduled_for": "2030-09-09T09:00:00.000+05:30",
"status": "queued",
"skip_reason": null
}
]
}Local wall-clock schedules
A schedule's offsets may be exact durations (legacy, e.g. 24 hours before) or calendar rules with a local time. Calendar rules carry at_local_time (strict HH:mm, only with unit: "days"):
{
"key": "day-before-morning",
"value": 1,
"unit": "days",
"at_local_time": "09:00",
"label": "9 AM one day before"
}at_local_time is resolved in the event's IANA timezone. Across a daylight-saving transition the reminder stays at the requested wall clock: during a spring-forward gap the resolved time moves forward, and during a fall-back overlap the earlier occurrence is used. Without at_local_time, 1 day remains exactly 24 hours.
Lifecycle
Read an event and its jobs with GET /api/v1/reminders/{id}. Reschedule with POST /api/v1/reminders/{id}/reschedule. Cancel with POST /api/v1/reminders/{id}/cancel. Record what actually happened with POST /api/v1/reminders/{id}/outcome (attended, no_show, or cancelled). See the full semantics in the Reminder API guide and the OpenAPI contract above.
Authentication
Every request carries a bearer key. Keys start with wsk_live_, belong to one workspace, and are stored only as a hash — if you lose one, revoke it and create another.
Authorization: Bearer wsk_live_xxxA missing, unknown, or revoked key returns 401. Never ship a key in browser or mobile code; call this API from your server.
OpenAPI contract
A machine-readable OpenAPI 3.1 document describes every shipped public route, schema, and status code. Generate a typed client or import it into Postman:
Documented routes: POST /api/v1/messages, GET /api/v1/messages/{id}, POST /api/v1/reminders, GET /api/v1/reminders/{id}, POST /api/v1/reminders/{id}/cancel, POST /api/v1/reminders/{id}/reschedule, and POST /api/v1/reminders/{id}/outcome.
Send a template message
/api/v1/messagesTemplates are the only way to start a conversation. They must already be approved in your WhatsApp Business account.
| Field | Type | Notes |
|---|---|---|
to | string | Required. Digits, spaces and + are all accepted. |
template | string | Required unless you send text. |
language | string | Optional, defaults to en. Must match the approved template. |
params | string[] | Optional. Positional body variables, in {{1}} order. |
header_image_url | string | Required when the template was approved with an image header; leave it out otherwise — Meta rejects both mismatches. A direct, public HTTPS link to a JPEG or PNG, not a web page. Can be a different image on every send. |
A 202 means queued, not delivered. Template sends run ahead of bulk broadcasts, so a campaign in progress will not delay a transactional message.
Send a session reply
/api/v1/messagesInside 24 hours of a customer's last message you may reply with free-form text — no template, no approval. Send text instead of template; supplying both is a 400.
{
"to": "919876543210",
"text": "Your order ships tomorrow."
}{
"id": "wamid.HBgMOTE...",
"status": "sent",
"message_id": "wamid.HBgMOTE...",
"to": "919876543210"
}This sends immediately rather than queueing, because a queued session message could only expire. If the 24-hour window has closed you get 409 outside_service_window — send a template to reopen it.
Session replies have no queue id, so id is the WhatsApp message id instead. Pass it to GET /api/v1/messages/{id} or match it against message.status events exactly as you would a template id.
Check a message
/api/v1/messages/{id}Use the id returned by the send — a numeric queue id for templates, a wamid. for session replies.
{
"id": "48213",
"status": "delivered",
"to": "919876543210",
"template": "order_confirmation",
"language": "en_US",
"attempts": 1,
"error": null,
"created_at": "2026-08-04T09:25:55.180Z",
"sent_at": "2026-08-04T09:26:03.471Z",
"delivered_at": "2026-08-04T09:26:03.000Z",
"read_at": null
}status is one of queued, sending, sent, delivered, read, failed, canceled. Keys only ever see their own workspace's messages; anything else returns 404.
Webhooks
Set an HTTPS URL in Settings → Primary webhook and generate a signing secret. Both event types arrive at that one URL — switch on type.
To send the same events to other systems too, add them under Settings → Extra webhooks (on plans that include them). Each extra webhook picks its events and gets its own signing secret, shown once when you add it. The primary webhook's secret will not verify an extra webhook's deliveries: give each endpoint the secret of that endpoint. Lost it? Press Generate new secret on that webhook and update your endpoint. Extra webhooks get message.inbound whoever answers the customer, but they only listen — when replies are handled by your own system, only the primary webhook is the one expected to reply.
message.status
Fires as a message you sent progresses. template is null for session replies.
{
"type": "message.status",
"data": {
"id": "48213",
"status": "delivered",
"to": "919876543210",
"template": "order_confirmation",
"timestamp": "2026-08-04T09:26:03.000Z",
"error": null
}
}message.inbound
Fires when a customer messages you — this is what an external bot listens to. It is only sent when your workspace is set to route inbound replies externally; otherwise the built-in bot builder answers and nothing is relayed. Exactly one of the two responds, so a customer never receives duplicate replies.
{
"type": "message.inbound",
"data": {
"from": "919876543210",
"name": "Gagan Thakur",
"message_id": "wamid.HBgMOTE...",
"type": "text",
"text": "where is my order",
"timestamp": "2026-08-04T10:33:03.000Z"
}
}Every delivery is persisted and retried with backoff until your endpoint returns a fast 2xx, up to 5 attempts. A 4xx reply (other than 408 and 429) means your endpoint rejected the request, for example a wrong secret, so it is marked failed at once instead of being retried. An extra webhook whose last 20 deliveries all failed is paused and the workspace owner is told; fix it and press Resume. A slow or failing endpoint never blocks message processing, but should still reply 200 quickly and do work asynchronously. A failed delivery can be retried manually; you can always reconcile state with GET /api/v1/messages/{id}.
Verifying signatures
When a secret is set, every delivery carries two headers.
| Header | Value |
|---|---|
X-Webhook-Timestamp | ISO-8601 UTC, identical to data.timestamp |
X-Webhook-Signature | sha256= + HMAC-SHA256 of `${timestamp}.${rawBody}` |
Sign the raw body, before any JSON parsing — re-serialising changes the bytes and the signature will never match. Compare in constant time.
const crypto = require('crypto')
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const timestamp = req.get('X-Webhook-Timestamp')
const signature = req.get('X-Webhook-Signature')
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(timestamp + '.' + req.body.toString())
.digest('hex')
const ok = signature && signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!ok) return res.sendStatus(401)
const event = JSON.parse(req.body.toString())
res.sendStatus(200) // acknowledge first
if (event.type === 'message.inbound') handleReply(event.data)
})import hmac, hashlib, os
from flask import request, abort
@app.post("/webhook")
def webhook():
raw = request.get_data()
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
return "", 200Errors
Errors are JSON with an error message, and a stable code where you may want to branch on it.
| Status | Code | Meaning |
|---|---|---|
| 200 | — | Session reply sent. |
| 202 | — | Template queued. |
| 400 | — | Validation failed, including both or neither of template / text. |
| 401 | — | Missing, unknown, or revoked key. |
| 402 / 403 | — | Plan inactive, or the feature is off for your workspace. |
| 404 | — | No such message in your workspace. |
| 409 | opted_out | The contact has opted out of messages. |
| 409 | outside_service_window | The 24-hour window closed. Send a template. |
| 429 | daily_limit_reached | Daily API send limit hit. Includes limit and used. |
| 503 | — | Temporarily unavailable. Retry with backoff. |
Limits
API sends have their own daily budget, counted separately from broadcasts — a large campaign can never eat the quota your backend depends on, and vice versa. The counter resets at 00:00 UTC.
{
"error": "Daily API send limit of 1000 reached",
"code": "daily_limit_reached",
"limit": 1000,
"used": 1000
}Your current usage is on the dashboard. Need more? See pricing.
Building a bot with n8n
Use the built-in bot builder for menu-style flows. For anything that needs your own logic — a database lookup, an LLM, an order system — drive it externally:
- Set your webhook URL to an n8n Webhook node and generate a secret.
- Ask us to switch your workspace to external inbound routing.
- Verify the signature, filter on
type === "message.inbound". - Reply with an HTTP Request node posting
{ to, text }back to/api/v1/messages.
POST https://waani.xyz/api/v1/messages
Headers
Authorization: Bearer wsk_live_xxx
Content-Type: application/json
Body
{
"to": "{{ $json.data.from }}",
"text": "{{ $json.reply }}"
}Because the reply lands inside the 24-hour window, no template approval is needed — you can change what your bot says as often as you like.
While your workflow runs, the customer sees their message marked read and a "typing…" indicator. It clears when your reply arrives or after 25 seconds, so keep slow steps (LLM calls, lookups) under that if you can.