Messaging API guide

Send WhatsApp messages from your own systems

Shipping notifications, appointment reminders, support follow-ups: anything your systems need to tell customers over WhatsApp. This guide walks you through the full API, from your first curl to production-ready delivery.

Overview

The Messaging API lets you send WhatsApp messages directly from your applications, with no manual sending required. Your e-commerce platform can send shipping updates, your CRM can send appointment reminders, and your support system can follow up with customers, all programmatically through simple HTTP requests.

Think of it as a bridge between your systems and WhatsApp: you make an API call, and Drusy handles delivering the message through Meta's WhatsApp Cloud API.

REST APIWhatsApp CloudAsync delivery

Base URL

https://api.drusy.ai/v1

Auth method

API key (Bearer or X-API-Key)

Content-Type

application/json

Provider

Meta WhatsApp Cloud API

What you'll need

Before making your first API call, make sure you have these in place:

  • A Drusy workspace with a connected WhatsApp channel
  • A plan that includes API access. Check that your plan allows API keys
  • An approved template in WhatsApp Manager (optional; only needed to send outside the reply window)
  • A test recipient WhatsApp number you control (there's no sandbox; every send is real)
New to WhatsApp Business? Start with our WhatsApp Setup Guide to connect your channel first, then come back here.

How it works

Understanding these three concepts makes everything else click:

1. Two types of messages

WhatsApp has strict rules about what businesses can send. There are two modes:

Templates

Pre-approved message formats (approved by Meta). You can send these anytime. They're how you initiate conversations. Examples: order confirmations, appointment reminders, verification codes.

Free-form text

Custom messages you write freely. You can only send these during Meta's ~24-hour reply window (Drusy enforces 23 hours). Great for support replies and follow-ups.

2. The reply window

When a customer sends your business a WhatsApp message, a ~24-hour window opens. During this window, you can send free-form text. Once it closes, you can only reach them with templates. Drusy checks this window with a 23-hour safety buffer, so you get a clear error rather than a silently rejected message.

3. Async delivery

When you submit a message, Drusy validates it immediately and returns a message ID. A background worker then delivers it to WhatsApp. You don't have to wait for delivery in your request. Poll the status endpoint or check the dashboard to see delivery progress (queued > sent > delivered > read).

Quick start

Get a message sent in under five minutes. Create an API key in the dashboard, then pick one of the examples below.

Which example should I use? If the recipient hasn't messaged you recently, start with the template example. Free-form text only works inside the reply window.

Send a free-form reply

The simplest way to get started. Only works if the customer messaged you recently (within the reply window).

curl: send free-form
curl -X POST "https://api.drusy.ai/v1/messages" \
  -H "Authorization: Bearer dk_live_your_key_here" \
  -H "Idempotency-Key: support-reply-42" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "type": "text",
    "text": {
      "body": "Hi Ada, your order is on the way!"
    }
  }'

Send a template message

Use templates for order updates, reminders, or any message outside the reply window.

curl: send template
curl -X POST "https://api.drusy.ai/v1/messages" \
  -H "Authorization: Bearer dk_live_your_key_here" \
  -H "Idempotency-Key: order-1234-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "type": "template",
    "template": {
      "name": "order_shipped",
      "language": "en",
      "parameters": { "body.1": "ORD-1234" }
    },
    "consentBasis": "transactional"
  }'

Authentication

Every API request requires an API key. Keys are created in the Drusy dashboard and follow a straightforward format. The plaintext secret is shown only once when you create it. If you lose it, revoke and create a new one.

Where to do this in DrusyAIMessaging > API Keys

Key format

key format
dk_live_<keyId>_<secret>

How to send it

Include your key using either header (both are equivalent):

Option A: Authorization header
Authorization: Bearer dk_live_a1b2c3d4_...
Option B: X-API-Key header
X-API-Key: dk_live_a1b2c3d4_...

Scopes (permissions)

