Messaging API

Send WhatsApp messages and read their delivery status from your own servers, authenticated with an API key pair. Every endpoint below accepts either an API key or a console session — no other endpoint on the platform accepts an API key.

Version 1.0Updated 16 August 2026Base URL: https://wa.draskenapis.com

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.

ItemWhere to find it
Access key + secret keyAPI Keys → Create API key. The secret is revealed once and cannot be retrieved later
Phone number idPhone Numbers → the id shown against each registered number
Template name and languageTemplates → any template with status Approved
Base URLhttps://wa.draskenapis.com

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.

HeaderValue
x-access-keyThe access key, e.g. ak_9f2c4a7b1e0d3f5a7c9e1b3d
x-secret-keyThe secret key, e.g. sk_a1b2c3d4e5f6... (shown once at creation)
Content-Typeapplication/json on requests with a body
A minimal authenticated requestcURL
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.

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.

SuccessJSON
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "id": 4821,
    "status": "sent"
  }
}
Validation failureJSON
{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "field": "to",
      "message": "to should not be empty"
    }
  ]
}
Status codes you should handle
CodeMeaningWhat to do
200SuccessRead data
400Invalid body, or Meta rejected the sendRead message — Meta's own reason is passed through — and fix the payload. Do not retry unchanged
401Missing, malformed or revoked keyCheck both headers; create a new key if this one was revoked
403The phone number id is not yoursSend from a number belonging to your organisation
404Message id not found in your organisationCheck the id; ids are not shared across organisations
429Rate limit exceededBack off and retry — see section 8
500Unexpected server errorRetry with backoff; if it persists, contact support with the timestamp

4. Send a message

POST/messages

Sends a WhatsApp message and records it against your organisation

Always required
FieldTypeNotes
phoneNumberIdstringThe registered number to send from. Must belong to your organisation
tostringRecipient in E.164 digits only, no + and no spaces — e.g. 447911123456
typestringOne of: text, image, video, audio, document, template, interactive, location
Required per type
typeAdditional fields
texttext — the message body
image, video, audio, documentmediaUrl — a public URL Meta can fetch. caption optional for image, video and document
templatetemplateName, templateLanguage (e.g. en_US). templateComponents for variable substitution
locationlatitude (−90 to 90), longitude (−180 to 180). locationName, locationAddress optional
interactiveinteractiveType (button / list / cta_url) plus interactiveBodyText, and the fields for that subtype — see section 5
Send a text messagecURL
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."
  }'
ResponseJSON
{
  "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.

Send an approved template with variablesJSON
{
  "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"
        }
      ]
    }
  ]
}
Send an authentication (OTP) templateJSON
{
  "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"
        }
      ]
    }
  ]
}
Send an image with a captionJSON
{
  "phoneNumberId": "109876543210987",
  "to": "447911123456",
  "type": "image",
  "mediaUrl": "https://cdn.example.com/receipts/48210.jpg",
  "caption": "Your receipt"
}
Send a locationJSON
{
  "phoneNumberId": "109876543210987",
  "to": "447911123456",
  "type": "location",
  "latitude": 19.076,
  "longitude": 72.8777,
  "locationName": "Drasken Labs",
  "locationAddress": "Bandra Kurla Complex, Mumbai"
}

5. Interactive messages

Set type: "interactive" and pick a subtype. interactiveBodyText is required for all three; interactiveHeaderText and interactiveFooterText are optional throughout.

interactiveTypeRequired fieldsLimits
buttoninteractiveButtons — array of { id, title }1 to 3 buttons. Title max 20 characters (Meta's limit)
listinteractiveButtonLabel, interactiveSections — array of { title, rows: [{ id, title, description? }] }At least 1 section. Row title max 24 characters, description max 72
cta_urlinteractiveCtaDisplayText, interactiveCtaUrlOne call-to-action per message
Reply buttonsJSON
{
  "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"
    }
  ]
}
List menuJSON
{
  "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

GET/messages

Every message sent by your organisation, newest first

Query parameterTypeNotes
pagenumber1-based. Supplying it switches the response to paginated mode
limitnumberPage 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.

Paginated responseJSON
{
  "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
  }
}

7. Get one message

GET/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.

ResponseJSON
{
  "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."
      }
    }
  }
}
Delivery status values
statusMeaning
sentAccepted by Meta and on its way
deliveredReached the recipient's device
readOpened by the recipient — only if they have read receipts on
failedMeta 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.

A failed messageJSON
{
  "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

GET/messages/analytics

Status totals, delivery and read rates, and a daily series

Query parameterTypeNotes
daysnumberRange in days, 1–90. Defaults to 14
ResponseJSON
{
  "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 metaMessageId against 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.

The envelope we postJSON
{
  "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": [
        "…"
      ]
    }
  }
}
HeaderValue
X-Drasken-Delivery-IdDelivery id, identical on every retry — deduplicate on it
X-Drasken-EventEvent kind, e.g. inbound_message or endpoint.test
X-Drasken-TimestampUnix seconds, part of the signed string
X-Drasken-Signature-256sha256=<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.

Verifying a delivery (Node + Express)Node.js
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 metaMessageId on 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

Node.js — send and then poll statusNode.js
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)
Python — send a templatePython
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"])