BotPulsar

Home/ Developers/Code examples

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.

Try it first

Test your key in 60 seconds

  1. Check the key works

    This returns your workspace and connected numbers. A 401 means the key is wrong, revoked or expired; 403 means it lacks the messages:read scope.

    curl
    curl https://api.botpulsar.com/api/v1/partner-api/workspace/ \
      -H "Authorization: ApiKey smk_your_prefix_your_secret"
  2. See your approved templates

    Pick one whose status is approved and note its name, language and how many variables it has.

    curl
    curl https://api.botpulsar.com/api/v1/partner-api/workspace/templates/ \
      -H "Authorization: ApiKey smk_your_prefix_your_secret"
  3. Send it to your own number

    curl
    curl -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

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.

Python (requests)
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"])
Node.js (18+, built-in fetch)
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 (cURL extension)
<?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"];
Full API

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.

Python
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"
Page through a list
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
Receive

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.

Python (Flask)
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
Node.js (Express)
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
<?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.

No code

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:

MethodPOST
URLhttps://api.botpulsar.com/api/v1/partner-api/workspace/messages/
HeaderAuthorization = ApiKey smk_your_prefix_your_secret
HeaderContent-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.

Google Sheets / Apps Script — send a message to each row marked "send"
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.

Recipes

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.

Go-live checklist

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 429 and 5xx, with backoff and Retry-After.
  • 409 and 400 handled, not retried.
  • Templates are approved before the first production send.

Receiving

  • Signature checked on the raw body; stale timestamps rejected.
  • Deliveries de-duplicated on X-Botpulsar-Delivery.
  • Endpoint answers 2xx in 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.
Troubleshooting

If a call is not working

You seeLikely causeFix
401 authentication_failedWrong, 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_deniedThe 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 createdThe 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_requiredPlain text outside the customer's 24-hour window.Send an approved template instead.
409 contact_suppressedThe customer opted out.Do not message them; only they can opt back in.
template_not_approved / template_not_foundStill 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_unavailableNo active WhatsApp number on the workspace.Connect or re-activate a number under Channels.
402 billing_limit_reachedThe plan's message or contact allowance is used up.Top up or upgrade in Billing.
429 throttledOver the hourly limit for this key.Wait Retry-After seconds, spread the load, or ask us to raise it.
Message stays queued or ends failedMeta 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 matchesThe 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 deadYour endpoint timed out or returned non-2xx.Fix the endpoint, then replay from Operations → Developer → Webhook deliveries.

Download Postman collection All error codes Talk to us

Start on WhatsApp today — from ₹300/month

Unlimited team members on every plan. 14-day trial, no setup fee, no annual lock-in.