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.xyz
Machine-readable contractopenapi.json

Quickstart

Reminders are the fastest way to start. The next section walks the full commitment lifecycle; this one sends a one-off template message.

  1. Create a key in Settings → API keys. It is shown once.
  2. Send a pre-approved template to a phone number.
  3. Poll the message, or point a webhook at your server for status updates.
cURL
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"
  }'
Response · 202
{ "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.

POST/api/v1/reminders
FieldTypeNotes
schedule_iduuidRequired. The reminder schedule that defines when messages go out.
external_idstringRequired. Your stable ID for this commitment. Replay with the same value upserts.
event_typestringRequired. e.g. appointment.
tostringRequired. Recipient phone, digits plus optional +.
titlestringRequired. Shown in message parameters.
scheduled_atstringRequired. Future ISO instant with Z or numeric offset.
timezonestringRequired IANA zone (e.g. Asia/Kolkata). Controls local wall-clock rules; never infer it from a numeric offset.
metadataobjectOptional. Free-form fields for message parameters.
cURL
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.

Response · 201
{
  "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"):

JSON
{
  "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_xxx

A 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:

/openapi.json

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

POST/api/v1/messages

Templates are the only way to start a conversation. They must already be approved in your WhatsApp Business account.

FieldTypeNotes
tostringRequired. Digits, spaces and + are all accepted.
templatestringRequired unless you send text.
languagestringOptional, defaults to en. Must match the approved template.
paramsstring[]Optional. Positional body variables, in {{1}} order.
header_image_urlstringRequired 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

POST/api/v1/messages

Inside 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.

JSON
{
  "to": "919876543210",
  "text": "Your order ships tomorrow."
}
Response · 200
{
  "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

GET/api/v1/messages/{id}

Use the id returned by the send — a numeric queue id for templates, a wamid. for session replies.

Response · 200
{
  "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.

JSON
{
  "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.

JSON
{
  "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.

HeaderValue
X-Webhook-TimestampISO-8601 UTC, identical to data.timestamp
X-Webhook-Signaturesha256= + 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.

Node.js / Express
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)
  })
Python / Flask
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 "", 200

Errors

Errors are JSON with an error message, and a stable code where you may want to branch on it.

StatusCodeMeaning
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.
409opted_outThe contact has opted out of messages.
409outside_service_windowThe 24-hour window closed. Send a template.
429daily_limit_reachedDaily 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.

Response · 429
{
  "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:

  1. Set your webhook URL to an n8n Webhook node and generate a secret.
  2. Ask us to switch your workspace to external inbound routing.
  3. Verify the signature, filter on type === "message.inbound".
  4. Reply with an HTTP Request node posting { to, text } back to /api/v1/messages.
n8n · HTTP Request node
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.