Overview
The base URL for every request is https://go.pulseautomation.app/api/v1. Requests and responses are JSON. All timestamps are ISO 8601 in UTC, for example 2026-10-09T10:02:41.000Z.
POST /orders from your backend.Quickstart
Use a pk_test_ key first. It sends no real message, so you can try everything safely. Create an order:
curl -X POST https://go.pulseautomation.app/api/v1/orders \
-H "Authorization: Bearer pk_test_YOUR_KEY" \
-H "Idempotency-Key: ORD-10452" \
-H "Content-Type: application/json" \
-d '{
"external_order_id": "ORD-10452",
"customer": { "name": "Ahmed Ali", "phone": "+201000000001" },
"items": [ { "title": "Black Party Pants", "quantity": 1 } ],
"total": 450,
"currency": "EGP",
"delivery_address": "12 Street, October City, Giza"
}'const res = await fetch("https://go.pulseautomation.app/api/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PULSE_API_KEY}`,
"Idempotency-Key": order.id, // your order id: a retry never sends twice
"Content-Type": "application/json",
},
body: JSON.stringify({
external_order_id: order.id,
customer: { name: order.customerName, phone: order.customerPhone },
items: order.items.map((i) => ({ title: i.name, quantity: i.qty })),
total: order.total,
currency: "EGP",
delivery_address: order.address,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);import os, requests
res = requests.post(
"https://go.pulseautomation.app/api/v1/orders",
headers={
"Authorization": f"Bearer {os.environ['PULSE_API_KEY']}",
"Idempotency-Key": order["id"], # your order id: a retry never sends twice
},
json={
"external_order_id": order["id"],
"customer": {"name": order["customer_name"], "phone": order["customer_phone"]},
"items": [{"title": i["name"], "quantity": i["qty"]} for i in order["items"]],
"total": order["total"],
"currency": "EGP",
"delivery_address": order["address"],
},
timeout=30,
)
data = res.json()
res.raise_for_status()The answer:
{
"id": "ord_8f3k2Qx1",
"external_order_id": "ORD-10452",
"status": "sent",
"created_at": "2026-10-09T10:00:00.000Z"
}A phone number ending in 0001 makes the sandbox confirm the order a few seconds later. See Sandbox for the other outcomes.
Authentication
Send your secret key in the Authorization header of every request:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxx
- You get two keys:
pk_test_(sandbox) andpk_live_(real messages). Orders made with one are invisible to the other. - Keep keys on your server. Never put them in a mobile app, a web page or a public repository.
- We store only a one-way hash of your key, so we cannot show it again. If you lose it, we issue a new one.
- Rotation: we issue a new key and the old one keeps working for 24 hours so you can switch without downtime.
- A missing, wrong, revoked or suspended key returns
401 unauthorized.
Every response has an X-Request-Id header. Quote it when you contact us about a request.
Create an order
Creates the order and sends the WhatsApp confirmation. The Idempotency-Key header is required: 1 to 200 printable characters, no spaces. Sending the same request again with the same key never messages the customer twice. You get the original answer back, with the header Idempotent-Replayed: true. Reusing a key with a different body returns 409 idempotency_key_reuse.
| Field | Rules |
|---|---|
| external_order_id | Your own order number. Required, up to 100 characters, unique per order. |
| customer.name | Required, up to 100 characters. |
| customer.phone | Required. International format with + and country code, for example +201001234567. Spaces and dashes are accepted. |
| items | Required, 1 to 50 entries. Each has a title (up to 200 characters) and an integer quantity from 1 to 999. |
| total | Required, a number of 0 or more. |
| currency | Required, 3 uppercase letters, for example EGP. |
| delivery_address | Optional, up to 300 characters. |
| language | Optional. Only "en" is supported in v1. |
The response is 201 with the order. If WhatsApp refuses the message immediately you still get 201, with "status": "failed" and a failure_reason. A replay of an earlier request returns 200.
Get an order
{
"id": "ord_8f3k2Qx1",
"external_order_id": "ORD-10452",
"status": "confirmed",
"created_at": "2026-10-09T10:00:00.000Z",
"timeline": [
{ "event": "created", "at": "2026-10-09T10:00:00.000Z" },
{ "event": "sent", "at": "2026-10-09T10:00:02.000Z" },
{ "event": "delivered", "at": "2026-10-09T10:00:05.000Z" },
{ "event": "confirmed", "at": "2026-10-09T10:02:41.000Z" }
]
}Status values
| Status | Meaning |
|---|---|
| sending | Created, being handed to WhatsApp. |
| sent · delivered · read | Message progress. |
| confirmed | The customer tapped Confirm Order. Final. |
| cancelled | The customer tapped Cancel Order. Final. |
| failed | The message could not be delivered. Final. failure_reason is one of invalid_phone, not_on_whatsapp, sender_error (a problem with your WhatsApp account) or send_failed. |
| no_reply | No answer within your timeout (24 hours by default). Final. PULSE changes nothing on your side; your system decides what to do. |
| withdrawn | You withdrew the order. Final. |
Withdraw an order
Works only while the status is sending or sent, meaning nobody has answered yet. The order becomes withdrawn. It cannot recall a message that was already delivered; it only stops PULSE acting on a late tap. No webhook is sent for a withdrawal you made yourself. Once the order is final you get 409 invalid_state.
Usage
{
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z",
"used": 412,
"cap": 1500,
"remaining": 1088,
"paid_until": "2026-11-08T10:57:33.272Z"
}The month is the calendar month in UTC. Only messages actually handed to WhatsApp count; sandbox orders never do. At the limit, new live orders return 402 quota_exceeded until the 1st of the next month.
Webhooks
Give us a public https address and PULSE calls it the moment an order reaches an outcome. You get one call per final outcome:
order.confirmedorder.cancelledorder.failedorder.no_reply
{
"event": "order.confirmed",
"event_id": "evt_Qx1mP9kLw2",
"id": "ord_8f3k2Qx1",
"external_order_id": "ORD-10452",
"status": "confirmed",
"at": "2026-10-09T10:02:41.000Z"
}order.failed also carries a failure_reason.
Verify every call
Anyone can send a request to your address, so check that it really came from PULSE. Each call has two headers:
X-Pulse-Timestamp: 1791545734 X-Pulse-Signature: sha256=3f1c...
The signature is the hex HMAC-SHA256 of the text <timestamp>.<raw request body>, using your webhook secret as the key. Three rules matter:
- Use the raw body exactly as received, before any JSON parsing. Parsing and re-serialising changes the bytes and the check fails.
- Compare in constant time (the functions below do).
- Reject old calls. If the timestamp is more than 5 minutes away from your clock, refuse it. This stops replays.
import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw keeps the body as untouched bytes
app.post("/pulse-webhook", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-Pulse-Timestamp");
const sig = req.header("X-Pulse-Signature") || "";
const rawBody = req.body.toString("utf8");
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.PULSE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`).digest("hex");
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(rawBody);
// 1. ignore it if you already handled event.event_id
// 2. update your order from event.external_order_id and event.status
res.sendStatus(200); // answer quickly
});import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PULSE_WEBHOOK_SECRET"]
@app.post("/pulse-webhook")
def pulse_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
ts = request.headers.get("X-Pulse-Timestamp", "")
sig = request.headers.get("X-Pulse-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
expected = "sha256=" + hmac.new(
SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = json.loads(raw)
# 1. ignore it if you already handled event["event_id"]
# 2. update your order from event["external_order_id"] and event["status"]
return "", 200 # answer quickly<?php
$secret = getenv('PULSE_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // raw body, before json_decode
$ts = $_SERVER['HTTP_X_PULSE_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PULSE_SIGNATURE'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400);
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// 1. ignore it if you already handled $event['event_id']
// 2. update your order from $event['external_order_id'] and $event['status']
http_response_code(200); // answer quicklyDelivery and retries
- Answer with any
2xxwithin 10 seconds. We do not follow redirects. - Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (7 attempts in total), then stop. The order itself is never lost; read it with
GET /orders/{id}. - The same event can arrive more than once. Handle each
event_idonly once. - Your address must be public
https. Private, local and plainhttpaddresses are refused.
Polling instead of a webhook
A webhook is optional. If you cannot host a public address, leave it unset and read the status yourself with GET /orders/{id}. Check each open order every minute or two, and stop once it reaches a final status. Staying under 10 requests per second keeps you inside the rate limit. A webhook is faster and needs far fewer calls, so use one when you can, and keep an occasional poll as a safety net.
Errors
Errors have one shape. Quote the request_id if you contact us.
{
"error": { "code": "invalid_phone", "message": "Phone must be in international format, e.g. +201001234567." },
"request_id": "req_a1b2c3d4e5f6a7b8"
}| HTTP · code | Meaning and what to do |
|---|---|
| 400 invalid_request | A field is missing or wrong. The message names it. |
| 400 invalid_phone | Use international format with + and country code. |
| 400 unsupported_language | Only "en" is supported. |
| 401 unauthorized | Missing, wrong, expired or revoked key, or the account is suspended. |
| 402 payment_required | Your subscription is not active. Contact PULSE to renew. Sandbox keys still work. |
| 402 quota_exceeded | Monthly message limit reached. It resets on the 1st (UTC). |
| 404 not_found | Unknown order. |
| 405 method_not_allowed | Wrong HTTP method for that address. |
| 409 duplicate_order | That external_order_id already exists. Read it with GET /orders. |
| 409 idempotency_key_reuse | The same key was used with a different request. |
| 409 sender_not_ready | The live key was used before your WhatsApp number is connected and approved. |
| 409 invalid_state | The order can no longer be withdrawn. |
| 413 payload_too_large | The body is over 32 KB. |
| 429 rate_limited | More than 10 requests per second (bursts of 20). Wait for Retry-After. |
| 500 server_error | Our side. Retry with the same Idempotency-Key; it is safe. |
Sandbox
With a pk_test_ key no WhatsApp message is sent and nothing counts against your limit. About 5 seconds after you create an order, it plays out an outcome chosen by the last four digits of the phone number:
| Phone ends in | Outcome |
|---|---|
| 0001 | confirmed |
| 0002 | cancelled |
| 0003 | failed (not_on_whatsapp) |
| 0004 | no_reply |
| anything else | delivered, with no final outcome |
For example +201000000001 confirms. Webhooks fire exactly as in production, so you can test your signature check and your retry handling end to end.
Going live
Live messages go out from your own WhatsApp Business number, so your customers see your brand. Meta bills you directly for the messages.
- Create a WhatsApp Business Account in Meta Business Manager, add a phone number and a payment method. The number must not be in use in the normal WhatsApp app.
- In Meta Business Manager, add PULSE as a partner on that account. We send you the Business ID to use.
- Send us your WhatsApp Business Account ID. We connect it, create the English confirmation template in your account and subscribe to its replies.
- Meta approves the template, usually within minutes and sometimes up to a day. We tell you when it is ready.
- We give you your
pk_live_key and set your webhook address and secret.
Until step 4 is finished, the live key returns 409 sender_not_ready. The test key works from the start.
Rules & limits
- Send messages only for orders your customer actually placed. No marketing and no bulk sends.
- Send only to customers who agreed to receive order updates on WhatsApp.
- Rate limit: 10 requests per second, with bursts of 20.
- Each account has a monthly message limit; read it from
GET /usage. - Order data is kept for 365 days, then deleted.
- Language: English only in v1.
request_id from the response.