Skip to content

API documentation

Send WhatsApp messages from your own website, app or CRM.

A small JSON API for messages, contacts and templates, and webhooks that tell your server when a customer writes back.

Base URL https://www.bulkwamessages.com/api/v1

Quick start

  1. Create an API key. Sign in, open Developers and create a key. It is shown once, so save it in your server's environment variables.
  2. Check it works. Call GET /account. You get back your workspace, your WhatsApp number and how many messages are left this month.
  3. Send a message. Call POST /messages with the name of a template Meta has approved.
curl -X POST https://www.bulkwamessages.com/api/v1/messages \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001-shipped" \
  -d '{
    "to": "+919876543210",
    "name": "Asha Verma",
    "type": "template",
    "template": {
      "name": "order_shipped",
      "language": "en_US",
      "variables": { "1": "Asha", "2": "1001", "url_0": "1001" }
    }
  }'

Keep the key on your server. Anyone who has it can send messages from your WhatsApp number. Never put it in website JavaScript or inside a mobile app; have your own backend call the API.

Authentication

Every request carries an API key in the Authorization header. A key belongs to one workspace and can do only what was ticked for it when it was created.

curl
curl https://www.bulkwamessages.com/api/v1/account \
  -H "Authorization: Bearer $BULKWA_API_KEY"
Ticked for the keyWhat it allows
MessagesSend templates, text and files, and check whether they were delivered.
ContactsRead, add, update and delete contacts with their tags and attributes.
TemplatesRead your approved templates and the variables they need.

A request without a valid key answers 401. A key that isn't allowed to use an endpoint answers 403 with the code insufficient_scope. Revoking a key on the Developers page stops it at once.

Requests and responses

  • JSON in, JSON out. Send Content-Type: application/json, except when uploading a file. A successful answer puts the result in data; lists also carry page, pageSize and total.
  • Phone numbers include the country code, like +919876543210. Spaces and dashes are fine. A 10-digit number without a + is read as an Indian number. Numbers always come back as + and digits.
  • Times are ISO 8601 in UTC, like 2026-10-10T06:30:10.282Z.
  • Rate limit. Each key can make 120 requests a minute. After that you get 429 until the minute is over.
  • Safe retries. Add an Idempotency-Key header (any text up to 100 characters, such as your order id) to POST /messages. If the same key is sent again within 24 hours, the message is not sent twice: you get the first answer back, with the header Idempotent-Replay: true.

Errors

Every error has the same shape. code never changes and is meant for your program; message is written for a person and may be reworded.

400 Bad Request
{
  "error": {
    "code": "missing_variables",
    "message": "template.variables is missing: 2."
  }
}
CodeStatusWhat happened
missing_api_key401There was no Authorization header, or it didn't start with Bearer.
invalid_api_key401The key doesn't exist. It may have been revoked.
insufficient_scope403The key works, but this part of the API isn't ticked for it.
workspace_suspended403The workspace has been suspended. Contact support.
message_limit403The workspace has used its monthly message limit, so a template can't be sent.
plan_ended403The workspace's free trial or plan has ended, so a template can't be sent.
plan_feature403The workspace's plan doesn't include the API. Keys work again after an upgrade.
contact_limit403The workspace's plan has no room for another contact.
rate_limited429Too many requests in a minute. Wait a minute and carry on.
invalid_request400A field is missing or has the wrong value. The message says which.
missing_variables400The template needs values that weren't sent. The message lists their keys.
missing_header_media400The template starts with an image, video or document and no headerMediaId was sent.
language_required400The template exists in several languages. Send template.language.
template_not_found404No template has this name (in this language).
template_not_approved409The template exists but Meta hasn't approved it.
window_closed409The customer hasn't written in the last 24 hours, so only a template can be sent.
whatsapp_not_connected409No WhatsApp number is connected to the workspace yet.
whatsapp_rejected400WhatsApp refused this message, for example because the number isn't on WhatsApp.
whatsapp_error502WhatsApp had a problem on its side. Try again shortly.
request_in_progress409A request with the same Idempotency-Key is still running.
conflict409The change clashes with something that exists, such as another contact with that number.
not_found404The contact, message, file or endpoint doesn't exist.
too_large413The uploaded file is over 4 MB.
server_error500Something went wrong on our side. Try again; if it keeps happening, contact support.

Account

Check a key

GET/api/v1/account

Works with any key, so it is the call to make first. monthlyLimit and remaining are null when the workspace has no limit.

