Send WhatsApp messages from your own software
By the Pulse team at CloudFigma· Updated
In short
One HTTPS request sends a WhatsApp message from your business’s own number: a POST to /api/v1/messages with your Pulse API key. Send an approved template at any time, or a free plain-text reply within 24 hours of the customer’s last message. Webhooks tell your server about deliveries, reads and replies. Pulse adds nothing per message; Meta bills your own card.
Before you start
Every request goes to https://pulse.cloudfigma.com over HTTPS, with your API key. Messages go out from your business’s own WhatsApp number, the one Pulse set up for you.
- Get a key. The account owner opens Settings › Developers in Pulse and creates one. It starts
cfn_live_and is shown once, so copy it then. Keep it on your server, never in a web page or an app. - Send it with every request, in either header below.
- Send JSON with
Content-Type: application/json. Answers are JSON too. Phone numbers go in international form, with the country code:+919876543210.
Authorization: Bearer cfn_live_YOUR_KEY
X-API-Key: cfn_live_YOUR_KEYThe endpoints
- POST /api/v1/messages
- Send an approved template, or a plain-text reply inside the 24-hour window.
- GET /api/v1/messages/{id}
- Where a message has got to: sent, delivered, read or failed.
- POST /api/v1/payment-requests
- Ask a customer for money on WhatsApp, through your own Razorpay.
- GET /api/v1/payment-requests/{id}
- Where a payment request has got to: opened, paid, expired or cancelled.
Every error has the same shape: error is a fixed code for your program to check, and message says what to fix, for a person. Errors lists every code.
{
"error": "template_not_found",
"message": "No approved template named \"order_update\" in en. Check the Templates page. It may still be pending Meta's approval."
}Send a template message
A template is a message Meta approved in advance. It can go at any time, even to a customer who has never written to you. Make templates in Pulse, under Templates; only approved ones can be sent.
POST https:/ / pulse. cloudfigma. com/ api/ v1/ messagesWhat to send
- to
- Required. The customer’s number with its country code, such as
+919876543210. - template
- Required. The template’s name, such as
order_update. - language
- The template’s language code, such as
en_USorhi. Left out,en. - variables
- The template’s blanks,
{{1}},{{2}}and so on, in order, as a list of strings. Send numbers as strings too. None may be blank. - header
- For a template with an image, video or document at the top:
{ "link": "https://…" }, a public https address WhatsApp can download, or{ "id": "…" }, a WhatsApp media id. Addfilenamefor a document. Left out, the template’s own file goes. - client_ref
- Your own reference, such as an order number. It comes back with the message and in its webhooks.
curl -X POST https://pulse.cloudfigma.com/api/v1/messages \
-H "Authorization: Bearer cfn_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001" \
-d '{
"to": "+919876543210",
"template": "order_update",
"variables": ["Asha","1001"],
"client_ref": "order-1001"
}'It answers 202: the message is stored and queued for WhatsApp, not yet delivered.
{
"id": "cm2msg0002",
"status": "queued",
"to": "+919876543210",
"category": "utility",
"charged": "₹0.00",
"balance_after": null,
"client_ref": "order-1001"
}idis Pulse’s id for the message. Keep it to check the message and to match its webhooks.categoryis the one Meta approved the template as (marketing, utility or authentication), never one the request chooses.chargedis always₹0.00andbalance_afterisnull: Pulse adds nothing per message. Meta bills your own card at its rate.
A first test
Meta adds a sample template, hello_world in en_US, to most new WhatsApp accounts. If it is on your Templates page, it is the quickest first send: it has no blanks.
{
"to": "+919876543210",
"template": "hello_world",
"language": "en_US"
}Link buttons
Some templates have a button whose link is Pulse’s own: pulse.cloudfigma.com/l/… counts taps, and pulse.cloudfigma.com/p/… opens a payment link. For these, put the whole link in variables, at the button’s place, after the message’s own blanks. Pulse puts a short code of its own on the button. A link that cannot go is refused with 400 invalid_link, and nothing is sent.
{
"to": "+919876543210",
"template": "diwali_offer",
"variables": [
"Priya",
"https://yourshop.com/diwali"
]
}Reply with plain text
In the 24 hours after a customer last wrote to you, you can answer with your own words instead of a template. WhatsApp charges nothing for these replies. Send text in place of template:
curl -X POST https://pulse.cloudfigma.com/api/v1/messages \
-H "Authorization: Bearer cfn_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ticket-5521-reply-1" \
-d '{
"to": "+919876543210",
"text": "Yes, your order left this morning. It arrives tomorrow.",
"client_ref": "ticket-5521"
}'{
"id": "cm2msg0003",
"status": "sent",
"to": "+919876543210",
"category": "service",
"charged": "₹0.00",
"balance_after": null,
"client_ref": "ticket-5521"
}- It goes to WhatsApp straight away, so
statusis alreadysent. Your team sees it in the Pulse Inbox, in the chat, as the business’s reply.categoryisserviceandchargedis₹0.00. textis 1 to 4,096 characters, and not only spaces. Otherwise:400 invalid_text.- Send
textortemplate, never both.variables,languageandheaderbelong to templates; besidetextthey are refused with400 invalid_request. - Outside the window, or to someone who has never written to you:
409 window_closed. Nothing is sent. Send an approved template instead. - If WhatsApp refuses the message:
502 send_failed, with the message’sid. It is kept as failed, as it would be in the Inbox.
Retry safely
A timeout does not tell you whether the message went. Send an Idempotency-Key header, unique to each message (your order id, or a UUID), and a retry is safe:
- Same key, same body:
200with the first message’sidand"idempotent_replay": true. Nothing is sent again. - Same key, different body:
422 idempotency_conflict. Use a new key for each different message. - A plain-text reply still being sent with that key:
409 idempotency_in_progress. Ask again in a moment. - A request that was refused does not use up its key, so the same request can go once the reason is gone.
Every request example on this page sends one, and payment requests take the same header.
Check a message
A 202 means accepted, not delivered. To see where a message has got to, ask with its id and your key:
GET https:/ / pulse. cloudfigma. com/ api/ v1/ messages/ {id}{
"id": "cm2msg0002",
"status": "read",
"to": "+919876543210",
"category": "utility",
"client_ref": "order-1001",
"error_code": null,
"error_detail": null,
"refunded": false,
"created_at": "2026-10-09T10:00:00.000Z",
"delivered_at": "2026-10-09T10:00:05.000Z",
"read_at": "2026-10-09T10:02:41.000Z",
"links": []
}statusmovesqueued→sent→delivered→read, or ends atfailed.error_codeanderror_detailsay why a message failed: WhatsApp’s error code, and what it means in words. Meta does not bill a message it could not deliver.refundedsays whether a charge for a failed message was given back. Pulse charges nothing per message, so it staysfalse.created_at,delivered_atandread_atare ISO 8601 times in UTC; the last two staynulluntil it happens.linkslists a tap-counting button’s page, itsclicks, and the first and last tap. Empty for a message without one.- An id that is not one of your account’s messages:
404 not_found.
Webhooks tell you the same as it happens, without asking.
Webhooks
Instead of asking, your server can be told. The account owner adds your https URL in Pulse under Settings › Developers, chooses its events, and copies its signing secret (whsec_…), which is shown once. Pulse then posts each event to the URL as JSON as it happens.
The events
- message.received
- A customer wrote to you. Once per message, and never from a number you have blocked.
- message.status
- A message sent through Pulse (the API, a campaign, the Inbox) was sent, delivered, read or failed. Meta can report these out of order; use the time in the event.
- campaign.completed
- A campaign has nobody left to send to. Deliveries and reads after that arrive as message.status.
- contact.opted_out
- A customer opted out of marketing: a STOP reply, the panel, an import or the API. Not repeated for a second STOP.
- contact.opted_in
- A customer who had opted out opted in again.
- link.clicked
- A customer tapped a tracked link (a button that goes through /l/…) in a message you sent. Every tap, with the count so far; a chat app drawing the link’s preview is not a tap.
What arrives
Every request has the same envelope: id (this delivery, the same on every retry), event, version (1), created_at (when it happened) and data. Numbers are written +919876543210, times are ISO 8601 in UTC, and a field the send API returns has the same name here: id is Pulse’s message id, client_ref is yours.
POST /pulse-webhook HTTP/1.1
Content-Type: application/json
User-Agent: CloudFigma-Pulse-Webhooks/1
X-Pulse-Event: message.received
X-Pulse-Delivery: cm2dlv0001
X-Pulse-Timestamp: 1760004000
X-Pulse-Signature: sha256=3b1f6c…(64 hex characters){
"id": "cm2dlv0001",
"event": "message.received",
"version": 1,
"created_at": "2026-10-09T10:00:00.000Z",
"data": {
"message": {
"id": "cm2msg0001",
"wa_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg",
"from": "+919876543210",
"type": "text",
"text": "Is my order out for delivery?",
"media": null,
"reply_to_wa_message_id": null,
"received_at": "2026-10-09T10:00:00.000Z"
},
"conversation_id": "cm2conv0001"
}
}Examples. Version 1 may gain fields; none is renamed or removed without a version 2.
Check the signature
X-Pulse-Signature is sha256= and the hex HMAC-SHA256 of X-Pulse-Timestamp, a dot and the raw request body, keyed with your webhook’s whole signing secret, whsec_ included. Compute it over the bytes you received, before parsing the JSON; compare in constant time; and refuse a timestamp more than 5 minutes from your clock, so a captured request cannot be replayed.
const crypto = require("crypto")
const express = require("express")
const app = express()
// Your webhook's secret (whsec_…), shown once.
const SECRET = process.env.PULSE_WEBHOOK_SECRET
// express.raw keeps the exact bytes it was sent.
app.post(
"/pulse-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Pulse-Timestamp") || ""
const sig = req.get("X-Pulse-Signature") || ""
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(ts + "." + req.body)
.digest("hex")
const fresh =
Math.abs(Date.now() / 1000 - Number(ts)) <= 300
const valid = sig.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(sig), Buffer.from(expected))
if (!fresh || !valid) return res.sendStatus(401)
const event = JSON.parse(req.body)
// event.id repeats on a retry: skip it if seen.
console.log(event.event, event.data)
res.sendStatus(200) // within 10 s; slow work after
},
)
app.listen(3000)Answering and retries
- Answer with any 2xx within 10 seconds, then do slow work. Anything else is a failure, including no answer and a redirect: Pulse never follows one.
- A failed event is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 attempts in all. Then it is marked failed in the URL’s delivery log in Pulse, where Retry now sends it again.
- Delivery is at least once and not in order: skip an
idyou have handled, and order by the event’s own time. - After 20 failures in a row, Pulse switches the URL off and emails the account owner. Events while it is off are not kept.
- URLs must be https and on the public internet. Pulse checks the address it connects to on every attempt.
- Send test event, in Settings, posts a
pingsigned like any other, with{ "test": true, "endpoint": { "id", "url" } }as itsdata.
Request a payment
With the business’s own Razorpay connected in Pulse (Settings › Payments), one request asks a customer for money on WhatsApp. Pulse makes a Razorpay payment link for exactly the amount, in the business’s own Razorpay account, and sends it: as a free reply inside the customer’s 24-hour window, or else as an approved template with Pulse’s Pay now button. The money goes straight to the business’s Razorpay.
POST https://pulse.cloudfigma.com/api/v1/payment-requests
Authorization: Bearer cfn_live_YOUR_KEY
Content-Type: application/json
Idempotency-Key: order-10482
{
"to": "+919876543210",
"amount": "800.00",
"purpose": "Cleaning, 7 Oct",
"due_date": "2026-10-16",
"client_ref": "order-10482"
}amountis rupees with at most two decimals, ₹1.00 or more; or sendamount_paiseas a whole number.purposeis required.due_dateisYYYY-MM-DD; left out, a week from today.templatenames an approved template with a Pay now button, for outside the window; left out, the first one the account has.languageandclient_refwork as they do for messages.- It answers
201with the request: itsid,status(requested,opened,paid,expiredorcancelled),amount,payment_link,sent_as(textortemplate) andmessage_id. GET /api/v1/payment-requests/{id}says where it has got to. Once paid,paymentholds Razorpay’s payment id, the amount and the method. Only Razorpay can mark a request paid.- Asked again with the same Idempotency-Key, it answers
200with the same request and"idempotent_replay": true, and makes no second link.
Errors
Every error answers { "error": …, "message": … }. Check error in your code; show or log message, which says what to fix. Nothing is sent when a request is refused.
| HTTP | error | What it means | What to do |
|---|---|---|---|
| 400 | invalid_recipient | to is not a phone number in international form. | Send a plus, the country code and the number: "+919876543210". |
| 400 | missing_template | Neither template nor text was sent. | Name an approved template, or send text to reply inside the 24-hour window. |
| 400 | invalid_template | template is not a string. | Send the template’s name as text, such as "order_update". |
| 400 | invalid_language | language is not a language code. | Send the template’s language as text, such as "en" or "en_US". |
| 400 | invalid_variables | variables is not a list of strings. | Send every value as a string, numbers too: ["Asha", "1042"]. |
| 400 | variable_count | The number of values does not match the template’s blanks. | Send one value per blank, in order. The message says how many it takes. |
| 400 | empty_variable | A value is blank, and WhatsApp refuses blank values. | Fill every value. |
| 400 | invalid_header | header is not exactly one of link (https) or id, or filename is not text. | Send one of link or id, and filename as a string. |
| 400 | missing_header_media | The template shows an image, video or document at the top, and has no file to send. | Send header, or add a default file to the template in Pulse. |
| 400 | unsupported_component | The template has a part Pulse cannot send yet, such as a location header. | Use a template without that part. |
| 400 | invalid_client_ref | client_ref is not a string. | Send it as text, such as "order-1042". |
| 400 | invalid_link | A link for one of Pulse’s link buttons cannot go. | The message says why. Send the whole https link. |
| 400 | invalid_text | text is not a string, is blank, or is over 4,096 characters. | Send the reply as 1 to 4,096 characters. |
| 400 | invalid_amount | amount is not rupees with at most two decimals, or amount_paise is not a whole number. | Send "800.50", or 80050 as amount_paise. |
| 400 | invalid_request | A field is missing or does not belong: text beside a template’s fields, a payment request without purpose, a due_date that is not YYYY-MM-DD, or an amount under ₹1.00. | The message names the field. Fix it and send again. |
| 401 | missing_api_key | No API key was sent. | Send Authorization: Bearer cfn_live_… or X-API-Key: cfn_live_…. |
| 401 | invalid_key | The key is not one Pulse knows (403 when the key may not send). | Copy the whole key again, or ask the account owner for a new one. |
| 401 | revoked | The key was revoked. | Ask the account owner for a new key. |
| 403 | tenant_suspended | Sending is paused on the Pulse account, for example while its renewal is unpaid. | The account owner settles it or contacts support. Nothing was sent. |
| 404 | template_not_found | No approved template has that name in that language. | Check Templates in Pulse: it may still be waiting for Meta’s approval. |
| 404 | not_found | No such message or payment request on this account, or no such address. | Check the id and the path. |
| 409 | waba_not_active | The WhatsApp account is not active, or needs reconnecting. | Nothing was sent. CloudFigma is told; the account owner can contact support. |
| 409 | no_active_number | The account has no active WhatsApp number. | Finish connecting a number in Pulse, or contact support. |
| 409 | marketing_paused | WhatsApp lowered the number’s quality rating, so marketing templates are paused. | Utility and authentication templates still go. |
| 409 | recipient_opted_out | The customer replied STOP, so marketing templates cannot go to them. | Utility and authentication templates still go. |
| 409 | recipient_blocked | The business blocked this customer in Pulse. | Unblock them on their customer page in Pulse first. |
| 409 | window_closed | The customer has not written in the last 24 hours (or never has), so a plain-text reply cannot go. | Send an approved template instead. |
| 409 | idempotency_in_progress | A plain-text reply with this Idempotency-Key is still being sent. | Ask again in a moment, or use a new key for a new message. |
| 409 | gateway_not_connected | The business has not connected its Razorpay in Pulse. | The account owner connects it in Settings › Payments. |
| 409 | no_payment_template | The customer is outside the 24-hour window, and there is no approved template with Pulse’s Pay now button to use. | Create one in Templates, or name one in template. |
| 409 | request_closed | A retried payment request (same Idempotency-Key) is no longer waiting for payment, or was never sent. | Read it with its id; use a new key for a new request. |
| 413 | payload_too_large | The request body is over 5 MB. | Send less. A file goes in header.link, not in the body. |
| 415 | unsupported_media_type | The body is not JSON. | Send JSON with Content-Type: application/json. |
| 422 | idempotency_conflict | This Idempotency-Key was already used with a different body. | Use a new key for each different message. |
| 429 | rate_limited | Over 6,000 requests in a minute on this key. | Wait retry_after_seconds, then try again. |
| 500 | internal_error | Something failed on Pulse’s side. | Try again in a minute. If it repeats, send support the request_id. |
| 502 | send_failed | WhatsApp refused a plain-text reply (the answer carries its id), or a payment request could not be sent. | The message has the reason. A refused reply is kept, as failed. |
| 502 | gateway_error | Razorpay would not make the payment link. | The message has Razorpay’s reason. Check the Razorpay account. |
A 500 never carries the fault itself. It carries a request_id, also sent as the x-request-id header: if a retry a minute later fails too, send it to support@cloudfigma.com and we can find what happened.
{
"error": "internal_error",
"message": "Something went wrong on our side. Try again in a minute; if it keeps happening, send us this request_id.",
"request_id": "5b0e7c2a-91d4-4f3e-8a61-2f6c0d9e4b17"
}Limits
- 6,000 requests a minute per API key, across every endpoint. Past it,
429 rate_limited: waitretry_after_seconds, then try again. - Request bodies up to 5 MB. A file for a template goes as a link in
header, not in the body. - A plain-text reply up to 4,096 characters.
- Template messages wait in a queue for your number and go to WhatsApp in turn, so a burst is sent rather than refused.
- Webhooks: answer within 10 seconds.
{
"error": "rate_limited",
"message": "Too many requests, the limit is 6000 per minute. Retry in 42s.",
"retry_after_seconds": 42
}Questions about the API: support@cloudfigma.com.