1. Getting started
You need three things: an API key pair, a connected WhatsApp Business Account, and the phone number id you want to send from. Create the key on the API Keys page — the secret is shown once — and copy the phone number id from Phone Numbers.
| Item | Where to find it |
|---|---|
| Access key + secret key | API Keys → Create API key. The secret is revealed once and cannot be retrieved later |
| Phone number id | Phone Numbers → the id shown against each registered number |
| Template name and language | Templates → any template with status Approved |
| Base URL | https://wa.draskenapis.com |
Keep the secret server-side
The secret key authenticates as your organisation and can send messages at your cost. Never ship it in a browser bundle, a mobile app or a public repository. If it leaks, revoke the key on the API Keys page — revocation takes effect immediately.
2. Authentication
Send both halves of the pair as headers on every request. There is no OAuth flow and no token to refresh — the pair is the credential.
| Header | Value |
|---|---|
| x-access-key | The access key, e.g. ak_9f2c4a7b1e0d3f5a7c9e1b3d |
| x-secret-key | The secret key, e.g. sk_a1b2c3d4e5f6... (shown once at creation) |
| Content-Type | application/json on requests with a body |
curl "https://wa.draskenapis.com/messages?page=1&limit=20" \
-H "x-access-key: $WA_ACCESS_KEY" \
-H "x-secret-key: $WA_SECRET_KEY"Requests are scoped to the organisation that owns the key. You can only send from phone numbers belonging to that organisation's WhatsApp Business Accounts, and you only ever read that organisation's messages.
Revoked keys fail closed
A revoked key returns 401 on the next request, including requests already in flight against a cached credential. There is no grace period.
3. Response format
Every response — success or failure — uses the same envelope. Read data on success; read message, and errors when present, on failure. Paginated list responses add a meta block.
{
"statusCode": 200,
"message": "Success",
"data": {
"id": 4821,
"status": "sent"
}
}{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{
"field": "to",
"message": "to should not be empty"
}
]
}| Code | Meaning | What to do |
|---|---|---|
| 200 | Success | Read data |
| 400 | Invalid body, or Meta rejected the send | Read message — Meta's own reason is passed through — and fix the payload. Do not retry unchanged |
| 401 | Missing, malformed or revoked key | Check both headers; create a new key if this one was revoked |
| 403 | The phone number id is not yours | Send from a number belonging to your organisation |
| 404 | Message id not found in your organisation | Check the id; ids are not shared across organisations |
| 429 | Rate limit exceeded | Back off and retry — see section 8 |
| 500 | Unexpected server error | Retry with backoff; if it persists, contact support with the timestamp |
4. Send a message
/messagesSends a WhatsApp message and records it against your organisation
| Field | Type | Notes |
|---|---|---|
| phoneNumberId | string | The registered number to send from. Must belong to your organisation |
| to | string | Recipient in E.164 digits only, no + and no spaces — e.g. 447911123456 |
| type | string | One of: text, image, video, audio, document, template, interactive, location |
| type | Additional fields |
|---|---|
| text | text — the message body |
| image, video, audio, document | mediaUrl — a public URL Meta can fetch. caption optional for image, video and document |
| template | templateName, templateLanguage (e.g. en_US). templateComponents for variable substitution |
| location | latitude (−90 to 90), longitude (−180 to 180). locationName, locationAddress optional |
| interactive | interactiveType (button / list / cta_url) plus interactiveBodyText, and the fields for that subtype — see section 5 |
reaction and contacts are not implemented
Both values are accepted by validation but produce an empty payload, so the send will fail at Meta. Treat the supported list above as authoritative.
curl -X POST "https://wa.draskenapis.com/messages" \
-H "x-access-key: $WA_ACCESS_KEY" \
-H "x-secret-key: $WA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "text",
"text": "Your order #48210 has shipped and arrives Friday."
}'{
"statusCode": 200,
"message": "Success",
"data": {
"id": 4821,
"metaMessageId": "wamid.HBgLNDQ3OTExMTIzNDU2FQIAERgSN0YxRDkxQzhBMkI0RDVFNkYA",
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "text",
"status": "sent",
"createdAt": "2026-08-01T09:24:11.482Z"
}
}id is this platform's id — use it with the endpoints in sections 6 and 7. metaMessageId is Meta's wamid, which is what arrives on delivery-status webhooks.
{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "template",
"templateName": "order_shipped",
"templateLanguage": "en_US",
"templateComponents": [
{
"type": "body",
"parameters": [
{
"type": "text",
"text": "Aanya"
},
{
"type": "text",
"text": "48210"
}
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{
"type": "text",
"text": "48210"
}
]
}
]
}Authentication templates always take a code
Meta writes the body copy of an authentication template itself, so the template you read back has no {{1}} in it — but the send still needs the one-time code, and it has to appear twice: once as the body parameter, once on the OTP button (addressed as sub_type url, whatever its otp_type). Omit either and the send fails with "(#132000) Number of parameters does not match the expected number of params". The code is limited to 15 characters.
{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "template",
"templateName": "auth",
"templateLanguage": "en_US",
"templateComponents": [
{
"type": "body",
"parameters": [
{
"type": "text",
"text": "J$FpnYnP"
}
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{
"type": "text",
"text": "J$FpnYnP"
}
]
}
]
}{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "image",
"mediaUrl": "https://cdn.example.com/receipts/48210.jpg",
"caption": "Your receipt"
}{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "location",
"latitude": 19.076,
"longitude": 72.8777,
"locationName": "Drasken Labs",
"locationAddress": "Bandra Kurla Complex, Mumbai"
}Two rejections that are not bugs
Sending to a contact marked opted out returns 400 with "Recipient … has opted out of messages" — that check runs before Meta is called and is deliberate. Outside the 24-hour customer service window, only approved templates are delivered; free-form sends are rejected by Meta and surface as 400 with Meta's own message.
5. Interactive messages
Set type: "interactive" and pick a subtype. interactiveBodyText is required for all three; interactiveHeaderText and interactiveFooterText are optional throughout.
| interactiveType | Required fields | Limits |
|---|---|---|
| button | interactiveButtons — array of { id, title } | 1 to 3 buttons. Title max 20 characters (Meta's limit) |
| list | interactiveButtonLabel, interactiveSections — array of { title, rows: [{ id, title, description? }] } | At least 1 section. Row title max 24 characters, description max 72 |
| cta_url | interactiveCtaDisplayText, interactiveCtaUrl | One call-to-action per message |
{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "interactive",
"interactiveType": "button",
"interactiveBodyText": "Your order is ready. How would you like it?",
"interactiveButtons": [
{
"id": "deliver",
"title": "Deliver"
},
{
"id": "collect",
"title": "Collect in store"
}
]
}{
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "interactive",
"interactiveType": "list",
"interactiveBodyText": "Pick a delivery slot.",
"interactiveButtonLabel": "View slots",
"interactiveSections": [
{
"title": "Tomorrow",
"rows": [
{
"id": "slot_am",
"title": "Morning",
"description": "09:00 – 12:00"
},
{
"id": "slot_pm",
"title": "Afternoon",
"description": "13:00 – 17:00"
}
]
}
]
}The id you set on a button or row is echoed back on the inbound webhook when the recipient taps it, so make it something your system can act on.
6. List messages
/messagesEvery message sent by your organisation, newest first
| Query parameter | Type | Notes |
|---|---|---|
| page | number | 1-based. Supplying it switches the response to paginated mode |
| limit | number | Page size, 1–100. Supplying it switches the response to paginated mode |
With neither parameter the full list is returned and there is no meta block. Supply both for anything with volume — the unpaginated form is kept only for backward compatibility.
{
"statusCode": 200,
"message": "Success",
"data": [
{
"id": 4821,
"metaMessageId": "wamid.HBgLNDQ3OTExMTIzNDU2FQIAERgSN0Yx",
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "text",
"status": "delivered",
"createdAt": "2026-08-01T09:24:11.482Z",
"updatedAt": "2026-08-01T09:24:14.901Z"
}
],
"meta": {
"total": 1284,
"totalPages": 65,
"page": 1,
"limit": 20
}
}The payload is omitted here
List items carry metadata only. Fetch a single message (section 7) when you need the body that was sent.
7. Get one message
/messages/{id}One message, including the payload sent to Meta
id is the platform id returned by the send call — not Meta's wamid. A message belonging to another organisation returns 404 rather than 403, so ids cannot be probed.
{
"statusCode": 200,
"message": "Success",
"data": {
"id": 4821,
"metaMessageId": "wamid.HBgLNDQ3OTExMTIzNDU2FQIAERgSN0Yx",
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "text",
"status": "read",
"createdAt": "2026-08-01T09:24:11.482Z",
"updatedAt": "2026-08-01T09:31:02.117Z",
"payload": {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "447911123456",
"type": "text",
"text": {
"body": "Your order #48210 has shipped."
}
}
}
}| status | Meaning |
|---|---|
| sent | Accepted by Meta and on its way |
| delivered | Reached the recipient's device |
| read | Opened by the recipient — only if they have read receipts on |
| failed | Meta could not deliver it. error carries the reason |
A failed message carries an error block — Meta's own account of the failure, as reported on the status webhook. It is present on list responses too, and absent on messages that failed before the reason was recorded.
{
"id": 4817,
"metaMessageId": "wamid.HBgMOTE5OTU4OTA2MDM1FQIAERgSODkx",
"phoneNumberId": "109876543210987",
"to": "447911123456",
"type": "text",
"status": "failed",
"error": {
"code": 131047,
"title": "Re-engagement message",
"detail": "Message failed to send because more than 24 hours have passed since the customer last replied to this number."
},
"createdAt": "2026-08-16T06:09:20.840Z",
"updatedAt": "2026-08-16T06:09:23.841Z"
}8. Analytics
/messages/analyticsStatus totals, delivery and read rates, and a daily series
| Query parameter | Type | Notes |
|---|---|---|
| days | number | Range in days, 1–90. Defaults to 14 |
{
"statusCode": 200,
"message": "Success",
"data": {
"rangeDays": 14,
"totals": {
"sent": 1284,
"delivered": 1268,
"read": 966,
"failed": 16,
"total": 1284
},
"deliveryRate": 0.987,
"readRate": 0.762,
"series": [
{
"date": "2026-07-19",
"delivered": 84,
"failed": 1
},
{
"date": "2026-07-20",
"delivered": 91,
"failed": 0
}
]
}
}deliveryRate is delivered ÷ total and readRate is read ÷ delivered, both as fractions between 0 and 1. Read rate is systematically understated because recipients can disable read receipts.
9. Rate limits and errors
- Exceeding the limit returns 429. Back off exponentially — retrying immediately will keep failing.
- Meta enforces its own messaging limits per phone number, based on your quality rating. Those surface as 400 with Meta's message, not 429.
- A 400 from a send is usually a permanent rejection — an unapproved template, a number outside the 24-hour window, a media URL Meta cannot fetch. Retrying the identical payload will not help.
- Treat 500 as transient: retry with backoff, and make the retry idempotent on your side, since a send that reached Meta before the error would deliver twice.
- Log
metaMessageIdagainst your own records. It is the only id Meta's webhooks and support tooling recognise.
10. Receiving replies and status updates
The API is send-and-read only — inbound messages and delivery updates arrive as webhooks rather than from polling. Register your own HTTPS endpoint on the Webhooks page (Your endpoints), and every event for that account is posted to it as JSON. The same page lists what was delivered, what came back, and lets you send a test event or retry a delivery.
Endpoints are registered per WhatsApp Business Account and can be narrowed to the event kinds you care about: customer replies, delivery status, template reviews and account changes. Subscribe to nothing in particular and you receive them all, including kinds added later.
{
"id": 84213,
"event": "inbound_message",
"wabaId": "220011334455",
"occurredAt": "2026-08-16T09:12:04.881Z",
"data": {
"kind": "inbound_message",
"title": "Reply received",
"detail": "Asha: Yes, that works",
"recipient": "919822010210",
"messageId": "wamid.HBgMOTE5ODIyMDEwMjEwFQIAEhgUM0E0…",
"metaField": "messages",
"raw": {
"messaging_product": "whatsapp",
"messages": [
"…"
]
}
}
}| Header | Value |
|---|---|
| X-Drasken-Delivery-Id | Delivery id, identical on every retry — deduplicate on it |
| X-Drasken-Event | Event kind, e.g. inbound_message or endpoint.test |
| X-Drasken-Timestamp | Unix seconds, part of the signed string |
| X-Drasken-Signature-256 | sha256=<hex HMAC of {timestamp}.{raw body}>. Sent only when the endpoint has a signing secret |
The signing secret is optional and yours to choose — set one when you add the endpoint, and verify it like this. Without a secret nothing is signed and no signature header is sent, which is fine for an endpoint already protected by its own gateway and a bad idea for a public URL.
import express from "express"
import crypto from "node:crypto"
const app = express()
// The raw body is what was signed — parse it after verifying, never before.
app.post("/hooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("X-Drasken-Signature-256") ?? ""
const timestamp = req.get("X-Drasken-Timestamp") ?? ""
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex")
const a = Buffer.from(signature)
const b = Buffer.from(expected)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401)
}
// Answer first, work afterwards: anything slower than a few seconds is
// treated as a failed delivery and retried.
res.sendStatus(200)
const event = JSON.parse(req.body.toString())
void handle(event)
})- Answer 2xx quickly and do the work asynchronously — a timeout or any non-2xx status is retried after 1, 5, 15, 60 then 180 minutes, and given up on after that.
- Deduplicate on the delivery id: a retry carries the same id and the same body as the attempt before it.
- An endpoint that fails 10 deliveries in a row is switched off automatically and the owner is emailed. Fix it, send a test event, then switch it back on.
- Redirects are not followed, so point the endpoint at its final URL.
- Status updates carry Meta's wamid, which maps to
metaMessageIdon this API. - An inbound reply opens a 24-hour customer service window with that recipient, during which free-form messages are allowed.
11. Full examples
const BASE = "https://wa.draskenapis.com"
const headers = {
"x-access-key": process.env.WA_ACCESS_KEY,
"x-secret-key": process.env.WA_SECRET_KEY,
"Content-Type": "application/json",
}
async function sendText(to, text) {
const res = await fetch(`${BASE}/messages`, {
method: "POST",
headers,
body: JSON.stringify({
phoneNumberId: process.env.WA_PHONE_NUMBER_ID,
to, // E.164 digits only: 447911123456
type: "text",
text,
}),
})
const body = await res.json()
if (!res.ok) {
// body.message carries Meta's own reason for a rejected send.
throw new Error(`${res.status}: ${body.message}`)
}
return body.data
}
async function getMessage(id) {
const res = await fetch(`${BASE}/messages/${id}`, { headers })
const body = await res.json()
return body.data
}
const sent = await sendText("447911123456", "Your order has shipped.")
console.log(sent.id, sent.metaMessageId)
console.log((await getMessage(sent.id)).status)import os
import requests
BASE = "https://wa.draskenapis.com"
HEADERS = {
"x-access-key": os.environ["WA_ACCESS_KEY"],
"x-secret-key": os.environ["WA_SECRET_KEY"],
}
payload = {
"phoneNumberId": os.environ["WA_PHONE_NUMBER_ID"],
"to": "447911123456",
"type": "template",
"templateName": "order_shipped",
"templateLanguage": "en_US",
"templateComponents": [
{"type": "body", "parameters": [{"type": "text", "text": "Aanya"}]}
],
}
response = requests.post(f"{BASE}/messages", json=payload, headers=HEADERS, timeout=30)
body = response.json()
if not response.ok:
raise RuntimeError(f"{response.status_code}: {body.get('message')}")
print(body["data"]["id"], body["data"]["metaMessageId"])