Each key has scopes that control what it can do. Follow the principle of least privilege: give each integration only the permissions it needs.

ScopeWhat it allows
messages:sendPOST /v1/messages
messages:readGET /v1/messages, GET /v1/messages/:id
templates:readGET /v1/templates

Rate limits

Rate limits protect the system and are applied per API key:

LimitValue
All /v1 requests600 / minute / key
POST /v1/messages60 / minute / key

If you exceed the limit, you'll receive a 429 RATE_LIMITED response. Check the Retry-After header to know when to retry.

Send free-form text

POST /v1/messages (scope: messages:send)

Free-form messages let you write anything you want, but only during the reply window (within ~23 hours of the customer's last message to you). Use these for custom support replies and personalized follow-ups.

Request body fields

FieldTypeRequiredNotes
tostringyesRecipient phone number in E.164 format
type"text"yesTells Drusy you're sending free-form text
text.bodystringyesYour message content (1-4096 characters)
Don't include template or consentBasis when sending free-form text. Those fields are for template messages only.

Required headers

HeaderDescription
Idempotency-KeyA unique string per send (1-128 chars: A-Z a-z 0-9 _ . : -). Prevents duplicate sends on retry.
Content-Typeapplication/json
Authorization (or X-API-Key)Your API key
Idempotency explained: If your code retries a request with the same Idempotency-Key, Drusy returns the original response (with "duplicate": true) instead of sending the message again. This makes retries safe.

Example

curl
curl -X POST "https://api.drusy.ai/v1/messages" \
  -H "Authorization: Bearer dk_live_your_key_here" \
  -H "Idempotency-Key: support-followup-42" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "type": "text",
    "text": {
      "body": "Hi Ada,\n\nYour order is out for delivery.\n\nOrder: ORD-1234\nETA: Today, before 6 PM\n\nReply here if you need anything."
    }
  }'

Formatting your messages

The text.body field is a JSON string. Use escape sequences and WhatsApp's built-in formatting for clear, readable messages:

SequenceResultExample
\nNew lineLine 1\nLine 2
\n\nParagraph breakIntro\n\nDetails
*text*Bold*Important update*
_text_Italic_Today, before 6 PM_
~text~Strikethrough~Old ETA: 4 PM~
`text`Monospace`ORD-1234`
\"Literal double-quoteShe said \"yes\"
\\Literal backslashCode A\\B

Example with formatting:

formatted message body
{
  "to": "+2348012345678",
  "type": "text",
  "text": {
    "body": "Hi Ada,\n\n*Your order is out for delivery.*\n\nOrder: `ORD-1234`\nETA: _Today, before 6 PM_\n\n~Old ETA: 4 PM~\nNew ETA: *6 PM*"
  }
}

What the customer sees on WhatsApp:

WhatsApp removes the formatting markers and applies the styles in the app. The preview below shows how the message above appears on the customer's phone.

Customer's screen

Acme Shop

Business account

TODAY
Hi Ada,

Your order is out for delivery.

Order: ORD-1234
ETA: Today, before 6 PM

Old ETA: 4 PM
New ETA: 6 PM5:02 PM

A successful send returns a message ID. The API returns 201 Created when Meta accepts it inline, 202 Accepted when retry is pending, or 200 OK for an idempotent replay. See Send response for the full response shape (shared by both message types).

Send a template

POST /v1/messages (scope: messages:send)

Template messages use pre-approved formats from WhatsApp Manager. They're the only way to start a conversation with a customer or reach them outside the reply window.

Request body fields

FieldTypeRequiredNotes
tostringyesRecipient phone number in E.164 format (e.g. +2348012345678)
type"template"yesTells Drusy you're sending a template
template.namestringyesThe approved template name from WhatsApp Manager
template.languagestringconditionalRequired when a template has multiple approved languages (e.g. en, fr)
template.parametersobjectconditionalKey-value pairs for template variables (see Template variables below)
consentBasis"transactional"conditionalRequired for utility/authentication templates when recipient hasn't opted in
When is consentBasis required? Include consentBasis: "transactional" for utility or authentication templates when the recipient hasn't opted in. Marketing templates require prior opt-in. See Consent & opt-out.

