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.
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)
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.
Send a free-form reply
The simplest way to get started. Only works if the customer messaged you recently (within the reply window).
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 -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.
Key format
dk_live_<keyId>_<secret>How to send it
Include your key using either header (both are equivalent):
Authorization: Bearer dk_live_a1b2c3d4_...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.
| Scope | What it allows |
|---|---|
messages:send | POST /v1/messages |
messages:read | GET /v1/messages, GET /v1/messages/:id |
templates:read | GET /v1/templates |
Rate limits
Rate limits protect the system and are applied per API key:
| Limit | Value |
|---|---|
All /v1 requests | 600 / minute / key |
POST /v1/messages | 60 / 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
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | yes | Recipient phone number in E.164 format |
type | "text" | yes | Tells Drusy you're sending free-form text |
text.body | string | yes | Your message content (1-4096 characters) |
template or consentBasis when sending free-form text. Those fields are for template messages only.Required headers
| Header | Description |
|---|---|
Idempotency-Key | A unique string per send (1-128 chars: A-Z a-z 0-9 _ . : -). Prevents duplicate sends on retry. |
Content-Type | application/json |
Authorization (or X-API-Key) | Your API key |
"duplicate": true) instead of sending the message again. This makes retries safe.Example
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:
| Sequence | Result | Example |
|---|---|---|
\n | New line | Line 1\nLine 2 |
\n\n | Paragraph break | Intro\n\nDetails |
*text* | Bold | *Important update* |
_text_ | Italic | _Today, before 6 PM_ |
~text~ | Strikethrough | ~Old ETA: 4 PM~ |
`text` | Monospace | `ORD-1234` |
\" | Literal double-quote | She said \"yes\" |
\\ | Literal backslash | Code A\\B |
Example with formatting:
{
"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
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
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | yes | Recipient phone number in E.164 format (e.g. +2348012345678) |
type | "template" | yes | Tells Drusy you're sending a template |
template.name | string | yes | The approved template name from WhatsApp Manager |
template.language | string | conditional | Required when a template has multiple approved languages (e.g. en, fr) |
template.parameters | object | conditional | Key-value pairs for template variables (see Template variables below) |
consentBasis | "transactional" | conditional | Required for utility/authentication templates when recipient hasn't opted in |
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.
| Header | Description |
|---|---|
Idempotency-Key | A unique string per send (1-128 chars: A-Z a-z 0-9 _ . : -). Prevents duplicate sends on retry. |
Content-Type | application/json |
Authorization (or X-API-Key) | Your API key |
Full example
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:
| Key | What it fills |
|---|---|
body.1 | First placeholder {{1}} in the message body |
body.2 | Second placeholder {{2}} in the message body |
header.1 | First placeholder {{1}} in the header |
button.0.1 | First placeholder in the first button's URL (zero-indexed) |
1 | Shorthand 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.
{
"to": "+2348012345678",
"type": "template",
"template": {
"name": "order_shipped",
"language": "en",
"parameters": {
"header.1": "ORD-1234",
"body.1": "Ada",
"button.0.1": "trk_9f2c"
}
}
}TEMPLATE_PARAM_MISMATCH error (the response tells you which keys are expected). Check GET /v1/templates to see the exact parameter keys your template needs.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):
{
"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.
| Field | Meaning |
|---|---|
id | Unique message ID. Use this to check delivery status later |
status | "sent" on the normal path, or "queued" when retry is pending |
to | Recipient number (digits only) |
category | Billing category (utility, marketing, authentication, service) |
kind | "template" or "text" |
duplicate | true if this was a safe replay of an idempotent request |
providerMessageId | Provider ID once Meta accepts the send; null while queued |
sentAt | When the provider accepted the send; null while queued |
error | null 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/66a1b2c3d4e5f6789012345
Authorization: Bearer dk_live_...{
"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_..." }
}
}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
| Param | Default | Notes |
|---|---|---|
limit | 25 | Number 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 |
GET /v1/messages?limit=25&status=failed
Authorization: Bearer dk_live_...{
"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
}"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
Authorization: Bearer dk_live_...{
"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.
APPROVED can be sent. Templates are approved in WhatsApp Manager (Meta). Drusy mirrors them but cannot approve them for you.Consent & opt-out
Drusy enforces consent rules to protect your customers and your reputation. The rules depend on the type of message you're sending and whether the recipient has opted in.
Free-form text messages are categorized as service. They only require an open reply window and don't need explicit consent fields. Template messages follow the matrix below:
| Recipient state | service | utility / authentication | marketing |
|---|---|---|---|
| opted_in | allowed | allowed | allowed |
| unknown | allowed | requires consentBasis: "transactional" | denied |
| opted_out | allowed | denied | denied |
suppressed (not failed) and is never delivered.Message lifecycle
Every message progresses through these statuses. You can track them via the GET /v1/messages/:id endpoint or the dashboard Activity tab.
| Status | What it means |
|---|---|
queued | Accepted by Drusy, waiting to be sent to WhatsApp |
sending | Being sent to Meta's WhatsApp Cloud API |
sent | Delivered to WhatsApp servers (not yet on customer's phone) |
delivered | Received on the customer's device |
read | Customer opened the message |
failed | Delivery failed permanently. Check the error field |
suppressed | Not sent because recipient opted out after queuing |
cancelled | Withdrawn before it was dispatched |
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": {
"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.
| Code | Status | What went wrong |
|---|---|---|
API_KEY_MISSING | 401 | No API key was included in the request |
API_KEY_INVALID | 401 | The API key doesn't exist or is malformed |
API_KEY_REVOKED | 401 | This key has been revoked in the dashboard |
API_KEY_EXPIRED | 401 | The key has passed its expiration date |
SCOPE_INSUFFICIENT | 403 | The key doesn't have permission for this action |
RECIPIENT_OPTED_OUT | 403 | This customer has opted out of messages |
CONSENT_REQUIRED | 403 | You need consentBasis or prior opt-in consent |
USAGE_LIMIT_REACHED | 403 | Your plan's message allowance is used up |
API_KEYS_NOT_AVAILABLE | 403 | Your plan doesn't include API access |
VALIDATION_FAILED | 400 | The request body is malformed (e.g. wrong type, missing fields) |
IDEMPOTENCY_KEY_REQUIRED | 400 | You forgot the Idempotency-Key header |
TEMPLATE_NOT_FOUND | 404 | No template with that name/language exists |
TEMPLATE_LANGUAGE_AMBIGUOUS | 400 | Multiple languages exist. Specify which one |
TEMPLATE_NOT_APPROVED | 409 | Template is pending, rejected, or paused |
TEMPLATE_PARAM_MISMATCH | 400 | Wrong parameter keys. The error lists what's expected |
OUTBOUND_BODY_REQUIRED | 400 | The text.body field is empty |
OUTBOUND_BODY_TOO_LONG | 400 | Message exceeds 4096 characters |
HUMAN_REPLY_WINDOW_EXPIRED | 409 | Can't send free-form. The reply window is closed |
OUTBOUND_RECIPIENT_INVALID | 400 | The phone number can't be used |
OUTBOUND_CHANNEL_NOT_FOUND | 400 | No active WhatsApp channel is connected |
OUTBOUND_MESSAGE_NOT_FOUND | 404 | No message with that ID exists |
INVALID_CURSOR | 400 | The pagination cursor is invalid |
RATE_LIMITED | 429 | Too 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.
- 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.
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.
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 do | Where to find it |
|---|---|
| Create or revoke API keys | Messaging > API Keys |
| Sync and inspect templates | Messaging > Templates |
| View delivery history | Messaging > Activity |
| Send a test message | Channels > 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.