Customer Pro API
Reference for the Customer Pro REST API — endpoints, authentication, and examples.
This page is public: fetch it as Markdown from https://customerpro.in/docs.md (or send
Accept: text/markdown to https://customerpro.in/docs). No credentials are needed to read it.
Overview
The Customer Pro REST API lets external systems send messages, manage templates, read conversation history, and upload media without a browser session.
Base URL: https://customerpro.in/api/v1
Authentication
There are two ways to hold a credential, and the right one depends on who you are.
Building an application that acts on behalf of businesses? Use Connect. The business clicks a button in your product, approves once, and the integration is live — nobody copies a secret between two products, and you never ask a customer to paste credentials. This is the right path for almost every integration, and it is documented immediately below.
Scripting against your own account? Generate a key yourself in Developer → API keys — no handshake, nothing to register. The secret is shown once and is never retrievable afterwards.
Either way, every request carries the same two headers:
x-client-id: client_xxxxxxxxxxxxxxxxxxxxxxxx
x-client-secret: secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Building with a coding agent?
There is a ready-made skill for each path. Install the one that matches who the credential belongs to — they are separate files because an agent loads a skill whole, and the handshake is only noise to someone scripting against their own account.
# Your own account: sends, templates, media, webhooks
mkdir -p .claude/skills/customerpro-api
curl -sL https://customerpro.in/skill.md -o .claude/skills/customerpro-api/SKILL.md
# On behalf of other businesses: the full Connect handshake
mkdir -p .claude/skills/customerpro-connect
curl -sL https://customerpro.in/connect/skill.md -o .claude/skills/customerpro-connect/SKILL.md
Connect: one-click integration for applications
Put a Connect button in your product. The business lands on Customer Pro, signs in if they are not already, chooses which WhatsApp number you may send from, and approves. You receive a credential scoped to that one organization. No secret is ever copied by hand.
Register your application first
Registration is self-service. Sign in to any Customer Pro account and open Developer →
Applications, or do it over the API with a key holding the applications.write ability:
curl -X POST https://customerpro.in/api/v1/applications \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "Bookings",
"redirect_uris": ["https://app.example.com/integrations/customerpro/callback"],
"scopes": ["messages.send", "templates.read"]
}'
You get back an app_… client id and an appsecret_… secret, shown exactly once. Rotating
is the only way back if you lose it, and rotation invalidates the old secret immediately.
Redirect URIs are matched by exact string equality: no prefix matching, no wildcard host, no subdomain allowance, and a trailing slash matters. A loose redirect match is the classic way authorization codes leak to someone who was never authorized.
Your application is approvable only by the account that registered it, which is what makes open registration safe — a name chosen to look like somebody else's product still cannot be approved by their customers. To be connectable by any business, an application has to be verified by Customer Pro; ask us once you are ready to go live.
applications.write is not a scope any Connect handshake can grant, so a connected credential
can never register further applications — or revoke another application's connections — on a
business's behalf.
Manage applications over the API
POST /applications · scope applications.write
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Shown on the consent screen. Up to 255 characters. |
redirect_uris |
array | yes | 1 to 10 http:// or https:// URLs. Matched exactly at authorize time. |
scopes |
array | yes | At least one. Each must be a scope a business can grant: messages.send, messages.read, templates.read, templates.write, conversations.read, contacts.read, contacts.write, campaigns.read, flows.start, webhooks.write, media.write, data_tables.read, data_tables.write, crm.read, crm.write. |
description |
string | no | One line about your product. Up to 200 characters. |
launch_url |
string | no | Where a business opens your product. An http:// or https:// URL with no # fragment. |
201 with the application:
{
"client_id": "app_…",
"name": "Bookings",
"description": null,
"redirect_uris": ["https://app.example.com/integrations/customerpro/callback"],
"launch_url": null,
"scopes": ["messages.send", "templates.read"],
"is_active": true,
"published": false,
"client_secret": "appsecret_…",
"authorize_url": "https://customerpro.in/connect/authorize",
"token_url": "https://customerpro.in/connect/token"
}
client_secret is in this response only. published is set by an owner or admin on
Developer → Applications, signed in; it cannot be changed over the API.
GET /applications · scope applications.write returns every application your organization
registered, newest first, as {"data": [ ... ]}. Each item has the fields above except
client_secret, authorize_url and token_url.
1. The button points at your own server, not at us
The authorize URL carries a PKCE challenge that you must generate and remember, so the button cannot be a bare link to Customer Pro. Point it at a route of your own that prepares the handshake and redirects:
<a href="/integrations/customerpro/connect">Connect Customer Pro</a>
That route generates a code verifier, stores it in the session, derives the challenge, and redirects the browser to:
https://customerpro.in/connect/authorize
?client_id=<your app_… client id>
&redirect_uri=<your registered URI, matched exactly>
&scope=messages.send%20templates.read
&state=<opaque value you generate>
&code_challenge=<base64url(sha256(verifier)), unpadded>
&code_challenge_method=S256
PKCE and state are both required. A rejected request shows an error page and never
redirects to the supplied URI — an unverified redirect target must not receive a response,
or the rejection itself becomes the leak.
2. Generating the PKCE pair
The verifier is a random string you keep; the challenge is what you send. Getting the encoding
subtly wrong is the single most common way this fails, and the exchange deliberately will not
tell you which check failed — you will just get invalid_grant. Two rules:
- Unpadded base64url — strip every trailing
=. - URL-safe alphabet —
-and_, never+and/.
$verifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
const b64url = b => btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const verifier = b64url(crypto.getRandomValues(new Uint8Array(64)));
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
Note the hash is taken over the raw bytes of the verifier string, and it is the digest that gets base64url-encoded — not a hex string of the digest.
3. Receive the redirect
On approval: ?code=<one-time code>&state=<your state>.
On cancellation: ?error=access_denied&state=<your state>.
Check state matches what you sent. The code is single-use and expires in 60 seconds.
Popup mode, and the drop-in button
Add &display=popup to the authorize URL and the consent screen renders without app chrome
and, instead of redirecting, posts the result to the window that opened it:
{ source: 'customerpro', type: 'connect_result', code: '<one-time code>', state: '<your state>' }
or { source: 'customerpro', type: 'connect_result', error: 'access_denied', state: ... } on
cancellation. Same code, same PKCE, same single 60-second life — only the delivery differs, and
your server still does the exchange.
The message is posted to the exact origin of your registered redirect_uri, never to *.
Check event.origin is https://customerpro.in before trusting a message, because any page may post
to your window. A rejected request still renders an error page and posts nothing at all: an
unverified redirect target must not receive a response, and that includes an error.
You do not have to write that listener. The button script does it, falls back to a full-page redirect when the popup is blocked, and hands the code to the callback route you already have:
<script src="https://customerpro.in/connect/customerpro.js" defer></script>
<button data-customerpro-connect data-authorize-url="<the URL from step 1>">
Connect Customer Pro
</button>
Attributes: data-authorize-url (required), data-redirect-uri (defaults to the redirect_uri
already in the authorize URL), data-unstyled (drop the default styling). To handle the code
in JavaScript instead of navigating, cancel the customerpro:connect event the button fires —
event.detail is { code, state, error }.
A redirect URI with no web origin — a native app's custom scheme — has no valid postMessage
target, so display=popup is ignored for it and the redirect happens as usual.
4. Exchange the code from your server
curl -X POST https://customerpro.in/connect/token \
-H "Content-Type: application/json" \
-d '{
"code":"<code>",
"code_verifier":"<your verifier>",
"client_id":"<your app_… client id>",
"client_secret":"<your appsecret_… secret>"
}'
This is server-to-server. Your application authenticates here with the credentials from registration — a code is redeemable only by the application it was issued to, so a stolen code is not enough on its own. The secret crosses the wire exactly once and never again, so a front-end-only integration cannot complete the handshake.
Two different pairs, confusingly similar names. The client_id/client_secret you send
identify your application and never change. The client_id/client_secret you get back
are the connection's own credential, one per business, and are what you send as
x-client-id/x-client-secret when calling the API.
200 with the connection's credential:
{
"client_id": "client_…",
"client_secret": "secret_…",
"organization": {"id": 7, "name": "Acme Garage"},
"whatsapp_account": {"id": 3, "display_name": "Acme Garage", "phone_number": "919812345678", "is_default": true},
"whatsapp_accounts": [
{"id": 3, "display_name": "Acme Garage", "phone_number": "919812345678", "is_default": true}
],
"scopes": ["messages.send", "templates.read"],
"rate_limit": 100,
"plan": {"name": "Growth", "tier": "growth", "active": true}
}
whatsapp_account is the number the business chose on the consent screen; whatsapp_accounts
lists every number the organization has. rate_limit is requests per minute, or null when the
credential has no cap. plan.active is false when the subscription has lapsed. For usage
against the plan, call GET /me. The secret is returned exactly once and can
never be retrieved again — store it before you respond to the redirect.
Any failure answers 400 with error: invalid_grant, without saying which check failed. A
caller holding a legitimate code and verifier never needs to know, and anyone probing gets
nothing to work with. A code that is not exchanged within 24 hours leaves a credential that
expires by itself and is then removed.
5. A worked example, end to end
// routes/web.php
Route::get('/integrations/customerpro/connect', function () {
$verifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$state = Str::random(40);
session(['cp_verifier' => $verifier, 'cp_state' => $state]);
return redirect()->away('https://customerpro.in/connect/authorize?'.http_build_query([
'client_id' => config('services.customerpro.client_id'),
'redirect_uri' => route('customerpro.callback'),
'scope' => 'messages.send templates.read',
'state' => $state,
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
]));
});
Route::get('/integrations/customerpro/callback', function (Request $request) {
abort_unless($request->query('state') === session('cp_state'), 403);
if ($request->query('error')) {
return redirect('/integrations')->with('error', 'Connection cancelled.');
}
$result = Http::acceptJson()->post('https://customerpro.in/connect/token', [
'code' => $request->query('code'),
'code_verifier' => session('cp_verifier'),
// Your application's own credentials, from registration — not the pair
// this call returns.
'client_id' => config('services.customerpro.client_id'),
'client_secret' => config('services.customerpro.client_secret'),
])->throw()->json();
// The only moment the secret exists outside a hash. Store it now.
auth()->user()->company->update([
'customerpro_client_id' => $result['client_id'],
'customerpro_client_secret' => $result['client_secret'], // encrypted cast
'customerpro_org_id' => $result['organization']['id'],
]);
session()->forget(['cp_verifier', 'cp_state']);
return redirect('/integrations')->with('status', 'Connected to '.$result['organization']['name']);
})->name('customerpro.callback');
Then send, using the stored pair:
Http::withHeaders([
'x-client-id' => $company->customerpro_client_id,
'x-client-secret' => $company->customerpro_client_secret,
])->post('https://customerpro.in/api/v1/messages', [
'type' => 'text',
'phone_number' => '919876543210',
'message' => 'Your booking is confirmed.',
]);
6. Storing the credential
Store client_id and an encrypted client_secret against your own account row, keyed by
organization.id. One business, one live credential per application. When a business
reconnects, the new credential replaces the old one the moment you exchange the code — the
previous key is deactivated and shows on their screen as Replaced. So key your storage by
organization and overwrite; appending leaves you holding a credential that has already stopped
working.
Two consequences worth planning for:
- Overwrite as part of handling the redirect, not later. If you store the new pair and keep
using the old one, your next call gets a
401. - Approving alone does not replace anything. Supersession happens when the code is exchanged, so a business who starts a reconnect and abandons it still has their original connection.
7. Disconnecting
When a business leaves your product, hand the credential back. Dropping your copy of the secret is not the same thing — the credential stays live on their account until they notice and delete it themselves.
curl -X DELETE https://customerpro.in/api/v1/me \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET"
204, and the credential stops working immediately. It needs no scope: holding the secret is
already full authority over that one connection. Every later call with it, including a second
DELETE, answers 401.
The key is deactivated rather than deleted, and stays on the business's Developer → API Keys screen marked Disconnected so they can see the integration ended and which application ended it. Removing the row is their decision, on their own screen.
To end every connection an application holds — you are winding the integration down, or the application secret leaked:
curl -X POST https://customerpro.in/api/v1/applications/<your app_… client id>/revoke-connections \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET"
→ 200 {"revoked": 14}. Needs applications.write, and works only on an application your own
organization registered. Any other client id, including one that exists under another
organization, answers 404 with {"error": "No such application on this organization.", "code": "application_not_found"}. Every affected business has to reconnect, so it is deliberately its own
endpoint: deleting an application does not revoke its connections, and never will.
The same button is on Developer → Applications.
Before you start
Two preconditions that will otherwise cost you an afternoon:
- The exchange must run server-side. There is no browser-only variant.
- The business must already have a WhatsApp number connected in Customer Pro. The consent screen has nothing to grant otherwise, and its Approve button stays disabled.
Endpoints at a glance
Every path below is relative to https://customerpro.in/api/v1. Each endpoint needs the scope in the third
column; a key without it gets 403. Pick scopes when you create the key (Developer → API keys)
or when a business approves your Connect application.
| Method | Path | Scope | What it does |
|---|---|---|---|
POST |
/messages |
messages.send |
Send a text, media, interactive or template message |
POST |
/messages/template |
messages.send |
Send an approved template (short form) |
GET |
/templates |
templates.read |
List templates and their approval status |
POST |
/templates |
templates.write |
Create a template and submit it for approval |
GET POST |
/conversations/history |
conversations.read or messages.read |
Message history for a phone number |
POST |
/media |
media.write |
Upload a file, get back a public URL |
GET |
/contacts |
contacts.read |
List contacts |
GET |
/contacts/search |
contacts.read |
Find a contact by email or phone |
GET |
/contacts/{id} |
contacts.read |
One contact |
POST |
/contacts |
contacts.write |
Create a contact, or update the one with that email or phone |
PATCH |
/contacts/{id} |
contacts.write |
Change some fields of a contact |
GET |
/contact-lists |
contacts.read |
Your contact lists |
POST |
/contacts/{id}/lists |
contacts.write |
Add a contact to a list |
POST |
/contacts/{id}/flows |
flows.start |
Start a flow for a contact |
GET |
/data-tables |
data_tables.read |
Your data tables and their columns |
GET |
/data-tables/{table}/rows |
data_tables.read |
Rows of a table |
POST |
/data-tables/{table}/rows |
data_tables.write |
Add a row |
PATCH |
/data-tables/{table}/rows/{row} |
data_tables.write |
Change a row |
GET |
/pipelines |
crm.read |
CRM pipelines and stages |
GET POST PATCH |
/deals |
crm.read / crm.write |
CRM deals |
POST |
/deals/{id}/move |
crm.write |
Move a deal to another stage |
GET POST PATCH |
/companies |
crm.read / crm.write |
CRM companies |
GET POST PATCH |
/tasks |
crm.read / crm.write |
CRM tasks |
POST |
/tasks/{id}/complete |
crm.write |
Mark a task done |
GET |
/campaigns |
campaigns.read |
Broadcast campaigns and their delivery numbers |
GET |
/campaigns/{id} |
campaigns.read |
One campaign |
POST |
/hooks |
webhooks.write |
Subscribe a URL to an event |
DELETE |
/hooks/{id} |
webhooks.write |
Remove that subscription |
GET |
/hooks/sample/{event} |
any | A sample payload for an event |
GET |
/me |
any | The business, sending number, scopes, plan and usage |
DELETE |
/me |
any | End this credential's connection |
GET |
/applications |
applications.write |
The Connect applications your organization registered |
POST |
/applications |
applications.write |
Register a Connect application |
POST |
/applications/{client_id}/revoke-connections |
applications.write |
End every connection an application holds |
"any" means any working credential: no scope is needed.
Conventions
-
Requests are JSON. Send
Content-Type: application/jsonon everyPOSTandPATCH.GETparameters go in the query string. -
Phone numbers are digits with the country code, e.g.
919876543210.+, spaces and dashes are stripped. A 10-digit number is treated as Indian and gets91in front. An 11-digit number that starts with0is treated as Indian too: the0becomes91, so09876543210is stored as919876543210. -
Times are returned in ISO 8601 with an offset (
2026-10-21T06:41:10+00:00). Dates you send can be2026-12-15or a full ISO 8601 time. -
Ids are integers. A record that is not in your workspace answers
404, the same as one that does not exist. -
Lists are paged. Pass
page(from 1) andper_page(default 50, max 200). The response carriesmeta:"meta": {"current_page": 1, "last_page": 4, "per_page": 50, "total": 173} -
Validation errors answer
422with a message per field. Fix the request; retrying it unchanged gives the same answer.{ "message": "One of phone_number, contact_id, or conversation_id is required.", "errors": { "phone_number": ["One of phone_number, contact_id, or conversation_id is required."] } }
Send Message
POST /messages · scope messages.send
Sends one WhatsApp message to one contact, from the number the API key is bound to. The
body is JSON. type picks which other fields are read.
Who to send to. Pass exactly one of these:
| Field | Type | Notes |
|---|---|---|
phone_number |
string | Digits with country code, e.g. 919876543210. Spaces, + and dashes are stripped. A 10-digit number, or 11 digits starting with 0, is treated as Indian (see Conventions). A number this WhatsApp number has not messaged before creates a contact. |
contact_id |
integer | A contact id from an earlier send or from POST /contacts. 404 if the contact is not on the number this key sends from. |
conversation_id |
integer | A conversation id from an earlier send. 404 if the conversation is not on the number this key sends from. |
Contacts and conversations belong to one WhatsApp number. A workspace with two numbers has a
separate contact for the same person on each, and contact_id and conversation_id only reach
the ones on the number this key sends from. A contact made with POST /contacts is filed under
the key's number, so its id works here. GET /contacts lists contacts across every number, so
an id from that list can belong to another number and answer 404. When in doubt, send by
phone_number.
What to send.
| Field | Type | Required | Notes |
|---|---|---|---|
type |
string | no | text (default), media, interactive or template. |
message |
string | when type is text |
The text. WhatsApp allows up to 4096 characters. |
media_url |
string (URL) | when type is media |
A public https:// link to the file. Upload a file with POST /media to get one. |
media_type |
string | no | image, video, document (default), audio or sticker. |
caption |
string | no | Shown under an image, video or document. Ignored for the others. |
interactive |
object | when type is interactive |
Reply buttons or a list. See Interactive messages. |
template |
object | when type is template |
name (required), language (default en_US), components. See Template components. |
fallback_template |
object | no | Same shape as template. Sent instead when the 24-hour window is closed. |
Header: send Idempotency-Key on every send so a retry cannot message the customer twice
(see Idempotency).
Examples
Text:
curl -X POST https://customerpro.in/api/v1/messages \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirm" \
-d '{"type":"text","phone_number":"919876543210","message":"Hello"}'
Image with a caption:
{
"type": "media",
"phone_number": "919876543210",
"media_type": "image",
"media_url": "https://cdn.example.com/catalogue/a55.jpg",
"caption": "Samsung A55, ₹32,999"
}
PDF document:
{
"type": "media",
"contact_id": 42,
"media_type": "document",
"media_url": "https://cdn.example.com/invoices/INV-1042.pdf",
"caption": "Invoice INV-1042"
}
Approved template (deliverable at any time):
{
"type": "template",
"phone_number": "919876543210",
"template": {
"name": "order_confirmation",
"language": "en",
"components": [
{"type": "body", "parameters": [
{"type": "text", "text": "Priya"},
{"type": "text", "text": "#1042"}
]}
]
}
}
Response
{
"success": true,
"message_id": 981,
"whatsapp_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSM...",
"conversation_id": 311,
"contact_id": 42,
"sent_as": "text"
}
sent_as is what actually went out. It differs from type in two cases: a
fallback_template was used because the window was closed ("template"), or a template
whose content is plain text was sent inside an open window ("text"). Inside the window
that text goes as an ordinary message, so it needs no template approval and costs nothing.
Keep whatsapp_message_id: the message.delivered, message.read and message.failed
webhooks refer to it.
The 24-hour window
WhatsApp only delivers free-form (non-template) messages within 24 hours of the
contact's last inbound message. Outside that window this endpoint refuses the send with
422 and code: window_closed without calling WhatsApp, and the response carries the
window's state:
{
"error": "The 24-hour messaging window has closed. Send an approved template to reach this contact.",
"code": "window_closed",
"window": {
"open": false,
"last_inbound_at": "2026-08-21T09:14:00+00:00",
"expires_at": "2026-08-22T09:14:00+00:00",
"seconds_remaining": 0
}
}
A closed window is permanent until the contact writes in again — retrying will never succeed. There are exactly two ways forward: wait for the contact to message you, or send an approved template, which is deliverable at any time. A contact who has never written in has never had a window, so the first free-form message to a new number is always refused; reach them with a template.
To do that in the same call, pass fallback_template. It is used only when the window is
closed; inside the window the free-form message is sent as usual. The response's sent_as
says which one went out ("text" or "template").
{
"type": "text",
"phone_number": "919876543210",
"message": "New lead: Ravi, Samsung A55, visiting Saturday",
"fallback_template": {
"name": "partner_new_lead",
"language": "en",
"components": [{"type": "body", "parameters": [{"type": "text", "text": "Ravi"}]}]
}
}
Interactive messages
interactive is passed to WhatsApp unchanged, in the shape of the
WhatsApp Cloud API interactive object.
interactive.type must be button (up to 3 reply buttons) or list. Like text, it is
free-form, so it needs an open window.
Reply buttons:
{
"type": "interactive",
"phone_number": "919876543210",
"interactive": {
"type": "button",
"body": {"text": "Can we confirm your visit on Saturday at 11am?"},
"action": {
"buttons": [
{"type": "reply", "reply": {"id": "confirm", "title": "Confirm"}},
{"type": "reply", "reply": {"id": "reschedule", "title": "Reschedule"}}
]
}
}
}
A list:
{
"type": "interactive",
"phone_number": "919876543210",
"interactive": {
"type": "list",
"body": {"text": "Pick a store for pickup"},
"action": {
"button": "Choose store",
"sections": [{
"title": "Pune",
"rows": [
{"id": "pune-fc", "title": "FC Road"},
{"id": "pune-kothrud", "title": "Kothrud", "description": "Open till 9pm"}
]
}]
}
}
}
The customer's tap comes back as an inbound message on the message.received webhook.
Button titles are at most 20 characters; list row titles at most 24.
Template components
components fills a template's variables and is passed to WhatsApp unchanged, in the shape of the
WhatsApp Cloud API template object.
Parameters fill {{1}}, {{2}}, ... in order, and language must be the code the template
was approved in (en, en_US, hi, ...; see GET /templates).
What a template needs on every send
Approval fixes a template's wording, not its values. Every send must supply what the template leaves open, and WhatsApp rejects the whole message if anything is missing or extra:
| The template has | Every send must include |
|---|---|
| No variables and no media header | Nothing. Leave components out, or send []. |
{{1}}…{{n}} in the body |
A body component with exactly n text parameters, in order. Not fewer, not more. |
A text header with {{1}} |
A header component with one text parameter. |
| An image, video or document header | A header component with the file's public https:// link, on every send. The sample file you submitted for review is not reused. |
A URL button whose URL ends in {{1}} |
A button component, sub_type url, with that button's index and one text parameter. |
| Quick-reply or phone-number buttons | Nothing. |
| An authentication (one-time code) template | The code twice: as the single body parameter and as the parameter of the copy-code button at index "0". |
components is optional only in the first row. Leaving it out of any other template is the most
common reason a send fails with 502 upstream_failed.
GET /templates returns each template's header type, body variables and buttons, so you can read
what a send needs from it instead of opening the template in the dashboard.
language is a code, not a language name: send en_US, not English. WhatsApp matches the
code exactly, so en and en_US are different templates to it. If you send a value that is not
one of the template's codes (a name like English, say) and the template is approved in only one
language, that language is used. Otherwise the value goes to WhatsApp unchanged and fails there.
If you leave it out, the API sends en_US, with the same rule. Send the code from
GET /templates and you never depend on this.
Full example: image header, three variables
A template approved as en_US with an image header and this body:
Dear {{1}}, a damage alert was raised for vehicle {{2}} at {{3}}.
needs the image and all three values:
{
"to": "919876543210",
"template_name": "vehicle_damage_alert",
"language": "en_US",
"components": [
{"type": "header", "parameters": [
{"type": "image", "image": {"link": "https://cdn.example.com/alerts/damage.jpg"}}
]},
{"type": "body", "parameters": [
{"type": "text", "text": "Rahul"},
{"type": "text", "text": "MH12AB1234"},
{"type": "text", "text": "FC Road, Pune"}
]}
]
}
Every component type
"components": [
{"type": "header", "parameters": [
{"type": "image", "image": {"link": "https://cdn.example.com/offer.jpg"}}
]},
{"type": "body", "parameters": [
{"type": "text", "text": "Priya"},
{"type": "text", "text": "20%"}
]},
{"type": "button", "sub_type": "url", "index": "0", "parameters": [
{"type": "text", "text": "ORDER1042"}
]}
]
-
A text header with a variable takes
{"type": "text", "text": "..."}. -
A media header takes
image,videoordocumentwith alink; a document can add"filename": "invoice.pdf". -
A URL button with a variable in its URL takes one
textparameter, the part that replaces{{1}}.indexis the button's position, counting from"0". -
Quick-reply and phone buttons need no parameters.
-
An authentication template's copy-code button is also sent as
"sub_type": "url":{"type": "body", "parameters": [{"type": "text", "text": "482913"}]}, {"type": "button", "sub_type": "url", "index": "0", "parameters": [ {"type": "text", "text": "482913"} ]} -
A carousel template takes a
carouselcomponent with one entry per card, each with its own header media and variables. Follow WhatsApp's carousel guide for the shape.
A template this account knows and WhatsApp has not approved is refused with 400 and
code: template_not_approved before anything is sent.
Why a template send fails
These are refused by WhatsApp, not by this API. They come back as 502 with
code: upstream_failed, and the body carries WhatsApp's own reason: details is its message,
and whatsapp_error holds its numeric code, message and details (see
Send errors). Read whatsapp_error.code to find the row below. Most of these
repeat on every retry until the request changes.
| Cause | Meta code | Fix |
|---|---|---|
language is not a code the template is approved in (en vs en_US), or template_name is misspelled or belongs to another WhatsApp number |
132001 |
Send the name and language code exactly as GET /templates lists them, with the API key bound to that number. |
Fewer or more body parameters than the template has {{n}} placeholders |
132000 |
Send exactly one text parameter per placeholder. |
| A parameter is empty, or contains a newline, a tab or more than four spaces in a row | 132012 or 131009 |
Send non-empty single-line values. |
A media header template sent without a header component, or a URL button with a variable sent without its button component |
131008 |
Add the missing component: the header with a public https:// link, the button with its index. |
The header link is not reachable, is not https, or is the wrong file type |
— | Use a public URL WhatsApp can download: JPG or PNG for image, MP4 for video, PDF or another document for document. POST /media gives you one. |
| The template was paused by WhatsApp for low quality | 132015 |
Check its status in GET /templates; use another template. |
| The template was disabled by WhatsApp | 132016 |
Use another template. |
| The message could not be delivered, e.g. the number is not on WhatsApp | 131026 |
Do not retry. Reach the contact another way. |
| WhatsApp is limiting marketing messages to this number | 131049 |
Do not retry now. Try again later, or reach the contact another way. |
When to retry. Retry only when whatsapp_error is absent (a timeout or outage: the request
never reached WhatsApp, or its answer could not be read) or its code is a transient one such as
131000, 131016, 130429 or 131056. Use backoff and the same Idempotency-Key, which makes
the retry safe. Never retry the 132xxx codes, 131008 or 131009: they fail the same way until
the request changes.
Send Template
POST /messages/template · scope messages.send
A shorter way to send an approved template. It behaves exactly like POST /messages with
type: template, and returns the same response.
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes, or phone_number |
Recipient phone number with country code, e.g. 919876543210. A 10-digit number is treated as Indian (91 is added), and so is an 11-digit number starting with 0 (the 0 is dropped). |
phone_number |
string | yes, or to |
Same as to. |
template_name |
string | yes | The template's name, as in GET /templates. |
language |
string | no | The template's language code exactly as in GET /templates (en_US, en, hi). Default en_US. Any other value, such as English, is replaced by the template's language when it is approved in only one; otherwise it goes to WhatsApp unchanged. Send the code. |
components |
array | when the template has variables, a media header or a URL button variable | The values for this send. See What a template needs on every send. |
curl -X POST https://customerpro.in/api/v1/messages/template \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "919876543210",
"template_name": "order_shipped",
"language": "en",
"components": [
{"type": "body", "parameters": [
{"type": "text", "text": "Priya"},
{"type": "text", "text": "#1042"}
]}
]
}'
Templates
List templates
GET /templates · scope templates.read
No parameters. Returns every template, newest first, with its approval status and the content you need to build a send.
{
"templates": [{
"id": 17,
"name": "order_shipped",
"language": "en",
"category": "utility",
"status": "approved",
"rejection_reason": null,
"whatsapp_template_id": "1234567890123456",
"meta_last_synced_at": "2026-10-20T08:12:44.000000Z",
"whatsapp_account_id": 4,
"header": { "type": "image", "example_url": "https://.../shipped.jpg" },
"body": {
"text": "Hi {{1}}, order {{2}} has shipped.",
"variables": [
{ "key": "1", "example": "Asha" },
{ "key": "2", "example": "#4521" }
]
},
"footer": { "text": "Reply STOP to opt out" },
"buttons": [{ "type": "URL", "text": "Track", "url": "https://example.com/t/{{1}}" }]
}]
}
status is draft, pending_approval, approved, rejected, paused or disabled.
Only approved templates can be sent; a rejected one carries rejection_reason. Use
the name and language shown here when you send one.
| Field | Notes |
|---|---|
whatsapp_account_id |
The WhatsApp number the template belongs to. A template sends only from that number. |
header |
null when there is none. A text header has text and variables. A media header (image, video, document) has example_url, the sample it was approved with; every send must still pass its own file in the header component. |
body.variables |
One entry per placeholder, in order. Send one body parameter per entry. example is the sample WhatsApp approved, or null when there is none. |
footer |
null when there is none. Footers never have variables. |
buttons |
As the template defines them. A URL button whose url holds {{1}} needs a button parameter on each send. |
See What a template needs on every send for how these
map to components.
Create a template
POST /templates · scope templates.write
Saves the template and submits it to WhatsApp for approval, from the number this key sends
from. Approval usually takes from a few minutes to a day; poll GET /templates for the result.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Up to 512 characters. Only lowercase letters, digits and underscores, e.g. order_shipped; anything else is 422. Cannot be changed later. |
category |
string | yes | marketing, utility or authentication. |
body_text |
string | yes | The message. Variables are {{1}}, {{2}}, ... in order. It cannot start or end with a variable. |
language |
string | no | Language code. Default en_US. |
header_type |
string | no | text, image, video or document. Leave it out for no header. |
header_content |
string | with header_type |
For text, the header text (may hold {{1}}). For a media header, a public URL of a sample file, which WhatsApp reviews. POST /media gives you one. |
footer_text |
string | no | Small grey text under the body. No variables. |
buttons |
array | no | Up to 10. Each has type and text (max 25 characters). |
buttons[].type |
string | yes | QUICK_REPLY, URL, PHONE_NUMBER or VOICE_CALL, in any case (url works too). VOICE_CALL needs only text. Any other type is 422. |
buttons[].url |
string | for URL |
Up to 2048 characters. May end in {{1}}, filled at send time. |
buttons[].phone_number |
string | for PHONE_NUMBER |
With country code. Up to 20 characters. |
curl -X POST https://customerpro.in/api/v1/templates \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "order_shipped",
"category": "utility",
"language": "en",
"header_type": "text",
"header_content": "Your order is on its way",
"body_text": "Hi {{1}}, your order {{2}} has shipped and will reach you in 2-3 days.",
"footer_text": "Reply STOP to opt out",
"buttons": [
{"type": "URL", "text": "Track order", "url": "https://shop.example.com/track/{{1}}"},
{"type": "QUICK_REPLY", "text": "Talk to us"}
]
}'
201 with {"template": {...}} once WhatsApp has accepted it for review (status
pending_approval). If WhatsApp refuses the submission, the template is kept as a draft and the
call returns 422 with {"template": {...}, "error": "<WhatsApp's reason>"}.
If the workspace has no WhatsApp number, nothing is submitted: the call answers 201 with the
template saved as status draft. Check status rather than the HTTP code.
When the plan's template limit is reached, the call answers 403 and saves nothing. The body
has the same shape as the send endpoints' 403:
{
"error": "…",
"code": "plan_limit_exceeded",
"resource": "templates",
"upgrade_url": "https://customerpro.in/subscription"
}
Conversation History
GET /conversations/history · POST /conversations/history · scope conversations.read or messages.read
Every message between one phone number and the WhatsApp number this key sends from. Messages
the same person exchanged with your other numbers are not included. Send the fields as query
parameters on GET, or as a JSON body on POST.
The contact is looked up on the key's number. An older contact with no number also matches; when both exist, the one on the key's number wins.
| Field | Type | Required | Notes |
|---|---|---|---|
phone_number |
string | yes | With country code. A 10-digit number, or 11 digits starting with 0, is treated as Indian (see Conventions). |
limit |
integer | no | 1 to 1000. Default 100. |
offset |
integer | no | Messages to skip. Default 0. |
order |
string | no | desc (newest first, default) or asc. |
curl "https://customerpro.in/api/v1/conversations/history?phone_number=919876543210&limit=50" \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET"
{
"contact": {
"id": 42,
"name": "Priya Sharma",
"phone_number": "919876543210",
"email": "priya@example.com",
"created_at": "2026-10-19T09:14:00.000000Z"
},
"conversations": [311],
"messages": [{
"id": 981,
"conversation_id": 311,
"channel": "whatsapp",
"whatsapp_message_id": "wamid.HBgMOTE5...",
"direction": "inbound",
"type": "text",
"status": "read",
"content": "Is the A55 in stock?",
"media_url": null,
"media_mime_type": null,
"media_filename": null,
"media_caption": null,
"template_data": null,
"interactive_payload": null,
"error_message": null,
"created_at": "2026-10-21T06:41:10.000000Z",
"delivered_at": "2026-10-21T06:41:12.000000Z",
"read_at": "2026-10-21T06:42:00.000000Z"
}],
"total_messages": 128,
"pagination": {"limit": 50, "offset": 0, "has_more": true}
}
| Field | Notes |
|---|---|
contact |
Exactly id, name, phone_number, email and created_at. Fetch GET /contacts/{id} for the full record. |
conversations |
Ids of this contact's conversations on the key's number. |
messages |
Oldest or newest first by order. Each has exactly the fields in the example above. |
messages[].direction |
inbound (from the customer) or outbound. |
messages[].type |
text, template or interactive; media for a media send over this API; for an inbound message, its kind, e.g. image, document or location. |
messages[].status |
Outbound: sent, delivered, read or failed, with the reason in error_message. Inbound messages start as delivered. |
messages[].template_data |
For a template send, the name, language and components that were sent. |
total_messages |
All messages that match, ignoring limit and offset. |
pagination.has_more |
true while there are messages after this page. |
Page with offset while has_more is true. A number with no contact on the key's number
(or with no number) answers 404 with {"error": "Contact not found"}. A known contact with no
conversation on the key's number answers 200 with an empty messages list.
Upload Media
POST /media · scope media.write
Stores a file and returns a public URL to use as media_url in a send, or as a template's
header_content. Send the file as base64 in a JSON body.
| Field | Type | Required | Notes |
|---|---|---|---|
base64 |
string | yes | The file, base64-encoded. A data:...;base64, prefix is allowed. Max 20 MB decoded. |
content_type |
string | yes | The file's MIME type. See the list below. |
filename |
string | no | Used to name the stored file. Only the part before the extension is kept; the extension comes from content_type. |
These content types get a matching file extension: image/jpeg, image/png, image/webp,
video/mp4, video/3gpp, audio/mpeg, audio/ogg, audio/wav, audio/aac, audio/mp4 and
application/pdf. Any other type is still accepted and stored with the content_type you sent,
but the stored file name ends in .bin, and so does public_url.
An empty or invalid base64, or a file over 20 MB, answers 400 with an error message.
curl -X POST https://customerpro.in/api/v1/media \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d "{\"content_type\":\"application/pdf\",\"filename\":\"INV-1042.pdf\",\"base64\":\"$(base64 < INV-1042.pdf | tr -d '\n')\"}"
{
"success": true,
"public_url": "https://<media host>/7/outbound/1761030000_inv-1042.pdf",
"path": "7/outbound/1761030000_inv-1042.pdf",
"content_type": "application/pdf",
"size_bytes": 48211
}
Then send it:
{"type": "media", "phone_number": "919876543210", "media_type": "document",
"media_url": "<public_url from above>", "caption": "Invoice INV-1042"}
WhatsApp's own limits apply to what it will deliver: images 5 MB, video and audio 16 MB, documents 100 MB.
Data Tables
Data tables are structured records your team defines — a row per lead, order or application. Humans fill them in from the app, and an AI agent can write into them while it is talking to a customer, so the API is usually how you read out what a conversation produced.
GET /data-tables
Lists your tables, by name, with their column definitions. Read this first: the key and
type of each column are exactly what a row's values object is keyed and typed by.
description is the table's own note, or null. is_active is the table's on/off setting in
the app; the API reads and writes rows of a table either way.
{
"data_tables": [{
"id": 1, "name": "Loan Applications", "slug": "loan_applications",
"description": "One row per loan enquiry", "is_active": true,
"columns": [
{"key": "name", "label": "Name", "type": "text", "options": [], "required": true},
{"key": "amount", "label": "Amount", "type": "number", "options": [], "required": false},
{"key": "status", "label": "Status", "type": "select", "options": ["new", "approved", "rejected"], "required": false}
]
}]
}
GET /data-tables/{table}/rows
{table} is the slug or the id. Optional: filter[<column>]=<value> to match on a
column value, contact_id, per_page (max 200), page.
curl -G "https://customerpro.in/api/v1/data-tables/loan_applications/rows" \
--data-urlencode "filter[status]=approved" \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET"
{
"rows": [{
"id": 88,
"values": {"name": "Ravi", "amount": 50000, "status": "approved"},
"source": "agent",
"contact_id": 1024,
"customer_handle": "919876543210",
"channel": "whatsapp",
"conversation_id": 5120,
"campaign_id": null,
"created_at": "2026-10-21T06:45:00+00:00",
"updated_at": "2026-10-21T07:02:00+00:00"
}],
"meta": {"current_page": 1, "last_page": 1, "per_page": 50, "total": 1}
}
source says who wrote the row: human (someone in the app), agent (an AI agent during a
conversation), api or import.
POST /data-tables/{table}/rows
Creates a row. Send values keyed by column key, plus an optional contact_id.
Unknown keys are ignored; a value that does not fit its column's type comes back as
a 422 with a per-column message.
curl -X POST https://customerpro.in/api/v1/data-tables/loan_applications/rows \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"values": {"name": "Ravi", "amount": 50000, "status": "new"}}'
PATCH /data-tables/{table}/rows/{row}
Merges values into an existing row — send only the fields you are changing; the
rest of the row is left alone. values is required, even if you only want to change
contact_id. contact_id is optional: send it to link the row to another contact, or leave
it out to keep the current one. It cannot be cleared to null over the API.
curl -X PATCH https://customerpro.in/api/v1/data-tables/loan_applications/rows/88 \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"values": {"status": "approved"}}'
Both writes return the row in the shape above, as {"row": { ... }} (201 on create).
contact_id must be a contact in your workspace. An unknown table or row answers 404.
Both writes can answer 422 in two shapes. A missing values, or a contact_id that is not
yours, is a request error in the usual validation shape:
{"message": "The values field is required.", "errors": {"values": ["The values field is required."]}}
A value that does not fit its column has error set to validation_failed:
{"error": "validation_failed", "errors": {"values.amount": ["Amount must be a number."]}}
Scopes: data_tables.read for the two GETs, data_tables.write for the writes.
Every write, from any source, emits data_row.created or data_row.updated. A table
can also carry alert rules ("when status becomes approved"), which fire
data_row.alert at the webhook the rule names — that is how you have a conversation
trigger your own API.
Contacts
A contact is one person you talk to. Every endpoint returns contacts in the same shape:
{
"id": 1024,
"name": "Priya Sharma",
"phone_number": "919876543210",
"email": "priya@example.com",
"instagram_username": null,
"company": "Sharma Traders",
"source": "api",
"lead_status": "qualified",
"lead_category": "Hot",
"qualified_at": "2026-10-20T11:02:00+00:00",
"qualification_reason": "Asked for pricing and a demo",
"opted_out": false,
"attributes": {"city": "Pune"},
"created_at": "2026-10-19T09:14:00+00:00",
"updated_at": "2026-10-20T11:02:00+00:00"
}
attributes holds your custom fields. lead_category is the contact's stage name.
opted_out is true once the contact has asked not to be messaged.
List contacts
GET /contacts · scope contacts.read
Newest first. Lists contacts from every WhatsApp number in the workspace, not only the number this key sends from, so the same person can appear once per number. To message one, see Who to send to.
| Parameter | Type | Notes |
|---|---|---|
updated_since |
ISO 8601 time | Only contacts changed at or after this time. Use it to sync changes. |
list_id |
integer | Only contacts in this list. |
page, per_page |
integer | See Conventions. |
curl "https://customerpro.in/api/v1/contacts?updated_since=2026-10-01T00:00:00Z&per_page=100" \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET"
{
"contacts": [{"id": 1024, "name": "Priya Sharma", "phone_number": "919876543210", "...": "..."}],
"meta": {"current_page": 1, "last_page": 2, "per_page": 100, "total": 173}
}
Find a contact
GET /contacts/search · scope contacts.read
Pass email or phone_number. Email is matched without regard to case. A phone number
matches with or without the country code, so 9876543210 finds 919876543210.
curl "https://customerpro.in/api/v1/contacts/search?phone_number=9876543210" \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET"
Returns {"contacts": [ ... ]} with one match, or an empty array. It never answers 404.
Get a contact
GET /contacts/{id} · scope contacts.read
Returns {"contact": { ... }}.
Create or update a contact
POST /contacts · scope contacts.write
Creates a contact, or updates the one that already has this email or phone number. A retry therefore never makes a duplicate.
The contact is filed under the key's WhatsApp number (the workspace's default number if the key
is not bound to one). The match looks for the email first, then the phone number, and only among
that number's contacts and older contacts with no number. An older contact it matches is moved
onto the key's number. A contact on another of your numbers is never matched or changed. The
returned id works directly as contact_id in POST /messages. In a
workspace with no WhatsApp number yet, the contact is not tied to a number.
| Field | Type | Required | Notes |
|---|---|---|---|
phone_number |
string | yes, or email |
Up to 32 characters. See Conventions. |
email |
string | yes, or phone_number |
A valid email address. |
name |
string | no | Up to 255 characters. |
company |
string | no | Up to 255 characters. |
notes |
string | no | Up to 5000 characters. |
instagram_username |
string | no | Without the @. |
attributes |
object | no | Custom fields, up to 50. Each value is text, a number or true/false, up to 1000 characters. Merged into the fields already stored. |
list_id |
integer | no | Also add the contact to this list. Checked before anything is written: an unknown list, or one that is not yours, is 422 and no contact is created or changed. |
source |
string | no | api (default) or lead_discovery. Applies only to a new contact. |
overwrite |
boolean | no | Default true. With false, an existing contact keeps the values it already has and only empty fields are filled in. |
curl -X POST https://customerpro.in/api/v1/contacts \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Sharma",
"phone_number": "9876543210",
"email": "priya@example.com",
"attributes": {"city": "Pune", "budget": 50000},
"list_id": 3
}'
201 when it created a contact, 200 when it updated one:
{"contact": {"id": 1024, "name": "Priya Sharma", "...": "..."}, "created": true}
Reaching the plan's contact limit answers 402 with
{"error": "plan_limit", "message": "..."}.
Update a contact
PATCH /contacts/{id} · scope contacts.write
Takes the same fields as create, except source and overwrite. Only the fields you send
change. attributes is merged, so send just the keys you are changing. Returns
{"contact": { ... }}.
list_id is accepted here but ignored: the contact is not added to the list. Use
POST /contacts/{id}/lists for that.
curl -X PATCH https://customerpro.in/api/v1/contacts/1024 \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"company": "Sharma Traders Pvt Ltd", "attributes": {"city": "Mumbai"}}'
Contact lists
GET /contact-lists · scope contacts.read returns {"lists": [{"id": 3, "name": "Diwali leads"}]}.
POST /contacts/{id}/lists · scope contacts.write with {"list_id": 3} adds the contact to
that list and answers {"ok": true}. Adding a contact that is already in the list does nothing.
Start a flow
POST /contacts/{id}/flows · scope flows.start
Starts one of your flows for the contact, whatever the flow's own trigger is.
curl -X POST https://customerpro.in/api/v1/contacts/1024/flows \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"flow_id": 12}'
201 with {"outcome": "enrolled"} when it started. Otherwise 422 with outcome set to
one of these:
outcome |
Meaning |
|---|---|
opted_out |
The contact has opted out of messages. |
inactive |
The flow is switched off. |
no_account |
There is no WhatsApp number to send from. |
already_enrolled |
The contact is already going through this flow. |
no_steps |
The flow has no steps yet. |
An unknown flow_id answers 404.
CRM: deals, companies and tasks
These endpoints exist only while the workspace has the CRM app installed (Settings >
Apps). Without it they answer 404. Scopes: crm.read for the GETs, crm.write for the
writes. Every list takes page and per_page and returns meta (see Conventions).
Money is sent and returned in paise (hundredths of a rupee), so 4999900 is ₹49,999.
owner_id and assignee_id are ids of people on your team.
Pipelines and stages
GET /pipelines
The pipelines and their stages in board order. Read this first: you need a stage id to open or move a deal.
{
"pipelines": [{
"id": 1, "name": "Sales", "is_default": true, "currency": "INR", "position": 0,
"stages": [
{"id": 7, "pipeline_id": 1, "name": "New lead", "color": "#3b82f6", "position": 0,
"lead_status": "new", "probability": 10, "is_terminal": false, "outcome": null, "is_active": true},
{"id": 12, "pipeline_id": 1, "name": "Won", "color": "#22c55e", "position": 4,
"lead_status": "converted", "probability": 100, "is_terminal": true, "outcome": "won", "is_active": true}
]
}]
}
A stage whose outcome is won or lost closes a deal that moves into it.
Deals
A deal in every response:
{
"id": 77,
"title": "Annual plan",
"pipeline_id": 1,
"stage_id": 7,
"stage": {"id": 7, "name": "New lead", "color": "#3b82f6"},
"contact_id": 1024,
"contact": {"id": 1024, "name": "Priya Sharma", "phone_number": "919876543210"},
"company_id": 9,
"company": {"id": 9, "name": "Sharma Traders"},
"owner_id": 5,
"owner": {"id": 5, "name": "Rahul"},
"value_paise": 4999900,
"currency": "INR",
"value": "₹49,999.00",
"expected_close_at": "2026-12-15",
"status": "open",
"closed_at": null,
"outcome": null,
"lost_reason": null,
"position": 0,
"source": "website",
"created_at": "2026-10-20T10:00:00+00:00",
"updated_at": "2026-10-20T10:00:00+00:00"
}
status is open, won or lost.
GET /deals lists deals, newest first. Optional filters: pipeline_id, stage_id,
contact_id, status (open, won, lost, closed or all, the default) and
updated_since. Returns {"deals": [...], "meta": {...}}.
GET /deals/{id} returns {"deal": { ... }}.
POST /deals opens a deal and answers 201 with {"deal": { ... }}.
| Field | Type | Required | Notes |
|---|---|---|---|
contact_id |
integer | yes | The person the deal is with. |
stage_id |
integer | yes | From GET /pipelines. It also picks the pipeline. |
title |
string | no | Defaults to the contact's name. Up to 255 characters. |
value_paise |
integer | no | The amount in paise. |
currency |
string | no | Three letters, e.g. INR. |
expected_close_at |
date | no | e.g. 2026-12-15. |
owner_id |
integer | no | A person on your team. |
company_id |
integer | no | From GET /companies. |
source |
string | no | Where the deal came from, up to 60 characters. |
lost_reason |
string | no | Up to 255 characters. |
curl -X POST https://customerpro.in/api/v1/deals \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"contact_id": 1024, "stage_id": 7, "title": "Annual plan", "value_paise": 4999900,
"expected_close_at": "2026-12-15"}'
Opening a deal puts the contact in that stage, the same stage the Contacts page and the inbox show.
PATCH /deals/{id} changes only the fields you send, from the table above except
contact_id and stage_id. To change the stage, use move.
POST /deals/{id}/move with {"stage_id": 12} moves the deal to another stage of its own
pipeline (a stage of a different pipeline is a 422). Optional position (0 or more) orders
the card in its column. Moving into a won or lost stage closes the deal; moving it back to an
open stage reopens it. The contact's stage follows. Returns {"deal": { ... }}.
Companies
A company in every response:
{
"id": 9, "name": "Sharma Traders", "domain": "sharmatraders.in",
"website": "https://sharmatraders.in", "phone": "912012345678", "industry": "Retail",
"owner_id": 5, "owner": {"id": 5, "name": "Rahul"},
"contacts_count": 4, "open_deals_count": 1,
"created_at": "2026-10-01T08:00:00+00:00", "updated_at": "2026-10-20T10:00:00+00:00"
}
contacts_count is the number of contacts linked to the company. open_deals_count counts its
deals that are not yet closed won or lost. Both are integers on list, get, create and update.
GET /companies lists companies. Optional q searches the name and domain;
updated_since limits to recent changes. Returns {"companies": [...], "meta": {...}}.
GET /companies/{id} returns {"company": { ... }}.
POST /companies answers 201 with the company, including owner; PATCH /companies/{id}
changes only the fields you send.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes on create | Unique in your workspace, ignoring case. Up to 255 characters. |
domain |
string | no | e.g. sharmatraders.in. |
website |
string | no | |
phone |
string | no | Up to 32 characters. |
industry |
string | no | Up to 120 characters. |
owner_id |
integer | no | A person on your team. |
curl -X POST https://customerpro.in/api/v1/companies \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "Sharma Traders", "domain": "sharmatraders.in", "industry": "Retail"}'
Tasks
A task in every response:
{
"id": 301,
"title": "Call Priya about the proposal",
"description": null,
"status": "open",
"priority": "normal",
"due_at": "2026-10-22T05:30:00+00:00",
"remind_at": "2026-10-22T05:00:00+00:00",
"is_overdue": false,
"about": {"type": "deal", "id": 77},
"contact_id": 1024,
"contact": {"id": 1024, "name": "Priya Sharma"},
"assignee_id": 5,
"assignee": {"id": 5, "name": "Rahul"},
"created_by": null,
"completed_at": null,
"completed_by": null,
"created_at": "2026-10-21T06:00:00+00:00"
}
status is open or done. about says what the task concerns (deal, company or
contact), or is null.
GET /tasks lists tasks, soonest due first. Optional filters: status (open or done),
assignee_id, contact_id, due_before. Returns {"tasks": [...], "meta": {...}}.
GET /tasks/{id} returns {"task": { ... }}.
POST /tasks answers 201; PATCH /tasks/{id} changes only the fields you send.
PATCH can change title, description, due_at, remind_at, priority and
assignee_id. It ignores contact_id, deal_id and company_id: what a task is about is
fixed when it is created. A new remind_at sets a fresh reminder.
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes on create | Up to 255 characters. |
description |
string | no | Up to 5000 characters. |
due_at |
time | no | ISO 8601. |
remind_at |
time | no | ISO 8601. |
priority |
string | no | low, normal or high. |
assignee_id |
integer | no | A person on your team. |
contact_id |
integer | no | The person the task is about. A deal's task takes the deal's contact. |
deal_id |
integer | no | Send at most one of deal_id and company_id. |
company_id |
integer | no |
curl -X POST https://customerpro.in/api/v1/tasks \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"title": "Call Priya about the proposal", "deal_id": 77,
"due_at": "2026-10-22T11:00:00+05:30", "priority": "high"}'
POST /tasks/{id}/complete marks the task done and returns {"task": { ... }}. status
cannot be set with PATCH, and a done task cannot be reopened over the API.
Campaigns
GET /campaigns · GET /campaigns/{id} · scope campaigns.read
Your broadcast campaigns, newest first, with their delivery numbers. Optional status
filter, plus page and per_page.
curl "https://customerpro.in/api/v1/campaigns?status=completed" \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET"
{
"campaigns": [{
"id": 77,
"name": "Diwali offer",
"channel": "whatsapp",
"status": "completed",
"template_name": "diwali_offer",
"total_recipients": 500,
"sent_count": 498,
"delivered_count": 480,
"read_count": 350,
"failed_count": 2,
"started_at": "2026-10-20T04:30:00+00:00",
"completed_at": "2026-10-20T04:41:00+00:00"
}],
"meta": {"current_page": 1, "last_page": 1, "per_page": 50, "total": 1}
}
GET /campaigns/{id} returns {"campaign": { ... }}.
Webhooks
Webhooks tell your server when something happens: a customer replies, a message is delivered or read, a contact changes. Add an endpoint in Developer → Webhooks, pick its events, and copy its secret. You can send a test event and see every delivery there.
Subscribable events:
message.receivedmessage.sentmessage.deliveredmessage.readmessage.failedphone.verifiedconversation.createdconversation.updatedcontact.createdcontact.updateddata_row.createddata_row.updateddata_row.alertlead.createdconversation.closedlead.qualifiedcampaign.startedcampaign.completedworkflow.completedappointment.bookedappointment.canceledappointment.rescheduledappointment.attendedappointment.no_showorder.createdorder.paidorder.fulfilledorder.deliveredorder.canceledorder.refundedcheckout.abandonedpayment.succeededpayment.failedpayment.refundedcustomer.inactivedeal.createddeal.stage_changeddeal.wondeal.losttask.completedtask.overdue
What we send
Each event is a POST to your URL with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Webhook-Event |
The event name, e.g. message.received |
X-Webhook-Signature |
HMAC SHA-256 of the raw body, keyed by your webhook secret, as lowercase hex |
User-Agent |
WhatsAppBusiness-Webhook/1.0 |
Every body has the same envelope:
{
"event": "message.received",
"timestamp": "2026-10-21T06:41:10+00:00",
"organization_id": 7,
"data": { }
}
Answer with any 2xx within 10 seconds (the default). Anything else, or no answer, counts as
a failure and is retried up to 3 times, waiting 2, 4 and 8 seconds. Every attempt is logged in
Developer → Webhooks, where you can also resend one. Deliveries can arrive more than once
and out of order, so key your handling on the ids inside data.
Checking the signature
Compute the HMAC over the raw body bytes, before parsing the JSON, and compare it with the header in constant time.
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, $webhookSecret);
if (! hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
// Node.js with Express: app.post('/hook', express.raw({type: 'application/json'}), handler)
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', webhookSecret).update(req.body).digest('hex');
const given = req.get('X-Webhook-Signature') || '';
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
import hmac, hashlib
expected = hmac.new(webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", ""))
Message events
message.received fires for every message a customer sends you:
{
"event": "message.received",
"timestamp": "2026-10-21T06:41:10+00:00",
"organization_id": 7,
"data": {
"message": {
"id": 90210,
"whatsapp_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg",
"type": "text",
"content": "Hi, what are your prices?",
"media_url": null
},
"conversation_id": 5120,
"contact_id": 1024
}
}
type is the kind of message, e.g. text, image, video, audio, document,
sticker, location or interactive. For media, media_url links to a copy of the file
and content holds the caption. For interactive (the customer tapped a reply button,
list row or template quick reply), content is the title they tapped. Fetch the contact with
GET /contacts/{contact_id}, and reply with
POST /messages using contact_id or conversation_id. The customer's
message has just opened the 24-hour window, so a free-form reply goes through. A contact can
be excluded from message.received in Developer → Webhooks.
message.sent, message.delivered, message.read and message.failed track a message you
sent. Match them to your send by message_id or whatsapp_message_id from the
send response:
{
"event": "message.failed",
"timestamp": "2026-10-21T06:41:12+00:00",
"organization_id": 7,
"data": {
"message_id": 981,
"whatsapp_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSM",
"status": "failed",
"error_message": "Message undeliverable"
}
}
error_message is null except on message.failed. A message can skip a status (a read
receipt may arrive without a delivered one), so treat read as delivered too.
Contact and conversation events
contact.created and contact.updated carry contact, in the same shape as the
Contacts endpoints. contact.updated adds changed, a list of the field names
that changed. lead.qualified carries the contact once it is marked qualified.
conversation.created, conversation.updated and conversation.closed carry conversation:
{"conversation": {"id": 5120, "channel": "whatsapp", "contact_id": 1024, "status": "open",
"is_archived": false, "assigned_to": null, "last_message_at": "2026-10-21T06:41:10+00:00",
"created_at": "2026-10-19T09:14:00+00:00"}}
campaign.started and campaign.completed carry campaign, in the same shape as
GET /campaigns.
data_row.created, data_row.updated and data_row.alert carry table (id, name,
slug), row (id, values, source, created_at, updated_at), and the contact_id,
conversation_id, campaign_id and agent_id the row came from (each may be null).
data_row.alert adds alert (id, name, column, operator, value), the alert rule that
matched.
phone.verified fires when a contact confirms their number with a WhatsApp one-time code:
{"verification_id": 640, "phone_number": "919876543210", "contact_id": 1024,
"verified_at": "2026-10-21T06:41:10+00:00"}
Leads, orders, payments and appointments
lead.created fires for Facebook Lead Ads, Google Ads lead forms, LinkedIn Lead Gen and website forms.
The payload has two shapes:
| From | Fields |
|---|---|
| Google, LinkedIn and website forms | lead_id, provider (google_ads_leads, linkedin_leads or website_forms), form_id, form_name, campaign_id, is_test, contact_id, fields, submitted_at |
| Facebook and Instagram lead ads | lead_id, leadgen_id, page_id, form_id, ad_id, platform (fb, ig or null), contact_id, fields, submitted_at |
fields holds the answers either way, with Meta's names for the standard ones (full_name,
phone_number, email).
Commerce events carry an order (order.created, order.paid, order.fulfilled,
order.delivered, order.canceled, order.refunded), a checkout (checkout.abandoned)
or a payment (payment.succeeded, payment.failed, payment.refunded), plus the contact.
Orders come from Shopify, WooCommerce and your WhatsApp catalog (order.provider says which);
amounts are given both as total and in minor units as total_minor. Payments come from
Razorpay, Stripe and Cashfree. customer.inactive fires for a past customer who has not
ordered within your win-back period.
appointment.booked, appointment.rescheduled, appointment.canceled, appointment.attended
and appointment.no_show carry an appointment and the contact (in the Contacts
shape):
{
"appointment": {
"id": 58,
"event_name": "30 minute demo",
"start_time": "2026-10-22T05:30:00+00:00",
"end_time": "2026-10-22T06:00:00+00:00",
"location": "https://meet.google.com/abc-defg-hij",
"provider": "native",
"status": "booked",
"meeting_url": "https://meet.google.com/abc-defg-hij",
"host_user_id": 5,
"booked_via": "booking_page"
},
"contact": {"id": 1024, "name": "Priya Sharma", "...": "..."}
}
| Field | Notes |
|---|---|
event_name |
The meeting's title. |
location |
The meeting link when there is one, else the place. |
provider |
Where it was booked: calendly, google_calendar, native (a booking page or the Calendar app), manual (added by hand on a contact) or email_invite. |
status |
booked, canceled, attended or no_show. |
host_user_id |
The teammate hosting it, or null. |
booked_via |
How a native booking was made, e.g. booking_page or team_member, or null. |
appointment.rescheduled adds previous_start_time. A Calendly booking or cancellation adds
cancel_url, reschedule_url and answers (the invitee's answers, keyed by question).
appointment.attended and appointment.no_show fire when a teammate marks whether the contact
came; marking the same outcome again sends nothing. All five fire only for meetings with a
contact, including ones added by hand.
workflow.completed fires when a contact finishes a flow:
{"workflow": {"id": 12, "enrollment_id": 3456}, "contact_id": 1024}
workflow.id is the flow's id, the same flow_id that
POST /contacts/{id}/flows takes.
CRM events
With the CRM app installed, deal events carry a deal (id, title, value_paise,
currency, pipeline, stage with its outcome, contact, owner, expected_close_at,
closed_at, outcome, lost_reason, created_at, updated_at). deal.created fires when a deal is opened;
deal.stage_changed on every move between stages and adds from_stage. A move into a
closing stage also fires deal.won or deal.lost, by the stage's outcome. task.completed
and task.overdue carry a task (id, title, due_at, status, priority, assignee,
contact); task.overdue fires once, when the due time passes on an open task.
To see the exact payload of any event, call
GET /hooks/sample/{event} or send a test
event from Developer → Webhooks.
REST hooks (Zapier, Make, your own automation)
An automation platform can manage its own subscriptions instead of someone registering
them by hand. Scope: webhooks.write to add or remove one. The sample endpoint needs no scope.
POST /hooks
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | An https:// URL, up to 2048 characters. |
event |
string | yes | One event from the list above. Subscribe once per event. |
source |
string | no | zapier, make or api. |
curl -X POST https://customerpro.in/api/v1/hooks \
-H "x-client-id: $CLIENT_ID" -H "x-client-secret: $CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/customerpro", "event": "message.received", "source": "api"}'
201 with {"id", "event", "url", "secret"}. Keep secret: it signs every delivery to this URL.
DELETE /hooks/{id} removes a subscription and answers 200 with {"ok": true}. A key can only
remove the hooks it created; any other id, or one already removed, answers 404.
GET /hooks/sample/{event} returns a sample envelope for the event, as a one-item array. Any
working credential can call it. Its data has the same fields as a real delivery of that event,
plus "test": true. An unknown event answers 404.
Handling a live connection
The connection appears on the business's Developer → API Keys screen like any other key, and they can delete it there at any time. Several failures look similar and mean completely different things, so handle them separately:
| Response | Meaning | What to do |
|---|---|---|
401 |
The connection ended: the business disabled or deleted the key, you revoked it yourself, or a reconnect superseded it. | Treat as disconnected. Stop retrying, fall back to your own channel, and prompt the business to reconnect. Not an error state to alarm on. If you have just handled a reconnect, check you stored the new credential — a superseded key answers 401 too. |
403 subscription_required |
The organization's CustomerPro subscription lapsed. | Keep the connection. This is a billing problem, not a revocation — degrade gracefully and tell the business to renew. |
403 plan_upgrade_required |
The organization has a live subscription, but its plan does not sell API access at all. | Keep the connection. It will start working the moment they move to a plan that includes the API, and never before — see Connected, but the plan cannot use it. |
403 plan_limit_exceeded, resource: api_calls |
The plan's daily API request ceiling is spent. | Keep the connection. Back off and resume tomorrow — this one resets at midnight, not on the 1st. |
403 plan_limit_exceeded, resource: messages |
The plan's monthly message allowance is spent. | Keep the connection. Stop sending and resume when the allowance resets on the 1st, or prompt the business to upgrade. Read usage.messages.remaining from /me to avoid hitting this at all. |
Only the 401 means you are disconnected. Every 403 above is the same status carrying a
different code, and two of them share a code and differ only by resource — so branch on
code and resource, never on the status alone. Telling them apart is the difference
between "come back tomorrow", "come back on the 1st" and "this will never work until you
upgrade".
Connected, but the plan cannot use it
A successful handshake proves the business authorized you. It does not prove their plan sells what you are about to call, and these are genuinely separate things: consent is theirs to give, API access is something they buy.
So the first call after a brand-new connection can answer 403 plan_upgrade_required even
though nothing went wrong with the handshake. This is the single most confusing moment in the
integration, and it is worth handling explicitly:
{
"error": "This organization's plan does not include API access.",
"code": "plan_upgrade_required",
"resource": "api_access",
"upgrade_url": "https://customerpro.in/subscription"
}
- Do not treat it as a failed connection. The credential is real and stays valid. Store it.
- Do not retry. Nothing about waiting changes the answer; only the business changing plan does.
- Say what actually has to happen, and send them to
upgrade_url— the business, not you, is the one who can fix it. "Connected. Their CustomerPro plan does not include API access yet, so sending is paused until they upgrade" is the honest message. /meanswers this too. The plan check runs at authentication, so every endpoint refuses identically — you cannot call/meto find out first. The403body is the answer.
The cleanest place to surface it is right after the exchange: make one GET /me call, and if
it comes back 403 plan_upgrade_required, show the business what they need to do while they
are still looking at your connect screen.
When you are the one ending the connection, say so rather than going quiet — see Disconnecting. A credential you stop using but never revoke stays live on the business's account.
Who am I
GET /me
Returns the organization the calling credential acts for, the WhatsApp number a send would actually come from, the granted scopes, the rate limit and the plan. Use it to render connection state and to size your own request volume.
curl https://customerpro.in/api/v1/me \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET"
{
"organization": {"id": 7, "name": "Acme Garage"},
"whatsapp_account": {"id": 3, "display_name": "Acme Garage", "phone_number": "919812345678", "is_default": true},
"whatsapp_accounts": [
{"id": 3, "display_name": "Acme Garage", "phone_number": "919812345678", "is_default": true}
],
"scopes": ["messages.send", "templates.read", "conversations.read", "media.write"],
"rate_limit": 100,
"rate_limit_unlimited": false,
"plan": {"name": "Free trial", "tier": "free", "active": true},
"usage": {
"messages": {"used": 412, "limit": 15000, "remaining": 14588},
"contacts": {"used": 87, "limit": null, "remaining": null}
},
"capabilities": ["contacts.source", "contacts.overwrite"]
}
capabilities lists optional request fields this server understands, so a client can
tell an older server apart.
DELETE /me on the same path hands the credential back and ends the connection — see
Disconnecting.
whatsapp_account is resolved the same way a send resolves it — the pinned account, else
the organization's default, else its first — so it is always the number your messages will
come from. A credential with no request cap reports "rate_limit": null with
"rate_limit_unlimited": true, never 0.
plan describes the subscription: name, tier, and active (false once it lapses, at
which point every endpoint answers 403 subscription_required).
usage describes consumption against it, one entry per resource. Every response has all
fifteen, each with used, limit and remaining:
| Resource | Counts | Resets |
|---|---|---|
messages |
WhatsApp messages sent | On the 1st of each month |
campaigns |
Campaigns created | On the 1st of each month |
emails |
Marketing emails sent | On the 1st of each month |
sms |
SMS sent | On the 1st of each month |
social_posts |
Social posts created | On the 1st of each month |
ai_conversations |
AI conversations opened | On the 1st of each month |
api_calls |
Requests to this API | Every day at midnight UTC |
contacts |
Contacts | Never: a standing total |
users |
Team members, plus invites not yet accepted | Never |
agents |
AI agents | Never |
whatsapp_accounts |
Connected WhatsApp numbers | Never |
instagram_accounts |
Connected Instagram accounts | Never |
templates |
Message templates | Never |
sms_senders |
SMS senders | Never |
social_accounts |
Accounts connected to the social scheduler | Never |
"usage": {
"messages": { "used": 412, "limit": 15000, "remaining": 14588 },
"contacts": { "used": 87, "limit": null, "remaining": null }
}
limit: null means unlimited, and remaining is null to match — pace against
remaining, and treat null as "no ceiling" rather than computing limit - used yourself.
When usage.messages.remaining reaches 0, sends answer 403 plan_limit_exceeded; when
usage.api_calls.remaining reaches 0, every endpoint does, until midnight UTC. Months
also start in UTC.
Idempotency
Every write that changes your data accepts an optional Idempotency-Key request header, so
a retry after a timeout cannot do the same thing twice:
Accepts Idempotency-Key |
Does not |
|---|---|
POST /messages, POST /messages/template |
POST /templates |
POST /contacts, PATCH /contacts/{id} |
POST /media |
POST /contacts/{id}/lists, POST /contacts/{id}/flows |
POST /hooks, DELETE /hooks/{id} |
POST /data-tables/{table}/rows, PATCH /data-tables/{table}/rows/{row} |
POST /applications, POST /applications/{client_id}/revoke-connections |
POST and PATCH on /deals, /companies and /tasks, POST /deals/{id}/move, POST /tasks/{id}/complete |
DELETE /me |
On an endpoint in the second column the header is ignored. The examples below use a send, but the rules are the same everywhere.
curl -X POST https://customerpro.in/api/v1/messages \
-H "x-client-id: $CLIENT_ID" \
-H "x-client-secret: $CLIENT_SECRET" \
-H "Idempotency-Key: reminder-4821-t24h" \
-H "Content-Type: application/json" \
-d '{"type":"text","phone_number":"919876543210","message":"See you at 4pm"}'
- Send the same key to retry safely. If the original request already succeeded,
the stored response is returned, with the same status and
"replayed": trueadded, and nothing is done again. For a send that means the samemessage_id,whatsapp_message_idandconversation_id, and no second WhatsApp message. A first request never carriesreplayed. - Keys are scoped to your credential. The same key value used by a different API key is an unrelated request.
- Keys are remembered for 24 hours, then forgotten. Reusing one after that is treated as a new request.
- Reusing a key for a different request is refused with
409andcode: idempotency_key_reused. A key is tied to the HTTP method, the path and the body. A different body, another endpoint, or another record (the samePATCHbody sent to/contacts/2after/contacts/1) is a different request. Re-sending the same values with JSON keys in a different order is not a conflict. - Two requests with one key at the same time: the first proceeds, the second is
refused with
409andcode: idempotency_key_in_flight. Retry it after the first finishes to get the replay. - Failures do not burn the key. Only a
2xxresponse is stored. If the request fails (502) or is refused (400,403,404,422), the key is released — retry with the same one. - Keys must be 1–255 characters. Anything else is refused with
400andcode: invalid_idempotency_key. - Omitting the header changes nothing. Two identical sends without a key send two messages, exactly as before.
A replayed request is authenticated, logged and rate-limited like any other — a replay consumes request budget even though it does no work.
Use a key derived from the thing you are sending, not a random value per attempt —
reminder-4821-t24h retries correctly, a fresh UUID per retry does not.
Rate Limits
Each API key is throttled to its configured rate_limit (default 100 requests/minute).
Exceeding it returns 429 with a Retry-After header.
Error Codes
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (contacts, templates, deals, companies, tasks, data-table rows, hooks, applications, a started flow) |
| 400 | Unapproved template, no WhatsApp number on the key, or a bad media upload |
| 401 | Missing or invalid client credentials |
| 403 | Key not permitted for this action, no active subscription, the plan does not include the API, or a plan limit is reached (messages, templates, or today's API calls) |
| 402 | The plan's contact limit is reached (POST /contacts) |
| 404 | The record does not exist or is not in your workspace, or the CRM app is not installed |
| 409 | An Idempotency-Key was reused for a different request, or is still in flight |
| 422 | Validation error (see Conventions), or a well-formed request the current state cannot accept (e.g. a closed messaging window) |
| 429 | Rate limit exceeded |
| 502 | Meta Graph API rejected the request or did not answer |
Send errors
The failures below, from POST /messages and POST /messages/template, carry a stable
code alongside the human-readable error. Branch on code, not on the prose — the wording
may change, the codes will not.
Some failures of these two endpoints carry no code. Tell them apart by status:
401: missing or invalid credentials,{"error": "..."}.403with nocode: the key lacks themessages.sendscope.404:{"error": "Contact not found"}or{"error": "Conversation not found"}for acontact_idorconversation_idthat is not on this key's number (see Who to send to).422witherrors: the request failed validation (see Conventions).429: rate limited, withRetry-After(see Rate Limits).
The account-level 403s, subscription_required, plan_upgrade_required, and
plan_limit_exceeded with resource: api_calls, can come from any endpoint and are described
in Handling a live connection.
code |
Status | Meaning | Retry? |
|---|---|---|---|
window_closed |
422 | The 24-hour customer-service window has closed, so free-form content cannot be delivered. Response includes a window object. |
No. Permanent until the contact messages you. Send an approved template instead. |
template_not_approved |
400 | The template exists on this account but WhatsApp has not approved it. | No. Not until the template is approved. |
no_account |
400 | The API key has no WhatsApp number to send from. | No. Bind a number to the key. |
plan_limit_exceeded |
403 | The organization has spent its plan's message allowance for this calendar month. Response includes resource and upgrade_url. |
No. Not until the plan is upgraded, or the allowance resets on the 1st. |
upstream_failed |
502 | WhatsApp rejected the request or did not answer. details is WhatsApp's own reason; whatsapp_error is present when WhatsApp answered with an error. |
Only when whatsapp_error is absent, or its code is transient (131000, 131016, 130429, 131056). Retry a few times with backoff and the same Idempotency-Key, then stop. Never retry 132xxx, 131008 or 131009. See Why a template send fails. |
idempotency_key_reused |
409 | This Idempotency-Key was already used for a different request: another body, endpoint or record. |
No. Use a different key, or resend the original request unchanged. |
idempotency_key_in_flight |
409 | Another request with this Idempotency-Key is still being processed. |
Yes, once the first finishes — the retry will return its stored outcome. |
invalid_idempotency_key |
400 | The Idempotency-Key header was empty or longer than 255 characters. |
No. Send a valid key. |
An upstream_failed response that WhatsApp answered:
{
"error": "Failed to send message via WhatsApp",
"code": "upstream_failed",
"details": "(#132001) Template name does not exist in the translation",
"whatsapp_error": {
"code": 132001,
"message": "(#132001) Template name does not exist in the translation",
"details": "template name (vehicle_damage_alert) does not exist in English"
}
}
| Field | Notes |
|---|---|
details |
WhatsApp's message when it answered; otherwise why the send did not reach it, e.g. a timeout. |
whatsapp_error.code |
WhatsApp's numeric error code. Branch on this. |
whatsapp_error.message |
WhatsApp's top-level message, or null. |
whatsapp_error.details |
WhatsApp's longer explanation, or null. |
whatsapp_error is absent when the request never reached WhatsApp or its reply could not be
read. That is the case to retry.
window_closed is computed from the inbound messages this system has recorded. If an
inbound webhook was missed, a window WhatsApp considers open may be reported as closed —
in which case a template still reaches the contact.