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.
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.
The response carries client_id, client_secret, the organization, the WhatsApp account the
credential is pinned to, every account the organization has, the granted scopes, the
rate_limit and the current plan. 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. 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.
Send Message
POST /messages
Send a text, media, interactive, or template message. One of phone_number,
contact_id, or conversation_id is required.
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" \
-d '{"type":"text","phone_number":"919876543210","message":"Hello"}'
Success: { "success": true, "message_id": 1, "conversation_id": 1, "contact_id": 1 }
Note: 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.
Send Template
POST /messages/template
Convenience wrapper for approved templates. Accepts to (alias of phone_number),
template_name, language, and components.
Templates
GET /templates · POST /templates
GET lists the tenant's templates. POST creates a draft and submits it to Meta;
category must be marketing, utility, or authentication.
Conversation History
GET /conversations/history · POST /conversations/history
Required: phone_number. Optional: limit (max 1000), offset, order (asc or desc).
Upload Media
POST /media
Accepts base64, content_type, and an optional filename. Max 20 MB. Returns a public URL.
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 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.
{
"data_tables": [{
"id": 1, "name": "Loan Applications", "slug": "loan_applications",
"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.
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.
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.
Webhooks
Register endpoints in the Developer Hub to receive signed event POSTs. Each request carries
an X-Webhook-Signature header — an HMAC SHA256 of the raw body keyed by your webhook secret.
Subscribable events:
message.receivedmessage.sentmessage.deliveredmessage.readmessage.failedphone.verifiedconversation.createdconversation.updatedcontact.createdcontact.updateddata_row.createddata_row.updateddata_row.alertlead.created
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"
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 — messages, contacts,
users, campaigns, agents, templates, whatsapp_accounts:
"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.
The message allowance is counted per calendar month and resets on the 1st; every other
resource is a standing total. When usage.messages.remaining reaches 0, sends answer
403 plan_limit_exceeded.
Idempotency
POST /messages and POST /messages/template accept an optional Idempotency-Key
request header, so a retry after a timeout cannot message the customer twice.
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 — same
message_id, samewhatsapp_message_id, sameconversation_id— with"replayed": trueadded, and no second WhatsApp message is sent. A first send 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. "Different" means the recipient, type or content changed; 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. If the send fails (
502) or is rejected as a client error (400,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 requests 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 |
| 400 | Validation error / unapproved template / no WhatsApp number on the key |
| 401 | Missing or invalid client credentials |
| 403 | Key not permitted for this action, no active subscription, or the plan's message allowance is spent |
| 404 | Contact or conversation not found |
| 422 | 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
Every failure of POST /messages and POST /messages/template carries a stable code
alongside the human-readable error. Branch on code, not on the prose — the wording may
change, the codes will not.
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. | Yes. Safe to retry with backoff. |
idempotency_key_reused |
409 | This Idempotency-Key was already used for a materially different request. |
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. |
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.