Home/ Developers/Code examples
Copy, paste, send
Working examples for the two things every integration does — send a message and receive a reply — in Python, Node.js, PHP and curl, plus no-code recipes for Zapier, Make, n8n and Google Sheets.
Sending is one authenticated POST with a template name and an
idempotency key; receiving is a webhook endpoint that verifies an HMAC and returns
200 fast. Replace smk_your_prefix_your_secret with your
key. Keep it in an environment variable or a secret manager — never in source control or
client-side code.
Test your key in 60 seconds
-
Check the key works
This returns your workspace and connected numbers. A
401means the key is wrong, revoked or expired;403means it lacks themessages:readscope.curlcurl https://api.botpulsar.com/api/v1/partner-api/workspace/ \ -H "Authorization: ApiKey smk_your_prefix_your_secret"
-
See your approved templates
Pick one whose
statusisapprovedand note itsname,languageand how many variables it has.curlcurl https://api.botpulsar.com/api/v1/partner-api/workspace/templates/ \ -H "Authorization: ApiKey smk_your_prefix_your_secret"
-
Send it to your own number
curlcurl -X POST https://api.botpulsar.com/api/v1/partner-api/workspace/messages/ \ -H "Authorization: ApiKey smk_your_prefix_your_secret" \ -H "Content-Type: application/json" \ -d '{ "to": "+919812345678", "template": {"name": "order_shipped", "language": "en", "params": ["Ada", "12045"]}, "idempotency_key": "test-001" }'
Send a template message
The same call in four languages. Reuse the same idempotency_key when you
retry, so a network blip never sends a customer two messages.
import os
import time
import requests
API = "https://api.botpulsar.com/api/v1/partner-api/workspace"
HEADERS = {"Authorization": f"ApiKey {os.environ['BOTPULSAR_API_KEY']}"}
def send_template(to, name, params, idempotency_key, language="en"):
body = {
"to": to,
"template": {"name": name, "language": language, "params": params},
"idempotency_key": idempotency_key,
}
for attempt in range(4):
r = requests.post(f"{API}/messages/", headers=HEADERS, json=body, timeout=15)
if r.status_code == 429: # rate limited: wait as told, then retry the SAME key
time.sleep(int(r.headers.get("Retry-After", 5)))
continue
if r.status_code >= 500: # transient: back off and retry the SAME key
time.sleep(2 ** attempt)
continue
if r.status_code >= 400: # our mistake or a business rule: do not retry blindly
raise RuntimeError(r.json()["error"]) # e.g. template_not_approved
return r.json() # {"id": ..., "delivery_status": "queued", ...}
raise RuntimeError("BotPulsar unavailable after retries")
message = send_template("+919812345678", "fee_due", ["Ada", "₹4,500", "15 Oct"], "fee-STU1042-2026-10")
print(message["id"], message["delivery_status"])
const API = "https://api.botpulsar.com/api/v1/partner-api/workspace";
async function sendTemplate({ to, name, params, idempotencyKey, language = "en" }) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${API}/messages/`, {
method: "POST",
headers: {
Authorization: `ApiKey ${process.env.BOTPULSAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to,
template: { name, language, params },
idempotency_key: idempotencyKey,
}),
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") || 5) * 1000));
continue;
}
if (res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
const data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data.error)); // e.g. template_not_approved
return data; // { id, delivery_status: "queued", ... }
}
throw new Error("BotPulsar unavailable after retries");
}
sendTemplate({
to: "+919812345678",
name: "fee_due",
params: ["Ada", "₹4,500", "15 Oct"],
idempotencyKey: "fee-STU1042-2026-10",
}).then(console.log);
<?php
$payload = json_encode([
"to" => "+919812345678",
"template" => ["name" => "fee_due", "language" => "en", "params" => ["Ada", "₹4,500", "15 Oct"]],
"idempotency_key" => "fee-STU1042-2026-10",
]);
$ch = curl_init("https://api.botpulsar.com/api/v1/partner-api/workspace/messages/");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"Authorization: ApiKey " . getenv("BOTPULSAR_API_KEY"),
"Content-Type: application/json",
],
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException(json_encode($body["error"])); // e.g. template_not_approved
}
echo $body["id"] . " " . $body["delivery_status"];
Contact → conversation → message
When you want the contact stored with attributes, or to reply inside an existing conversation, use the full API. Both create calls are idempotent, so this is safe to re-run.
import os
import uuid
import requests
BASE = "https://api.botpulsar.com/api/v1"
HEADERS = {
"Authorization": f"ApiKey {os.environ['BOTPULSAR_API_KEY']}",
"X-Tenant-ID": os.environ["BOTPULSAR_WORKSPACE_ID"],
}
def call(method, path, **kwargs):
r = requests.request(method, f"{BASE}{path}", headers=HEADERS, timeout=15, **kwargs)
if r.status_code >= 400:
raise RuntimeError(r.json()["error"])
return r.json()
contact = call("POST", "/contacts/", json={
"phone_e164": "+919812345678",
"display_name": "Ada Lovelace",
"custom_attributes": {"admission_no": "A-1042"},
})
conversation = call("POST", "/conversations/", json={
"contact_id": contact["id"],
"channel_account_id": os.environ["BOTPULSAR_CHANNEL_ID"],
})
message = call("POST", f"/conversations/{conversation['id']}/messages/", json={
"content_type": "template",
"template_id": os.environ["BOTPULSAR_TEMPLATE_ID"],
"template_parameters": {"1": "Ada", "2": "12045"},
"client_message_id": str(uuid.uuid4()), # store it: retrying with it never double-sends
})
print(message["delivery_status"]) # "queued"
def all_contacts():
path = "/contacts/"
while path:
page = call("GET", path)
yield from page["results"]
# `next` is a full URL; keep only the part after the API base.
path = page["next"].split("/api/v1", 1)[1] if page["next"] else None
A webhook endpoint that is safe to expose
Verify the signature on the raw body, reject stale timestamps, ignore
deliveries you have already handled, answer 200 immediately, and do the real
work afterwards. Inbound events carry ids only — fetch the text with the message endpoint.
import hashlib
import hmac
import os
import time
import requests
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["BOTPULSAR_WEBHOOK_SECRET"] # whsec_...
API_KEY = os.environ["BOTPULSAR_API_KEY"]
seen = set() # use Redis or a database table in production
def valid(raw: bytes, timestamp: str, header: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header.removeprefix("v1="))
@app.post("/hooks/botpulsar")
def botpulsar():
raw = request.get_data() # the exact bytes, before any JSON parsing
if not valid(raw, request.headers.get("X-Botpulsar-Timestamp", "0"),
request.headers.get("X-Botpulsar-Signature", "")):
abort(401)
delivery_id = request.headers["X-Botpulsar-Delivery"]
if delivery_id in seen: # retries reuse the same id
return "", 200
seen.add(delivery_id)
event = request.get_json()
if event["type"] == "message.received":
r = requests.get(
f"https://api.botpulsar.com/api/v1/partner-api/workspace/messages/{event['data']['message_id']}/",
headers={"Authorization": f"ApiKey {API_KEY}"},
timeout=10,
)
msg = r.json()
print("reply from", msg["from"], ":", msg["text"]) # queue real work instead
elif event["type"] == "message.delivery_status.changed":
print("status update", event["data"])
return "", 200
const crypto = require("crypto");
const express = require("express");
const app = express();
const seen = new Set(); // use Redis or a database table in production
// IMPORTANT: capture the raw body. Do not use express.json() on this route.
app.post("/hooks/botpulsar", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Botpulsar-Timestamp") || "0";
const header = (req.get("X-Botpulsar-Signature") || "").replace(/^v1=/, "");
const expected = crypto
.createHmac("sha256", process.env.BOTPULSAR_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body) // Buffer
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const ok =
fresh &&
header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
if (!ok) return res.sendStatus(401);
const deliveryId = req.get("X-Botpulsar-Delivery");
if (seen.has(deliveryId)) return res.sendStatus(200);
seen.add(deliveryId);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data); // hand off to a queue, then respond
res.sendStatus(200);
});
app.listen(3000);
<?php
$raw = file_get_contents("php://input"); // exact bytes
$timestamp = $_SERVER["HTTP_X_BOTPULSAR_TIMESTAMP"] ?? "0";
$header = preg_replace('/^v1=/', '', $_SERVER["HTTP_X_BOTPULSAR_SIGNATURE"] ?? "");
$expected = hash_hmac("sha256", $timestamp . "." . $raw, getenv("BOTPULSAR_WEBHOOK_SECRET"));
if (abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $header)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// De-duplicate on $_SERVER["HTTP_X_BOTPULSAR_DELIVERY"], then queue the work.
error_log($event["type"]);
http_response_code(200);
Developing locally? Your machine is not reachable from the internet and subscriptions only accept public HTTPS URLs, so expose it with a tunnel (for example ngrok or Cloudflare Tunnel) and use that URL while testing.
Zapier, Make, n8n and Google Sheets
Any tool that can send an HTTPS request with custom headers can send a WhatsApp message. The request is always the same one:
| Method | POST |
|---|---|
| URL | https://api.botpulsar.com/api/v1/partner-api/workspace/messages/ |
| Header | Authorization = ApiKey smk_your_prefix_your_secret |
| Header | Content-Type = application/json |
| Body (JSON) | {"to": "+91…", "template": {"name": "order_shipped", "language": "en", "params": ["Ada", "12045"]}, "idempotency_key": "order-12045"} |
Zapier
Add an action Webhooks by Zapier → Custom Request. Method
POST, the URL above, Data Pass-Through off, Data = the JSON body with
values mapped from your trigger, and the two headers. Map a unique field from the trigger
(an order or row id) into idempotency_key so a Zap replay cannot double-send.
Make
Add HTTP → Make a request. Method POST, the URL above,
the two headers, Body type Raw, Content type JSON (application/json), and
the request content as the JSON body with mapped values. Turn on Parse response
to use the returned message id in later steps.
n8n
Use the HTTP Request node: Method POST, the URL above,
Authentication Generic Credential Type → Header Auth with name
Authorization and value ApiKey smk_…, Send Body as JSON.
To receive replies, use a Webhook node as the trigger and paste its
production URL into a BotPulsar webhook subscription.
Receiving in no-code tools
These tools cannot easily compute the HMAC. Use the hard-to-guess webhook URL they give
you, then fetch the message from the API by the id in the event
(GET …/workspace/messages/{id}/) rather than trusting the event body — the read
is authenticated by your key, so a forged event cannot inject content.
function sendPending() {
const KEY = PropertiesService.getScriptProperties().getProperty("BOTPULSAR_API_KEY");
const sheet = SpreadsheetApp.getActiveSheet();
const rows = sheet.getDataRange().getValues(); // columns: A phone, B name, C amount, D status
rows.forEach((row, i) => {
if (i === 0 || row[3] !== "send") return; // skip header and anything not marked "send"
const res = UrlFetchApp.fetch(
"https://api.botpulsar.com/api/v1/partner-api/workspace/messages/",
{
method: "post",
contentType: "application/json",
headers: { Authorization: "ApiKey " + KEY },
muteHttpExceptions: true,
payload: JSON.stringify({
to: String(row[0]),
template: { name: "fee_due", language: "en", params: [row[1], String(row[2])] },
idempotency_key: "sheet-" + sheet.getSheetId() + "-row-" + (i + 1),
}),
}
);
sheet.getRange(i + 1, 4).setValue(res.getResponseCode() < 300 ? "sent" : "error: " + res.getContentText());
});
}
Store the key in Project Settings → Script properties, not in the sheet. The
idempotency_key is built from the sheet and row, so running the script twice
cannot message the same row twice.
Common integration patterns
ERP / billing: send on an event
On "invoice created" or "payment received", call the Simple send API with a template and
an idempotency_key built from the document id. Listen for
message.delivery_status.changed to mark the document "notified", and for
message.outbound.failed to route failures to a human.
Nightly reminders from a database
Create an API
campaign once (for example fee-reminder), then loop over due rows and call
POST /campaigns/api-campaigns/send/ with destination and
template_params. Read metrics on the campaign for the day's totals.
Sync replies into your CRM
Subscribe to message.received. On each event fetch the message, find the
customer by from, and create a note or ticket. Use
conversation.status.changed to mirror open/closed state.
Start a bot flow from your system
Publish an automation with the API request trigger and call
POST /automations/{id}/trigger/ with an Idempotency-Key header
and the contact id — for example when an order is paid.
Keep contacts in sync
Upsert from your user table with POST /contacts/ (safe to repeat). Use
PATCH /contacts/{id}/ when a name or email changes. Existing contacts are
never overwritten by a create.
Migrating from AiSensy
Keep your payload. The send endpoint accepts campaignName,
userName and templateParams — change the URL to
https://api.botpulsar.com/api/v1/campaigns/api-campaigns/send/, the header to
Authorization: ApiKey smk_… plus X-Tenant-ID, and create a matching
API campaign by name.
Before you point production at it
Credentials
- Key has only the scopes this integration uses, and an expiry date.
- Stored in a secret manager or environment variable — not in git, not in a browser.
- A second key ready, so you can rotate without downtime: create, deploy, then revoke the old.
Sending
- Every send has an idempotency key from your own data.
- Retries only on
429and5xx, with backoff andRetry-After. 409and400handled, not retried.- Templates are
approvedbefore the first production send.
Receiving
- Signature checked on the raw body; stale timestamps rejected.
- Deliveries de-duplicated on
X-Botpulsar-Delivery. - Endpoint answers
2xxin seconds; work happens in a queue. - A dead-letter alert, and you know how to replay from the Developer page.
Compliance
- You hold opt-in for every customer you message; STOP replies are honoured automatically.
- Marketing templates are not used for transactional messages, and vice versa.
- Phone numbers and message text are not written to your logs.
If a call is not working
| You see | Likely cause | Fix |
|---|---|---|
401 authentication_failed | Wrong, revoked or expired key; X-Tenant-ID missing or for another workspace; header not in the form ApiKey <key>. | Re-copy the key from creation (it is shown once) or create a new one; check the workspace id. |
403 permission_denied | The key does not carry the scope for that endpoint, or its creator lost the role needed. | Check the scope on the reference page; create a key with it. |
404 on an id you just created | The id belongs to another workspace, or you used a different workspace's key. | Use ids from the same workspace as the key. |
409 whatsapp_template_required | Plain text outside the customer's 24-hour window. | Send an approved template instead. |
409 contact_suppressed | The customer opted out. | Do not message them; only they can opt back in. |
template_not_approved / template_not_found | Still pending review, rejected, or the name or language does not match exactly. | List templates and copy name and language as returned. |
no_connected_number / channel_unavailable | No active WhatsApp number on the workspace. | Connect or re-activate a number under Channels. |
402 billing_limit_reached | The plan's message or contact allowance is used up. | Top up or upgrade in Billing. |
429 throttled | Over the hourly limit for this key. | Wait Retry-After seconds, spread the load, or ask us to raise it. |
Message stays queued or ends failed | Meta rejected it — see error on the message (number not on WhatsApp, quality limit, template paused). | Read the message's error; check the number's quality and limit under Channels. |
| Webhook signature never matches | The body was parsed and re-serialised before hashing, or the wrong secret (a rotated one). | Hash the raw bytes; use the secret from the latest create or rotate. |
Deliveries show failed or dead | Your endpoint timed out or returned non-2xx. | Fix the endpoint, then replay from Operations → Developer → Webhook deliveries. |