200 OK
{
  "data": {
    "workspace": {
      "id": "5af719fc-4986-439e-9ba5-a44898a7547b",
      "name": "Verma Sweets"
    },
    "whatsapp": {
      "connected": true,
      "phoneNumber": "+91 98765 00000",
      "displayName": "Verma Sweets"
    },
    "usage": {
      "sentThisMonth": 1840,
      "monthlyLimit": 5000,
      "remaining": 3160
    },
    "key": {
      "name": "Website order updates",
      "scopes": [
        "messages",
        "templates"
      ]
    }
  }
}

Templates

List approved templates

GET/api/v1/templatesNeeds Templates

Returns the templates Meta has approved, because those are the ones a message can use. variables lists every value a message needs and the key to send it under.

Keys in variables

1, 2, customer_name…body or header
A {{1}} or {{customer_name}} placeholder in the text.
url_0, url_1…url
The changing end of a link button. The number is the button's position, counted from 0.
code_0…code
The coupon code on a copy-code button.
200 OK
{
  "data": [
    {
      "id": "1203948571203",
      "name": "order_shipped",
      "language": "en_US",
      "category": "utility",
      "header": {
        "type": "text",
        "text": "Order update"
      },
      "body": "Hi {{1}}, your order {{2}} has shipped.",
      "footer": null,
      "buttons": [
        {
          "type": "url",
          "text": "Track order",
          "value": "https://example.com/track/{{1}}"
        }
      ],
      "variables": [
        {
          "key": "1",
          "where": "body",
          "example": "Asha"
        },
        {
          "key": "2",
          "where": "body",
          "example": "1001"
        },
        {
          "key": "url_0",
          "where": "url",
          "example": "1001"
        }
      ]
    }
  ]
}

Messages

WhatsApp has one rule that shapes this API: you can write freely to a customer only for 24 hours after their last message. Outside that window, the message has to be a template Meta has approved.

Send a message

POST/api/v1/messagesNeeds Messages

Body

tostringrequired
The customer's WhatsApp number with the country code.
typestringrequired
template, text, image, video, audio or document.
namestring
The customer's name. Saved on the contact when the number is new or has no name yet.
template.namestring
For a template: its name, as shown on the Templates page.
template.languagestring
Its language code, like en_US or hi. Needed only when the template exists in more than one language.
template.variablesobject
A value for every key the template needs. See List approved templates.
template.headerMediaIdstring
For a template that starts with an image, video or document: the id from Upload a file. Add template.headerFilename to name a document.
textstring
For a text message: up to 4,096 characters.
mediaIdstring
For an image, video, audio or document: the id from Upload a file.
captionstring
Text under an image, video or document, up to 1,024 characters.
filenamestring
The name the customer sees for a document.
curl -X POST https://www.bulkwamessages.com/api/v1/messages \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001-shipped" \
  -d '{
    "to": "+919876543210",
    "name": "Asha Verma",
    "type": "template",
    "template": {
      "name": "order_shipped",
      "language": "en_US",
      "variables": { "1": "Asha", "2": "1001", "url_0": "1001" }
    }
  }'
201 Created
{
  "data": {
    "id": "e213b03e-b71f-470b-8c42-6eee1e9a19aa",
    "to": "+919876543210",
    "contactId": "638289f4-d5f4-4334-9273-ec598635ca74",
    "type": "template",
    "status": "sent",
    "error": null,
    "whatsappMessageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS",
    "createdAt": "2026-10-10T06:30:10.282Z",
    "updatedAt": "2026-10-10T06:30:10.282Z"
  }
}
  • A template can go to any number. If the number is new, a contact is created for it with the source “API”.
  • Text and files are accepted only inside the 24-hour window. Outside it the answer is 409 with the code window_closed.
  • Templates count towards the workspace's monthly message limit. Replies inside the window don't.
  • The message appears in Live chat, in the customer's thread, marked with the key's name. A chat that the API starts stays under Archived until the customer replies, so automated messages don't fill the inbox.
  • 201 means WhatsApp accepted the message, not that it arrived. Follow it with Get a message or the webhooks.

Reply inside the 24-hour window

curl
curl -X POST https://www.bulkwamessages.com/api/v1/messages \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+919876543210", "type": "text", "text": "Yes, it is in stock. Shall I keep one aside?" }'

Send a sign-in code

An authentication template takes one value, the code. It fills both the message and the copy button.

curl
curl -X POST https://www.bulkwamessages.com/api/v1/messages \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+919876543210",
    "type": "template",
    "template": { "name": "login_otp", "variables": { "1": "482913" } }
  }'

Get a message

GET/api/v1/messages/{id}Needs Messages

