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.
Send templates, text and files, and check whether they were delivered.
Contacts
Read, add, update and delete contacts with their tags and attributes.
Templates
Read 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.
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.
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.
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.
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.
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.
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.
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.
Event
Sent when
message.received
A customer sent you a message or tapped a button.
message.sent
A message left your number, from the inbox or the API.
message.delivered
It reached the customer's phone.
message.read
The customer opened it.
message.failed
WhatsApp 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.
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.