Required headers

Same as free-form sends: Idempotency-Key, Content-Type, and your API key. See Send free-form text for details on idempotency.

HeaderDescription
Idempotency-KeyA unique string per send (1-128 chars: A-Z a-z 0-9 _ . : -). Prevents duplicate sends on retry.
Content-Typeapplication/json
Authorization (or X-API-Key)Your API key

Full example

curl
curl -X POST "https://api.drusy.ai/v1/messages" \
  -H "Authorization: Bearer dk_live_your_key_here" \
  -H "Idempotency-Key: order-1234-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348012345678",
    "type": "template",
    "template": {
      "name": "order_shipped",
      "language": "en",
      "parameters": { "body.1": "ORD-1234", "body.2": "tomorrow" }
    },
    "consentBasis": "transactional"
  }'

Template variables

Templates have placeholders (like {{1}} in the body or header). You fill these with the parameters object. Each key identifies which placeholder to fill:

KeyWhat it fills
body.1First placeholder {{1}} in the message body
body.2Second placeholder {{2}} in the message body
header.1First placeholder {{1}} in the header
button.0.1First placeholder in the first button's URL (zero-indexed)
1Shorthand for body.1 (works for body-only templates)

For body-only templates, you can use the shorthand (just "1"). Use the qualified format when your template also has a header or link button.

multi-component template
{
  "to": "+2348012345678",
  "type": "template",
  "template": {
    "name": "order_shipped",
    "language": "en",
    "parameters": {
      "header.1": "ORD-1234",
      "body.1": "Ada",
      "button.0.1": "trk_9f2c"
    }
  }
}

Send response

Both free-form and template sends return the same response shape. A successful send returns 201 Created when Meta accepts it inline, 202 Accepted when retry is pending, or 200 for idempotent replays):

response (template send)
{
  "id": "66a1b2c3d4e5f6789012345",
  "status": "sent",
  "to": "2348012345678",
  "category": "utility",
  "kind": "template",
  "duplicate": false,
  "providerMessageId": "wamid.HBgNMjM0ODAxMjM0NTY3OA==",
  "sentAt": "2026-07-28T18:00:00.000Z",
  "error": null
}

The kind field reflects what you sent: "text" for free-form, "template" for templates.

FieldMeaning
idUnique message ID. Use this to check delivery status later
status"sent" on the normal path, or "queued" when retry is pending
toRecipient number (digits only)
categoryBilling category (utility, marketing, authentication, service)
kind"template" or "text"
duplicatetrue if this was a safe replay of an idempotent request
providerMessageIdProvider ID once Meta accepts the send; null while queued
sentAtWhen the provider accepted the send; null while queued
errornull unless the send failed

Track delivery

After sending, poll these endpoints to check whether your messages were delivered. The message ID from the send response is your lookup key.

Get a single message

GET /v1/messages/:id (scope: messages:read)

Returns the full detail for one message, including delivery status, provider pricing, and the parameters you sent.

GET /v1/messages/:id
GET /v1/messages/66a1b2c3d4e5f6789012345
Authorization: Bearer dk_live_...
response (abbreviated)
{
  "id": "66a1b2c3d4e5f6789012345",
  "status": "delivered",
  "to": "2348012345678",
  "kind": "template",
  "templateName": "order_shipped",
  "sentAt": "2026-07-28T18:00:00.000Z",
  "delivery": {
    "pricing": { "category": "utility", "model": "CBP" },
    "conversation": { "id": "conv_..." }
  }
}
The delivery.pricing field shows what Meta actually charged, authoritative even if the template's category later changes.

List all messages

GET /v1/messages (scope: messages:read)

