Customer Pro

Developer

API Documentation

Reference for the Customer Pro REST API — endpoints, authentication, and examples.

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.

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/json on every POST and PATCH. GET parameters 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 gets 91 in front. An 11-digit number that starts with 0 is treated as Indian too: the 0 becomes 91, so 09876543210 is stored as 919876543210.

  • Times are returned in ISO 8601 with an offset (2026-10-21T06:41:10+00:00). Dates you send can be 2026-12-15 or 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) and per_page (default 50, max 200). The response carries meta:

    "meta": {"current_page": 1, "last_page": 4, "per_page": 50, "total": 173}
    
  • Validation errors answer 422 with 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, video or document with a link; a document can add "filename": "invoice.pdf".

  • A URL button with a variable in its URL takes one text parameter, the part that replaces {{1}}. index is 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 carousel component 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.received
  • message.sent
  • message.delivered
  • message.read
  • message.failed
  • phone.verified
  • conversation.created
  • conversation.updated
  • contact.created
  • contact.updated
  • data_row.created
  • data_row.updated
  • data_row.alert
  • lead.created
  • conversation.closed
  • lead.qualified
  • campaign.started
  • campaign.completed
  • workflow.completed
  • appointment.booked
  • appointment.canceled
  • appointment.rescheduled
  • appointment.attended
  • appointment.no_show
  • order.created
  • order.paid
  • order.fulfilled
  • order.delivered
  • order.canceled
  • order.refunded
  • checkout.abandoned
  • payment.succeeded
  • payment.failed
  • payment.refunded
  • customer.inactive
  • deal.created
  • deal.stage_changed
  • deal.won
  • deal.lost
  • task.completed
  • task.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.
  • /me answers this too. The plan check runs at authentication, so every endpoint refuses identically — you cannot call /me to find out first. The 403 body 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": true added, and nothing is done again. For a send that means the same message_id, whatsapp_message_id and conversation_id, and no second WhatsApp message. A first request never carries replayed.
  • 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 409 and code: 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 same PATCH body sent to /contacts/2 after /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 409 and code: idempotency_key_in_flight. Retry it after the first finishes to get the replay.
  • Failures do not burn the key. Only a 2xx response 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 400 and code: 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": "..."}.
  • 403 with no code: the key lacks the messages.send scope.
  • 404: {"error": "Contact not found"} or {"error": "Conversation not found"} for a contact_id or conversation_id that is not on this key's number (see Who to send to).
  • 422 with errors: the request failed validation (see Conventions).
  • 429: rate limited, with Retry-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.