# 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](#connect-one-click-integration-for-applications). 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:

```html
<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 `/`.

```php
$verifier  = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
```

```js
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:

```js
{ 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:

```html
<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

```php
// 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:

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

```json
{
  "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.

```bash
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 `GET`s, `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](#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:

```json
{
  "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](#7-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](#7-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`:

```json
"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.