Returns the message with its latest status.

The message object

idstring
Our id for the message. Use it to ask for the status later.
tostring
The customer's number, always as + and digits.
contactIdstring
The contact this number belongs to.
typestring
template, text, image, video, audio or document.
statusstring
sent, delivered, read or failed.
errorstring | null
Why WhatsApp couldn't deliver it, when status is failed.
whatsappMessageIdstring | null
WhatsApp's own id for the message.
createdAt, updatedAtstring
ISO 8601 times in UTC.
200 OK
{
  "data": {
    "id": "e213b03e-b71f-470b-8c42-6eee1e9a19aa",
    "to": "+919876543210",
    "contactId": "638289f4-d5f4-4334-9273-ec598635ca74",
    "type": "template",
    "status": "read",
    "error": null,
    "whatsappMessageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS",
    "createdAt": "2026-10-10T06:30:10.282Z",
    "updatedAt": "2026-10-10T06:31:02.911Z"
  }
}

Media

Upload a file

POST/api/v1/mediaNeeds Messages

Send the file as multipart/form-data in a field named file, up to 4 MB. Images can be JPG or PNG, video MP4 or 3GP, audio AAC, MP3, M4A, AMR or OGG, and documents PDF, Word, Excel, PowerPoint or plain text. WhatsApp keeps the file for 30 days, so one upload can be used for many messages.

curl
curl -X POST https://www.bulkwamessages.com/api/v1/media \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -F "file=@invoice-1001.pdf;type=application/pdf"
201 Created
{
  "data": {
    "id": "1489203345712098",
    "type": "document",
    "mimeType": "application/pdf",
    "filename": "invoice-1001.pdf"
  }
}

Then send it

curl
curl -X POST https://www.bulkwamessages.com/api/v1/messages \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+919876543210",
    "type": "document",
    "mediaId": "1489203345712098",
    "filename": "invoice-1001.pdf",
    "caption": "Your invoice"
  }'

Download a file

GET/api/v1/media/{id}Needs Messages

Returns the file itself, not JSON. Use it for photos and documents customers send you: the id arrives in the message.received webhook as data.message.media.id.

curl
curl https://www.bulkwamessages.com/api/v1/media/1489203345712098 \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  --output photo.jpg

Contacts

The contact object

idstring
Our id for the contact.
namestring
May be empty.
phonestring
Unique in the workspace.
statusstring
active or inactive. Inactive contacts are left out of broadcasts.
sourcestring
How the contact arrived: manual, import, organic (they messaged you) or api.
tagsstring[]
Up to 30 tags.
attributesobject
Values for the custom attributes set up under Contacts, by attribute key.
createdAt, updatedAtstring
ISO 8601 times in UTC.

Add or update a contact

POST/api/v1/contactsNeeds Contacts

Adds the contact, or updates the one that already has this phone number, so you can send the same customer again without checking first. The answer is 201 with created: true for a new contact and 200 with created: false for an update.

Body

phonestringrequired
With the country code.
namestring
Up to 80 characters.
statusstring
active or inactive.
tagsstring[]
On an update these are added to the tags the contact already has.
attributesobject
Values by attribute key. The attribute has to exist already; send "" to clear a value.
curl
curl -X POST https://www.bulkwamessages.com/api/v1/contacts \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+919876543210",
    "name": "Asha Verma",
    "tags": ["VIP", "Delhi"],
    "attributes": { "city": "Delhi" }
  }'
201 Created
{
  "data": {
    "id": "638289f4-d5f4-4334-9273-ec598635ca74",
    "name": "Asha Verma",
    "phone": "+919876543210",
    "status": "active",
    "source": "api",
    "tags": [
      "VIP",
      "Delhi"
    ],
    "attributes": {
      "city": "Delhi"
    },
    "createdAt": "2026-10-10T06:12:44.120Z",
    "updatedAt": "2026-10-10T06:12:44.120Z"
  },
  "created": true
}

List or find contacts

GET/api/v1/contactsNeeds Contacts

Query

phonestring
Finds the one contact with exactly this number. Other filters are ignored.
searchstring
Part of a name, or at least three digits of a number.
tagstring
Repeat it to require several tags: ?tag=VIP&tag=Delhi.
statusstring
active or inactive.
pagenumber
Starts at 1. Each page has 50 contacts, newest first.
curl
curl "https://www.bulkwamessages.com/api/v1/contacts?tag=VIP&status=active&page=1" \
  -H "Authorization: Bearer $BULKWA_API_KEY"