Lists your API-sent messages (newest first), with cursor-based pagination and optional filters.

Query parameters

ParamDefaultNotes
limit25Number of results per page (max 100)
cursor-Pagination cursor from a previous response
status-Filter by status (e.g. delivered, failed)
category-Filter by billing category
to-Filter by recipient number
example: list failed messages
GET /v1/messages?limit=25&status=failed
Authorization: Bearer dk_live_...
response
{
  "messages": [
    {
      "id": "66a1...",
      "channelId": "66b2...",
      "to": "2348012345678",
      "status": "delivered",
      "kind": "template",
      "templateName": "order_shipped",
      "languageCode": "en",
      "category": "utility",
      "source": "api",
      "attempts": 1,
      "providerMessageId": "wamid....",
      "sentAt": "2026-07-28T18:00:00.000Z",
      "createdAt": "2026-07-28T17:59:50.000Z",
      "error": null
    }
  ],
  "nextCursor": null
}
These endpoints only return messages sent through the API (source: "api"). They don't include inbound customer messages, AI assistant replies, dashboard test sends, or other system messages.

List templates

GET /v1/templates (scope: templates:read)

Lists all templates synced from your WhatsApp Business Account. Use this to confirm which templates are approved and what parameter keys they need before sending.

GET /v1/templates
GET /v1/templates
Authorization: Bearer dk_live_...
response
{
  "templates": [
    {
      "name": "order_shipped",
      "language": "en",
      "category": "UTILITY",
      "status": "APPROVED",
      "parameters": [
        { "component": "header", "index": 1, "example": "ORD-1234" },
        { "component": "body", "index": 1, "example": "Ada" },
        {
          "component": "button",
          "subType": "url",
          "buttonIndex": 0,
          "index": 1,
          "example": "https://example.com/t/9f2c"
        }
      ],
      "lastSyncedAt": "2026-07-28T12:00:00.000Z"
    }
  ]
}

How to build parameter keys from this response: Combine the component and index fields. For buttons, add the buttonIndex. The example above gives you header.1, body.1, and button.0.1.

Message lifecycle

Every message progresses through these statuses. You can track them via the GET /v1/messages/:id endpoint or the dashboard Activity tab.

