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.

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

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"

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, same whatsapp_message_id, same conversation_id — with "replayed": true added, and no second WhatsApp message is sent. A first send 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. "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 409 and code: 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 400 and code: 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.