200 OK
{
  "data": [
    {
      "id": "638289f4-d5f4-4334-9273-ec598635ca74",
      "name": "Asha Verma",
      "phone": "+919876543210",
      "status": "active",
      "source": "api",
      "tags": [
        "VIP",
        "Delhi"
      ],
      "attributes": {
        "city": "Delhi"
      },
      "createdAt": "2026-10-10T06:12:44.120Z",
      "updatedAt": "2026-10-10T06:12:44.120Z"
    }
  ],
  "page": 1,
  "pageSize": 50,
  "total": 1
}

Get a contact

GET/api/v1/contacts/{id}Needs Contacts

Returns one contact by id.

Update a contact

PATCH/api/v1/contacts/{id}Needs Contacts

Changes only the fields you send: name, phone, status, tags and attributes. Here tags replaces the contact's tags.

curl
curl -X PATCH https://www.bulkwamessages.com/api/v1/contacts/638289f4-d5f4-4334-9273-ec598635ca74 \
  -H "Authorization: Bearer $BULKWA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "inactive", "tags": ["Delhi"] }'

Delete a contact

DELETE/api/v1/contacts/{id}Needs Contacts

Removes the contact, and its lead if it had one on the CRM pipeline. Messages already sent are kept. Answers with { "data": { "id": "…", "deleted": true } }.

Webhooks

A webhook is a URL on your server that we call when something happens, so you don't have to keep asking. Add one under Developers → Webhooks and tick the events you want. The URL has to start with https:// and be reachable from the internet.

EventSent when
message.receivedA customer sent you a message or tapped a button.
message.sentA message left your number, from the inbox or the API.
message.deliveredIt reached the customer's phone.
message.readThe customer opened it.
message.failedWhatsApp couldn't deliver it.

Events cover messages in Live chat and messages sent with the API. Messages that go out as part of a broadcast are not reported, but a customer's reply to a broadcast is.

What we send

A POST with a JSON body. data.message is the message as it is now, so a message.read event carries status: "read". data.contact is included on message.received.

POST to your URL
{
  "id": "evt_3f6c2a51-8a1e-4a0b-9f8e-2f8f0b1c7d11",
  "type": "message.received",
  "createdAt": "2026-10-10T06:42:18.412Z",
  "data": {
    "message": {
      "id": "0df2e0e3-9ae9-4f7a-92c2-432bd9703380",
      "direction": "inbound",
      "phone": "+919876543210",
      "contactId": "638289f4-d5f4-4334-9273-ec598635ca74",
      "type": "text",
      "text": "Is it in stock?",
      "caption": null,
      "media": null,
      "location": null,
      "template": null,
      "status": null,
      "error": null,
      "whatsappMessageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgU",
      "sentBy": null,
      "createdAt": "2026-10-10T06:42:17.000Z"
    },
    "contact": {
      "id": "638289f4-d5f4-4334-9273-ec598635ca74",
      "name": "Asha Verma",
      "phone": "+919876543210"
    }
  }
}

Headers

X-Bulkwa-Signaturestring
sha256= followed by the HMAC-SHA256 of the request body, made with the webhook's signing secret.
X-Bulkwa-Eventstring
The event type, the same as type in the body.
X-Bulkwa-Deliverystring
An id for this delivery. It stays the same when we try again.

Check the signature

Anyone can send a request to a public URL, so check that each one came from us. The signing secret is shown once, when you add the webhook. Compute the signature over the raw body, before any JSON parsing.

import crypto from "node:crypto";
import express from "express";

const app = express();

// The signature is made from the exact bytes we sent, so read the raw body before parsing it.
app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.BULKWA_WEBHOOK_SECRET).update(req.body).digest("hex");
  const given = req.get("X-Bulkwa-Signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);
  if (event.type === "message.received") {
    console.log(event.data.message.phone, "wrote:", event.data.message.text);
  }
  res.sendStatus(200);
});

app.listen(3000);

Answering, retries and pausing

  • Answer with any 2xx status within 10 seconds. Do slow work after answering. Redirects are not followed.
  • If the call fails, we try up to 3 more times, with a growing wait in between. The same event may therefore arrive twice; use the event id to ignore repeats.
  • Events can arrive out of order. Trust data.message.status and createdAt, not the order of arrival.
  • After 20 deliveries in a row fail, the webhook is paused and shown as paused on the Developers page. Resume it there once your server is fixed. Events that happen while it is paused are not sent later.
  • The Developers page keeps the last 7 days of deliveries for each webhook, with your server's answer, and has a “Send test” button that sends a webhook.test event.
Stuck on something?Tell us what you are building and where it stops.
Contact support