StatusWhat it means
queuedAccepted by Drusy, waiting to be sent to WhatsApp
sendingBeing sent to Meta's WhatsApp Cloud API
sentDelivered to WhatsApp servers (not yet on customer's phone)
deliveredReceived on the customer's device
readCustomer opened the message
failedDelivery failed permanently. Check the error field
suppressedNot sent because recipient opted out after queuing
cancelledWithdrawn before it was dispatched
Automatic retries: If delivery fails transiently, Drusy retries at increasing intervals (30s, 2m, 10m, 30m) for up to 5 attempts. Permanent errors (wrong template, closed window, etc.) fail immediately with no retries.

Error reference

All errors follow the same envelope format. The code field is machine-readable (use it in your code), and message is human-readable (good for logs and debugging).

error response format
{
  "error": {
    "message": "Human-readable description of what went wrong",
    "code": "MACHINE_READABLE_CODE",
    "requestId": "abc-123"
  }
}

Each response also includes an x-request-id header you can use when contacting support.

CodeStatusWhat went wrong
API_KEY_MISSING401No API key was included in the request
API_KEY_INVALID401The API key doesn't exist or is malformed
API_KEY_REVOKED401This key has been revoked in the dashboard
API_KEY_EXPIRED401The key has passed its expiration date
SCOPE_INSUFFICIENT403The key doesn't have permission for this action
RECIPIENT_OPTED_OUT403This customer has opted out of messages
CONSENT_REQUIRED403You need consentBasis or prior opt-in consent
USAGE_LIMIT_REACHED403Your plan's message allowance is used up
API_KEYS_NOT_AVAILABLE403Your plan doesn't include API access
VALIDATION_FAILED400The request body is malformed (e.g. wrong type, missing fields)
IDEMPOTENCY_KEY_REQUIRED400You forgot the Idempotency-Key header
TEMPLATE_NOT_FOUND404No template with that name/language exists
TEMPLATE_LANGUAGE_AMBIGUOUS400Multiple languages exist. Specify which one
TEMPLATE_NOT_APPROVED409Template is pending, rejected, or paused
TEMPLATE_PARAM_MISMATCH400Wrong parameter keys. The error lists what's expected
OUTBOUND_BODY_REQUIRED400The text.body field is empty
OUTBOUND_BODY_TOO_LONG400Message exceeds 4096 characters
HUMAN_REPLY_WINDOW_EXPIRED409Can't send free-form. The reply window is closed
OUTBOUND_RECIPIENT_INVALID400The phone number can't be used
OUTBOUND_CHANNEL_NOT_FOUND400No active WhatsApp channel is connected
OUTBOUND_MESSAGE_NOT_FOUND404No message with that ID exists
INVALID_CURSOR400The pagination cursor is invalid
RATE_LIMITED429Too many requests. Slow down and retry

Integration checklist

Follow these steps to go from zero to production-ready messaging. Each step builds on the previous one.

Create an API key in the dashboard

Go to Messaging > API Keys and create a new key. Select the scopes you need, typically messages:send plus templates:read and messages:read. Copy the key immediately; it's shown only once.

Where to do this in DrusyAIMessaging > API Keys
  • Key created with the appropriate scopes
  • Plaintext secret stored securely (it won't be shown again)

Look up your approved templates

Call GET /v1/templates to see which templates are available. Note down the name, language, and parameter keys for the ones you plan to use.

  • Template status is APPROVED
  • You know the language code (e.g. en)
  • You've identified all parameter keys (body.1, header.1, etc.)

Send your first message

POST to /v1/messages with a type field and the matching content object. Include a unique Idempotency-Key header. This prevents duplicate sends if you retry the same request.

  • Idempotency-Key is unique per logical send (e.g. order-1234-shipped)
  • type matches the content: "template" with template object, or "text" with text object

Track delivery status

Store the message ID from the response. Poll GET /v1/messages/:id to check whether the message was delivered, failed, or was suppressed. You can also check status in the dashboard Activity tab.

  • Store the returned message ID
  • Handle failed and suppressed statuses in your code

Handle the reply window correctly

If a customer messaged your business within the last 23 hours, you can send free-form text. Otherwise, you must use a template. Drusy checks this for you and returns a clear error if the window is closed.

Drusy applies a 23-hour buffer under Meta's 24-hour window so you get a deterministic API error instead of a queued message that Meta rejects later.

Billing & metering

Understanding how messages are counted helps you plan costs and stay within your allowance.

How messages are counted

Outbound API messages are tracked separately from inbound messages. The final billing category and cost come from Meta's delivery receipts, not from estimates at enqueue time.

Plan limits

API access is gated by your plan (some plans set apiKeysEnabled: false which means no API access). There's no separate API-only cap. API messages share your overall message allowance.

Meta pricing update (2026-10-01): In-window service messages (including assistant replies) have historically been free. From October 2026, Meta bills them at market utility rates. See Pricing for current details.

Dashboard tools

Not everything requires code. The Drusy dashboard provides visual tools for managing keys, inspecting templates, and monitoring delivery. Useful for both technical and non-technical team members.

What you can doWhere to find it
Create or revoke API keysMessaging > API Keys
Sync and inspect templatesMessaging > Templates
View delivery historyMessaging > Activity
Send a test messageChannels > Send test message

Dashboard access uses your regular login session. It's completely separate from API keys. Team members who need to monitor delivery or manage templates don't need API keys.

Put your customer conversations on autopilot

Join businesses using DrusyAI to sell more, answer questions accurately, and support customers instantly, across every channel and connected to the systems they already run on.