` header so the value survives onto the message. The authoritative copy is the one stored against the send.
Plain-string tags behave exactly as they did, so existing calls need no change.
### Automatic plain text
Send `html` with no `text` and a plain-text alternative is generated from your HTML. A message with no text part reads badly in text-only clients and scores worse with spam filters, so this is the default.
Link destinations are kept alongside their label as `label (https://url)`, and block-level markup becomes line breaks so the text keeps the shape of the document. Scripts and styles are dropped entirely.
It is a best-effort reading of your HTML, never an exact rendering. Two ways to take control:
- **Supply `text` yourself** for exact copy. Anything you send is used as-is.
- **Send `"text": ""`** to opt out and ship an HTML-only message. An empty string is treated as a deliberate choice, not an omission.
### Open and click tracking
Tracking is **per sending domain and off by default**. Turn it on with [`PATCH /api/v1/email/domains/{id}`](/docs/email-domains#tracking-and-sending-toggles):
```bash
curl -X PATCH https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"open_tracking": true, "click_tracking": true}'
```
Once a domain opts in, every HTML send from it is rewritten as it goes out:
- **Opens** append a 1x1 pixel at the end of the HTML body. It is marked `aria-hidden` with an empty `alt`, so a screen reader does not announce it, and it never displaces visible content.
- **Clicks** rewrite `http`/`https` links to a signed redirect that forwards the recipient to the original destination. `mailto:`, `tel:` and `cid:` links are left alone, as are unquoted `href` attributes, so nothing in your markup is mangled.
Only the HTML part is tracked; the plain-text part always keeps the real destinations. Both toggles are independent, and whether a send carried tracking is recorded at send time, so [metrics](/docs/email-logs#engagement-metrics) stay meaningful across a window where you flipped a toggle.
Read the results from [engagement metrics](/docs/email-logs#engagement-metrics).
**Headers.**
| Header | Notes |
|--------|-------|
| `Authorization` | `Bearer cm_...`, the key needs the **email** permission (required) |
| `Idempotency-Key` | Optional. A repeat with the same key returns the first send's result without sending or charging again |
## Semantics
- **cc vs bcc.** `cc` recipients are written to the `Cc` header and delivered; `bcc` recipients are delivered but never appear in any header.
- **De-duplication.** Each of `to`, `cc` and `bcc` is de-duplicated case-insensitively, then the three are merged into one recipient set. An address listed in both `to` and `cc` is dropped from the `Cc` header and delivered, and billed, once; a `bcc` address already covered by `to` or `cc` is likewise dropped.
- **Suppression.** Recipients on your [suppression list](/docs/email-logs#suppressions) are dropped from `to`, `cc`, and `bcc` before sending, and returned in `suppressed`.
- **Recipient limit: 50 per message.** A single (non-batch) send accepts at most **50** recipients, counted across `to` + `cc` + `bcc` **after** de-duplication and suppression filtering, so 60 addresses of which 12 are suppressed and 3 are duplicates does pass. Over the limit is `422 too_many_recipients`. Batches have their own, larger limits; see [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions).
- **Size limit: 25 MB per message.** The fully assembled message (headers, both bodies, and every attachment **after base64 encoding**) must stay under 25 MB, else `422 message_too_large`. Base64 inflates attachment bytes by roughly 1.37x, so the practical raw-attachment budget is nearer **18 MB**, less whatever the bodies take. The same 25 MB figure caps a `url` attachment while it is being fetched.
- **Validation.** Recipient addresses are validated first: a malformed address, or one containing control characters, is rejected with `422` before any charge. That rejection is a **schema** error, so its body is the validation-array shape, not `{"reason": …}`. See [Errors](/docs/email-limits#response-shapes).
- **Idempotency.** Send the same `Idempotency-Key` on a retry to guarantee the message is sent and charged at most once; the original response is replayed.
The message is DKIM-signed with the From domain's key and handed to delivery.
## Response
A successful call returns `202 Accepted`:
```json
{
"id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"messageId": "<1a2b3c4d@acme.com>",
"messageIds": ["<1a2b3c4d@acme.com>"],
"status": "sent",
"suppressed": ["blocked@example.com"]
}
```
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | CallMissed send id, use it with `GET /api/v1/email/sends` |
| `message_id` | string | RFC 5322 `Message-ID` of the sent message |
| `messageId` | string | Brevo-compatible; equal to `message_id` |
| `messageIds` | string[] | Brevo-compatible; `[message_id]` |
| `status` | string | `sent` when accepted for delivery |
| `suppressed` | string[] | Recipients dropped by your suppression list |
View delivery history and spend: see [Delivery Log & Usage](/docs/email-logs).
## Common send failures
`relay_failed` is flat (no `detail` wrapper) and carries the `id` of the send row; every other reason here is nested under `detail`; schema rejections are an array under `detail`. Full shapes and the complete table: [Limits, Quotas & Errors](/docs/email-limits).
| Status | Reason | Meaning |
|--------|--------|---------|
| 402 | `payment_required` | Not enough credit balance to cover the send |
| 403 | `email_not_enabled` | The API key lacks the email permission |
| 403 | `domain_not_verified` | The From domain is registered but hasn't passed verification |
| 403 | `all_recipients_suppressed` | Every recipient is on your suppression list |
| 422 | `empty_body` | Neither `text` nor `html` (nor a template body) was present |
| 422 | `too_many_recipients` | Over 50 recipients on a single send |
| 422 | `message_too_large` | The assembled message exceeds 25 MB |
| 422 | `unresolvable_template_vars` | The subject or body references `{{ contact.something }}`, which nothing can populate. Pass the value in `params` instead |
| 429 | `rate_limited` / `monthly_cap_exceeded` / `quota_exceeded` | A plan or domain ceiling was hit |
| 502 | `relay_failed` | The message could not be accepted for delivery |
| 503 | `sender_propagating` | The `from` address was just registered as a sender and is not live yet. Retry shortly; no further setup is needed |
## Switching from Brevo
The endpoint accepts Brevo `sendTransacEmail` payloads unchanged (`sender`, recipient objects, `replyTo`, `htmlContent`/`textContent`, `attachment` with `url` or `content`, `tags`, and the `Idempotency-Key` header) and returns `messageId` / `messageIds` alongside our native fields. To migrate, point your client at `https://api.callmissed.com/api/v1/email/send` and send `Authorization: Bearer cm_...`.
```bash
curl -X POST https://api.callmissed.com/api/v1/email/send \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"sender": { "email": "donotreply@acme.com", "name": "Acme Ops" },
"to": [{ "email": "customer@example.com", "name": "Ada" }],
"subject": "Your receipt",
"htmlContent": "Thanks for your order.
",
"textContent": "Thanks for your order.",
"replyTo": { "email": "support@acme.com", "name": "Acme Support" }
}'
```
Two things do **not** carry over unchanged. Your Brevo `sender` must be an address on a verified domain of yours, and its local part must be a registered sender (see [Sender Addresses](/docs/email-domains#sender-addresses)). And a single send here is capped at 50 recipients rather than Brevo's higher per-message limit, so split a larger list across calls or use [messageVersions](/docs/email-scheduled#batch-messageversions).
---
### Email Templates
URL: /docs/email-templates
> Save a reusable subject and body once, then send it with per-recipient substitution values.
## Templates
Save a reusable subject + body once, then send it with per-recipient values. Templates are tenant-scoped and managed with the same `cm_` key (email permission).
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/email/templates` | Create a template (`201`). Duplicate `name` for the same account → `409` |
| `GET /api/v1/email/templates` | List your templates, newest first. `limit` (1–200, default 50) and `offset` (≥0, default 0) |
| `GET /api/v1/email/templates/{id}` | Fetch one (`404` if not yours) |
| `PUT /api/v1/email/templates/{id}` | Partial update, only supplied fields change |
| `DELETE /api/v1/email/templates/{id}` | Delete a template (`204`) |
## Create a template
**`POST /api/v1/email/templates`**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | Yes | 1–255 chars; unique per account |
| `subject` | string | Yes | Base subject (overridable per send) |
| `html` | string | Yes | Base HTML body |
| `text` | string | No | Base plain-text body |
| `default_sender` | string | No | Used when the send omits `from` / `sender` |
| `default_reply_to` | string | No | Used when the send omits `reply_to` |
| `tags` | array | No | String tags for your own categorisation |
| `is_active` | boolean | No | Defaults to `true`; an inactive template can't be sent |
The template object returns `id`, `name`, `subject`, `html`, `text`, `default_sender`, `default_reply_to`, `tags`, `is_active`, `created_at`, `updated_at`. The `id` is a UUID.
`PUT /templates/{id}` takes the same fields, all optional, and applies only the ones you actually send.
Two write-time rejections apply to both create and update, and both come back as a `422` with a plain string `detail`:
- A control character in `subject`, `default_sender` or `default_reply_to`. Those values are rendered into raw headers at send time.
- A body or subject that references `{{ contact.anything }}`. Nothing can populate that namespace, so the reference would render as an empty string and ship a broken message. Pass the value in `params` instead. The same reference on a send is rejected as `422 unresolvable_template_vars`.
## Using a template on send
Add `templateId` and `params` to `POST /api/v1/email/send`:
:::tabs
```bash [cURL]
# Create a template
curl -X POST https://api.callmissed.com/api/v1/email/templates \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "receipt",
"subject": "Your receipt, {{ params.name }}",
"html": "Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.
",
"default_sender": "Acme Ops "
}'
# Send from it - send-call fields override the template
curl -X POST https://api.callmissed.com/api/v1/email/send \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"to": ["Ada "],
"params": { "name": "Ada", "order_id": "1043" }
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
tpl = httpx.post(f"{BASE}/templates", headers=h, json={
"name": "receipt",
"subject": "Your receipt, {{ params.name }}",
"html": "Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.
",
"default_sender": "Acme Ops ",
}).json()
httpx.post(f"{BASE}/send", headers=h, json={
"templateId": tpl["id"],
"to": ["Ada "],
"params": {"name": "Ada", "order_id": "1043"},
})
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
const tpl = await fetch(`${BASE}/templates`, {
method: "POST",
headers,
body: JSON.stringify({
name: "receipt",
subject: "Your receipt, {{ params.name }}",
html: "Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.
",
default_sender: "Acme Ops ",
}),
}).then((r) => r.json());
await fetch(`${BASE}/send`, {
method: "POST",
headers,
body: JSON.stringify({
templateId: tpl.id,
to: ["Ada "],
params: { name: "Ada", order_id: "1043" },
}),
});
```
:::
The send returns the same `202 Accepted` body as any other send. See [Send Email](/docs/email-send#response).
- **Override rule.** The template's `subject` / `html` / `text` are the base; an explicit `subject` / `html` / `text` / `from` / `reply_to` on the send **wins**. `default_sender` / `default_reply_to` fill in only when the send omits them.
- **Substitution.** `{{ params.KEY }}` (and nested `{{ params.a.b }}`) are replaced from `params`; a missing key renders empty. Values placed into the HTML body are HTML-escaped. This is plain variable substitution and **not** a programming language: no logic, loops, or expressions, and it can only read the `params` you pass. A substituted value is inserted once and never re-scanned, so a param whose value itself contains `{{ ... }}` is not expanded again.
- **Inline substitution.** `params` also renders placeholders in a `subject` / `text` / `html` you pass directly on the send, so you can use `{{ params.KEY }}` with no `templateId` at all. Whichever value is actually used, yours or the template's, is rendered exactly once.
> **Note:** unlike Brevo's integer template id, a CallMissed `templateId` is a UUID.
## Manage templates
```bash
# List (limit / offset supported)
curl "https://api.callmissed.com/api/v1/email/templates?limit=50&offset=0" \
-H "Authorization: Bearer cm_your_key"
# Fetch one
curl https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key"
# Partial update - only the fields you send change
curl -X PUT https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"subject": "Your updated receipt, {{ params.name }}", "is_active": true}'
# Delete (204)
curl -X DELETE https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key"
```
## Common template failures
| Status | Reason | Meaning |
|--------|--------|---------|
| 404 | `template_not_found` | The `templateId` doesn't exist or isn't yours |
| 422 | `template_inactive` | The template exists but is not active (`is_active: false`) |
| 422 | `invalid_headers` | A rendered header value contains invalid characters, usually a template `param` with a newline in it |
| 422 | `unresolvable_template_vars` | The rendered subject or body references `{{ contact.something }}` |
| 409 | *(string `detail`)* | Duplicate template name for the same account, on create **or** on a rename |
`409` and `404` on the template routes use a plain string `detail` with no `reason`; `template_not_found` and `template_inactive` on a send are nested under `detail`. See [Limits, Quotas & Errors](/docs/email-limits#response-shapes).
---
### Email Webhooks
URL: /docs/email-webhooks
> Subscribe your own endpoint to email events: bounces, complaints, delivery and engagement, signed with HMAC-SHA256 and logged per attempt.
## Overview
Register an HTTPS endpoint and we POST each email event to it as it happens, signed with a per-subscription secret. This is how you learn about a bounce or a spam complaint without polling the [send log](/docs/email-logs).
These webhooks are scoped to **your own email events** and are managed with the same `cm_` key you send with. Up to **20 subscriptions** per account.
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/email/webhooks` | Create a subscription (`201`). The only response that carries the signing secret |
| `GET /api/v1/email/webhooks` | List your subscriptions, newest first |
| `GET /api/v1/email/webhooks/deliveries` | The delivery log: every attempt, its result and its error |
| `PATCH /api/v1/email/webhooks/{id}` | Enable or disable without losing the URL or the secret |
| `DELETE /api/v1/email/webhooks/{id}` | Remove the subscription (`204`) |
Creating, updating and deleting need a write key. Listing and the delivery log are reads, so a read-only key works.
## Create a subscription
**`POST /api/v1/email/webhooks`**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `url` | string | Yes | Your endpoint, 1–2048 chars. Must be a public `http`/`https` URL; an internal or private target is refused at creation with `422 webhook_url_forbidden` |
| `description` | string | No | Your own label, up to 255 chars |
| `events` | array | No | Which events to receive. Omit it (or send an empty list) to receive **every** event. An unknown name is a `422` listing the supported set |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/email/webhooks \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/hooks/email",
"description": "Bounce + complaint handler",
"events": ["email.bounced", "email.complained"]
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
created = httpx.post(f"{BASE}/webhooks", headers=h, json={
"url": "https://your-app.com/hooks/email",
"description": "Bounce + complaint handler",
"events": ["email.bounced", "email.complained"],
}).json()
secret = created["secret"] # store this now: it is never returned again
webhook_id = created["webhook"]["id"]
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
const created = await fetch(`${BASE}/webhooks`, {
method: "POST",
headers,
body: JSON.stringify({
url: "https://your-app.com/hooks/email",
description: "Bounce + complaint handler",
events: ["email.bounced", "email.complained"],
}),
}).then((r) => r.json());
const secret = created.secret; // store this now: it is never returned again
```
:::
The `201` response wraps the subscription and the secret:
```json
{
"webhook": {
"id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"url": "https://your-app.com/hooks/email",
"description": "Bounce + complaint handler",
"events": ["email.bounced", "email.complained"],
"secret_prefix": "whsec_A1b2C3",
"is_active": true,
"created_at": "2026-08-13T09:41:02.118Z"
},
"secret": "whsec_A1b2C3d4E5f6..."
}
```
**The `secret` is returned once, here, and never again.** Every later read exposes only `secret_prefix`. Store it when you create the subscription; if you lose it, delete the subscription and create a new one.
| `WebhookOut` field | Type | Notes |
|--------------------|------|-------|
| `id` | string (UUID) | Use it with `PATCH` / `DELETE` and as the `webhook_id` filter on the delivery log |
| `url` | string | Where we POST |
| `description` | string \| null | Your label |
| `events` | array \| null | The subscribed events. `null` means every event |
| `secret_prefix` | string | The first characters of the secret, for identifying which secret a subscription holds |
| `is_active` | boolean | `false` stops deliveries; the URL and secret are kept |
| `created_at` | string | When it was registered |
## Events
| Event | Fires when | Status |
|-------|-----------|--------|
| `email.bounced` | A recipient's mail server rejected the message. The address is also added to your [suppression list](/docs/email-logs#suppressions) | **Live** |
| `email.complained` | A recipient marked the message as spam. The address is suppressed too | **Live** |
| `email.sent` | The message was accepted for delivery | Subscribable; not emitted yet |
| `email.delivered` | Delivery to the recipient's mailbox was confirmed | Subscribable; not emitted yet |
| `email.opened` | A tracked message was opened | Subscribable; not emitted yet |
| `email.received` | Inbound mail arrived at one of your receiving addresses | Subscribable; not emitted yet |
You can subscribe to any of the six today. The four marked *not emitted yet* are accepted so your subscription does not have to be rewritten when they start firing — until then they simply deliver nothing. For inbound mail right now, use the per-address `forward_url` on [Receive Email](/docs/email-inbound), which is live; for opens and clicks, read the aggregates from [engagement metrics](/docs/email-logs#engagement-metrics).
### Payload
Every delivery is a POST with this envelope:
```json
{
"type": "email.bounced",
"created_at": "2026-08-13T09:41:02.118431+00:00",
"data": {
"email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"recipient": "customer@example.com",
"detail": "550 5.1.1 recipient address rejected",
"domain": "acme.com"
}
}
```
| Field | Notes |
|-------|-------|
| `type` | The event name |
| `created_at` | When we generated the event, ISO 8601 UTC. This value **is** covered by the signature, so it is the timestamp to trust for an age check |
| `data.email_id` | The send id from `POST /send`; `null` if the event could not be matched to a send |
| `data.message_id` | The RFC 5322 `Message-ID` |
| `data.recipient` | The address that bounced or complained |
| `data.detail` | The reported reason, when one was given |
| `data.domain` | Your sending domain the message went out on |
`email.bounced` and `email.complained` carry the shape above.
### Headers
```
Content-Type: application/json
X-CallMissed-Signature: sha256=
X-CallMissed-Event: email.bounced
X-CallMissed-Delivery: 3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d
```
`X-CallMissed-Delivery` is the delivery id, so a row in the [delivery log](#delivery-log) can be matched to the request your handler saw. Use it to make your handler idempotent: a retried delivery reuses the same id.
## Verifying the signature
The digest is `HMAC-SHA256(secret, raw_request_body)`. Compute it over the **raw bytes** you received, before any JSON parsing, and compare with a constant-time function. Re-serialising the parsed JSON will not reproduce the signed bytes.
:::tabs
```python [Python]
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", header)
```
```javascript [JavaScript]
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
:::
There is deliberately **no timestamp header**: a timestamp the signature does not cover could be rewritten, so an age check based on it would not be trustworthy. Read `created_at` from the signed body instead.
## Delivery behaviour
- **Retries.** A non-2xx response or a transport error is retried up to **3 attempts** with exponential backoff. Anything in the 2xx range counts as success, so answer `200` as soon as you have accepted the event and do your work afterwards.
- **Timeout.** Each attempt allows 10 seconds for a response.
- **Redirects are not followed.** Point the subscription at its final URL.
- **The URL is re-checked on every attempt.** A URL that resolves to a private or internal address at send time is refused and the delivery is marked `blocked` rather than retried, even if it passed validation when you created the subscription.
- **Events are not delayed by your endpoint.** Delivery runs outside the request that produced the event, so a slow handler never slows a send or the processing of a bounce.
- **Order is not guaranteed.** Use `created_at` from the payload if you need to sequence events.
## Delivery log
**`GET /api/v1/email/webhooks/deliveries`** returns every attempt chain, newest first, so you can tell "we never sent it" from "my handler returned 500".
| Query param | Notes |
|-------------|-------|
| `webhook_id` | Optional UUID; restrict the log to one subscription |
| `limit` | 1–200, default 50 |
| `offset` | ≥0, default 0 |
| `WebhookDeliveryOut` field | Type | Notes |
|----------------------------|------|-------|
| `id` | string (UUID) | Matches the `X-CallMissed-Delivery` header your handler received |
| `webhook_id` | string (UUID) | Which subscription this went to |
| `event` | string | The event name |
| `send_id` | string (UUID) \| null | The send the event was about, when it could be matched |
| `status` | string | `pending`, `delivered`, `failed` or `blocked` — see below |
| `attempt_count` | integer | How many POSTs were made. `0` on a `blocked` row, because no connection was opened |
| `response_code` | integer \| null | The last HTTP status your endpoint returned; `null` on a transport error |
| `error_detail` | string \| null | Why the last attempt failed |
| `created_at` | string | When the event was generated |
| `last_attempt_at` | string \| null | When we last tried |
| `delivered_at` | string \| null | When your endpoint accepted it |
| `status` | Meaning |
|----------|---------|
| `pending` | Created, not yet attempted |
| `delivered` | Your endpoint answered 2xx |
| `failed` | Non-2xx or a transport error, and the retries are exhausted |
| `blocked` | The URL resolved somewhere we refuse to POST to, so no request was sent |
```bash
# Everything, newest first
curl "https://api.callmissed.com/api/v1/email/webhooks/deliveries?limit=50&offset=0" \
-H "Authorization: Bearer cm_your_key"
# Just one subscription
curl "https://api.callmissed.com/api/v1/email/webhooks/deliveries?webhook_id=9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e" \
-H "Authorization: Bearer cm_your_key"
```
## Pause, resume and delete
**`PATCH /api/v1/email/webhooks/{id}`** takes one field, `is_active` (boolean), and returns the updated `WebhookOut`. This is how you stop a noisy endpoint without re-registering and redeploying a new secret.
```bash
# Stop deliveries, keep the URL, secret and history
curl -X PATCH https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"is_active": false}'
# Resume
curl -X PATCH https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"is_active": true}'
# Remove it entirely
curl -X DELETE https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key"
```
`DELETE` is a hard delete and **also removes that subscription's delivery history**, so a deleted subscription leaves none of your payloads behind. To stop deliveries while keeping the audit trail, `PATCH is_active=false` instead.
## Common failures on these routes
| Status | Body | Meaning |
|--------|------|---------|
| 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header |
| 403 | string `detail` | The API key is read-only and this route writes |
| 404 | string `detail` | The webhook id is not yours |
| 422 | `reason: webhook_url_forbidden` | The `url` is not a permitted public URL |
| 422 | `reason: too_many_webhooks` | You already have 20 subscriptions |
| 422 | schema array `detail` | An unknown event name in `events` |
The two `reason` bodies are nested under `detail` alongside an `error` string. Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes).
## Related
- [Delivery, Suppressions & Usage](/docs/email-logs) for the send log, the suppression list and engagement metrics.
- [Receive Email](/docs/email-inbound) for inbound mail, which is forwarded per address rather than through this subsystem.
- [Domains & Senders](/docs/email-domains#tracking-and-sending-toggles) to turn open and click tracking on.
---
### Email API
URL: /docs/email
> Send and receive email from your own domain over the API: verified-domain onboarding, DKIM signing, delivery and suppression tracking.
## Overview
The Email API sends and receives email from a domain you own. You verify the domain once (we generate its DKIM key and the DNS records to publish), then send over the API and, optionally, receive mail at addresses on that domain.
**Base path:** `https://api.callmissed.com/api/v1/email`
Authentication uses your existing CallMissed API key, the same `cm_` key you use for every other API. The key needs the **email** permission enabled (toggle it on the [API keys](https://console.callmissed.com/developer/keys) page). No separate email key.
> **Read this before you write your first send.** Verification registers exactly one sender username on the domain, `donotreply`, so `donotreply@your-domain` always works. Any other local part on a verified domain has to be registered as a sender first: either up front with `POST /api/v1/email/domains/{domain_id}/senders`, or implicitly, because the send path registers the `from` local part on its first refusal and retries. Registration is eventually consistent, so a send from a brand-new sender can still come back as `503 sender_propagating`, meaning retry shortly and nothing else is needed. See [Sender Addresses](/docs/email-domains#sender-addresses).
:::flow
icon:app | Your app | Add a domain, publish the DNS records we generate
icon:gateway | CallMissed | Verify ownership, SPF and both DKIM records, then accept sends from that domain
icon:done | Recipients | Receive DKIM-signed mail from your own domain
:::
> **Billing:** Email is fully credit-based. Every send is charged to your credit balance at **30 credits (₹30) per 1,000 emails** (per recipient), the same wallet as every other API. There is no separate email invoice. Full detail: [Pricing](/docs/email-logs#pricing).
## The pages in this section
:::cards
/docs/email-domains | Domains & Senders | globe | Add a domain, publish DNS, verify, and the donotreply sender rule
/docs/email-send | Send Email | send | POST /api/v1/email/send with every field, header, response and the Brevo migration
/docs/email-templates | Templates | file-text | Reusable subject and body with per-send substitution values
/docs/email-scheduled | Scheduled & Batch Sending | calendar-clock | Send later with scheduledAt, or many recipient sets in one call
/docs/email-inbound | Receive Email | inbox | Claim addresses on a verified domain, read inbound mail, or have it forwarded to your app
/docs/email-logs | Delivery, Suppressions & Usage | chart-column | Send log, suppression list, engagement metrics, spend, and pricing
/docs/email-webhooks | Email Webhooks | webhook | Subscribe your endpoint to bounces and complaints, signed and logged per attempt
/docs/email-limits | Limits, Quotas & Errors | gauge | Send rate, monthly cap, daily quota, and every error shape and reason
:::
## End to end in three calls
Every request below uses the real base URL and the real auth header. Replace `cm_your_key` with your key and `acme.com` with your domain.
:::tabs
```bash [cURL]
# 1. Register the domain - the response carries the DNS records to publish
curl -X POST https://api.callmissed.com/api/v1/email/domains \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"domain": "acme.com"}'
# 2. After publishing every required record, verify it (repeat until verified is true)
curl -X POST https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/verify \
-H "Authorization: Bearer cm_your_key"
# 3. Send
curl -X POST https://api.callmissed.com/api/v1/email/send \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Ops ",
"to": ["Ada "],
"subject": "Your receipt",
"text": "Thanks for your order.",
"html": "Thanks for your order.
",
"reply_to": "support@acme.com"
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
created = httpx.post(f"{BASE}/domains", headers=h, json={"domain": "acme.com"}).json()
for rec in created["dns_records"]:
print(rec["type"], rec["host"], rec["value"]) # publish these at your DNS host
# once published (repeat until verified is true):
httpx.post(f"{BASE}/domains/{created['domain']['id']}/verify", headers=h)
httpx.post(f"{BASE}/send", headers=h, json={
"from": "Acme Ops ",
"to": ["Ada "],
"subject": "Your receipt",
"text": "Thanks for your order.",
"html": "Thanks for your order.
",
"reply_to": "support@acme.com",
})
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
const created = await fetch(`${BASE}/domains`, {
method: "POST",
headers,
body: JSON.stringify({ domain: "acme.com" }),
}).then((r) => r.json());
// publish created.dns_records at your DNS host, then (repeat until verified is true):
await fetch(`${BASE}/domains/${created.domain.id}/verify`, {
method: "POST",
headers: { Authorization: "Bearer cm_your_key" },
});
await fetch(`${BASE}/send`, {
method: "POST",
headers,
body: JSON.stringify({
from: "Acme Ops ",
to: ["Ada "],
subject: "Your receipt",
text: "Thanks for your order.",
html: "Thanks for your order.
",
reply_to: "support@acme.com",
}),
});
```
:::
A successful send returns `202 Accepted`:
```json
{
"id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"messageId": "<1a2b3c4d@acme.com>",
"messageIds": ["<1a2b3c4d@acme.com>"],
"status": "sent",
"suppressed": ["blocked@example.com"]
}
```
Field-by-field detail for that body is on [Send Email](/docs/email-send#response).
## When a call fails
Error bodies come in four shapes and they are not interchangeable, so branch on the HTTP status first, then check whether `detail` is an object, a string or an array before reaching for `reason`. The shapes, the full reason table, and the three sending ceilings are on [Limits, Quotas & Errors](/docs/email-limits).
Two failures dominate the first send. `403 domain_not_verified` means the domain has not passed all four DNS checks yet. `503 sender_propagating` means the `from` address was just registered as a sender and the mail service has not finished propagating it, so retry shortly. Both are covered on [Sender Addresses](/docs/email-domains#sender-addresses).
---
### Account MCP Server
URL: /docs/agent-tools-mcp
> Give an AI agent 41 tools that act on your CallMissed account: place and review calls, work the inbox, search and update the CRM, send WhatsApp and email, generate images, and check credits, over the Model Context Protocol. Connect by signing in with your CallMissed account, or with an API key.
:::cards
/docs/mcp-server | Docs MCP Server | book | Searchable CallMissed docs inside your coding agent
/docs/quickstart | Quickstart | rocket | Make your first API call in under a minute
:::
## Overview
The **Account MCP server** lets an AI agent take actions in your CallMissed account through the [Model Context Protocol](https://modelcontextprotocol.io). Point any MCP client at one URL, sign in with your CallMissed account or pass an API key, and the agent gets 41 tools: place and inspect phone calls, read transcripts, work the shared inbox, search and update the CRM, summarise and score calls, send WhatsApp messages and email, generate images, and check what it all cost.
It is hosted, so there is nothing to install and nothing to run locally.
This is a **different server** from the [Docs MCP Server](/docs/mcp-server). That one is a local npm package that makes this documentation searchable inside your editor and needs no credentials. This one is hosted, needs your account, and acts on your real data and credits.
## Endpoint
```
https://api.callmissed.com/api/v1/mcp
```
The transport is **Streamable HTTP**: every call is a single `POST` that returns one JSON response. There is no session to open or close, so the server works behind any load balancer and needs no sticky routing. `GET` and `DELETE` return `405` by design, because this server does not offer a server-initiated event stream.
The server answers `initialize`, `tools/list`, `tools/call`, `resources/list` and `resources/read`, and declares the `tools` and `resources` capabilities.
## Connect
Two ways in. Both reach the same tools; they differ in how the connection is authorised and how much you have to set up.
### Option 1: sign in with your CallMissed account
The easy path, and the recommended one. Paste the server URL into your client, click **Connect**, sign in to CallMissed, and tick what the connection is allowed to do. There is no key to create and nothing to paste into a header.
```
https://api.callmissed.com/api/v1/mcp
```
Paste that URL exactly as written, path included. Discovery is bound to the URL you type, so a shortened or altered one is not recognised as this server and sign-in never starts.
If your client cannot complete the sign-in, use an API key instead (option 2 below).
#### Choose what to allow
The consent screen offers three permission groups.
| Group | What it allows | On by default | Can spend credits |
| --- | --- | --- | --- |
| **Read your data** | See your calls, contacts, companies, deals, tasks, notes, conversations, agents and usage. Cannot change anything. | Yes | No |
| **Read call transcripts** | Read what was said on your voice calls, and what each one cost. This also lets the connection use AI models on your account, which spends credits. | No | Yes |
| **Take actions** | Create and update records, send WhatsApp messages and email, place and end calls, and generate images. Spends credits. | No | Yes |
**Read your data** is the only group ticked when the screen opens. The other two start off and are granted only if you tick them yourself, so a single click cannot hand an agent the ability to message a customer or spend credits.
At least one group has to be ticked. Approving nothing would create a connection that fails every call, so the consent screen asks you to choose something or press **Deny**.
Against the tool tables further down: **Read your data** covers every `:read` scope, **Read call transcripts** grants the `stt`, `tts` and `llm` permissions the three voice-session tools need, and **Take actions** covers every `:write` scope plus `whatsapp:send` and the `image` and `email` permissions.
#### The connection appears as an API key
Approving creates an API key on your account named `MCP connection: {host}`, where the host is the one shown on the consent screen as the client that asked. You will find it at **Developer → API keys** in the [console](https://console.callmissed.com/developer/keys) next to your other keys, carrying exactly the scopes you ticked, with the same budget, rate limit and credit accounting as any key you make by hand. Request logging is off for it.
Its value is never shown and cannot be revealed or copied out, so it can only ever be used by the connection it was made for.
**To disconnect, deactivate that key.** That is the whole revocation story: once the key is inactive, every token issued to that connection stops working immediately.
#### Staying connected
The access token your client receives is short-lived and your client renews it in the background using the refresh token issued alongside it. You are not asked to sign in again each time you use the tools. You will be asked again if you deny, if the key is deactivated, if you want to change what is allowed, or after the connection has gone unused for a long stretch.
#### Discovery endpoints
A client finds the flow from the URL you paste, by fetching these:
| Endpoint | What it is for |
| --- | --- |
| `GET /.well-known/oauth-protected-resource` | Protected-resource metadata (RFC 9728): the MCP resource URL, the authorization server, and the scopes this server understands. |
| `GET /.well-known/oauth-protected-resource/api/v1/mcp` | The same document at the path-suffixed address, which is what a client derives from the full server URL. Both forms are published so discovery works either way. |
| `GET /.well-known/oauth-authorization-server` | Authorization-server metadata (RFC 8414): the authorization and token endpoints, the supported grant types, and the PKCE methods. |
The flow itself is a standard OAuth 2.1 authorization code exchange:
| Endpoint | Method |
| --- | --- |
| `https://api.callmissed.com/api/v1/mcp/oauth/authorize` | `GET` |
| `https://api.callmissed.com/api/v1/mcp/oauth/token` | `POST`, form-encoded |
Two things to know if you are writing the client yourself:
* **PKCE with `S256` is required.** There is no client secret, so a request without `code_challenge_method=S256` is refused.
* **`client_credentials` is not supported.** The only grant types are `authorization_code` and `refresh_token`, because every connection needs a person to approve it on the consent screen.
There is no registration step. `client_id` is an `https://` URL that identifies your client, and the consent screen shows its host as plain text so the person approving can see who is asking.
Redirect addresses are limited to ones CallMissed has approved, plus loopback addresses for native apps. Any other `redirect_uri` is refused with a `400` before the consent screen is ever shown, so nothing is redirected to an address we have not vetted.
### Option 2: connect with an API key
For clients that do not do the sign-in flow, and for calling the endpoint yourself, pass an API key from your dashboard exactly as you would for any other CallMissed endpoint:
```
Authorization: Bearer cm_your_api_key
```
**A dashboard login token is refused with `401`**, even though it works elsewhere in the API. Scope checks are what keep an agent inside its lane, and those apply to keys, so this endpoint takes an API key or a token from the sign-in flow above and nothing else.
Everything attached to the key still applies: its scopes, its spend budget, its rate limit, and its domain allowlist. A key that runs out of budget gets `402`; one over its rate limit gets `429`.
## What a connection needs
Two different gates decide whether a tool works, whether the connection came from signing in or from a key you made by hand:
* **Resource scopes** such as `contacts:read` or `whatsapp:send`. Most tools use these, and the table below names the one each tool needs. A tool whose scope is missing returns a readable refusal naming the scope to add, so the agent can tell you what to fix.
* **Service permissions** on the key. The three voice-session tools need a key with the `stt`, `tts` and `llm` permissions; the two image tools need the `image` permission. `get_credit_balance` needs nothing beyond a valid key.
Give each key only what its agent needs. A read-only key is a perfectly good way to let an agent look at the account without letting it spend anything or reach a real person.
Eight tools spend credits and several of them reach real people. They are marked in the tables below. For an agent that should look but not act, tick only **Read your data** when you sign in, or use a key holding only the read scopes.
## Tools
41 tools in eight groups. `Access` is the tool's own `readOnlyHint`. `Credits` marks the tools that draw down your balance. Every tool's full JSON Schema, including argument names, types and bounds, comes back from `tools/list`.
### Voice and calls
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `place_call` | Place an outbound call from one of your numbers, answered by one of your voice agents | Write | `telephony:write` | Yes |
| `list_calls` | List calls, newest first, with status, duration and cost | Read | `telephony:read` | No |
| `get_call` | Fetch one call by id, with hangup cause and cost | Read | `telephony:read` | No |
| `end_call` | Hang up a call that is still in progress. Immediate and not undoable | Write, destructive | `telephony:write` | No |
| `get_call_recording` | Get a short-lived download link for a call's recording | Read | `telephony:read` | No |
| `click_to_call` | Ring one of your people, dial the contact, and bridge the two. No AI agent involved | Write | `telephony:write` | Yes |
| `list_phone_numbers` | List your numbers and their status, to find the `from_number_id` `place_call` takes | Read | `telephony:read` | No |
| `list_voice_sessions` | List voice agent sessions, newest first | Read | `stt` + `tts` + `llm` permissions | No |
| `get_voice_session_transcript` | Get a session's full turn-by-turn transcript | Read | `stt` + `tts` + `llm` permissions | No |
| `get_voice_session_cost` | Break down what one voice session cost in credits | Read | `stt` + `tts` + `llm` permissions | No |
### Conversations
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_conversations` | List conversations across channels with a preview and unread count | Read | `conversations:read` | No |
| `get_conversation_messages` | Read the messages in one conversation, oldest first | Read | `conversations:read` | No |
| `set_conversation_status` | Change a conversation's status, for example to close or escalate it | Write | `conversations:write` | No |
| `list_handoffs` | List conversations escalated to a person and still waiting | Read | `conversations:read` | No |
| `resolve_handoff` | Mark an escalated conversation handled, optionally keeping the AI quiet | Write | `conversations:write` | No |
### CRM
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `crm_search` | Search contacts, companies, deals, notes and tasks in one call | Read | `crm_search:read` | No |
| `crm_timeline` | One contact's or company's full history: conversations, calls, notes, tasks, deals | Read | `crm_timeline:read` | No |
| `list_contacts` | List contacts with their per-channel opt-in state | Read | `contacts:read` | No |
| `create_contact` | Add a person to the CRM. Needs at least a phone or an email | Write | `contacts:write` | No |
| `update_contact` | Change fields on an existing contact | Write | `contacts:write` | No |
| `get_contact_memory` | Get the facts your agents have learned about one contact | Read | `contacts:read` | No |
| `create_deal` | Open a deal in a pipeline | Write | `crm_deals:write` | No |
| `move_deal` | Move a deal to another stage of its own pipeline | Write | `crm_deals:write` | No |
| `create_task` | Add a follow-up task, optionally attached to a record | Write | `crm_tasks:write` | No |
| `complete_task` | Mark a task done | Write | `crm_tasks:write` | No |
| `create_crm_note` | Attach a note to a contact, company or deal | Write | `crm_notes:write` | No |
### Call intelligence
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_call_notes` | Get the notes already generated for a call: summary, action items, outcome | Read | `conversations:read` | No |
| `generate_call_notes` | Read a call's transcript and write structured notes from it | Write | `conversations:write` | Yes |
| `score_call` | Grade a call against one of your scorecards and store the result | Write | `conversations:write` | Yes |
### Messaging
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `send_whatsapp_message` | Send a free-form WhatsApp text, inside the 24-hour service window only | Write | `whatsapp:send` | Yes |
| `send_whatsapp_template` | Send an approved template, the only way to reach someone outside that window | Write | `whatsapp:send` | Yes |
| `list_whatsapp_campaigns` | List broadcast campaigns with status and recipient counts | Read | `whatsapp:read` | No |
| `send_email` | Send an email from a domain this account has verified | Write | `email` permission | Yes |
### Images
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `generate_image` | Generate an image from a prompt. Returns the image itself, so the model can see it, plus a link to the full-size version | Write | `image` permission | Yes |
| `list_generated_images` | List images made with this key, with fresh short-lived links | Read | `image` permission | No |
### Agents and knowledge
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_agents` | List the voice and chat agents on the account, to find a `bot_id` | Read | `bots:read` | No |
| `get_agent` | Fetch one agent's configuration by id | Read | `bots:read` | No |
| `knowledge_search` | Search the knowledge base and get the passages that match | Read | `knowledge:read` | No |
| `add_agent_memory` | Store a durable fact for one agent, applied on every future conversation | Write | `bots:write` | No |
### Usage and credits
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_credit_balance` | Check how many credits are left on the account | Read | Any valid key | No |
| `get_usage_summary` | Summarise recent spend, broken down by service | Read | `usage:read` | No |
### Telephony tools are deployment-gated
Seven of the 41 tools are the telephony ones: `place_call`, `list_calls`, `get_call`, `end_call`, `get_call_recording`, `click_to_call` and `list_phone_numbers`. They appear only where calling is switched on for the deployment. Where it is not, `tools/list` returns the other 34 and the seven are simply absent, because the REST routes behind them are not mounted there. Calling one by name anyway returns the same `Unknown tool` error as a typo.
If you do not see them and you expect to, talk to us and we will get calling turned on for you.
## Public catalog
```
GET https://api.callmissed.com/api/v1/mcp/catalog
```
Unauthenticated, no key needed. It returns the live tool list this deployment serves, which is what the tables above are built from, so you can check what is available before you wire anything up.
```bash
curl https://api.callmissed.com/api/v1/mcp/catalog
```
```json
{
"serverUrl": "https://api.callmissed.com/api/v1/mcp",
"protocolVersion": "2025-06-18",
"count": 41,
"categories": { "voice": "Voice and calls", "crm": "CRM" },
"tools": [
{
"name": "list_contacts",
"title": "List contacts",
"description": "List contacts in your address book, newest first...",
"category": "crm",
"categoryLabel": "CRM",
"readOnly": true,
"destructive": false,
"costsCredits": false,
"scope": "contacts:read"
}
]
}
```
It carries declarations only: no argument schemas, nothing about the caller, nothing about tools this deployment has gated off. For the JSON Schema of a tool's arguments, call `tools/list` with your key.
## Interactive views
Some results render as an interactive view instead of a wall of JSON, using the official MCP Apps extension (`io.modelcontextprotocol/ui`). Each view is one self-contained HTML document served over `resources/read`; a tool points at it through `_meta.ui.resourceUri` in its declaration.
| View | Shows | Tools that use it |
| --- | --- | --- |
| `ui://callmissed/call-log` | Calls with status, duration and cost | `list_calls` |
| `ui://callmissed/transcript` | A call's turns, caller and agent side by side | `get_voice_session_transcript` |
| `ui://callmissed/inbox` | Conversations with previews and unread counts | `list_conversations`, `list_handoffs` |
| `ui://callmissed/timeline` | A contact's history in one stream | `crm_timeline` |
| `ui://callmissed/image-gallery` | Generated images | `generate_image`, `list_generated_images` |
So five views across seven tools, or six tools where telephony is off.
**Where they render.** claude.ai, Claude Desktop, Claude mobile and Claude Cowork render MCP Apps, as do ChatGPT, VS Code Copilot and Cursor. The **Claude Code terminal does not**: it shows the text result instead. That is not a degraded mode. Every tool returns its full data as text and as `structuredContent` whether or not a view exists, so a client that ignores the extension loses nothing but the pictures.
## Connect a client
If your client supports signing in, add the server by URL alone and click **Connect**: no header, no key. See [option 1](#option-1-sign-in-with-your-callmissed-account) above.
Otherwise, most MCP clients accept a remote server as a URL plus a header. In Claude Code:
```bash
claude mcp add --transport http callmissed \
https://api.callmissed.com/api/v1/mcp \
--header "Authorization: Bearer cm_your_api_key"
```
For clients configured by file, the shape is usually:
```json
{
"mcpServers": {
"callmissed": {
"type": "http",
"url": "https://api.callmissed.com/api/v1/mcp",
"headers": {
"Authorization": "Bearer cm_your_api_key"
}
}
}
}
```
Keep the key out of version control. Reference an environment variable if your client supports it.
## Call it directly
The endpoint is plain JSON-RPC, so you can drive it with `curl`. List the tools your key can reach:
```bash
curl https://api.callmissed.com/api/v1/mcp \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
```
Call one:
```bash
curl https://api.callmissed.com/api/v1/mcp \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_contacts",
"arguments": { "q": "jane", "limit": 10 }
}
}'
```
A successful call returns the data twice, once as `structuredContent` and once serialized into a text block, so clients that only read one shape still work:
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "[{\"id\":\"...\",\"name\":\"Jane\"}]" }],
"structuredContent": { "result": [{ "id": "...", "name": "Jane" }] },
"isError": false
}
}
```
## Errors
Two different shapes, matching the protocol.
**A malformed request**, meaning an unknown tool, a missing argument, or a bad method, comes back as a JSON-RPC `error`:
```json
{
"jsonrpc": "2.0",
"id": 3,
"error": { "code": -32602, "message": "Unknown tool: send_sms" }
}
```
**A refused action**, meaning a missing scope, no credits, or a closed messaging window, is a successful result carrying `isError`, so the agent can read the reason and adapt instead of treating it as a crash:
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [{ "type": "text", "text": "API key missing required scope: whatsapp:send." }],
"isError": true
}
}
```
Authentication failures happen before any tool runs and use normal HTTP status codes: `401` for a missing or malformed credential, a dashboard login token, or a connection that has been revoked; `402` when the key is out of budget; `429` when it is over its rate limit.
Every `401` from this endpoint carries a `WWW-Authenticate` header pointing at the protected-resource document, including the very first call a client makes with no credential at all. That is what lets a client offer "Connect and sign in" instead of asking you to paste a key.
## Protocol version
The server implements MCP revision `2025-06-18` and also accepts `2025-03-26`. Send the version you speak on every call after initializing:
```
MCP-Protocol-Version: 2025-06-18
```
Omit the header and the server assumes `2025-03-26`, per the specification. Send a version it does not support and the call returns `400`. Batched requests are not accepted, because the `2025-06-18` revision removed them, so send one request object per POST.
---
### API Speed: Best Practices
URL: /docs/api-speed
> How to make CallMissed API calls feel as fast as the playground — streaming, model choice, connection reuse, and Kimi instant mode.
> **About the numbers on this page.** [Inference] Latency and throughput figures here (token rates, time-to-first-byte, per-call timings) are representative measurements taken under specific conditions — small prompts, warm connections, a given region and provider. They illustrate *relative* differences between settings; they are not SLAs or guarantees and will vary with prompt size, model, upstream load, and your network. AI behavior is not guaranteed and may vary.
## Why the playground feels faster
A single playground call and a typical API call hit the **exact same endpoint** at `https://api.callmissed.com/v1/chat/completions`. When the API feels slower, three compounding factors are usually at work:
| Setting | Playground default | Common API default | Effect |
| --- | --- | --- | --- |
| Streaming | `stream: true` | `stream: false` | Non-streaming waits for the *whole* generation before any byte returns |
| Model | `gpt-oss-120b` (fast free model) | `gpt-5.6-sol` | Bigger model → 2-3× wall-clock for the same prompt |
| Connection | One persistent HTTP/2 connection | New TCP+TLS per call | Adds ~150-300ms handshake to every request |
Same endpoint, very different perceived speed. Below: how to close the gap.
## 1. Use stream: true
With `stream: false` your client waits for the full generation. With `stream: true` the first byte typically arrives in well under a second — often around 100ms on fast models — and tokens flow as the model produces them instead of all at the end.
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1",
)
# stream=True is the default in the Anthropic SDK; explicit here.
stream = client.chat.completions.create(
model="gpt-oss-120b",
messages=[{"role": "user", "content": "Explain quicksort in one paragraph."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
print(delta, end="", flush=True)
```
```ts [TypeScript]
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const stream = await client.chat.completions.create({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "Explain quicksort in one paragraph." }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
```
## 2. Pick the right model
Same prompt, three different routes. The first-token and total times below are **indicative only**, not a dated benchmark: they were carried over unchanged when the model list was last revised, so they have not been measured against the models now named. Use them for rough shape and measure your own prompt size and region before designing around them.
| Model | Route | TTFB | Total |
| --- | --- | --- | --- |
| `gpt-oss-120b` | Direct-routed | ~50ms | ~1.5s |
| `kimi-k2.6` | Direct-routed | ~90ms | ~2.0s |
| `gpt-5.6-sol` | First-party flagship | ~70ms | ~4.8s |
| `gpt-5.6-luna` | First-party fast | ~80ms | ~2.0s |
For latency-sensitive integrations (autocomplete, agent tool loops), prefer **`gpt-oss-120b`** or **`kimi-k2.6`** — both direct-routed, both OpenAI-compatible, both sub-2s on small prompts.
## 3. Reasoning effort by model
Reasoning models can spend 100+ tokens "thinking" before producing visible content. On short answers, that's both a wall-clock and a credit cost the user never sees. Use `reasoning_effort` to dial it down — or off, where supported.
```json
{
"model": "kimi-k2.6",
"messages": [{ "role": "user", "content": "What is 2+2?" }],
"reasoning_effort": "none"
}
```
Behaviour per model — verified live against each upstream on 2026-05-01:
| Model | `"none"` | `"low"` | `"medium"` | `"high"` | `"xhigh"` | `"minimal"` |
| --- | --- | --- | --- | --- | --- | --- |
| `gpt-5.6-sol` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5.6-terra` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5.6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5.5` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `kimi-k2.5` | ✅ off | ✅ | ✅ | ✅ | — | ↓ `"none"` |
| `kimi-k2.6` | ✅ off | ✅ | ✅ | ✅ | — | ↓ `"none"` |
| `gpt-oss-120b` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` |
| `nemotron-3-super` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` |
| `glm-4.7-flash` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off |
| `glm-5.2` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off |
| `gemma-4-26b-a4b-it` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off |
| `sarvam-105b`, `sarvam-105b-conversations` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` |
| `kimi-k2.7-code` | ⊘ | ⊘ | ⊘ | ⊘ | — | ⊘ |
| `mistral-small-3.1` | ⊘ | ⊘ | ⊘ | ⊘ | — | ⊘ |
Legend: ✅ = sent to upstream verbatim · ✅ off = thinking is switched off · ↓ = mapped to the listed value before forwarding · ⊘ = dropped from the request; the model runs at its default thinking behaviour · — = not accepted by that model.
Three notes worth reading before you rely on a value:
- `glm-4.7-flash`, `glm-5.2` and `gemma-4-26b-a4b-it` honour only the off switch. `"none"` and `"minimal"` turn thinking off. `"low"`, `"medium"` and `"high"` are dropped, so the model thinks at its own default — they are not an intensity dial.
- `kimi-k2.7-code` and `mistral-small-3.1` expose no reasoning control. Every value is dropped. Both still return 200.
- The GPT-5.5 / GPT-5.6 family accepts the full `none`/`low`/`medium`/`high`/`xhigh` ladder. `"minimal"` is rejected upstream, so we map it to `"low"`. Send `xhigh`, not `max` or `ultra`.
Concrete numbers on `kimi-k2.6` answering "What is 2+2?":
| Mode | Answer | Completion tokens |
| --- | --- | --- |
| Default (thinking on) | `2 + 2 = 4` | 101 |
| `reasoning_effort: "none"` | `2 + 2 = 4` | **9** |
Same answer, **11× fewer tokens** and a proportionally shorter wall-clock. Pass per-request based on whether you need the trace.
## 4. Reuse the HTTP connection
CallMissed serves HTTP/2. A persistent connection multiplexes many requests with no extra TLS handshake per call. Most modern HTTP clients do this *if you reuse the same client instance*:
```python [Python]
# Bad — new connection per call (~150-300ms TLS overhead each time)
def ask(prompt):
return OpenAI(api_key=K, base_url=URL).chat.completions.create(...)
# Good — reuse one client (and its underlying connection pool)
client = OpenAI(api_key=K, base_url=URL)
def ask(prompt):
return client.chat.completions.create(...)
```
```ts [TypeScript]
// Bad
async function ask(prompt: string) {
const c = new OpenAI({ apiKey: K, baseURL: URL });
return c.chat.completions.create(...);
}
// Good — module-scoped client
const client = new OpenAI({ apiKey: K, baseURL: URL });
async function ask(prompt: string) {
return client.chat.completions.create(...);
}
```
Three back-to-back streaming calls on a reused HTTP/2 connection land in the region of **400-460ms each** for a small prompt, again indicative rather than a dated measurement; the first call without reuse pays an extra TLS handshake on top.
## Checklist
Before reporting "the API feels slow," verify:
- [ ] `stream: true` is set on every chat completion request
- [ ] Model is one of `gpt-oss-120b`, `kimi-k2.6`, `mistral-small-3.1`, or `gpt-5.6-luna` (avoid the 1M-context flagships for latency-bound paths)
- [ ] For Kimi: `reasoning_effort: "none"` is passed when you don't need the reasoning trace
- [ ] One `OpenAI()` / `Anthropic()` client instance is shared across requests
- [ ] HTTP client supports HTTP/2 (the official OpenAI / Anthropic SDKs do)
If all four are checked and you still see latency that doesn't match the playground, share the request and the upstream model — there are very few request shapes that beat playground default for the same model.
---
### Connect Your Store
URL: /docs/connect-your-store
> Let a WhatsApp or voice agent answer from your real catalogue and order data instead of guessing. Connect Shopify or WooCommerce in a click, or point the agent at your own backend with no code.
:::cards
/docs/agent-tools-mcp | Account MCP Server | wrench | Give an external AI agent tools that act on your account
/docs/whatsapp-setup | WhatsApp Setup | message-circle | Connect a number and go live
:::
## Overview
An agent with no access to your store can only talk in generalities. It will invent a price, promise stock it cannot see, and guess at where an order is. Connecting your store fixes that: the agent looks the answer up at the moment it replies.
There are three routes, and they all end in the same place. Pick the one that matches your site:
| Your site | Route | What it takes |
| --- | --- | --- |
| Shopify | Built-in connector | Sign in, grant access |
| WooCommerce / WordPress | Built-in connector | Paste a read-only REST key |
| Anything else | Custom tools | Expose a few read-only endpoints, then point the agent at them |
All three work for **WhatsApp and voice agents alike**, with one exception noted under [sending product photos](#send-product-photos).
## Shopify
1. Open **Integrations** on your agent.
2. Choose **Shopify**, then **Connect**. You are taken to Shopify to sign in and grant access, then brought back.
3. The Shopify tools are switched on for that agent automatically.
The agent can then look up an order's payment and fulfilment status with tracking, search products for price and stock, find a customer, and start a draft order.
## WooCommerce
WooCommerce uses a read-only API key you create yourself, so there is no app to install on your site.
1. In WordPress, go to **WooCommerce → Settings → Advanced → REST API** and **Add key**. Set **Permissions** to **Read**. Copy the consumer key and consumer secret. WooCommerce shows the secret once.
2. In CallMissed, open **Integrations** on your agent, choose **WooCommerce**, and paste three values: your store URL, the consumer key, and the consumer secret.
3. Press **Connect**. We make one authenticated read to confirm the key works before saving it, and the WooCommerce tools switch on for that agent.
Your store URL must be **https**. WooCommerce only accepts key-and-secret authentication over TLS; over plain `http` the credentials would be readable in transit, so we refuse the connection rather than send them in the clear.
The key is stored encrypted and used only on your account's conversations. Reconnecting replaces it.
## Any other site
If you run your own backend (custom, headless, Magento, Django, Rails, a spreadsheet behind an API), expose a few read-only endpoints and register them as tools. Nothing runs on your side but the endpoints themselves: the rest is configuration, in the console or through the [Agent Tools API](/docs/agent-tools).
### Design the endpoints
The agent is a language model, not a browser, so shape these for a model rather than for a web page.
- **Keep responses small.** A tool response is truncated past **32 KB**. Return flat objects with the handful of fields a customer actually asks about, and cap list results at about 5 to 10. Never return a full catalogue document.
- **Return the price the customer will actually pay.** If your storefront displays a tax-inclusive price, return the tax-inclusive number. An agent quoting your pre-tax figure will under-quote every customer.
- **Return absolute URLs.** Both the product page link and the image URL must be complete `https://` addresses, because the agent sends them to the customer as-is.
- **Only return what is published.** Filter out drafts, hidden and inactive items. A public catalogue endpoint often does not, and an agent will happily recommend an unreleased product.
- **Say "not found" in words.** Return something like `{"found": false, "message": "No orders for that number."}` rather than an empty list or a 404. The model relays the message.
- **Authenticate with a header.** A single static key in a header such as `X-API-Key`, compared in constant time, is enough. Restrict the key to letters and digits.
A product search that returns this is doing its job:
```json
{
"count": 1,
"products": [
{
"id": "kit-104",
"title": "Line Follower Robot Kit",
"price": 1180,
"currency": "INR",
"inStock": true,
"url": "https://yourstore.com/product/line-follower",
"image": "https://yourstore.com/media/line-follower.jpg"
}
]
}
```
### Register the tool
1. Open your agent, go to **Tools**, and choose **New tool**.
2. Give it a **name** and a **description**. The description is the only thing telling the model when to reach for it, so write it as an instruction: "Search the catalogue by keyword. Returns price, stock, the product link and an image URL."
3. Set the **method** and **URL**, for example `GET https://api.yourstore.com/agent-tools/products/search`.
4. Add the parameters:
- Your API key: a **Header** parameter named `X-API-Key`, source **Secret**. It is encrypted and never shown again.
- The search term: a **Query** parameter named `q`, source **Agent decides**, described as "what the customer is looking for".
5. **Test** it, then **Save**. Repeat for each endpoint: product search, product detail, order status.
Your endpoint must be reachable on the public internet over HTTPS. Requests to private, loopback or cloud-metadata addresses are refused, and every redirect is re-checked, so an internal URL cannot be reached through a redirect either.
## Identify the customer safely
Order history is the case where a small mistake matters. If a tool takes the phone number as a value the **agent** supplies, then whatever the customer types becomes the lookup: someone can ask for orders belonging to a number that is not theirs, and the agent will fetch them.
Bind the identity to the conversation instead:
1. On the order-lookup tool, add the parameter that carries the customer's number, for example a **Query** parameter named `phone`.
2. Set its source to **From the conversation**, and pick **Their phone number**.
That value is now filled in by the server from the person actually in the chat. The model never sees the parameter and cannot set it, so a customer asking about someone else's number changes nothing. The same control can bind their name, their email, or the conversation ID.
A voice call or a web chat may have no known contact. An unbound value is simply left out, so decide what your endpoint should do: either mark the parameter **required** so the tool fails cleanly rather than running an unscoped query, or have the endpoint return "not found" when the identity is missing.
**Test** runs outside any conversation, so there is no real customer to bind to. It sends obvious placeholders instead (`+10000000000`, `test@example.com`), which is enough to prove your endpoint is reachable and your key works. Expect it to answer "not found". Check the real behaviour from a live chat.
If you would rather let a customer ask about an order they have the number for, take the order ID **and** a detail only the real customer knows, such as the last four digits of the phone on the order. Return the same "not found" response for a wrong ID and a failed check, so the endpoint cannot be used to discover which order IDs exist.
## Send product photos
A customer asking to see something wants a picture, not a URL.
Switch on the **`send_image`** tool on your agent. When the model has an image URL from your catalogue tool, it sends the photo with a caption. Put the product name, the price and the link in the caption so one message carries everything.
Two requirements: the image URL must be a public `https://` link that needs no login, and it must be a **JPEG or PNG**. WhatsApp fetches the image from your server directly, so a URL behind an auth wall silently fails to send.
`send_image` is WhatsApp-only. On a voice call the agent will explain the product and can text the link instead.
## Tell the agent how to use it
Connecting tools is half the job. The agent's instructions decide whether it uses them well. Add something like this to your agent's prompt:
```text
You answer for {store name}. Never invent a price, stock level or delivery date:
look it up with your tools, and if a tool returns nothing, say so plainly.
When a customer asks about a product, search the catalogue, then send the photo
with send_image and put the name, price and link in the caption.
When a customer asks about an order, look it up. If you cannot identify them,
ask for their order number, then for the last four digits of the phone on the
order before sharing any detail.
```
## Check it works
1. Open the tool and press **Test** to confirm the endpoint answers and your key is accepted.
2. Message your agent on WhatsApp with a real product name and confirm the price it quotes matches your website exactly.
3. Ask it to show you the product and confirm the photo arrives.
4. Ask about an order from a number that has one, then from a number that does not, and confirm the second says it found nothing.
5. Ask it for orders belonging to a number that is not yours. It should not return them.
---
### Credits & Rate Limits
URL: /docs/credits-rate-limits
> How CallMissed credits are priced and spent, the per-plan call caps and request rates, and how to handle 402 and 429.
## Credits
One currency for every service. **1 credit = ₹1 = $0.01.**
Every call deducts credits: LLM tokens, STT audio, TTS characters, an image, a
web search. Credits do not expire.
| Grant | Amount |
|-------|--------|
| Signup bonus (once per account) | 1,000 credits |
| Free plan, monthly | 100 credits |
| Starter, monthly | 550 credits |
| Pro, monthly | 6,000 credits |
| Enterprise, monthly | 26,000 credits |
Usage is always metered. There is no unlimited tier — Enterprise removes the
monthly call caps, not the per-call credit cost.
## How each service is metered
| Service | Unit | Worked example |
|---------|------|----------------|
| LLM | per 1M input + 1M output tokens | `kimi-k2.5` at $0.81 in / $4.05 out: 500 in + 200 output tokens = $0.001215 = **0.1215 credits** |
| Speech to text | per audio hour | `saaras:v3` at $0.30/hr: a 4-minute call = **2 credits** |
| Text to speech | per 10,000 characters | `bulbul:v3` at $0.30/10K: a 400-character reply = **1.2 credits** |
| Image generation | per image | `flux-2-klein-9b` at $0.10: one image = **10 credits** |
| Web search | flat | **1 credit** per search, whichever provider serves it |
Per-model rates are in the [model catalog](/docs/models#pricing) and live at
`GET /api/v1/models`.
## Plan call caps
Monthly caps counted per service, reset on the 1st. `-1` means uncapped.
| Plan | LLM | STT | TTS | Image | Conversations | Storage | Team |
|------|-----|-----|-----|-------|---------------|---------|------|
| Free | 100 | 50 | 50 | 50 | 50 | 100 MB | 2 |
| Starter | 5,000 | 2,500 | 2,500 | 500 | 1,000 | 1 GB | 5 |
| Pro | 50,000 | 25,000 | 25,000 | 5,000 | 10,000 | 10 GB | 20 |
| Enterprise | uncapped | uncapped | uncapped | uncapped | uncapped | uncapped | uncapped |
Caps are separate from credits. Exceeding a cap returns `429` even with credits
in the balance; running out of credits returns `402` even under the cap.
## Request rate
Per API key, requests per minute:
| Plan | Default RPM |
|------|-------------|
| Free | 60 |
| Starter | 500 |
| Pro | 3,000 |
| Enterprise | 10,000 |
Override a single key with `rate_limit_rpm` in the dashboard. An explicit
override wins over the plan default.
## Response headers
Every response to a metered endpoint carries the current cap state.
| Header | Meaning |
|--------|---------|
| `X-RateLimit-Limit` | Monthly call cap for that service |
| `X-RateLimit-Remaining` | Calls left this month |
| `X-RateLimit-Reset` | ISO-8601 timestamp when the cap resets (the 1st) |
| `X-Usage-Warning` | Present at 80% (`warning:`) and 95% (`critical:`) of the cap |
| `X-Credits-Balance` | Credits remaining (sent on 402 responses and on search) |
## 402 — out of credits
```json
{
"error": {
"message": "Insufficient credits (balance: 0.0). Purchase more at https://console.callmissed.com/org/billing",
"type": "insufficient_quota",
"code": "insufficient_credits"
}
}
```
Do not retry. Top up first.
## 429 — monthly cap reached
```json
{
"error": {
"message": "Plan limit exceeded: 100/100 llm calls this month. Upgrade your plan at console.callmissed.com/org/billing",
"type": "insufficient_quota",
"code": "quota_exceeded"
}
}
```
This 429 carries `Retry-After` in seconds until the 1st of next month. Do not
retry inside that window — upgrade the plan instead.
## 429 — too many concurrent requests
```json
{
"error": {
"message": "Too many concurrent requests for this API key. Retry shortly.",
"type": "rate_limit_error",
"code": "too_many_concurrent_requests"
}
}
```
This one clears in seconds. Retry with jittered backoff.
```python
import time
from openai import OpenAI, RateLimitError
client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1")
try:
resp = client.chat.completions.create(
model="kimi-k2.5",
messages=[{"role": "user", "content": "Hello"}],
)
except RateLimitError as e:
retry_after = int(e.response.headers.get("Retry-After", 0))
if 0 < retry_after < 120:
time.sleep(retry_after)
resp = client.chat.completions.create(
model="kimi-k2.5",
messages=[{"role": "user", "content": "Hello"}],
)
else:
raise # monthly cap — upgrade rather than wait
```
See [Errors](/docs/errors) for the full status and code tables.
---
### How CallMissed Works
URL: /docs/how-it-works
> The architecture behind CallMissed — one OpenAI-compatible gateway that routes to the best provider for each model, billed in a single credit currency.
## The Gateway
CallMissed is a single, OpenAI-compatible gateway in front of many AI providers. You point the official OpenAI (or Anthropic) SDK at `https://api.callmissed.com/v1`, authenticate with a `cm_` key, and call chat, speech, image, and search — without integrating each provider yourself.
The same endpoint powers the dashboard playground and your production code, so behavior is identical everywhere.
:::flow
icon:app | Your app | One SDK, one base URL, one `cm_` key for every capability
icon:gateway | CallMissed gateway | Authenticate, enforce tenant isolation + rate limits, route by model id
icon:provider | Best provider | the best-fit backend for each model — chosen automatically
icon:db | Credits & logs | Deduct from one credit balance and record usage for every request
:::
## Model Routing
The model id picks the backend. You never manage multiple SDKs or keys.
| Model id | Routed to |
| --- | --- |
| `sarvam-*` (e.g. `sarvam-105b`) | Indic LLM |
| `saaras:*`, `whisper*`, `nova-3`, `deepgram-*`, `gnani-prisma-*` | Speech to text |
| `bulbul:*`, `aura-2-*`, `melotts`, `gnani-timbre-*` | Text to speech |
| `flux-*`, `gpt-image-*`, `lucid-origin`, `phoenix-1.0`, … | Image generation |
| everything else (e.g. `kimi-k2.5`, `gpt-5.6-terra`, `glm-5.2`) | Chat completions |
Every id is a plain CallMissed id. One API surface, one key, one bill.
Need a model that isn't in the catalog? We deploy 300+ more on demand — see
[Models on demand](/docs/models#models-on-demand).
## One Credit Currency
Every call — LLM tokens, STT minutes, TTS characters, an image, a web search — deducts **credits**. **1 credit = ₹1.** This removes per-provider pricing math: top up once, spend across every capability. See [Credits & Rate Limits](/docs/credits-rate-limits) for the per-service rates.
## Tenancy & Isolation
Your organization is a **tenant**. Every user, bot, API key, conversation, and log belongs to exactly one tenant, and every database query is scoped to it — data is never shared across tenants. Roles (owner / admin / agent) gate sensitive actions, and you manage members and roles from the dashboard.
## Channels vs APIs
There are two ways to use CallMissed:
- **Direct APIs** — call `/v1/*` from your own app (chat, speech, image, search, voice sessions).
- **Channels** — let CallMissed run a bot end-to-end on [WhatsApp](/docs/whatsapp) or [voice calls](/docs/voice), handling the inbound webhook, the AI turn, and the reply.
---
### Idempotency
URL: /docs/idempotency
> Make mutating requests safely retryable with an Idempotency-Key so network retries never duplicate an action.
## Why Idempotency
If a request times out or the connection drops, you often can't tell whether the server processed it. Retrying blindly risks doing the action twice — charging a card twice, creating two bots. An **idempotency key** lets you retry safely: the server processes the first request and returns the same stored result for any replay with the same key.
## Using the Header
Send an `Idempotency-Key` header with a unique value (a UUID works well) on any mutating request (`POST`, `PUT`, `PATCH`):
```bash
curl -X POST https://api.callmissed.com/api/v1/bots \
-H "Authorization: Bearer cm_your_key" \
-H "Idempotency-Key: 3f9c1d2e-0b9a-4c7d-8e1f-2a3b4c5d6e7f" \
-H "Content-Type: application/json" \
-d '{"name":"Support Bot","type":"whatsapp"}'
```
Reuse the **same** key when retrying the same logical request. Use a **new** key for a genuinely new action.
## Behavior
- A replay with the same key **and** the same body returns the original response — the action runs only once.
- A replay with the same key but a **different** body returns `409 Conflict`.
- Keys are scoped to your tenant and retained for a limited window, then expire.
- Idempotency is most important for resource-creation endpoints, where a blind retry would otherwise create a duplicate.
---
### Welcome to CallMissed API
URL: /docs/introduction
> CallMissed provides AI-powered communication APIs to deploy WhatsApp chatbots and voice call agents for your business.
:::cards
/docs/quickstart | Quickstart | play | Make your first API call in under a minute
/docs/models | Models | boxes | 125 models — Indic STT/TTS, direct-routed LLMs, realtime voice, image gen, embeddings
/docs/managed-voice-agent | Managed Voice Agent | audio-lines | Speech-to-speech over one WebSocket — Deepgram-compatible or native
/docs/voice-agent | Voice Agent | phone | Real-time WebRTC agents with Indic speech pipeline
:::
## Overview
CallMissed is an AI Communication Infrastructure platform. Use our APIs to:
- Deploy **WhatsApp chatbots** with custom knowledge bases
- Build **AI voice call agents** for inbound calls
- Create **Smart IVR** flows with AI escalation
- Call **125 models** — LLM, STT, TTS, realtime voice, image and embeddings — from one endpoint, plus **300+ more we deploy on demand**
- Use **OpenAI-compatible APIs** — same SDK, just change the base URL
- Manage **multi-tenant** deployments for your customers
## Base URL
All API requests go to:
```
https://api.callmissed.com
```
## Key Features
- **Indic Models** — STT, TTS, and LLM purpose-built for 22 Indic languages
- **Multi-tenant** — full data isolation between tenants
- **Real-time** — WebSocket voice streaming with ultra-low-latency STT→LLM→TTS pipeline
- **Webhooks** — WhatsApp Business API and Twilio integration
- **OpenAI-compatible** — use the OpenAI SDK with your `cm_` API key
- **Request Logging** — per-key request logs with latency, model, cost, and error tracking
---
### Docs MCP Server
URL: /docs/mcp-server
> Connect the CallMissed documentation to your AI coding agent with the official callmissed-docs MCP server — searchable docs inside Claude, Cursor, VS Code, Windsurf, and OpenCode.
:::cards
/docs/quickstart | Quickstart | rocket | Make your first API call in under a minute
/docs/sdks | SDKs & Libraries | package | Use the OpenAI & Anthropic SDKs
:::
## Overview
The **CallMissed Docs MCP server** brings this entire documentation site into your AI coding agent through the [Model Context Protocol](https://modelcontextprotocol.io). Instead of copy-pasting docs, your agent can search, grep, and read CallMissed docs directly while it writes your integration.
It is published on npm as [`callmissed-docs-mcp`](https://www.npmjs.com/package/callmissed-docs-mcp), runs locally over stdio via `npx`, and stays current by fetching the live docs export from the CallMissed API with an offline cache fallback.
- **Always up to date** — pulls the latest docs on startup, caches for 1 hour
- **Works offline** — falls back to the local cache when the API is unreachable
- **Fast search** — fuzzy search plus regex grep over every doc page
- **Zero install** — `npx` runs the latest version on demand
> **Tip:** Use the **Copy page** dropdown at the top of any docs page to copy the MCP config or one-click connect to Cursor and VS Code.
## Install
The server runs through `npx`, so no global install is required. To install it globally anyway:
```bash
npm install -g callmissed-docs-mcp
```
## Install via CLI (one command)
The fastest way in. Several coding agents can add an MCP server from a single terminal command — no config file to hand-edit.
**Any agent (Cursor, Claude Code, Codex, Windsurf, VS Code, and more)** — the cross-agent installer detects the agents on your machine and writes the right config for each:
```bash
npx add-mcp "npx -y callmissed-docs-mcp" --name callmissed-docs
```
**Claude Code:**
```bash
claude mcp add callmissed-docs -- npx -y callmissed-docs-mcp
```
**Codex CLI:**
```bash
codex mcp add callmissed-docs -- npx -y callmissed-docs-mcp
```
After it runs, restart the agent — the CallMissed docs tools appear alongside your other tools. Verify with `claude mcp list` or `codex mcp list`.
> Cursor, VS Code, Windsurf, OpenCode and Kilo Code are configured through their MCP config file (no dedicated `mcp add` command). Use the JSON below, or the cross-agent `npx add-mcp` one-liner above.
## Connect Your Client
Add the server to your client's MCP configuration.
:::tabs
```json [Claude Desktop]
{
"mcpServers": {
"callmissed-docs": {
"command": "npx",
"args": ["-y", "callmissed-docs-mcp"]
}
}
}
```
```json [Cursor]
{
"mcpServers": {
"callmissed-docs": {
"command": "npx",
"args": ["-y", "callmissed-docs-mcp"],
"env": {}
}
}
}
```
```json [VS Code]
{
"servers": {
"callmissed-docs": {
"command": "npx",
"args": ["-y", "callmissed-docs-mcp"],
"type": "stdio"
}
}
}
```
```json [Windsurf]
{
"mcpServers": {
"callmissed-docs": {
"command": "npx",
"args": ["-y", "callmissed-docs-mcp"]
}
}
}
```
```json [OpenCode]
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"callmissed-docs": {
"type": "local",
"command": ["npx", "-y", "callmissed-docs-mcp"],
"enabled": true
}
}
}
```
:::
Config file locations:
| Client | Path |
|--------|------|
| Claude Desktop | `claude_desktop_config.json` |
| Cursor | `~/.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` (workspace) or User Configuration |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
| OpenCode | `opencode.json` (project root) |
After adding the config, restart your client. The CallMissed docs tools then appear automatically alongside your other tools.
## Use via Context7
[Context7](https://context7.com) aggregates library documentation for AI agents. CallMissed docs are indexed there under the library ID `/callmissed/callmissed-docs` — so if you already run the Context7 MCP server, you can pull CallMissed docs through it without installing a separate server.
Reference the library directly in your prompt:
```text
Use the CallMissed docs (/callmissed/callmissed-docs) from Context7 to wire up streaming chat completions.
```
> **Tip:** The dedicated `callmissed-docs-mcp` server always serves the live docs export. Context7 is a convenient option when it's already part of your toolchain.
## Available Tools
Once connected, your agent gains these tools:
| Tool | Description |
|------|-------------|
| `callmissed_search` | Fuzzy search across all documentation |
| `callmissed_grep` | Regex search over doc content |
| `callmissed_cat` | Read a documentation chunk by ID |
| `callmissed_ls` | List available documentation pages |
| `callmissed_find` | Substring search across pages |
## Configuration
The server reads one optional environment variable:
| Variable | Default | Description |
|----------|---------|-------------|
| `CALLMISSED_DOCS_API` | `https://api.callmissed.com/api/v1/docs/export` | Docs export endpoint the server fetches on startup |
To point the server at a different docs export endpoint, set the variable in your MCP client's `env` block:
```json
{
"mcpServers": {
"callmissed-docs": {
"command": "npx",
"args": ["-y", "callmissed-docs-mcp"],
"env": {
"CALLMISSED_DOCS_API": "https://api.callmissed.com/api/v1/docs/export"
}
}
}
}
```
**Ready to connect?** Use the **Copy page** menu at the top of any docs page to grab the config or one-click install into Cursor or VS Code.
---
### Developer Quickstart
URL: /docs/quickstart
> Get started with CallMissed APIs in under a minute using just a few lines of code
> **Note:** CallMissed APIs are OpenAI-compatible — use the official OpenAI SDK and change only the base URL and API key prefix (`cm_`).
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1")
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Hello in Hindi"}],
)
print(response.choices[0].message.content)
```
```typescript [TypeScript]
import OpenAI from "openai";
const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1" });
const response = await client.chat.completions.create({
model: "sarvam-105b",
messages: [{ role: "user", content: "Hello in Hindi" }],
});
console.log(response.choices[0].message.content);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"model":"sarvam-105b","messages":[{"role":"user","content":"Hello in Hindi"}]}'
```
:::
:::cards
/docs/sdks | SDKs & Libraries | package | Install the OpenAI SDK in your language
/docs/models | Model Catalog | boxes | 125 models — LLM, STT, TTS, realtime voice, image, embeddings
:::
## Get started
::::steps
## Create an API Key
Visit the [CallMissed Dashboard](https://console.callmissed.com) and create a new API key. Keep this key secure — you'll need it to authenticate your requests.
## Set up your environment
Export your API key as an environment variable:
:::tabs
```bash [macOS / Linux]
export CALLMISSED_API_KEY="your_api_key_here"
```
```powershell [Windows (PowerShell)]
$env:CALLMISSED_API_KEY = "your_api_key_here"
```
```cmd [Windows (CMD)]
set CALLMISSED_API_KEY=your_api_key_here
```
:::
## Install the SDK
Choose your preferred language and install the OpenAI SDK:
:::tabs
```bash [Python]
pip install openai
```
```bash [JavaScript / TypeScript]
npm install openai
```
```bash [Go]
go get github.com/openai/openai-go
```
```bash [PHP]
composer require openai-php/client
```
```bash [Ruby]
gem install ruby-openai
```
```bash [Java]
# Add to pom.xml or build.gradle — use OkHttp or any HTTP client
```
:::
## Make your first API call
Use the same pattern as the hero example above — pass your `cm_` API key and pick any [free-tier model](/docs/model-access).
> **Tip:** Store your API key in environment variables. Never commit keys to version control.
::::
**Ready to explore?** See [Chat Completion](/docs/chat-completion) for streaming and tool calling, or browse the [Model Catalog](/docs/models).
## CallMissed APIs
### Chat Completion
:::tabs
```python [Python]
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Explain quantum computing"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
```javascript [JavaScript]
const stream = await client.chat.completions.create({
model: "sarvam-105b",
messages: [{ role: "user", content: "Explain quantum computing" }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content || "");
}
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "sarvam-105b",
"messages": [{"role": "user", "content": "Explain quantum computing"}],
"stream": true
}'
```
:::
### Speech to Text
:::tabs
```python [Python]
with open("audio.wav", "rb") as f:
response = client.audio.transcriptions.create(
model="saaras:v3",
file=f
)
print(response.text)
```
```javascript [JavaScript]
import fs from "fs";
const response = await client.audio.transcriptions.create({
model: "saaras:v3",
file: fs.createReadStream("audio.wav"),
});
console.log(response.text);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/audio/transcriptions \
-H "Authorization: Bearer cm_your_key" \
-F file=@audio.wav \
-F model=saaras:v3
```
:::
### Text to Speech
:::tabs
```python [Python]
response = client.audio.speech.create(
model="bulbul:v3",
voice="shubh",
input="Namaste, kaise hain aap?"
)
response.stream_to_file("speech.mp3")
```
```javascript [JavaScript]
const response = await client.audio.speech.create({
model: "bulbul:v3",
voice: "shubh",
input: "Namaste, kaise hain aap?",
});
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync("speech.mp3", buffer);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/audio/speech \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model": "bulbul:v3", "input": "Namaste, kaise hain aap?", "voice": "shubh"}' \
--output speech.mp3
```
:::
## Next steps
:::cards
/docs/models | Models | boxes | LLM, STT, TTS, and image models across every major provider
/docs/chat-completion | Chat Completion | message-square | Streaming, tool calling, vision, and the Responses API
/docs/managed-voice-agent | Managed Voice Agent | audio-lines | Speech-to-speech over one WebSocket — no SDK, Deepgram-compatible
/docs/voice-agent | Voice Agent | phone | Real-time WebRTC voice agents with Indic STT/TTS
:::
---
### Rate Limits & Quotas
URL: /docs/rate-limits
> How CallMissed limits request rate — per-key RPM, budget caps, response headers, and how to handle 429s.
## Limit Layers
Requests pass through several limits, in order:
| Layer | Limit | Scope |
| --- | --- | --- |
| Per-key RPM | plan defaults: Free 60 · Starter 500 · Pro 3,000 · Enterprise 10,000 (override per key) | per API key |
| Monthly budget | configurable credit cap | per tenant / per key |
| Plan limits | tier-based caps on LLM/STT/TTS calls, conversations, storage, team size | per tenant |
Abuse protection also runs in front of the API and may throttle traffic that looks automated or hostile, independently of your plan's per-key RPM.
Set a per-key RPM and a [budget cap](/docs/keys) when issuing keys, then track live consumption for each key from the dashboard.
## Response Headers
Rate-limited responses include standard headers so you can pace requests:
| Header | Meaning |
| --- | --- |
| `Retry-After` | Seconds to wait before retrying (on 429) |
| `X-RateLimit-Limit` | The ceiling for the current window |
| `X-RateLimit-Remaining` | Requests left in the window |
## Handling 429
When you receive `429 Too Many Requests`:
1. Read `Retry-After` and wait at least that long.
2. Use exponential backoff with jitter for repeated 429s.
3. Spread bursty workloads across time, or request a higher per-key RPM.
See [Error Codes](/docs/errors) for the full status/code reference.
---
### Libraries & SDKs
URL: /docs/sdks
> Use the OpenAI SDK to integrate CallMissed APIs — our endpoints are fully OpenAI-compatible.
## Official Libraries
CallMissed supports **two SDK families** — use whichever you prefer. Just change the base URL and use your `cm_` API key.
### OpenAI SDK (recommended for most use cases)
| Language | Package | Manager |
|----------|---------|---------|
| Python | `openai` | PyPI |
| JavaScript / TypeScript | `openai` | npm |
| Go | `openai-go` | go modules |
| PHP | `openai-php/client` | Composer |
| Ruby | `ruby-openai` | RubyGems |
| Java / Kotlin | HTTP client | Maven / Gradle |
### Anthropic SDK (for /v1/messages endpoint)
| Language | Package | Manager |
|----------|---------|---------|
| Python | `anthropic` | PyPI |
| JavaScript / TypeScript | `@anthropic-ai/sdk` | npm |
> **Tip:** The Anthropic SDK talks to `/v1/messages`. See the [Anthropic API docs](/docs/anthropic-api) for full details.
## Installation
:::tabs
```bash [Python]
pip install openai
```
```bash [JavaScript / TypeScript]
npm install openai
```
```bash [Go]
go get github.com/openai/openai-go
```
```bash [PHP]
composer require openai-php/client
```
```bash [Ruby]
gem install ruby-openai
```
```bash [Python (Anthropic)]
pip install anthropic
```
```bash [JS/TS (Anthropic)]
npm install @anthropic-ai/sdk
```
:::
Upgrade to the latest version:
:::tabs
```bash [Python]
pip install --upgrade openai
```
```bash [JavaScript / TypeScript]
npm install openai@latest
```
```bash [Go]
go get -u github.com/openai/openai-go
```
```bash [PHP]
composer update openai-php/client
```
```bash [Ruby]
gem update ruby-openai
```
:::
## Configuration
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_api_key",
base_url="https://api.callmissed.com/v1"
)
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Hello"}]
)
print(response.choices[0].message.content)
```
```typescript [TypeScript]
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "cm_your_api_key",
baseURL: "https://api.callmissed.com/v1",
});
const response = await client.chat.completions.create({
model: "sarvam-105b",
messages: [{ role: "user", content: "Hello" }],
});
console.log(response.choices[0].message.content);
```
```javascript [JavaScript]
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "cm_your_api_key",
baseURL: "https://api.callmissed.com/v1",
});
const response = await client.chat.completions.create({
model: "sarvam-105b",
messages: [{ role: "user", content: "Hello" }],
});
console.log(response.choices[0].message.content);
```
```go [Go]
package main
import (
"context"
"fmt"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
)
func main() {
client := openai.NewClient(
option.WithAPIKey("cm_your_api_key"),
option.WithBaseURL("https://api.callmissed.com/v1"),
)
resp, _ := client.Chat.Completions.New(context.Background(),
openai.ChatCompletionNewParams{
Model: openai.F("sarvam-105b"),
Messages: openai.F([]openai.ChatCompletionMessageParamUnion{
openai.UserMessage("Hello"),
}),
},
)
fmt.Println(resp.Choices[0].Message.Content)
}
```
```php [PHP]
withApiKey('cm_your_api_key')
->withBaseUri('https://api.callmissed.com/v1')
->make();
$response = $client->chat()->create([
'model' => 'sarvam-105b',
'messages' => [['role' => 'user', 'content' => 'Hello']],
]);
echo $response->choices[0]->message->content;
```
```ruby [Ruby]
require "openai"
client = OpenAI::Client.new(
access_token: "cm_your_api_key",
uri_base: "https://api.callmissed.com/v1"
)
response = client.chat(
parameters: {
model: "sarvam-105b",
messages: [{ role: "user", content: "Hello" }]
}
)
puts response.dig("choices", 0, "message", "content")
```
```bash [cURL]
curl https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"model": "sarvam-105b", "messages": [{"role": "user", "content": "Hello"}]}'
```
:::
## Resources
| Resource | Link |
|----------|------|
| API Reference | [docs.callmissed.com](https://docs.callmissed.com) |
| Dashboard | [console.callmissed.com](https://console.callmissed.com) |
| LinkedIn | [linkedin.com/company/callmissed](https://www.linkedin.com/company/callmissed) |
---
### Knowledge Base & RAG
URL: /docs/knowledge
> Store content your bots use to answer questions, plus a vector Knowledge API that chunks, embeds, and semantically retrieves your sources for RAG.
## Overview
There are two independent layers of knowledge, and they do not share storage:
1. **Bot knowledge base** — a flat document store scoped to a single bot. Add plain text or upload PDF/DOCX/TXT (max 20 MB); text is extracted on upload. This layer is **not** searched by RAG.
2. **Knowledge API (RAG)** — a vector store. Ingest text, URLs, or PDFs as **sources**; each is chunked and embedded, then retrieved by semantic search and passed as context to the model.
Every source you ingest is attached to a bot via a required `bot_id`. Retrieval is always tenant-scoped, and additionally bot-scoped whenever you pass a `bot_id`.
> Every endpoint on both layers accepts either a `cm_` API key or a dashboard session (JWT). API keys need the `knowledge:read` / `knowledge:write` [scopes](/docs/keys); scopes do not apply to JWT callers, whose access is role-based.
**Base paths:** `https://api.callmissed.com/api/v1/knowledge` for RAG, and `https://api.callmissed.com/api/v1/bots/{bot_id}/knowledge` for the flat store.
### Which layer to use
| You want to… | Use |
| --- | --- |
| Ground an LLM reply in your documents | **Knowledge API (RAG)** |
| Run semantic search over your content | **Knowledge API (RAG)** |
| Keep a plain list of documents attached to a bot | **Bot knowledge base** |
| Upload DOCX, or a file up to 20 MB | **Bot knowledge base** |
New integrations should use the Knowledge API. The flat store predates it and is kept for existing bots — it still counts toward the storage total in your usage rollup.
## Bot Knowledge Base
A flat, non-vector list of entries on one bot. The bot must belong to your tenant or the request returns `404`.
`GET /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:read`
Returns every entry for the bot, newest first.
`POST /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:write`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string (1–255) | Yes | Label for the entry |
| `content` | string (1–100000) | Yes | The raw text |
| `metadata` | object | No | Arbitrary JSON you can read back |
```bash
curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"name":"Refund policy","content":"Refunds are issued within 7 days of purchase."}'
```
**Response (201 Created)** — a knowledge entry:
```json
{
"id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab",
"name": "Refund policy",
"content": "Refunds are issued within 7 days of purchase.",
"metadata": null,
"file_url": null,
"file_size_bytes": null,
"format": null,
"status": "indexed",
"error_message": null,
"created_at": "2026-07-20T09:15:00Z"
}
```
### Upload a document
`POST /api/v1/bots/{bot_id}/knowledge/upload` · scope `knowledge:write`
A multipart upload of a single `file`. Accepted extensions are **PDF, DOCX, and TXT**, up to **20 MB**. Text is extracted server-side and stored in `content`.
```bash
curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge/upload \
-H "Authorization: Bearer cm_your_key" \
-F 'file=@handbook.pdf'
```
The entry comes back with `format` set to the extension, `file_size_bytes` set, and `status` either `indexed` (text extracted) or `failed` with an `error_message`. A scanned PDF with no text layer yields `failed` — there is no OCR.
### Delete an entry
`DELETE /api/v1/bots/{bot_id}/knowledge/{entry_id}` · scope `knowledge:write` · returns `204 No Content`
## Knowledge API (RAG)
### How ingestion works
Ingestion is **synchronous** — the response returns only once the source is fully indexed, so there is no job to poll:
:::flow
icon:file-text | Extract | Text is taken as-is, fetched from a URL, or pulled out of a PDF
icon:scissors | Chunk | Split into ~600-token chunks with a 100-token overlap
icon:database | Embed & store | Each chunk is embedded to a 768-dimension vector and indexed for cosine similarity
:::
Embedding tokens are billed against your [credits](/docs/credits-rate-limits). If your balance cannot cover the embedding, the source is saved with `status: "failed"` and an `error_message` saying so — top up and re-ingest.
### The source object
| Field | Type | Notes |
|-------|------|-------|
| `id` | uuid | Source identifier |
| `tenant_id` | uuid | Owning tenant |
| `bot_id` | uuid | Bot the source is attached to |
| `kind` | string | `text`, `pdf`, or `url` |
| `title` | string \| null | Your label; defaults to the URL for URL sources |
| `uri` | string \| null | Source URL, or the uploaded filename for PDFs |
| `status` | string | `pending` → `ingesting` → `ready`, or `failed` |
| `error_message` | string \| null | Set when `status` is `failed` |
| `byte_size` | int \| null | Size of the extracted text in bytes |
| `token_count` | int \| null | Total tokens embedded |
| `chunk_count` | int | Number of retrievable chunks |
| `created_at` | datetime | |
| `ingested_at` | datetime \| null | Set when indexing completes |
All three ingest endpoints return the same envelope — the source plus a flat summary:
```json
{
"source": { "id": "…", "kind": "text", "status": "ready", "chunk_count": 12, "…": "…" },
"status": "ready",
"chunk_count": 12,
"token_count": 7043
}
```
### Ingest text
`POST /api/v1/knowledge/sources` · scope `knowledge:write` · returns `201`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `bot_id` | uuid | Yes | Must be a bot in your tenant |
| `title` | string (1–512) | Yes | Label for the source |
| `content` | string | Yes | The text to index; max **5 MB** of UTF-8 |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/knowledge/sources \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab",
"title": "Shipping FAQ",
"content": "We ship across India in 3-5 business days. Express delivery reaches metro cities next day."
}'
```
```python [Python]
import httpx
resp = httpx.post(
"https://api.callmissed.com/api/v1/knowledge/sources",
headers={"Authorization": "Bearer cm_your_key"},
json={
"bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab",
"title": "Shipping FAQ",
"content": "We ship across India in 3-5 business days.",
},
)
result = resp.json()
print(result["status"], result["chunk_count"]) # "ready" 1
```
:::
### Ingest a URL
`POST /api/v1/knowledge/sources/url` · scope `knowledge:write` · returns `201`
The server fetches the page itself, strips HTML to text, and indexes the result.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `bot_id` | uuid | Yes | Must be a bot in your tenant |
| `url` | string | Yes | A bare domain works — `https://` is added when no scheme is present |
| `title` | string (≤512) | No | Defaults to the URL |
```bash
curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/url \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"bot_id":"'$BOT_ID'","url":"acme.in/help/returns","title":"Returns policy"}'
```
> **Only public URLs.** Requests that resolve to private or internal address ranges are rejected with `400 URL rejected`. The fetch caps the download at **2 MB**, times out after **30 seconds**, and follows at most **5 redirects** — each hop is re-validated.
A page that yields no extractable text returns `400`, so JavaScript-rendered pages with no server-side HTML will not ingest.
### Ingest a PDF
`POST /api/v1/knowledge/sources/pdf` · scope `knowledge:write` · returns `201`
A multipart upload. Unlike the text and URL endpoints, the fields are **form fields**, not JSON.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `bot_id` | uuid (form) | Yes | Must be a bot in your tenant |
| `title` | string (form) | Yes | Label for the source |
| `file` | file | Yes | PDF only, max **5 MB** |
```bash
curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/pdf \
-H "Authorization: Bearer cm_your_key" \
-F "bot_id=$BOT_ID" \
-F 'title=Product catalogue' \
-F 'file=@catalogue.pdf'
```
> A scanned PDF is an image, not text. With no text layer to extract, the upload returns `400` — there is no OCR in v1.
### List sources
`GET /api/v1/knowledge/sources` · scope `knowledge:read`
| Query | Type | Default | Notes |
|-------|------|---------|-------|
| `bot_id` | uuid | — | Filter to one bot; omit to list the whole tenant |
| `limit` | int (1–200) | `50` | Page size |
| `offset` | int (0–10000) | `0` | Page offset |
Returns `{ "items": [...], "total": 42 }`, newest first, where `total` is the count before paging.
`GET /api/v1/knowledge/sources/{source_id}` returns a single source, or `404` if it is not in your tenant.
### Delete a source
`DELETE /api/v1/knowledge/sources/{source_id}` · scope `knowledge:write` · returns `204 No Content`
Deleting a source also deletes all of its chunks, which removes it from retrieval immediately.
## Semantic search
`POST /api/v1/knowledge/search` · scope `knowledge:read`
Runs the retrieval step on its own. Use it to tune `k` and `min_score`, or to build your own RAG prompt.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `query` | string (1–4096) | — | Required. The text to match against |
| `bot_id` | uuid | `null` | Restrict to one bot; omit to search every source in the tenant |
| `k` | int (1–50) | `6` | How many chunks to return |
| `min_score` | float (0–1) | `0.0` | Drop chunks below this score |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/knowledge/search \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab",
"query": "how long does delivery take?",
"k": 4,
"min_score": 0.6
}'
```
```python [Python]
import httpx
resp = httpx.post(
"https://api.callmissed.com/api/v1/knowledge/search",
headers={"Authorization": "Bearer cm_your_key"},
json={"query": "how long does delivery take?", "k": 4, "min_score": 0.6},
)
for chunk in resp.json()["chunks"]:
print(round(chunk["score"], 3), chunk["content"][:80])
```
:::
**Response (200 OK)**
```json
{
"query": "how long does delivery take?",
"chunks": [
{
"id": "e4a1b2c3-d4e5-6f70-8192-a3b4c5d6e7f8",
"source_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"chunk_index": 0,
"content": "We ship across India in 3-5 business days...",
"token_count": 412,
"score": 0.8317
}
]
}
```
### Reading the score
`score` is cosine similarity — `1.0` is an exact semantic match and `0.0` is unrelated. Two details matter when tuning:
- `min_score` is applied **after** ranking, not before. You can get back fewer than `k` chunks, including zero.
- A default `min_score` of `0` returns the nearest chunks whether or not they are relevant. For question-answering, start around **`0.6`** to drop off-topic matches.
Searching a bot with no ingested chunks returns an empty list without spending credits. Otherwise each search embeds the query, which is billed as a small number of embedding tokens.
## Grounding a chat completion
You do not have to call `/search` and assemble a prompt yourself. Pass `bot_id` to [chat completions](/docs/chat-completion) and the server retrieves for you, prepending the matched chunks to the system message before the model runs.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `bot_id` | string | — | Enables retrieval against that bot's sources |
| `knowledge_top_k` | int (1–50) | `6` | Chunks to retrieve |
| `knowledge_min_score` | float (0–1) | `0.0` | Minimum score to include |
```bash
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab",
"knowledge_top_k": 4,
"knowledge_min_score": 0.6,
"messages": [{"role": "user", "content": "Do you deliver to Pune?"}]
}'
```
Behaviour worth knowing:
- Retrieval runs against the **latest user message** in `messages`, not the system prompt or conversation history.
- Retrieved context is merged into your existing system message, or inserted as one if you did not send any.
- If retrieval fails, the completion still runs — just without context, rather than returning an error.
- A `bot_id` belonging to another tenant is treated as "no knowledge" instead of returning `404`.
Cost is one embedding call for the query plus the added context tokens, billed at your model's normal input rate.
## Limits
| | Bot knowledge base | Knowledge API (RAG) |
| --- | --- | --- |
| Storage | Raw documents | Chunks + vectors |
| Formats | PDF, DOCX, TXT, plain text | Plain text, PDF, URL |
| Max upload | 20 MB | 5 MB (2 MB for a URL fetch) |
| Max text per entry | 100,000 characters | 5 MB of UTF-8 |
| Chunking | None | ~600 tokens, 100-token overlap |
| Semantic search | No | Yes |
| Scoping | One bot | Ingest per bot; search per bot or tenant-wide |
## Status codes
| Code | Meaning |
|------|---------|
| `201` | Source ingested |
| `204` | Source or entry deleted |
| `400` | No extractable text, a non-PDF upload, an unsupported format, or a rejected URL |
| `403` | Key is missing the `knowledge:read` / `knowledge:write` scope |
| `404` | Bot or source not found in your tenant |
| `413` | Content or upload exceeds the size cap |
| `502` | Indexing failed after the text was extracted — safe to retry |
A `502` means extraction succeeded but embedding did not, so nothing was stored. Retry the same request. See [Errors](/docs/errors) for the standard error shape.
---
### Anthropic-Compatible API
URL: /docs/anthropic-api
> Use the Anthropic SDK with CallMissed — just change the base URL. Full Messages API compatibility.
## Overview
CallMissed provides an **Anthropic Messages API-compatible endpoint** alongside the OpenAI-compatible API. If you're already using the Anthropic SDK, you can switch to CallMissed by changing only the `base_url`.
**Endpoints:**
- `POST /v1/messages` — chat completions (streaming + non-streaming)
- `POST /v1/messages/count_tokens` — token count estimation (real BPE, not char-based)
- `GET /anthropic/v1/models` — list models in Anthropic shape with capability metadata
- `GET /anthropic/v1/models/{model_id}` — single model detail
- `POST /anthropic/v1/messages` — alternate path for the chat endpoint
**Authentication:** Use either header style:
- `x-api-key: cm_your_key` (Anthropic SDK default)
- `Authorization: Bearer cm_your_key` (OpenAI style)
## Basic Usage
:::tabs
```python [Python]
import anthropic
client = anthropic.Anthropic(
api_key="cm_your_key",
base_url="https://api.callmissed.com"
)
message = client.messages.create(
model="gpt-5.6-sol",
max_tokens=1024,
system="You are a helpful assistant.",
messages=[
{"role": "user", "content": "What is the capital of India?"}
]
)
print(message.content[0].text)
```
```javascript [JavaScript]
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com",
});
const message = await client.messages.create({
model: "gpt-5.6-sol",
max_tokens: 1024,
system: "You are a helpful assistant.",
messages: [
{ role: "user", content: "What is the capital of India?" },
],
});
console.log(message.content[0].text);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/messages \
-H "x-api-key: cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [
{"role": "user", "content": "What is the capital of India?"}
]
}'
```
:::
**Response:**
```json
{
"id": "msg-abc123def456",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "The capital of India is New Delhi."}
],
"model": "gpt-5.6-sol",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 25,
"output_tokens": 12
}
}
```
## Streaming
Set `stream: true` to receive Server-Sent Events with the full Anthropic streaming lifecycle:
:::tabs
```python [Python]
import anthropic
client = anthropic.Anthropic(
api_key="cm_your_key",
base_url="https://api.callmissed.com"
)
with client.messages.stream(
model="gpt-5.6-sol",
max_tokens=1024,
messages=[{"role": "user", "content": "Tell me a short story."}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
```
```javascript [JavaScript]
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com",
});
const stream = client.messages.stream({
model: "gpt-5.6-sol",
max_tokens: 1024,
messages: [{ role: "user", content: "Tell me a short story." }],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/messages \
-H "x-api-key: cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"max_tokens": 1024,
"stream": true,
"messages": [
{"role": "user", "content": "Tell me a short story."}
]
}'
```
:::
**SSE event lifecycle:**
```
event: message_start → message metadata + input token count
event: content_block_start → new content block begins
event: content_block_delta → text chunks (repeats)
event: content_block_stop → content block complete
event: message_delta → stop_reason + output token count
event: message_stop → stream complete
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `model` | string | Yes | Model ID (e.g. `gpt-5.6-sol`, `sarvam-105b`, `kimi-k2.6`) |
| `max_tokens` | integer | Yes | Maximum tokens to generate |
| `messages` | array | Yes | List of `{role, content}` objects |
| `system` | string | No | System prompt (top-level, not in messages) |
| `stream` | boolean | No | Enable streaming (default: false) |
| `temperature` | number | No | Sampling temperature (0–1) |
| `top_p` | float | No | Nucleus sampling (0–1) |
| `top_k` | integer | No | Top-K sampling |
| `stop_sequences` | array | No | Stop sequences |
| `metadata` | object | No | Request metadata (e.g. `{"user_id": "u123"}`) |
> **Note:** Unlike the OpenAI API, `max_tokens` is **required** and `system` is a **top-level parameter** (not a message with `role: "system"`).
## Model Selection
Send any model ID from the [Models](/docs/models) catalog — not just Anthropic-shaped names. The `model` field takes the same values as `/v1/chat/completions`.
```json
{ "model": "gpt-5.6-sol", "max_tokens": 1024, "messages": [...] }
```
## Token Counting
Estimate input token count before sending a request. The endpoint uses a BPE
tokenizer (tiktoken `cl100k_base`) — close to Claude's real tokenizer on
typical English prompts, and noticeably more accurate than char-length
heuristics.
```bash
curl -X POST https://api.callmissed.com/v1/messages/count_tokens \
-H "x-api-key: cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"messages": [{"role": "user", "content": "Hello, how are you?"}],
"system": "You are a helpful assistant."
}'
```
**Response:**
```json
{"input_tokens": 15}
```
Image content blocks contribute a fixed estimate (~258 tokens per image) rather
than a fetch-and-resize pass. Tool definitions are counted against the total by
serializing each to JSON and tokenizing the schema.
## Listing Models
List all available models via the Anthropic-shape endpoint:
```bash
curl https://api.callmissed.com/anthropic/v1/models \
-H "x-api-key: cm_your_key"
```
**Response:**
```json
{
"data": [
{
"type": "model",
"id": "gpt-5.6-sol",
"display_name": "GPT-5.6 Sol",
"created_at": "2023-11-14T22:13:20+00:00",
"description": "Frontier model for complex professional work. Multimodal, reasoning + tools.",
"category": "llm",
"context_window": 1050000,
"context_length": 1050000,
"pricing": {"input": 5.00, "output": 30.00, "unit": "per_million_tokens", "currency": "USD"},
"supports_streaming": true,
"supports_tools": true,
"supports_reasoning": true,
"supports_vision": true
}
],
"has_more": false,
"first_id": "...",
"last_id": "..."
}
```
Fetch a single model at `GET /anthropic/v1/models/{model_id}`.
## Vision (Image Input)
Send images on any model whose `supports_vision` flag is `true` in the model
listing. That is the authoritative source; see the
[vision list](/docs/chat-completion#vision-image-input) for the current set.
Models without vision reject image content with `400 invalid_request_error`
before the upstream call — you are not charged.
```bash
curl -X POST https://api.callmissed.com/v1/messages \
-H "x-api-key: cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}
]
}]
}'
```
## Error Format
Errors return the Anthropic format (different from the OpenAI endpoints):
```json
{
"type": "error",
"error": {
"type": "authentication_error",
"message": "Invalid API key"
}
}
```
| Error type | HTTP Status | When |
|------------|-------------|------|
| `authentication_error` | 401 | Bad or missing API key |
| `permission_error` | 403 | Account inactive, domain blocked, or free tier model restriction |
| `invalid_request_error` | 400/402 | Bad request or insufficient credits |
| `rate_limit_error` | 429 | Plan limit or API key rate limit exceeded |
| `not_found_error` | 404 | Model not found |
| `api_error` | 502 | Provider failure |
**Rate limit headers** are returned in Anthropic format:
```
anthropic-ratelimit-requests-limit: 60
anthropic-ratelimit-requests-remaining: 45
anthropic-ratelimit-requests-reset: 2026-05-01T00:00:00+00:00
```
## Differences from Anthropic
This endpoint is designed to work with the Anthropic SDK out of the box. Key differences from the official Anthropic API:
- **`anthropic-version` header** is accepted but not required
- **Model routing** — requests can target any model in the CallMissed catalogue, not just Anthropic-shaped names.
- **Token counting** uses a BPE tokenizer approximation (tiktoken `cl100k_base`). Expect ~5-10% variance from Anthropic's native counts on English prompts; larger on CJK and heavy-punctuation text.
- **Tools** are supported — `tools` and `tool_choice` work as documented, and `tool_use`/`tool_result` content blocks are preserved.
- **Vision** is supported on models whose `supports_vision` flag is `true`. Image content sent to text-only models is rejected with a `400 invalid_request_error` before the upstream call, so your credits are safe.
- **Message Batches API** (`/v1/messages/batches`) is not implemented — use the regular `/v1/messages` endpoint.
- **Billing** uses CallMissed credits, not Anthropic billing.
---
### Chat Completion
URL: /docs/chat-completion
> Generate text responses using our OpenAI-compatible chat completion API.
:::cards
/docs/chat-streaming | Streaming | play | Server-sent events for real-time responses
/docs/chat-function-calling | Function Calling | settings | Tool use with structured outputs
/docs/models | Model Catalog | boxes | Pick the right model for your workload
/docs/anthropic-api | Anthropic API | file-text | Messages API compatible endpoint
:::
## Overview
The Chat Completion API generates AI responses given a list of messages. It's fully OpenAI-compatible — use the same SDK and request format.
**Endpoint:** `POST /v1/chat/completions`
### How a request flows
Every chat completion takes the same path through the platform — your app never talks to the underlying provider directly:
:::flow
icon:app | Your app | Send `POST /v1/chat/completions` with `model` + `messages`
icon:gateway | CallMissed gateway | Authenticate the `cm_` key, check credits, route by model id
icon:provider | Provider | Run inference on the best-fit backend — picked from the model id
icon:gateway | CallMissed gateway | Stream tokens back and deduct credits when the response completes
icon:done | Your app | Receive the completion (all at once, or token-by-token when streaming)
:::
> **Tip:** The model id decides routing automatically — you never pick a backend. See [How CallMissed Works](/docs/how-it-works).
## Make your first request
:::steps
## Get an API key
Create a key in the [dashboard](https://console.callmissed.com/developer/keys) (**Developer → API keys**). It looks like `cm_xxxx…` and is shown once.
## Point your SDK at CallMissed
Set the base URL to `https://api.callmissed.com/v1` and pass your `cm_` key. No other change to your OpenAI code.
## Send messages and read the reply
Call `chat.completions.create` with a `model` and a `messages` array. Read `response.choices[0].message.content`.
:::
:::capabilities
### Basic completion
Send a single-turn or multi-turn conversation and receive a complete response. Use any OpenAI SDK — set `base_url` to `https://api.callmissed.com/v1` and `api_key` to your `cm_` key.
### Streaming
Set `stream: true` to receive tokens as they're generated. See [Streaming](/docs/chat-streaming) for full examples.
### Function calling
Pass a `tools` array to let the model call your functions. See [Function Calling](/docs/chat-function-calling).
:::
## Basic Usage
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1"
)
response = client.chat.completions.create(
model="sarvam-105b",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of India?"}
]
)
print(response.choices[0].message.content)
```
```javascript [JavaScript]
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const response = await client.chat.completions.create({
model: "sarvam-105b",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What is the capital of India?" },
],
});
console.log(response.choices[0].message.content);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "sarvam-105b",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of India?"}
]
}'
```
:::
## Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `model` | string | Model ID (e.g. `sarvam-105b`, `gpt-5.6-luna`) |
| `messages` | array | List of `{role, content}` objects. System prompt goes here as `{"role": "system", "content": "..."}` |
| `stream` | boolean | Enable streaming SSE responses |
| `temperature` | number | Sampling temperature (0–2) |
| `max_tokens` | integer | Maximum tokens to generate |
| `n` | integer | Number of completions to generate (default 1) |
| `top_p` | float | Nucleus sampling (0–1) |
| `top_k` | integer | Top-K sampling |
| `frequency_penalty` | float | Penalize repeated tokens (−2 to 2) |
| `presence_penalty` | float | Penalize new topics (−2 to 2) |
| `repetition_penalty` | float | Reduce repetition (0–2) |
| `seed` | integer | Deterministic sampling |
| `stop` | array | Stop sequences |
| `logit_bias` | object | Token probability adjustments |
| `logprobs` | boolean | Return log probabilities |
| `top_logprobs` | integer | Top N log probs per token |
| `tools` | array | Tool/function definitions for function calling |
| `parallel_tool_calls` | boolean | Allow parallel function calls |
| `response_format` | object | `{"type": "json_object"}` or `{"type": "json_schema", "json_schema": {...}}` |
| `structured_outputs` | boolean | Enforce strict JSON schema |
| `stream_options` | object | `{"include_usage": true}` to get token counts in stream |
| `reasoning_effort` | string | `"none"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` — see the per-model matrix below. `"xhigh"` (maximum reasoning) is accepted by the GPT-5.5 / GPT-5.6 family; other models map it down to their highest supported value. |
> **OpenAI Python SDK note** — The OpenAI client validates kwargs against its
> known parameters, so a CallMissed-specific field such as `reasoning_effort`
> raises `TypeError: Completions.create() got an unexpected keyword argument`.
> Pass it via `extra_body` instead:
>
> ```python
> client.chat.completions.create(
> model="kimi-k2.6",
> messages=[...],
> extra_body={"reasoning_effort": "none"},
> )
> ```
>
> Raw HTTP / curl users can keep it at the top level — only the OpenAI SDK gates kwargs.
## Model Substitution
CallMissed never substitutes your model. Send a `model` and you get that model,
or a clean error (`429`/`503` with `Retry-After`). You are never billed for a
model you did not name.
Need a model that is not in the catalog? See
[Models on demand](/docs/models#models-on-demand).
## Vision (Image Input)
Multimodal content (text + image parts) is accepted on any model whose
`supports_vision` flag is `true` in `GET /v1/models`. Models without vision
support reject image content with `400 unsupported_image_input` **before** the
upstream call, so you're not charged.
```python
from openai import OpenAI
client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1")
resp = client.chat.completions.create(
model="gpt-5.6-sol", # supports_vision: true
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
],
}],
)
```
Vision-capable models: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`,
`gpt-5.5`, `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `grok-4.3`, `kimi-k2.5`,
`kimi-k2.5-fast`, `kimi-k2.6`, `kimi-k2.7-code`, `gemma-4-26b-a4b-it`,
`mistral-small-3.1`.
`GET /v1/models` is authoritative. Read `supports_vision` there rather than
hard-coding this list.
## Context Window
Every model in the catalog advertises a `context_window` (token count for the
combined prompt + completion). The `GET /v1/models` response exposes it under
two keys for cross-client compatibility:
- `context_window` (OpenAI/CallMissed canonical name)
- `context_length` (OpenAI SDK convention — same value)
```python
from openai import OpenAI
client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1")
for m in client.models.list():
extra = m.model_extra or {}
print(m.id, extra.get("context_window"), extra.get("supports_vision"))
```
Snapshot — `GET /v1/models` is authoritative:
| Model | context_window |
|-------|----------------|
| `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 |
| `gpt-4.1` | 1,047,576 |
| `gpt-5.5`, `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | 1,000,000 |
| `gpt-5-mini` | 400,000 |
| `kimi-k2.6`, `kimi-k2.7-code`, `glm-5.2` | 262,144 |
| `kimi-k2.5`, `kimi-k2.5-fast`, `nemotron-3-super` | 256,000 |
| `grok-4.3` | 200,000 |
| `sarvam-105b`, `sarvam-105b-conversations`, `glm-4.7-flash`, `gemma-4-26b-a4b-it` | 131,072 |
| `gpt-4o`, `gpt-oss-120b`, `mistral-small-3.1` | 128,000 |
## Responses API
For clients built on OpenAI's newer **Responses API**, CallMissed exposes a compatible `POST /v1/responses` endpoint. It accepts a Responses-shaped body and translates to the same chat engine under the hood — so you can point an OpenAI Responses client at `https://api.callmissed.com/v1` without changes.
**Endpoint:** `POST /v1/responses`
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1")
resp = client.responses.create(
model="gpt-4.1",
input="Write a haiku about databases.",
)
print(resp.output_text)
```
```bash [cURL]
curl https://api.callmissed.com/v1/responses \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4.1",
"input": "Write a haiku about databases."
}'
```
:::
- `input` accepts a plain string or the Responses message-array form.
- Streaming is supported (`stream: true`) and emits Responses-style SSE events.
- The same models, pricing, vision, and tool-calling support as `/v1/chat/completions` apply — this is a request/response-shape adapter, not a different model set.
If you're starting fresh, `/v1/chat/completions` is the most widely-supported surface; use `/v1/responses` when porting an existing Responses-API integration.
## Error Format
All errors return OpenAI-compatible format:
```json
{
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}
```
---
### Function Calling
URL: /docs/chat-function-calling
> Use tool calls and function calling with the chat completion API.
## Overview
Function calling lets the model invoke your functions. Pass a `tools` array and the model returns structured `tool_calls` when it wants to call a function.
## Defining Tools
```json
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
}
]
}
```
## Tool Choice
| Value | Description |
|-------|-------------|
| `"auto"` | Model decides whether to call a tool (default) |
| `"none"` | Never call tools |
| `"required"` | Always call at least one tool |
| `{"type": "function", "function": {"name": "get_weather"}}` | Force a specific function |
Set `parallel_tool_calls: true` to allow the model to call multiple tools in one response.
## Handling Response
When the model calls a tool, `finish_reason` is `"tool_calls"` and `message.content` is `null`:
```json
{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{"city": "Mumbai"}"
}
}]
}
}]
}
```
Send the tool result back as a `tool` role message:
```json
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{"temperature": 32, "condition": "sunny"}"
}
```
## Counting Tool Calls
Every response includes a `usage.tool_call_count` field — the number of tool calls the model made in that response (`0` when none). It is present on both streaming and non-streaming responses, so you can track tool usage per request:
```json
{
"choices": [{ "finish_reason": "tool_calls", "message": { "tool_calls": [/* ... */] } }],
"usage": { "prompt_tokens": 18, "completion_tokens": 25, "total_tokens": 43, "tool_call_count": 2 }
}
```
When streaming, each chunk that carries a `delta.tool_calls` fragment also includes a top-level `tool_call_count` that increments as new tool calls begin — useful for showing a live "tools called" counter in your UI. The definitive total is always in the final `usage` chunk (requires `stream_options: {"include_usage": true}`).
## Full Example
```python
# Step 1: Send initial request with tools
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "What's the weather in Mumbai?"}],
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}],
tool_choice="auto"
)
# Step 2: Check if model wants to call a tool
msg = response.choices[0].message
if msg.tool_calls:
tool_call = msg.tool_calls[0]
# Execute your function here...
result = get_weather(json.loads(tool_call.function.arguments)["city"])
# Step 3: Send result back
final = client.chat.completions.create(
model="sarvam-105b",
messages=[
{"role": "user", "content": "What's the weather in Mumbai?"},
msg, # assistant message with tool_calls
{"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result)}
]
)
print(final.choices[0].message.content)
```
---
### Streaming
URL: /docs/chat-streaming
> Stream chat completion responses in real-time using Server-Sent Events.
## Overview
Enable streaming by setting `"stream": true`. The response is a Server-Sent Events (SSE) stream with `Content-Type: text/event-stream`.
## SSE Format
Each event is a line starting with `data: ` followed by a JSON chunk:
```
data: {"id":"...","choices":[{"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"...","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"...","choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
```
- **First chunk** always includes `{"delta": {"role": "assistant", "content": ""}}`
- **Content chunks** carry `{"delta": {"content": "token"}}`
- **Final chunk** has `{"delta": {}, "finish_reason": "stop"}`
- **End marker** is `data: [DONE]`
## Usage in Stream
To get token usage in the stream, set `stream_options: {"include_usage": true}`. A final chunk with a `usage` field is sent before `[DONE]`:
```json
data: {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46,"tool_call_count":0}}
data: [DONE]
```
The `usage.tool_call_count` field is the number of tool calls the model made in this response (`0` when none). It is always present in the usage chunk. While the response streams, any chunk that carries a `delta.tool_calls` fragment also includes a running `tool_call_count` at the top level, so you can show a live counter as tools are invoked.
## Code Example
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1"
)
stream = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
stream_options={"include_usage": True}
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
```
```javascript [JavaScript]
const stream = await client.chat.completions.create({
model: "sarvam-105b",
messages: [{ role: "user", content: "Hello" }],
stream: true,
stream_options: { include_usage: true },
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) process.stdout.write(content);
}
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"sarvam-105b","messages":[{"role":"user","content":"Hello"}],"stream":true}'
```
:::
---
### Embeddings
URL: /docs/embeddings
> Turn text into vectors with the OpenAI-compatible embeddings endpoint — batching, dimensions, base64 output, and per-token pricing.
## Overview
`POST /v1/embeddings` converts text into a dense float vector you can store in your own vector database and search with cosine similarity. It is the primitive behind retrieval, semantic search, clustering, deduplication and classification.
The request and response are **OpenAI-compatible**, so the official OpenAI SDKs work unchanged once you point them at `https://api.callmissed.com/v1` with a `cm_` key.
> If you want retrieval without running your own vector store, use [Knowledge & RAG](/docs/knowledge) instead — it ingests, chunks, embeds and searches for you.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
This endpoint is gated by the key's **service permission**, not by a resource scope. The key needs `llm` (or `*`). There is no separate `embedding` permission — a key that can call `/v1/chat/completions` can call `/v1/embeddings`.
A key without it returns `403`:
```json
{
"error": {
"message": "This API key does not have permission for embeddings (requires LLM permission). Update key permissions in your dashboard.",
"type": "invalid_request_error",
"code": "permission_denied"
}
}
```
## Models
Both embedding models are **free-plan callable** — they are metered per input token, so your credit balance is the only governor.
| Model | Dimensions | Max input | Price (per 1M input tokens) |
| --- | --- | --- | --- |
| `text-embedding-3-small` | 1536 | 8,192 tokens | $0.02 |
| `text-embedding-3-large` | 3072 | 8,192 tokens | $0.13 |
Start with `text-embedding-3-small`: it is the better price/performance choice for large corpora. Move to `-large` only when you have measured that retrieval quality is the bottleneck.
Both appear in `GET /v1/models` with `"owned_by": "openai"`.
## Quickstart
```bash
curl https://api.callmissed.com/v1/embeddings \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"model": "text-embedding-3-small",
"input": "Where is my order?"
}'
```
```json
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": [0.0023064255, -0.009327292, 0.015797347]
}
],
"model": "text-embedding-3-small",
"usage": {
"prompt_tokens": 5,
"total_tokens": 5
}
}
```
```python
from openai import OpenAI
client = OpenAI(
api_key="cm_your_api_key",
base_url="https://api.callmissed.com/v1",
)
resp = client.embeddings.create(
model="text-embedding-3-small",
input=["Where is my order?", "How do I return this?"],
)
vectors = [row.embedding for row in resp.data]
```
## Request
| Field | Type | Required | Default | Constraints |
| --- | --- | --- | --- | --- |
| `model` | `string` | Yes | — | `text-embedding-3-small` or `text-embedding-3-large` |
| `input` | `string` or `string[]` | Yes | — | Up to **128 items** per request; each item non-empty, at most **100,000 characters** and within the model's 8,192-token limit. Pre-tokenised integer arrays are **not** accepted |
| `encoding_format` | `string` | No | `float` | `float` or `base64` |
| `dimensions` | `integer` | No | model native | `1 <= dimensions <= 3072` (large) or `1536` (small) |
| `user` | `string` | No | — | At most 256 characters. An opaque end-user identifier for your own abuse tracing |
### Batching
Send an array to embed up to 128 strings in one round trip. The `index` on each row matches the position in your `input` array, so you can zip the results back onto your records without re-ordering.
```json
{
"model": "text-embedding-3-small",
"input": ["first chunk", "second chunk", "third chunk"]
}
```
A batch of 129 or more returns `422`:
```json
{ "error": { "message": "`input` array too long: 200 items (maximum 128). Split the batch across multiple requests.", "type": "invalid_request_error", "code": "invalid_request_error" } }
```
### Shortening vectors with `dimensions`
Both models support Matryoshka-style truncation. Passing `dimensions` returns a shorter, renormalised vector — smaller index, faster search, slightly lower recall.
```json
{ "model": "text-embedding-3-large", "input": "hello", "dimensions": 256 }
```
`dimensions` must be between `1` and the model's native size. Anything else returns `422`.
> Vectors of different lengths are not comparable. Pick one model **and** one `dimensions` value per index and keep it fixed — re-embed the whole corpus if you change either.
### `encoding_format: "base64"`
`base64` returns each vector as a base64 string of little-endian `float32` values instead of a JSON array. It is roughly a third of the payload size, which matters when you are embedding thousands of chunks.
```python
import base64, struct
raw = base64.b64decode(resp.data[0].embedding)
vector = list(struct.unpack(f"<{len(raw) // 4}f", raw))
```
## Response
| Field | Type | Notes |
| --- | --- | --- |
| `object` | `string` | Always `list` |
| `data[].object` | `string` | Always `embedding` |
| `data[].index` | `integer` | Position in your `input` array |
| `data[].embedding` | `number[]` or `string` | Float array, or a base64 string when `encoding_format` is `base64` |
| `model` | `string` | The model that served the request |
| `usage.prompt_tokens` | `integer` | Input tokens billed |
| `usage.total_tokens` | `integer` | Same as `prompt_tokens` — embeddings have no output tokens |
## Billing
Embeddings are metered on **input tokens only**. Credits are deducted as `tokens / 1,000,000 x rate`, where 1 credit = $0.01.
- `text-embedding-3-small` — 2 credits per 1M input tokens
- `text-embedding-3-large` — 13 credits per 1M input tokens
A request that fails with a `4xx` or `5xx` is recorded in your usage log but **not charged**. Track spend with [`GET /v1/usage/summary`](/docs/usage-api).
## Errors
| Status | `code` | When |
| --- | --- | --- |
| `400` | `invalid_request_error` | Body is not a JSON object |
| `400` | `context_length_exceeded` | An input item exceeds the model's 8,192-token limit |
| `401` | `invalid_api_key` / `api_key_expired` | Missing, malformed, or expired key |
| `402` | `insufficient_credits` | Balance is exhausted. The `X-Credits-Balance` header carries the current balance |
| `402` | `budget_exceeded` | The key's own budget cap was hit |
| `403` | `permission_denied` | Key lacks the `llm` permission |
| `403` | `model_not_allowed` | The key's `allowed_models` list excludes this model |
| `404` | `model_not_found` | Unknown embedding model id |
| `413` | `invalid_request_error` | An input item is over 100,000 characters |
| `422` | `invalid_request_error` | Batch too long, empty input, bad `dimensions`, unsupported `encoding_format`, or a token array instead of a string |
| `429` | `rate_limit_exceeded` | Per-key request rate exceeded. Retry with backoff |
| `429` | `quota_exceeded` | Plan or monthly budget cap reached. Honour `Retry-After` |
| `502` | `upstream_error` | Embedding generation failed. Safe to retry |
| `503` | `service_unavailable` | Temporary capacity problem. Retry with backoff |
Every error uses the standard envelope:
```json
{ "error": { "message": "…", "type": "invalid_request_error", "code": "model_not_found", "request_id": "req_…" } }
```
## Building a search index
1. Chunk your documents to roughly 200–500 tokens with a little overlap.
2. Embed chunks in batches of 128 with `text-embedding-3-small`.
3. Store `{id, text, vector, metadata}` in your vector database.
4. At query time, embed the query with the **same model and `dimensions`**, then retrieve by cosine similarity.
5. Pass the top chunks to [`POST /v1/chat/completions`](/docs/chat-completion) as context.
```python
query = client.embeddings.create(
model="text-embedding-3-small",
input="refund policy",
).data[0].embedding
# hits = your_vector_db.search(query, top_k=5)
```
---
### Model Access by Plan
URL: /docs/model-access
> See which models each plan tier (Free, Starter, Pro, Enterprise) can access, with cURL, Python, and JavaScript examples.
## Overview
The Model Access endpoint returns which model IDs each plan tier can call, bucketed by category (LLM, STT, TTS, Image). This is useful for:
- Showing users which models they can access on their current plan
- Building model selectors that grey out unavailable models
- Checking whether a specific model requires an upgrade
**No authentication required.**
## Endpoint
```
GET /api/v1/models/access
```
## Response Shape
```json
{
"plans": {
"free": {
"models": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "glm-4.7-flash", "glm-5.2", "gpt-oss-120b", "nemotron-3-super", "gemma-4-26b-a4b-it", "mistral-small-3.1", "saaras:v3", "saaras:v4", "whisper-large-v3-turbo", "nova-3", "bulbul:v3", "aura-2-en", "aura-2-es", "melotts", "flux-2-klein-9b", "flux-2-dev", "lucid-origin", "phoenix-1.0", "sdxl-lightning", "dreamshaper-8-lcm", "text-embedding-3-small", "text-embedding-3-large"],
"by_category": {
"llm": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "glm-4.7-flash", "glm-5.2", "gpt-oss-120b", "nemotron-3-super", "gemma-4-26b-a4b-it", "mistral-small-3.1"],
"stt": ["saaras:v3", "saaras:v4", "whisper-large-v3-turbo", "nova-3"],
"tts": ["bulbul:v3", "aura-2-en", "aura-2-es", "melotts"],
"image": ["flux-2-klein-9b", "flux-2-dev", "lucid-origin", "phoenix-1.0", "sdxl-lightning", "dreamshaper-8-lcm"],
"embedding": ["text-embedding-3-small", "text-embedding-3-large"]
},
"restriction": "27 models across 5 categories"
},
"starter": {
"models": ["...every model in the catalog"],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models"
},
"pro": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models"
},
"enterprise": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models + models deployed on demand"
}
}
}
```
## Code Examples
:::tabs
```bash [cURL]
curl https://api.callmissed.com/api/v1/models/access
```
```python [Python]
import requests
resp = requests.get("https://api.callmissed.com/api/v1/models/access")
data = resp.json()
# List free LLM models
free_llms = data["plans"]["free"]["by_category"]["llm"]
print("Free LLM models:", free_llms)
# Check if a model is available on free plan
model = "gpt-5.6-luna"
is_free = model in data["plans"]["free"]["models"]
print(f"{model} on free plan: {is_free}") # False
```
```typescript [JavaScript / TypeScript]
const resp = await fetch("https://api.callmissed.com/api/v1/models/access");
const data = await resp.json();
// List free LLM models
const freeLLMs = data.plans.free.by_category.llm;
console.log("Free LLM models:", freeLLMs);
// Check if a model is available on free plan
const model = "gpt-5.6-luna";
const isFree = data.plans.free.models.includes(model);
console.log(model, "on free plan:", isFree); // false
```
:::
## Free Plan Models
The free tier includes **27 models**:
| Category | Models |
|----------|--------|
| LLM (11) | `sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `glm-4.7-flash`, `glm-5.2`, `gpt-oss-120b`, `nemotron-3-super`, `gemma-4-26b-a4b-it`, `mistral-small-3.1` |
| STT (4) | `saaras:v3`, `saaras:v4`, `whisper-large-v3-turbo`, `nova-3` |
| TTS (4) | `bulbul:v3`, `aura-2-en`, `aura-2-es`, `melotts` |
| Image (6) | `flux-2-klein-9b`, `flux-2-dev`, `lucid-origin`, `phoenix-1.0`, `sdxl-lightning`, `dreamshaper-8-lcm` |
| Embedding (2) | `text-embedding-3-small`, `text-embedding-3-large` |
Every other model — `kimi-k2.5-fast`, the first-party OpenAI / xAI / DeepSeek IDs, the realtime voice models, the Deepgram direct line, and the paid image models — requires Starter, Pro, or Enterprise.
## Error Handling
When a free-plan user calls a paid model, the API returns:
```json
{
"error": {
"message": "Model 'gpt-5.6-luna' requires a paid plan. See GET /api/v1/models/access for the full list of free-plan models. Upgrade at https://console.callmissed.com/org/billing",
"type": "invalid_request_error",
"code": "model_not_available"
}
}
```
HTTP status: `403`
---
### Indic Models
URL: /docs/models-indic
> Indic STT, TTS, and LLM models — optimized for Indian languages.
## LLM
### sarvam-105b
- **Architecture:** 105B MoE, MLA architecture
- **Context:** 128K tokens
- **Training:** Pre-trained on 12T tokens
- **Best for:** Complex reasoning, agentic tasks, long documents
- **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"`
### sarvam-105b-conversations
- **Architecture:** 105B MoE, tuned for conversation and voice
- **Context:** 128K tokens
- **Tool calling:** yes
- **Streaming:** yes
- **Best for:** Multi-turn dialogue, voice agents, assistants that talk
- **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"`
Same family, same price and same 128K window as `sarvam-105b` — tuned for spoken dialogue rather than long-form work. It does not accept image input.
### Thinking Mode
The `sarvam-105b` models support hybrid thinking mode:
```python
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Solve this complex problem step by step"}],
extra_body={"reasoning_effort": "high"}
)
```
| Value | Description |
|-------|-------------|
| `"low"` | Minimal reasoning — fastest, cheapest |
| `"medium"` | Balanced reasoning |
| `"high"` | Deep reasoning — best quality, slower |
The `sarvam-*` models reject `"none"` and `"minimal"`; the API maps both
of those values down to `"low"` so an OpenAI-style client that sends
`reasoning_effort: "none"` for thinking-off still works. Full
thinking-disable is available on the direct-routed `kimi-k2.5` / `kimi-k2.6` /
`kimi-k2.7-code` / `gemma-4-26b-a4b-it` models — see the [reasoning_effort matrix](/docs/api-speed#3-reasoning-effort-by-model).
## Speech to Text
### saaras:v3
- **Languages:** 23 (22 Indic + English)
- **Output modes:** transcribe, translate, verbatim, translit, codemix
- **Auto language detection:** yes
- **Telephony support:** 8kHz audio
- **Endpoint:** `POST /v1/audio/transcriptions`
Supported languages include: Hindi, Bengali, Gujarati, Kannada, Malayalam, Marathi, Odia, Punjabi, Tamil, Telugu, Urdu, Assamese, Bodo, Dogri, Kashmiri, Konkani, Maithili, Manipuri, Nepali, Sanskrit, Santali, Sindhi, and English.
### saaras:v4
- **Languages:** 24
- **Output modes:** transcribe, translate, verbatim, translit, codemix
- **Auto language detection:** yes
- **Endpoint:** `POST /v1/audio/transcriptions`
Five output modes on one model — standard transcription, English translation, verbatim (fillers kept), Latin-script transliteration, and code-mixed output. Select one with the `mode` form field; `transcribe` is the default.
## Text to Speech
### bulbul:v3
- **Voices:** 37 speakers
- **Languages:** 11
- **Audio codecs:** WAV, MP3, OPUS, FLAC, AAC, Mulaw, Alaw, PCM
- **Pace:** 0.5–2.0 (maps to `speed` parameter)
- **Sample rates:** 8000, 16000, 22050, 24000, 48000 Hz
- **Endpoint:** `POST /v1/audio/speech`
Default voice: `shubh`. See the [Voices](/docs/tts-voices) page for the full list.
---
### Kimi K2.5 Fast (Maintenance)
URL: /docs/models-kimi-fast
> High-throughput Kimi K2.5 inference tier — currently under maintenance. Use kimi-k2.5 in the meantime.
> **Under maintenance.** `kimi-k2.5-fast` is temporarily unavailable. Requests return HTTP 503 with `code: "model_under_maintenance"`. Use [`kimi-k2.5`](/docs/models) for production traffic; both ride on the same Kimi K2.5 model from Moonshot AI.
## Overview
The `kimi-k2.5-fast` tier targets ultra-low-latency voice-agent workloads via a high-throughput inference partner. While it's under maintenance, route the same workload through `kimi-k2.5` — the model and tokeniser are identical, only the inference latency differs.
## Kimi K2.5 Fast
| Field | Value |
|-------|-------|
| Model ID | `kimi-k2.5-fast` |
| Status | **Under maintenance** — returns 503 |
| Recommended fallback | `kimi-k2.5` |
| Architecture | MoE (Mixture of Experts) |
| Context window | 256,000 tokens |
| Supports streaming | Yes |
| Supports tools | Yes |
Kimi K2.5 (by Moonshot AI) is a 1T-parameter MoE model with 32B active parameters. It excels at reasoning, coding, and multilingual tasks.
## Usage
While `kimi-k2.5-fast` is in maintenance, point your code at `kimi-k2.5`:
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.callmissed.com/v1",
api_key="cm_your_api_key",
)
response = client.chat.completions.create(
model="kimi-k2.5", # kimi-k2.5-fast is under maintenance
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Explain quantum computing briefly."},
],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="", flush=True)
```
## Pricing
| Direction | Cost per 1M tokens |
|-----------|-------------------|
| Input | $0.81 |
| Output | $4.05 |
**1 credit = ₹1 = $0.01.** A typical voice-agent turn — 500 input + 200 output tokens — costs $0.001215, or **0.1215 credits**. These rates apply once `kimi-k2.5-fast` leaves maintenance. See [Credits & Rate Limits](/docs/credits-rate-limits).
---
### Models
URL: /docs/models
> Every model CallMissed serves — Indic STT/TTS/LLM, fast direct-routed LLMs, first-party flagships, realtime voice and image — through one OpenAI-compatible API, plus 300+ more we deploy on demand.
:::cards
/docs/model-access | Model Access by Plan | key | Free, Starter, Pro, and Enterprise model tiers
/docs/models-indic | Indic Models | mic | Indic STT, TTS, and LLM models
/docs/models-kimi-fast | Fast LLMs | zap | High-throughput Kimi tier for voice-agent latency
/docs/api-speed | API Speed | gauge | Latency benchmarks and reasoning-effort matrix
:::
## Overview
125 models, one OpenAI-compatible API. Same auth, same request shape — change
the `model` field and nothing else.
| Group | What it is |
|-------|------------|
| **Fast LLMs** | Kimi K2.5 at up to ~414 tok/s. The default for voice agents. |
| **Indic models** | STT, TTS and LLM built for 22 Indian languages. |
| **Direct-routed LLMs** | Sub-2s open-weights models: Kimi K2.5/K2.6/K2.7 Code, GPT-OSS, Gemma 4, GLM, Nemotron, Mistral Small. |
| **First-party** | `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-*`, `grok-4.3`, `DeepSeek-V4-*`, realtime voice, plus first-party STT/TTS. |
| **On demand** | [300+ more we deploy on request](#models-on-demand). |
## Models API
List all available models programmatically. **No authentication required.**
```bash
# List all models
curl https://api.callmissed.com/api/v1/models
# Filter by category: llm, stt, tts
curl https://api.callmissed.com/api/v1/models?category=llm
# Filter free-plan models only
curl https://api.callmissed.com/api/v1/models?free=true
# Get a specific model
curl https://api.callmissed.com/api/v1/models/sarvam-105b
# Which models each plan tier can call
curl https://api.callmissed.com/api/v1/models/access
```
Response includes: `id`, `name`, `description`, `category`, `owned_by`, `context_window`, `context_length` (alias of `context_window` for OpenAI-style clients), `pricing`, `free`, `supports_streaming`, `supports_tools`, `supports_reasoning`, and `supports_vision`.
The OpenAI-compatible listing at `GET /v1/models` (requires `Authorization: Bearer cm_*`) returns the same fields but a **shorter list**: it hides models that are only valid for voice sessions (`nova-sonic*`, `gpt-realtime*`, `deepgram-voice-*`). Use `GET /api/v1/models` for the full catalog. The Anthropic-shape listing at `GET /anthropic/v1/models` returns the same set inside Anthropic's `{data, has_more, first_id, last_id}` envelope.
## Free Plan Models
The free tier includes **27 models** across five categories. Use `GET /api/v1/models?free=true` to list them, or see the [Model Access by Plan](/docs/model-access) page for the full breakdown.
### LLM (11 models)
| Model ID | Description |
|----------|-------------|
| `sarvam-105b` | 105B MoE — complex reasoning, Indic languages |
| `sarvam-105b-conversations` | 105B MoE tuned for conversation and voice — 128K context, tool calling |
| `kimi-k2.5` | Moonshot K2.5 — 256K context, reasoning |
| `kimi-k2.6` | Moonshot K2.6 — improved reasoning + coding, 262K context |
| `kimi-k2.7-code` | Moonshot K2.7 Code — frontier 1T-param agentic coding, 262K context, vision + tools |
| `glm-4.7-flash` | GLM 4.7 Flash — fast inference |
| `glm-5.2` | GLM 5.2 — Z.ai flagship agentic coding, 262K context, tools + reasoning |
| `gpt-oss-120b` | GPT-OSS 120B — open-weights large model |
| `nemotron-3-super` | Nvidia Nemotron 3 Super |
| `gemma-4-26b-a4b-it` | Google Gemma 4 26B |
| `mistral-small-3.1` | Mistral Small 3.1 — 24B instruct, tool use |
### STT (4 models)
| Model ID | Description |
|----------|-------------|
| `saaras:v3` | 23 langs (22 Indic + English), best for code-mixed |
| `saaras:v4` | 24 langs — five output modes: transcribe, translate, verbatim, transliterate, code-mix |
| `whisper-large-v3-turbo` | Whisper — 99 langs with auto-detect, transcribe + translate |
| `nova-3` | Nova 3 — 11 langs, diarization, smart-format, streaming-capable |
### TTS (4 models)
| Model ID | Description |
|----------|-------------|
| `bulbul:v3` | 37 voices, 11 Indian languages |
| `aura-2-en` | Aura 2 — 40 English voices, low-latency streaming |
| `aura-2-es` | Aura 2 — 10 Spanish voices, low-latency streaming |
| `melotts` | MeloTTS — en + fr, cheapest TTS available |
### Image (6 free + 6 paid)
| Model ID | Description |
|----------|-------------|
| `flux-2-klein-9b` | Flux 2 Klein — highest quality |
| `flux-2-dev` | Flux 2 Dev — flagship fidelity |
| `lucid-origin` | Lucid Origin — cinematic |
| `phoenix-1.0` | Phoenix — photorealistic |
| `sdxl-lightning` | SDXL Lightning — fast |
| `dreamshaper-8-lcm` | DreamShaper 8 LCM — fast |
### Embedding (2 models)
| Model ID | Description |
|----------|-------------|
| `text-embedding-3-small` | 1536 dimensions, 8,192-token inputs — best price/performance |
| `text-embedding-3-large` | 3072 dimensions, 8,192-token inputs — highest accuracy |
| `flux-2-pro` | Flux 2 Pro — flagship BFL quality *(paid)* |
| `flux-1.1-pro` | Flux 1.1 Pro — fast high-quality *(paid)* |
| `gpt-image-2.5-sunburst` | OpenAI GPT Image 2.5 Sunburst — most capable image generation + editing *(paid)* |
| `gpt-image-2.5-flare` | OpenAI GPT Image 2.5 Flare — fast, high-quality everyday generation *(paid)* |
| `gpt-image-2` | OpenAI GPT Image 2 — accurate on-image text *(paid)* |
| `gpt-image-1.5` | OpenAI GPT Image 1.5 — precise image editing, strong logo/face preservation *(paid)* |
| `nano-banana-2` | Google Gemini 3.1 Flash Image — multimodal, highest LM-Arena Elo *(paid)* |
| `nano-banana-pro` | Google Gemini 3 Pro Image — flagship typography + fidelity *(paid)* |
A free-plan key calling a paid model gets `403 model_not_available` — it is not billed, it is refused. Upgrade to Starter or above first.
All other models — including `kimi-k2.5-fast`, first-party IDs (`gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-*`, `grok-4.3`, `DeepSeek-V4-*`, `gpt-realtime*`, `nova-sonic*`, first-party STT/TTS), the Deepgram direct line (`deepgram-nova-3`, `deepgram-flux-general-en/multi`, `deepgram-nova-2*`, `deepgram-enhanced*`, `deepgram-base*`, `deepgram-whisper-*`, `deepgram-aura-2`, `deepgram-aura-1`, Deepgram Voice Agent `deepgram-voice-*` ids, the `deepgram-summarize/topics/sentiment/intents` Audio Intelligence features, and the `deepgram-text-summarize/topics/sentiment/intents` Text Intelligence features), and paid image models (`flux-2-pro`, `gpt-image-2.5-*`, `gpt-image-2`, `gpt-image-1.5`, `nano-banana-*`) — require Starter, Pro, or Enterprise.
### Pricing
All models are pay-per-use. Pricing is in USD.
| Model | Input / 1M tokens | Output / 1M tokens |
|-------|-------------------|-------------------|
| `kimi-k2.5-fast` | $0.81 | $4.05 |
| `sarvam-105b` | $0.35 (₹30) | $0.35 (₹30) |
| `sarvam-105b-conversations` | $0.35 (₹30) | $0.35 (₹30) |
| `gpt-5.6-sol` | $5.00 | $30.00 |
| `gpt-5.6-terra` | $2.00 | $12.00 |
| `gpt-5.6-luna` | $0.20 | $1.20 |
| `nova-sonic-2` | $4.00 | $15.00 |
| `nova-sonic` | $4.50 | $17.00 |
| `gpt-realtime` | $4.00 | $16.00 |
| `gpt-realtime-mini` | $0.60 | $2.40 |
| `gpt-realtime-2` | $4.00 | $24.00 |
| `gpt-realtime-1.5` | $4.00 | $16.00 |
| `gpt-realtime-2.1` | $4.00 | $24.00 |
| `gpt-realtime-2.1-mini` | $0.60 | $2.40 |
| `deepgram-voice-*` | per-minute Voice Agent tier | Standard $0.075/min, Advanced $0.163/min *(voice-agent only)* |
| STT Model | Price |
|-----------|-------|
| `saaras:v3` | $0.30 / hour (₹30/hr) |
| `saaras:v4` | $0.30 / hour (₹30/hr) |
| `gnani-prisma-v2.5` | $0.27 / hour |
| `ink-whisper` | $0.18 / hour |
| `ink-2` | $0.54 / hour *(voice sessions only)* |
| `whisper-large-v3-turbo` | $0.06 / hour |
| `nova-3` | $0.50 / hour |
| `deepgram-nova-3` | $0.29 / hour |
| `deepgram-nova-3-medical` | $0.29 / hour |
| `deepgram-flux-general-en` | $0.39 / hour |
| `deepgram-flux-general-multi` | $0.47 / hour |
| `deepgram-nova-2` (+ domain variants) | $0.35 / hour |
| `deepgram-nova` / `deepgram-whisper-*` | $0.35 / hour |
| `deepgram-enhanced` (+ variants) | $0.99 / hour |
| `deepgram-base` (+ variants) | $0.87 / hour |
| TTS Model | Price |
|-----------|-------|
| `bulbul:v3` | $0.30 / 10K chars (₹30/10K) |
| `gnani-timbre-v2.0` | $0.27 / 10K chars |
| `aura-2-en` | $0.40 / 10K chars |
| `aura-2-es` | $0.40 / 10K chars |
| `deepgram-aura-2` | $0.30 / 10K chars |
| `deepgram-aura-1` | $0.15 / 10K chars |
| `sonic-3.6` | $0.50 / 10K chars |
| `melotts` | $0.05 / 10K chars |
| Intelligence feature (not a model ID) | Price |
|-------------------------------|-------|
| `deepgram-summarize` / `-topics` / `-sentiment` / `-intents` (audio) | $0.0003 / 1K input + $0.0006 / 1K output tokens |
| `deepgram-text-summarize` / `-text-topics` / `-text-sentiment` / `-text-intents` | $0.0003 / 1K input + $0.0006 / 1K output tokens |
These eight values go in the `features` field, not in `model`. They are not
catalog models — `GET /api/v1/models/deepgram-summarize` returns 404.
Full pricing for all models is available via the API: `GET /api/v1/models`
```python
import requests
# List all LLM models
models = requests.get("https://api.callmissed.com/api/v1/models?category=llm").json()
for m in models["data"]:
print(f"{m['id']} — {m['name']} ({m['context_window']} tokens) {'FREE' if m['free'] else 'PAID'}")
```
## Fast LLMs
High-throughput Kimi K2.5 inference tier optimized for voice-agent latency.
| Model ID | Status | Context | Best For |
|----------|--------|---------|----------|
| `kimi-k2.5-fast` | **Under maintenance** — fall back to `kimi-k2.5` | 256K | Voice agents, fast inference, reasoning tasks |
While `kimi-k2.5-fast` is in maintenance (returns HTTP 503), use `kimi-k2.5`:
```python
response = client.chat.completions.create(
model="kimi-k2.5",
messages=[{"role": "user", "content": "Hello"}]
)
```
## Indic Models
### Speech to Text
| Model | Description | Languages |
|-------|-------------|-----------|
| `saaras:v3` | Latest STT — best accuracy on Indian + code-mixed | 23 languages (22 Indic + English) |
| `saaras:v4` | Five output modes on one model — transcribe, translate, verbatim, transliterate, code-mix | 24 languages |
| `gnani-prisma-v2.5` | India-first telephony STT — code-switching, sub-4% WER on Indian English | 10 Indian languages |
For 99-language general-purpose transcription, see `whisper-large-v3-turbo`. For diarization + smart-format on calls, see `nova-3`. Both are free-tier and live under the [audio model routes](#audio-models).
`ink-whisper` also covers Hindi, Urdu and Tamil as part of its 100-language set at $0.18 / hr — cheaper than the Indic-specialist models, though without their code-mix output modes. `ink-2` is **English only**, so it is not an option for Indic speech.
### Text to Speech
| Model | Description | Voices |
|-------|-------------|--------|
| `bulbul:v3` | Natural TTS — 37 voices, 11 Indian languages | shubh (default) + 36 more |
| `gnani-timbre-v2.0` | India-first neural TTS — context-aware tone, low-latency | 73 voices (English + Hindi + Indic) |
| `sonic-3.6` | Cartesia Sonic 3.6 — most natural conversational speech, 44 languages with native-quality Hindi | Searchable public library + 16 featured aliases (skylar default) |
For low-latency English / Spanish voice agents, see `aura-2-en` / `aura-2-es`. For ultra-cheap en/fr notification audio, see `melotts`. All three are free-tier.
### Chat Completion (LLM)
| Model | Params | Context | Best For |
|-------|--------|---------|----------|
| `sarvam-105b` | 105B MoE | 128K tokens | Complex reasoning, agentic tasks, long documents |
| `sarvam-105b-conversations` | 105B MoE | 128K tokens | Conversation and voice agents, tool calling |
Both Sarvam models support hybrid thinking via `reasoning_effort: "low" | "medium" | "high"`. `"none"` and `"minimal"` map down to `"low"`, so an OpenAI-style client sending `"none"` gets a 200 rather than an error — but thinking stays on. To turn thinking fully off, use `kimi-k2.5`, `kimi-k2.6`, `glm-4.7-flash`, `glm-5.2`, or `gemma-4-26b-a4b-it` with `reasoning_effort: "none"`. See the [per-model matrix](/docs/api-speed#3-reasoning-effort-by-model).
## Audio Models
Free-tier on every plan. See the [Pricing](/docs/pricing) page for current rates.
### Speech to Text
| Model | Languages | Best for | Price |
|-------|-----------|----------|-------|
| `whisper-large-v3-turbo` | 99 with auto-detect | Multilingual general-purpose; transcribe + translate | $0.06 / hour |
| `nova-3` | 11 BCP-47 incl. `multi` auto-detect | Diarization, smart-format, streaming voice agents | $0.50 / hour |
| `whisper` | 99 with auto-detect | Whisper batch + translate | $0.40 / hour |
| `gpt-4o-transcribe` | Streaming | Higher-accuracy OpenAI transcription | $0.40 / hour |
| `gpt-4o-mini-transcribe` | Streaming | Low-cost OpenAI transcription | $0.24 / hour |
| `gpt-4o-transcribe-diarize` | Streaming + diarization | Multi-speaker meetings / calls | $0.40 / hour |
**Deepgram (direct)** — the full Deepgram speech-to-text line, billed per audio hour at the rates below:
| Model | Languages | Best for | Price |
|-------|-----------|----------|-------|
| `deepgram-flux-general-en` | English | Conversational voice agents — model-native turn detection, ultra-low latency | $0.39 / hour |
| `deepgram-flux-general-multi` | 10 (multilingual) | Multilingual voice agents with code-switching | $0.47 / hour |
| `deepgram-nova-3` | 45+ incl. `multi` | Flagship general-purpose ASR, keyterm prompting, PII redaction | $0.29 / hour |
| `deepgram-nova-3-medical` | English | Clinical / medical terminology | $0.29 / hour |
| `deepgram-nova-2` | 36 incl. `multi` | High-accuracy ASR + filler-word detection | $0.35 / hour |
| `deepgram-nova-2-{meeting,phonecall,finance,conversationalai,voicemail,video,medical,drivethru,automotive,atc}` | English | Domain-tuned Nova-2 variants | $0.35 / hour |
| `deepgram-nova` / `-phonecall` / `-medical` | en/es/hi | Legacy Nova-1 | $0.35 / hour |
| `deepgram-enhanced` (+ meeting/phonecall/finance) | 13 | Legacy, keyword boosting | $0.99 / hour |
| `deepgram-base` (+ 6 variants) | 17 | Legacy, high-volume batch | $0.87 / hour |
| `deepgram-whisper-{tiny,base,small,medium,large}` | 99 | Deepgram-managed Whisper Cloud | $0.35 / hour |
### Text to Speech
| Model | Languages | Voices | Price |
|-------|-----------|--------|-------|
| `aura-2-en` | English | 40 (luna default) | $0.40 / 10K chars |
| `aura-2-es` | Spanish | 10 (aquila default) | $0.40 / 10K chars |
| `deepgram-aura-2` | en/es/de/fr/nl/it/ja | 90+ (thalia default) | $0.30 / 10K chars |
| `deepgram-aura-1` | English | 12 (asteria default) | $0.15 / 10K chars |
| `melotts` | English + French | 1 per language | $0.05 / 10K chars |
| `gpt-4o-mini-tts` | Multilingual steerable | 6 OpenAI voices | $0.20 / 10K chars |
Aura 2 returns linear16 PCM streamed at 24 kHz for low-latency playback. MeloTTS returns base64 MP3. Output formats may vary as models are updated.
Deepgram Flux TTS is a voice-agent-first model and is **not** available on this `/v1/audio/speech` endpoint. It is offered only through the managed Voice Agent (see the Voice Sessions API), selectable with `tts_engine: "flux"`, where it is billed inside the per-minute voice rate.
### Audio Intelligence (Deepgram)
Deepgram Audio Intelligence runs analysis over an uploaded audio file via `POST /v1/audio/intelligence` (English only, 150K input-token limit). Token-billed at $0.0003/1K input + $0.0006/1K output.
| Feature | Model ID | Returns |
|---------|----------|---------|
| Summarization | `deepgram-summarize` | A concise `summary` of the audio |
| Topic Detection | `deepgram-topics` | Per-segment `topics` with confidence |
| Sentiment Analysis | `deepgram-sentiment` | Per-segment + average `sentiments` |
| Intent Recognition | `deepgram-intents` | Per-segment `intents` with confidence |
Request multiple features in one call with a comma-separated `features` form field (e.g. `features=deepgram-summarize,deepgram-sentiment`).
### Text Intelligence (Deepgram)
Deepgram Text Intelligence runs the same four analyses over **text** input (a string or a hosted text URL) via `POST /v1/text/intelligence` (English only, 150K input-token limit). Token-billed at $0.0003/1K input + $0.0006/1K output. Requires the `llm` key permission.
| Feature | Model ID | Returns |
|---------|----------|---------|
| Summarization | `deepgram-text-summarize` | A concise `summary` of the text |
| Topic Detection | `deepgram-text-topics` | Per-segment `topics` with confidence |
| Sentiment Analysis | `deepgram-text-sentiment` | Per-segment + average `sentiments` |
| Intent Recognition | `deepgram-text-intents` | Per-segment `intents` with confidence |
Send a JSON body with `features` (array or comma-separated string) and exactly one of `text` or `url`:
```json
{
"features": ["deepgram-text-summarize", "deepgram-text-sentiment"],
"text": "Your text to analyze here."
}
```
## Direct-Routed LLMs
Low-latency models routed directly through CallMissed — sub-2s end-to-end on small prompts and free-tier eligible per the [reasoning_effort matrix](/docs/api-speed#3-reasoning-effort-by-model).
| Model ID | Creator | Context |
|----------|---------|---------|
| `kimi-k2.5` | Moonshot AI | 256K |
| `kimi-k2.6` | Moonshot AI | 262K |
| `kimi-k2.7-code` | Moonshot AI | 262K |
| `gpt-oss-120b` | OpenAI (open-weights) | 128K |
| `gemma-4-26b-a4b-it` | Google | 128K |
| `glm-4.7-flash` | Zhipu | 128K |
| `glm-5.2` | Z.ai | 262K |
| `nemotron-3-super` | NVIDIA | 256K |
| `mistral-small-3.1` | Mistral | 128K |
## Models on Demand
`GET /api/v1/models` lists everything that is live today: **125** model IDs
callable right now with a `cm_` key.
Beyond that we deploy **300+ further models on demand** on CallMissed
infrastructure. Send the model you need and your expected throughput to
`sales@callmissed.com`. Once deployed it appears in your `GET /api/v1/models`
response with a plain CallMissed ID and published per-token pricing, on the
same `/v1/chat/completions` endpoint as every other model. Same key, same
credit balance, no new SDK.
Enterprise accounts get dedicated capacity. Starter and Pro get shared capacity
where the model allows it.
## First-Party Models
Credit-covered first-party models. Use the bare model ID in API requests — e.g. `gpt-4o`.
| Model ID | Type | Notes |
|----------|------|-------|
| `gpt-4o` | LLM | Multimodal text + vision, 128K context |
| `gpt-4.1` | LLM | Long-context (1M) multimodal |
| `gpt-5-mini` | LLM | Fast reasoning, 400K context |
| `gpt-5.5` | LLM | GPT-5.5 reasoning flagship, 1M context, vision + tools |
| `gpt-5.6-sol` | LLM | GPT-5.6 flagship, 1.05M context, vision + tools |
| `gpt-5.6-terra` | LLM | GPT-5.6 balanced intelligence/cost, 1.05M context |
| `gpt-5.6-luna` | LLM | GPT-5.6 fast + affordable, 1.05M context |
| `grok-4.3` | LLM | xAI Grok, 200K context |
| `DeepSeek-V4-Pro` | LLM | Flagship DeepSeek reasoning, 1M context, vision + tools |
| `DeepSeek-V4-Flash` | LLM | Fast DeepSeek reasoning, 1M context, tools |
| `nova-sonic-2` | Realtime voice | Default speech-to-speech voice model — 16 voices, Hindi + en-IN, live |
| `nova-sonic` | Realtime voice | First-generation Amazon speech-to-speech voice model |
| `gpt-realtime` | Realtime voice | OpenAI flagship speech-to-speech (10 concurrent), live |
| `gpt-realtime-mini` | Realtime voice | Lowest-cost realtime, ~3× cheaper than gpt-realtime, live |
| `gpt-realtime-2` | Realtime voice | Newest realtime with stronger tool calling, live |
| `gpt-realtime-1.5` | Realtime voice | Pinned 1.5 snapshot of gpt-realtime, live |
| `gpt-realtime-2.1` | Realtime voice | Latest realtime — better recognition, silence/interrupt handling, configurable reasoning, live |
| `gpt-realtime-2.1-mini` | Realtime voice | Distilled low-cost 2.1 realtime, live |
| `whisper` | STT | OpenAI Whisper — 99 langs |
| `gpt-4o-transcribe` | STT | Streaming transcription |
| `gpt-4o-mini-transcribe` | STT | Low-cost streaming STT |
| `gpt-4o-transcribe-diarize` | STT | Speaker diarization |
| `gpt-4o-mini-tts` | TTS | Steerable OpenAI TTS, 6 voices |
See [Credits & Rate Limits](/docs/credits-rate-limits) for per-model USD pricing.
## Full Model Catalog
A curated, representative slice of the **135** models (64 LLM · 45 STT · 9 TTS · 15 image · 2 embedding) served by `GET /api/v1/models` as of the latest deploy — the per-domain variants of the direct Deepgram STT line and the `deepgram-voice-*` managed LLM ids are covered in their own sections above rather than repeated below. For live pricing and capability flags (`supports_vision`, `supports_tools`, `free`), query the API — it always reflects the current catalog.
### LLM (37 models)
| Model ID | Description | Context | Free | Pricing |
|----------|-------------|---------|------|---------|
| `sarvam-105b` | 105B MoE. Complex reasoning, agentic tasks, long documents. | 131K | Yes | $0.35 in / $0.35 out per 1M |
| `sarvam-105b-conversations` | 105B MoE tuned for conversation and voice. Tool calling. | 131K | Yes | $0.35 in / $0.35 out per 1M |
| `gpt-4o` | Multimodal text + vision. | 128K | No | $2.50 in / $10.00 out per 1M |
| `gemini-3.8-flash` | Fast multimodal flagship. Thinking low/medium/high. | 1M | No | $1.50 in / $7.50 out per 1M |
| `gemini-3.7-flash` | Fast multimodal. Thinking low/medium/high. | 1M | No | $1.50 in / $7.50 out per 1M |
| `gemini-3.6-flash` | Fast multimodal. Thinking minimal→high. | 1M | No | $1.50 in / $7.50 out per 1M |
| `gemini-3.5-flash` | Balanced multimodal workhorse. | 1M | No | $1.50 in / $9.00 out per 1M |
| `gemini-3.5-flash-lite` | Cheapest 1M-context Gemini. | 1M | No | $0.30 in / $2.50 out per 1M |
| `gemini-3.1-pro-preview` | Reasoning-heavy Gemini tier. | 1M | No | $2.00 in / $12.00 out per 1M |
| `gemini-3.1-flash-lite` | Low-cost multimodal, tool use. | 1M | No | $0.25 in / $1.50 out per 1M |
| `gpt-4.1` | Long-context multimodal. Strong instruction following. | 1M | No | $2.00 in / $8.00 out per 1M |
| `gpt-5-mini` | Fast, affordable reasoning. | 400K | No | $0.25 in / $2.00 out per 1M |
| `gpt-5.5` | Reasoning flagship. Vision, tools, prompt caching. | 1M | No | $5.00 in / $30.00 out per 1M |
| `gpt-5.6-sol` | Frontier model for complex professional work. Vision, reasoning, tools. | 1.05M | No | $5.00 in / $30.00 out per 1M |
| `gpt-5.6-terra` | Balances intelligence and cost. Vision, reasoning, tools. | 1.05M | No | $2.00 in / $12.00 out per 1M |
| `gpt-5.6-luna` | Cost-sensitive, high-volume workloads. Vision, reasoning, tools. | 1.05M | No | $0.20 in / $1.20 out per 1M |
| `grok-4.3` | xAI Grok 4.3. Reasoning + vision. | 200K | No | $3.50 in / $15.00 out per 1M |
| `DeepSeek-V4-Pro` | Flagship DeepSeek reasoning. Vision + tools. | 1M | No | $1.32 in / $3.96 out per 1M |
| `DeepSeek-V4-Flash` | Fast, affordable DeepSeek reasoning. Tools. | 1M | No | $0.44 in / $1.32 out per 1M |
| `kimi-k2.5` | Strong on coding and math. Vision. | 256K | Yes | $0.81 in / $4.05 out per 1M |
| `kimi-k2.5-fast` *(maintenance)* | Kimi K2.5 at ~414 tok/s for voice-agent latency. | 256K | No | $0.81 in / $4.05 out per 1M |
| `kimi-k2.6` | Improved reasoning and coding over K2.5. Vision. | 262K | Yes | $1.28 in / $5.40 out per 1M |
| `kimi-k2.7-code` | 1T-param agentic coding. Vision + tools. | 262K | Yes | $1.28 in / $5.40 out per 1M |
| `glm-4.7-flash` | Fast, cost-efficient bilingual model. Strong tool use. | 131K | Yes | $0.50 in / $2.00 out per 1M |
| `glm-5.2` | Flagship agentic coding. Tools + reasoning. | 262K | Yes | $1.89 in / $5.94 out per 1M |
| `gpt-oss-120b` | Open-weight 120B MoE. Reasoning-grade at lower cost. | 128K | Yes | $1.00 in / $4.00 out per 1M |
| `nemotron-3-super` | 120B MoE tuned for long-context reasoning. | 256K | Yes | $1.50 in / $6.00 out per 1M |
| `gemma-4-26b-a4b-it` | 26B MoE (4B active). Efficient instruct model. Vision. | 131K | Yes | $0.40 in / $1.60 out per 1M |
| `mistral-small-3.1` | 24B instruct. Strong tool use, fast. Vision. | 128K | Yes | $0.47 in / $0.76 out per 1M |
| `nova-sonic-2` | Amazon Nova 2 Sonic. Native speech-to-speech voice model — STT, reasoning, and TTS in one; 16 voices across 8 languages including Hindi + en-IN. | 32K | No | $4.00 in / $15.00 out per 1M • $0.064/min |
| `nova-sonic` | Amazon Nova Sonic 1.0. Native speech-to-speech voice model with 11 voices across English, Spanish, French, Italian, and German. | 32K | No | $4.50 in / $17.00 out per 1M • $0.071/min |
| `gpt-realtime` | OpenAI flagship realtime speech-to-speech model — STT + reasoning + function calling + TTS in one. 10 concurrent. | 32K | No | $4.00 in / $16.00 out per 1M • $0.375/min |
| `gpt-realtime-mini` | Lowest-cost realtime — same single-model shape as gpt-realtime, ~3× cheaper. 20 concurrent. | 32K | No | $0.60 in / $2.40 out per 1M • $0.118/min |
| `gpt-realtime-2` | Newest realtime with stronger tool calling. 128K text context. | 128K | No | $4.00 in / $24.00 out per 1M • $0.375/min |
| `gpt-realtime-1.5` | Pinned 1.5 snapshot of gpt-realtime. Use when you want version stability. | 32K | No | $4.00 in / $16.00 out per 1M • $0.375/min |
| `gpt-realtime-2.1` | Latest realtime speech-to-speech — better alphanumeric recognition, silence/noise + interruption handling, configurable reasoning effort. Voice-agent only. | 128K | No | $4.00 in / $24.00 out per 1M • $0.375/min |
| `gpt-realtime-2.1-mini` | Distilled, lower-cost realtime for faster voice interactions. Voice-agent only. | 128K | No | $0.60 in / $2.40 out per 1M • $0.117/min |
### Speech to Text (11 models)
| Model ID | Description | Context | Free | Pricing |
|----------|-------------|---------|------|---------|
| `saaras:v3` | 23 languages (22 Indic + English). Best on code-mixed speech. | — | Yes | $0.30 / hr |
| `saaras:v4` | 24 languages. Five output modes: transcribe, translate, verbatim, transliterate, code-mix. | — | Yes | $0.30 / hr |
| `gnani-prisma-v2.5` | India-first telephony STT. 10 Indian languages, code-switching. | — | No | $0.27 / hr |
| `ink-whisper` | Cartesia Ink Whisper — 100 languages including Hindi, Urdu and Tamil. Dynamic chunking reduces hallucination across pauses and silence. File transcription + streaming. | — | No | $0.18 / hr |
| `ink-2` | Cartesia Ink 2 — top-ranked for voice agents (8% WER on AppTek's 14-accent call-centre benchmark, vs 10% Deepgram Flux and 12% ElevenLabs). Self-detects turns. **English only. Voice sessions only — not available for file transcription.** | — | No | $0.54 / hr |
| `whisper-large-v3-turbo` | 99 languages with auto-detect. Transcribe + translate. | — | Yes | $0.06 / hr |
| `nova-3` | Diarization, punctuation, smart-format. Streaming-capable. | — | Yes | $0.50 / hr |
| `whisper` | 99 languages. Transcription + translation to English. | — | No | $0.40 / hr |
| `gpt-4o-transcribe` | Higher accuracy than Whisper. Streaming. | — | No | $0.40 / hr |
| `gpt-4o-mini-transcribe` | Cheaper, faster streaming transcription. | — | No | $0.24 / hr |
| `gpt-4o-transcribe-diarize` | Streaming transcription with speaker labels. | — | No | $0.40 / hr |
### Text to Speech (7 models)
| Model ID | Description | Voices | Free | Pricing |
|----------|-------------|--------|------|---------|
| `bulbul:v3` | Indic TTS across 11 Indian languages. | 37 | Yes | $0.30 / 10K chars |
| `gnani-timbre-v2.0` | India-first neural TTS, English + Hindi + Indic. Context-aware tone. | 73 | No | $0.27 / 10K chars |
| `sonic-3.6` | Cartesia Sonic 3.6 — most natural conversational TTS. 44 languages, native-quality Hindi + Hinglish, sub-90ms first audio. | Searchable library | No | $0.50 / 10K chars |
| `aura-2-en` | Conversational English TTS, low-latency streaming. | 40 | Yes | $0.40 / 10K chars |
| `aura-2-es` | Spanish TTS, low-latency streaming. | 10 | Yes | $0.40 / 10K chars |
| `melotts` | Lightweight English + French TTS. Cheapest available. | 1 per language | Yes | $0.05 / 10K chars |
| `gpt-4o-mini-tts` | Steerable — takes an `instructions` field to direct tone. | 6 | No | $0.20 / 10K chars |
### Image Generation (15 models)
| Model ID | Description | Free | Pricing |
|----------|-------------|------|---------|
| `flux-2-klein-9b` | Flux 2 Klein. 1024×1024 default. | Yes | $0.10 / image |
| `flux-2-dev` | Flux 2 Dev. Higher fidelity, 50-step inference. | Yes | $0.12 / image |
| `flux-2-pro` | Flux 2 Pro. Flagship BFL fidelity. | No | $0.10 / image |
| `flux-1.1-pro` | Flux 1.1 Pro. Fast, production-grade. | No | $0.05 / image |
| `gpt-image-2.5-sunburst` | Most capable generation + editing. Inpainting, quality tiers to `max`. | No | $0.25 / image |
| `gemini-3.1-flash-lite-image` | Low-cost text-to-image with reference edits. 1K resolution. | No | $0.0336 / image |
| `gpt-image-2.5-flare` | Fast, high-quality everyday generation. On-image text + edits. | No | $0.25 / image |
| `gpt-image-2` | Accurate on-image text rendering. | No | $0.25 / image |
| `gpt-image-1.5` | Precise image editing. Strong logo/face preservation. | No | $0.25 / image |
| `lucid-origin` | Vibrant, cinematic compositions. | Yes | $0.08 / image |
| `phoenix-1.0` | Strong prompt adherence, photorealistic portraits. | Yes | $0.10 / image |
| `sdxl-lightning` | 4-step inference. Fastest for iterative prompting. | Yes | $0.04 / image |
| `dreamshaper-8-lcm` | Stylised illustrations, fast generation. | Yes | $0.04 / image |
| `nano-banana-2` | Fast multimodal image generation. | No | $0.067 / image |
| `nano-banana-pro` | Flagship typography and fidelity. | No | $0.134 / image |
### Embeddings (2 models)
| Model ID | Description | Dimensions | Free | Pricing |
|----------|-------------|------------|------|---------|
| `text-embedding-3-small` | Fast, low-cost embeddings. Best price/performance for large corpora. | 1536 | Yes | $0.02 / 1M input tokens |
| `text-embedding-3-large` | Highest-accuracy embeddings. | 3072 | Yes | $0.13 / 1M input tokens |
Both accept 8,192-token inputs and support shortening the vector with `dimensions`. See [Embeddings](/docs/embeddings).
> **Tip:** Filter programmatically — `GET /api/v1/models?category=llm`, `?category=stt`, `?category=tts`, `?category=image`, `?category=embedding`, or `?free=true` for free-plan models only.
## Model Selection
Pass the model ID in your request:
```python
# Indic LLM
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Hello in Hindi"}]
)
# Indic LLM with thinking mode
response = client.chat.completions.create(
model="sarvam-105b",
messages=[{"role": "user", "content": "Solve this step by step"}],
extra_body={"reasoning_effort": "high"}
)
# First-party flagship model
response = client.chat.completions.create(
model="gpt-5.6-luna",
messages=[{"role": "user", "content": "Hello"}]
)
```
The API automatically routes to the correct backend based on the model ID:
- Bare names (`kimi-k2.5`, `gpt-4o`, `DeepSeek-V4-Pro`, `mistral-small-3.1`, …) → direct-routed or first-party
- `sarvam-*` prefix → Indic LLMs
- `saaras:*` / `bulbul:*` / `deepgram-*` / image IDs → the matching speech or image backend
Every ID is a plain CallMissed ID with no vendor prefix — including models we
[deploy on demand](#models-on-demand).
---
### Image Generation
URL: /docs/image-generation
> Generate images from a text prompt. OpenAI-compatible endpoint.
## Overview
Generate images from a text prompt. The request and response shape match OpenAI's `images.generate`, so any existing OpenAI SDK works by pointing `base_url` at `https://api.callmissed.com/v1`.
**Endpoint:** `POST /v1/images/generations`
Images come back as base64-encoded PNG (or JPEG, depending on the model) in the `data[].b64_json` field.
:::flow
icon:app | Your app | Send a `prompt`, `model`, and `size` to `POST /v1/images/generations`
icon:gateway | CallMissed gateway | Route to the image provider and deduct per-image credits
icon:image | Image model | Render the image from your prompt
icon:done | Your app | Decode `data[].b64_json` (base64 PNG/JPEG) and save or display it
:::
## Basic Usage
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1",
)
res = client.images.generate(
model="flux-2-klein-9b",
prompt="A golden retriever in a sunlit library, cinematic bokeh",
n=1,
size="1024x1024",
)
# res.data[0].b64_json → base64 image
```
```javascript [JavaScript]
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const res = await client.images.generate({
model: "flux-2-klein-9b",
prompt: "A golden retriever in a sunlit library, cinematic bokeh",
n: 1,
size: "1024x1024",
});
// res.data[0].b64_json → base64 image
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/images/generations \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "flux-2-klein-9b",
"prompt": "A golden retriever in a sunlit library",
"n": 1,
"size": "1024x1024"
}'
```
:::
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model` | string | — | Model ID (see below). Required. |
| `prompt` | string | — | Text description. 1–4000 characters. Required. |
| `n` | integer | 1 | Number of images. 1–4. Billed per image. |
| `size` | string | 1024x1024 | Width×height, e.g. `1024x1024`, `768x768`, `1024x1536`. |
| `response_format` | string | `b64_json` | Only `b64_json` supported today. |
| `negative_prompt` | string | — | Concepts to avoid (e.g. "lowres, blurry"). |
| `seed` | integer | random | Reproducibility. Same seed + prompt + model → same image. |
| `steps` | integer | auto | Denoising steps, 1–50. Higher = slower + more detail. |
### Response
```json
{
"created": 1731234567,
"data": [
{ "b64_json": "", "revised_prompt": null }
]
}
```
## Models
| ID | Creator | Plan | Speed | Best for |
|----|---------|------|-------|----------|
| `flux-2-klein-9b` | Black Forest Labs | Free | Slow | Final output, print, marketing |
| `flux-2-dev` | Black Forest Labs | Free | Slow | Maximum fidelity, hero imagery |
| `lucid-origin` | Leonardo | Free | Medium | Cinematic, concept art |
| `phoenix-1.0` | Leonardo | Free | Medium | Photorealistic portraits |
| `sdxl-lightning` | ByteDance | Free | Fast | Prototyping, iteration |
| `dreamshaper-8-lcm` | Lykon | Free | Fast | Stylised illustrations |
| `flux-2-pro` | Black Forest Labs | Paid | Medium | Flagship FLUX fidelity |
| `flux-1.1-pro` | Black Forest Labs | Paid | Fast | Production-grade at lower cost |
| `gpt-image-2.5-sunburst` | OpenAI | Paid | Medium | Most capable generation + editing, inpainting |
| `gemini-3.1-flash-lite-image` | Google | Paid | Fast | Low-cost generation with reference edits |
| `gpt-image-2.5-flare` | OpenAI | Paid | Fast | Fast everyday generation, on-image text |
| `gpt-image-2` | OpenAI | Paid | Medium | Accurate on-image text, marketing visuals |
| `gpt-image-1.5` | OpenAI | Paid | Medium | Precise editing, logo/face preservation |
| `nano-banana-pro` | Google | Paid | Medium | Infographics, accurate typography |
| `nano-banana-2` | Google | Paid | Fast | Multimodal (text + reference images) |
Free-plan keys can call the six **Free** rows. Every **Paid** row needs Starter
or above; a free key gets `403 model_not_available`.
## Sizes
Common presets: `512x512`, `768x768`, `1024x1024`, `1024x1536`, `1536x1024`.
Any width/height from 64 to 4096 is accepted, but providers may clamp or round down to their supported values.
## Pricing
Flat per-image price, converted to credits at 1 credit = ₹1.
| Model | USD per image | Credits per image |
|-------|---------------|-------------------|
| `gpt-image-2.5-sunburst` | $0.25 | 25 |
| `gpt-image-2.5-flare` | $0.25 | 25 |
| `gpt-image-2` | $0.25 | 25 |
| `gpt-image-1.5` | $0.25 | 25 |
| `nano-banana-pro` | $0.134 | 13.4 |
| `flux-2-dev` | $0.12 | 12 |
| `flux-2-klein-9b` | $0.10 | 10 |
| `flux-2-pro` | $0.10 | 10 |
| `phoenix-1.0` | $0.10 | 10 |
| `lucid-origin` | $0.08 | 8 |
| `nano-banana-2` | $0.067 | 6.7 |
| `flux-1.1-pro` | $0.05 | 5 |
| `sdxl-lightning` | $0.04 | 4 |
| `dreamshaper-8-lcm` | $0.04 | 4 |
| `gemini-3.1-flash-lite-image` | $0.0336 | 3.36 |
Prices are for a standard-resolution (1K) image.
Credits are deducted **after** the upstream call returns successfully. A failed generation does not cost credits.
## List History
Retrieve images previously generated with your API key, newest first. Useful for galleries and audit trails.
**Endpoint:** `GET /v1/images/history`
:::tabs
```python [Python]
import requests
resp = requests.get(
"https://api.callmissed.com/v1/images/history",
headers={"Authorization": "Bearer cm_your_key"},
params={"limit": 20},
)
data = resp.json()
for item in data["data"]:
print(item["id"], item["model"], item["url"])
# Paginate with the returned cursor
if data["next_cursor"]:
next_page = requests.get(
"https://api.callmissed.com/v1/images/history",
headers={"Authorization": "Bearer cm_your_key"},
params={"limit": 20, "before": data["next_cursor"]},
).json()
```
```bash [cURL]
curl "https://api.callmissed.com/v1/images/history?limit=20" \
-H "Authorization: Bearer cm_your_key"
```
:::
| Query param | Type | Default | Description |
|-------------|------|---------|-------------|
| `limit` | integer | `20` | Rows to return (1–100). |
| `before` | string | — | ISO-8601 cursor — return rows created before this timestamp. Use `next_cursor` from the previous page. |
**Response:**
```json
{
"data": [
{
"id": "a1b2c3d4-...",
"url": "https://...signed-url...",
"model": "nano-banana-pro",
"size": "1024x1024",
"prompt": "a red bicycle on a beach",
"negative_prompt": null,
"revised_prompt": null,
"seed": 42,
"steps": 28,
"created": 1760000000
}
],
"next_cursor": "2026-04-12T10:00:00+00:00"
}
```
`url` is a short-lived signed link — download or re-host promptly. The API key must have `image` permission (otherwise `403 permission_denied`). `next_cursor` is `null` on the last page.
## Errors
| HTTP | Code | Meaning |
|------|------|---------|
| 400 | `invalid_request_error` | Bad prompt / size / n. Check the parameter table. |
| 402 | `insufficient_credits` | Balance below the request's cost. Top up in the dashboard. |
| 403 | `permission_denied` | API key lacks `image` permission. Edit the key in the dashboard. |
| 404 | `model_not_found` | Unknown model ID. |
| 429 | `quota_exceeded` | Monthly plan cap hit. Upgrade tier. |
| 400 | `invalid_request` | Invalid parameters (e.g. unsupported size). Upstream validation error passed through. |
| 502 | `upstream_error` | Network failure reaching the image provider. No credits debited — safe to retry. |
---
### Social Post Studio
URL: /docs/social-posts
> Generate a ready-to-publish social post — image, caption and hashtags — in one call.
## Overview
One call turns a topic into a post you can publish: an AI-generated image, a
caption written for the platform, and a set of hashtags.
**Endpoint:** `POST /v1/social/posts/generate`
It composes the two metered surfaces it sits beside — image generation and the
LLM — and bills as exactly that: the caption at the drafting model's token rate,
the image at your chosen model's per-image rate. Both figures come back on every
response.
:::flow
icon:app | Your app | Send a `topic` (and optionally a `platform`, `tone` and `image_model`)
icon:gateway | CallMissed gateway | Draft the caption and hashtags, then render the image
icon:image | Image model | Produce the post image
icon:done | Your app | Publish the caption, hashtags and image
:::
## Basic Usage
:::tabs
```python [Python]
import requests
res = requests.post(
"https://api.callmissed.com/v1/social/posts/generate",
headers={"Authorization": "Bearer cm_your_key"},
json={
"topic": "our bakery's new sourdough, baked fresh at 6am",
"platform": "instagram",
"tone": "warm",
"hashtag_count": 8,
"image_model": "flux-2-klein-9b",
"size": "1024x1024",
},
).json()
print(res["caption"])
print(" ".join(res["hashtags"]))
# res["image"]["b64_json"] → base64 PNG
# res["credits"]["total"] → what this post cost
```
```javascript [JavaScript]
const res = await fetch(
"https://api.callmissed.com/v1/social/posts/generate",
{
method: "POST",
headers: {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
topic: "our bakery's new sourdough, baked fresh at 6am",
platform: "instagram",
tone: "warm",
hashtag_count: 8,
}),
},
).then((r) => r.json());
console.log(res.caption, res.hashtags.join(" "));
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/social/posts/generate \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"topic": "our bakery'"'"'s new sourdough, baked fresh at 6am",
"platform": "instagram",
"tone": "warm",
"hashtag_count": 8
}'
```
:::
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `topic` | string | — | What the post is about. 3–2000 characters. Required. |
| `platform` | string | `generic` | `facebook`, `instagram` or `generic`. Sets the caption length ceiling and the voice. |
| `tone` | string | — | Optional voice, e.g. `warm`, `professional`, `playful`. Max 40 characters. |
| `hashtag_count` | integer | `8` | How many hashtags to return, 0–30. Instagram allows at most 30. |
| `generate_image` | boolean | `true` | Set `false` for a caption-only draft — no image credits, and no `image` key permission needed. |
| `image_model` | string | `flux-2-klein-9b` | Any model from the [image generation](/docs/image-generation) catalogue. |
| `image_prompt` | string | — | An explicit prompt for the image. When omitted, the image prompt is written **for you** from the `topic` — see below. Max 4000 characters. |
| `size` | string | — | Width×height for the image, e.g. `1024x1024`, `1024x1280`. Defaults to a 4:5 portrait, the ratio the Instagram and Facebook feeds prioritise. |
| `quality` | string | — | For models that support it (GPT Image): `low`, `medium`, `high` or `auto`. Ignored by models without a quality tier. |
| `reference_images` | string[] | — | Up to **16** base64 PNG/JPEG images used as visual references, so the picture is built **from your own** logo or product shot. Requires a `gpt-image-*` model (`gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`, `gpt-image-1.5`). |
| `reference_pdf` | string | — | One base64 PDF. Its extracted **text** becomes brand context for the image prompt. Requires a `gpt-image-*` model (`gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`, `gpt-image-1.5`). |
Caption length follows the platform: 2,200 characters for `instagram`, 5,000 for
`facebook`. `generic` uses the smaller of the two, so one draft is publishable on
either.
### Reference images and a brand PDF
Pass `reference_images` to put your real brand assets in the generated picture —
a logo, a product shot, a packaging photo. The image is then produced as an
**edit** of those references rather than a text-only generation, so the mark in
the output is yours instead of an invented lookalike.
Each item is base64, either bare or as a full `data:image/png;base64,…` URL (what
a browser's `FileReader.readAsDataURL` gives you, so no string surgery needed).
PNG and JPEG only — the format is checked from the file's own bytes, not from a
declared type. Up to 16 images, each at most 10 MB decoded.
`reference_pdf` is a different kind of reference: a brand guideline, a spec sheet
or a menu, whose **text** is extracted and folded into the image prompt as
context. The file itself never reaches the image model, and a PDF with no
extractable text (a scan) is ignored rather than failing the call. At most 10 MB
decoded.
**Both require an image model with an edit surface: `gpt-image-2.5-sunburst`,
`gpt-image-2.5-flare`, `gpt-image-2` or `gpt-image-1.5`.** Sent with any other `image_model`, the request fails `422`
before a single credit is reserved. That is deliberate — the alternative is
quietly dropping your references and charging full price for a picture carrying
none of your branding. For the same reason, a reference-carrying request will not
silently fall back to a model that cannot honour them.
**Pricing does not change.** A referenced image is billed at exactly the same
per-image rate as a plain generation of the same model, size and quality.
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/social/posts/generate \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"topic": "our winter service package, booked till February",
"platform": "instagram",
"image_model": "gpt-image-2.5-sunburst",
"reference_images": ["data:image/png;base64,iVBORw0KGgoAAAANS..."],
"reference_pdf": "JVBERi0xLjQKJcfs..."
}'
```
### How the image prompt is written
When you don't pass `image_prompt`, the endpoint does **not** send your `topic`
to the image model directly. A marketing topic ("winter tune-up, ₹1,200, booked
until February") sent to an image model renders the words literally — garbled
numbers, invented signage. Instead the caption drafter produces a separate,
purely-visual prompt: it keeps the facts (prices, dates, your business name) in
the **caption**, and describes a real, photographable scene for the **image**.
The result is a photo that represents the offer rather than a picture trying to
spell it out.
Pass your own `image_prompt` to override this and control the image directly.
### Response
```json
{
"created": 1731234567,
"topic": "our bakery's new sourdough, baked fresh at 6am",
"platform": "instagram",
"caption": "Pulled from the oven at 6am. Crackling crust, open crumb, still warm.",
"hashtags": ["#sourdough", "#freshbread", "#bakery"],
"image": {
"b64_json": "",
"revised_prompt": null,
"url": "https://...signed-url..."
},
"model_used": "glm-4.7-flash",
"image_model_used": "flux-2-klein-9b",
"credits": {
"image": 10.0,
"llm": 0.065,
"total": 10.065
}
}
```
| Field | Description |
|-------|-------------|
| `caption` | The drafted caption, already trimmed to the platform's limit. |
| `hashtags` | Hashtags, each with its leading `#`. Empty when `hashtag_count` is 0. |
| `image` | `b64_json` plus a short-lived signed `url`. `null` when `generate_image` is `false`. |
| `model_used` | The model that drafted the caption. |
| `image_model_used` | The image model that actually served. `null` for a caption-only call. |
| `credits` | What you were charged, split by leg. `total` is the sum. |
`image.url` is a short-lived signed link — download or re-host it promptly. The
base64 payload in the same response has no expiry.
Generated images also appear in [`GET /v1/images/history`](/docs/image-generation#list-history)
alongside your other generations.
## Permissions
| Call | Required key permissions |
|------|--------------------------|
| `generate_image: true` (default) | `llm` **and** `image` |
| `generate_image: false` | `llm` only |
A key missing the permission for a leg gets `403 permission_denied` before
anything is generated or charged.
## Pricing
Billed as two separate line items on your usage, at the same rates as calling the
endpoints individually:
- **Caption + hashtags** — per token, at the drafting model's rate. A typical post
is well under one credit.
- **Image** — the flat per-image price of `image_model`. See the
[image pricing table](/docs/image-generation#pricing).
Every response carries the exact split in `credits`. A caption-only call is
charged for the caption alone.
Image credits are reserved before generation and **refunded in full** if the post
does not complete — and a request that fails is never charged for the caption
either. A failed post costs nothing.
## Errors
| HTTP | Code | Meaning |
|------|------|---------|
| 402 | `insufficient_credits` | Balance below the image cost. Top up in the dashboard. |
| 403 | `permission_denied` | Key lacks `llm` (or `image`, when generating one). |
| 403 | `model_not_available` | `image_model` needs a paid plan. |
| 404 | `model_not_found` | Unknown `image_model`. |
| 422 | `invalid_request_error` | Bad `topic`, `platform` or `hashtag_count` (0–30); a malformed or oversized `reference_images` / `reference_pdf`; or references sent with an `image_model` that has no edit surface. |
| 429 | `quota_exceeded` | Monthly plan cap hit for the LLM or image service. |
| 429 | `too_many_concurrent_requests` | Too many in-flight requests on this key. Retry shortly. |
| 502 | `upstream_error` | The caption or the image failed. No credits charged — safe to retry. |
| 503 | `service_unavailable` | Image service temporarily unavailable. No credits charged. |
---
### Web Search API
URL: /docs/web-search
> Search the live web through a single endpoint. Two modes — shorter (Serper / Google) and detailed (Exa / neural). Flat ₹1 per search.
## Overview
One endpoint. By default we serve **Serper web search** (fast, current, citation-backed results); you can also pick **shorter** or **detailed** modes, or set an explicit `provider`. We route for you, charge a flat ₹1 per search, and return a normalised response shape.
**Endpoint:** `POST /v1/search`
**Auth:** `Authorization: Bearer cm_your_key` — the key must have the `search` permission (or `*`).
**Cost:** 1 credit (= ₹1) per successful search, regardless of mode or number of results. Failed upstream calls are not charged.
:::flow
icon:app | Your app | Send a `query` + `mode` to `POST /v1/search`
icon:gateway | CallMissed gateway | Check the `search` permission and pick the provider for the mode
icon:search | Search provider | Default **Serper** web search · override with `provider`
icon:done | Your app | Receive a normalized result list and get charged ₹1 only on success
:::
## Basic Usage
:::tabs
```python [Python]
import httpx
r = httpx.post(
"https://api.callmissed.com/v1/search",
headers={"Authorization": "Bearer cm_your_key"},
json={
"query": "latest Indian AI startups raising funding",
"mode": "shorter", # or "detailed" / "auto"
"num_results": 10,
},
timeout=15,
)
print(r.json()["results"][:3])
```
```javascript [JavaScript]
const res = await fetch("https://api.callmissed.com/v1/search", {
method: "POST",
headers: {
"Authorization": "Bearer cm_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "latest Indian AI startups raising funding",
mode: "shorter", // or "detailed" / "auto"
num_results: 10,
}),
});
const data = await res.json();
console.log(data.results.slice(0, 3));
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/search \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"query": "latest Indian AI startups raising funding",
"mode": "shorter",
"num_results": 10
}'
```
:::
## Modes
| `mode` | Underlying | Best for | p50 latency |
|------|------|------|------|
| `shorter` | Serper web search | fast, current, citation-backed results | ~1–2s |
| `detailed` | Exa search | richer answers with cited sources | ~1–3s |
| `auto` | tenant default → platform default (Serper) | let CallMissed pick | depends |
By default all modes use **Serper web search**. You can override with `provider: "serper" | "exa" | "firecrawl" | "linkup"` directly; when both `mode` and `provider` are set, `provider` wins. `exa`/`serper`/`firecrawl`/`linkup` are all available and act as automatic fallbacks for resilience — if one provider errors, the request transparently retries another so you always get a result. All providers return the same normalised shape and the same flat ₹1 per search.
Operators can set the **tenant default** from **Settings → Web search default**.
## Request Body
| Field | Type | Default | Notes |
|---|---|---|---|
| `query` | string | (required) | 1–2000 chars |
| `mode` | string | `"auto"` | `auto` / `shorter` / `detailed` |
| `provider` | string | — | Optional raw override: `serper` / `exa` / `firecrawl` / `linkup`. Wins over `mode` |
| `num_results` | int | `10` | 1–50 |
| `search_type` | string | mode default | Exa: `auto`/`fast`/`instant`/`deep-lite`/`deep`. Serper: `search`/`news`/`images` |
| `include_domains` | string[] | — | detailed mode only |
| `exclude_domains` | string[] | — | detailed mode only |
| `start_published_date` | `YYYY-MM-DD` | — | detailed mode only |
| `end_published_date` | `YYYY-MM-DD` | — | detailed mode only |
| `include_content` | bool | `false` | detailed mode only — fetch page text + highlights |
| `gl` | string | — | shorter mode only — country ISO (e.g. `in`, `us`) |
| `hl` | string | — | shorter mode only — language ISO |
| `tbs` | string | — | shorter mode only — time filter, e.g. `qdr:d` (past day) |
## Response Shape
Responses are **normalised across providers** — same keys regardless of which backend ran the query.
```json
{
"query": "latest Indian AI startups raising funding",
"mode": "shorter",
"provider": "serper",
"results": [
{
"title": "Acme AI raises $...",
"url": "https://example.com/article",
"snippet": "Acme AI announced a funding round led by...",
"content": "Full text if include_content=true, else null",
"published_date": "2026-04-10T00:00:00.000Z",
"score": 0.92,
"source": "example.com"
}
],
"answer": "Optional grounded answer (provider-dependent)",
"images": null,
"credits_used": 1,
"balance": 495.0,
"request_id": "search-a3f8c1d2e0b9",
"latency_ms": 920
}
```
## Pricing & Credits
- **Flat rate:** 1 credit per successful search. 1 credit = ₹1.
- Failed requests (upstream 5xx, rate limits, etc.) are **not charged**.
- The charge is visible immediately in the `credits_used` + `balance` fields on the response, and in your credit history in the dashboard.
- Per-key budget caps and the tenant monthly budget cap both apply — hitting either returns HTTP 402 `insufficient_credits`.
## Permissions
API keys must have `search` (or `*`) in their service permissions. Edit a key in your dashboard (↳ **Profile → API Keys → Edit**) and tick the **Search** chip.
You can also restrict which **search providers** a single key may call by editing **Allowed web-search providers** on the same edit panel. A key restricted to `serper` can still use the endpoint, but calls with `mode: "detailed"` (or `provider: "exa"`) will return **HTTP 403 `search_provider_not_allowed`** before any upstream request is made.
## Errors
Error envelope matches the rest of `/v1`:
```json
{ "error": { "message": "...", "type": "...", "code": "..." } }
```
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_request_error` | Missing or malformed body |
| 401 | `invalid_api_key` | Bad or revoked key |
| 402 | `insufficient_credits` | Balance < 1 credit or monthly budget exhausted |
| 403 | `permission_denied` | Key lacks `search` permission |
| 403 | `search_provider_not_allowed` | Key's Allowed search providers excludes the requested provider |
| 429 | `rate_limit_exceeded` | Per-key RPM exceeded |
| 503 | `provider_error` | Upstream search provider unavailable |
---
### Bring Your Own Telephony
URL: /docs/bring-your-own-telephony
> Connect a telephony account you already own, import your existing numbers, and let a CallMissed AI voice agent answer the calls.
## Overview
**Bring your own telephony (BYO)** lets you keep the phone numbers and the carrier contract you already have, and use them with a CallMissed AI voice agent. Nothing is ported, and you do not rent a number from us.
A connection has two halves, and calls only work once **both** are done:
1. **You give CallMissed your provider credentials.** We verify them against your provider, store them encrypted, and provision the trunk that carries audio between your provider and our voice agents.
2. **You point the number's call routing at CallMissed.** Inbound calls have to arrive at us, so the routing on the number (or on the trunk it belongs to) is switched over to the SIP endpoint we show you after connecting.
:::flow
icon:app | Connect the provider | Paste your provider credentials into CallMissed; we verify them before storing
icon:gateway | Provision the trunk | CallMissed creates the SIP trunk on both sides and hands you the inbound SIP endpoint
icon:phone | Import numbers | Your voice-capable numbers are read from your account and imported
icon:done | Route and answer | Point the number's call routing at CallMissed and assign a voice-agent bot
:::
**Prerequisites**
- An **active account** with a supported telephony provider, holding at least one **voice-capable** number.
- **Admin access to that provider's dashboard**, enough to create or edit a trunk and change a number's call routing.
- A CallMissed account with the **owner** or **admin** role.
> **Note:** Numbers you bring keep their existing contract and billing with your provider. CallMissed does not charge you a monthly number rental for them, unlike numbers you rent from us through the [Telephony API](/docs/telephony-api).
## Rent from us, or bring your own
| | Rent from CallMissed | Bring your own |
|---|---|---|
| **Time to first call** | Fastest. Search, buy, assign a bot. | Depends on your provider's trunk setup. |
| **KYC and compliance** | We handle it. Submit one [KYC application](/docs/telephony-api) and we do the rest. | You already hold the number, so its compliance stays with your provider. |
| **Numbers** | New numbers, issued by us. | Your existing numbers, unchanged. |
| **Carrier billing** | One bill: rental plus usage, in credits. | Your provider keeps billing carriage; CallMissed bills the AI voice agent. |
| **Best for** | Starting from zero, or adding a line quickly. | Keeping published numbers, existing rates, or an existing carrier relationship. |
Both paths converge: once a number is in CallMissed, whether rented or imported, you assign a bot and configure the call the same way.
## Supported providers
| Provider | How it connects | Numbers auto-imported | Status |
|---|---|---|---|
| **Twilio** | SIP trunk (Elastic SIP Trunking) | Yes | Self-serve |
| **Plivo** (your own account) | SIP trunk (Zentrunk) | Yes | Self-serve |
| **Custom SIP** | SIP trunk (any provider) | Manual entry | Self-serve |
| **Exotel** | SIP trunk (vSIP) | Yes | Set up by our team |
| **Smartflo** (Tata) | Media streaming (WebSocket) | Yes | Set up by our team |
| **Pulse** | Contact us | n/a | Contact us |
| **InTalk** | Contact us | n/a | Contact us |
| **Vobiz** | Contact us | n/a | Contact us |
What the **Status** column means:
- **Self-serve**: you can connect it yourself from the dashboard, start to finish. The three self-serve providers are documented below.
- **Set up by our team**: the integration exists, but part of the trunk mapping has to be arranged with the provider on your behalf. Write to `support@callmissed.com` with your account details and we complete the connection with you.
- **Contact us**: not wired yet. Tell us which provider you are on at `support@callmissed.com` and we will scope it.
> **Do not follow the self-serve steps for a "Set up by our team" provider.** Their trunk mapping is done by the provider's own support team, not from your console, and a half-configured trunk silently drops inbound calls.
## Twilio
Connects as an **Elastic SIP Trunk** in your own Twilio account.
:::steps
## Get your Twilio credentials
From the [Twilio Console](https://console.twilio.com/) home page, under **Account Info**, copy:
- **Account SID**, starts with `AC…`.
- **Auth Token**, click to reveal.
Prefer a scoped credential? Instead of the Auth Token you can supply a Twilio **API Key SID** (starts with `SK…`) and its **API Key Secret**. Give both or neither: an API Key SID without its secret is rejected.
The credentials must belong to an account (or subaccount) allowed to manage **Elastic SIP Trunking** and to list incoming phone numbers.
## Enter the details in CallMissed
Open **Phone numbers → Bring your own telephony → Connect**, choose **Twilio**, and paste the Account SID and Auth Token. CallMissed makes a live call to Twilio to verify the pair before anything is stored. Bad credentials fail here, not later on a live call.
## We provision the trunk
CallMissed creates the Elastic SIP Trunk in your Twilio account and wires both directions: an **origination URI** pointing at our SIP endpoint for inbound calls, and a **termination URI** for outbound.
The termination domain Twilio issues always ends in `pstn.twilio.com`. If you are supplying an existing trunk instead of letting us create one, its termination domain must end in `pstn.twilio.com` or the connection is rejected.
## Import your numbers
Your voice-capable Twilio numbers are read from the account and listed for import. Numbers must be in **`+E.164`** form, with the leading `+` and the country code, for example `+14155550123`. A number that Twilio reports in any other format is skipped.
Pick the numbers you want CallMissed to answer. Each imported number is pointed at the trunk we created, which is what takes it off its old voice webhook and routes it to your agent.
:::
> **Inbound calls on a Twilio trunk are matched on the called number**, not on a SIP password. A number that is not imported, or that is still routed by its own voice webhook in the Twilio Console, will not reach your agent even though the credentials are valid.
## Plivo (your own account)
Connects as a **Zentrunk** SIP trunk in your own Plivo account. This is the BYO path. It is separate from numbers you rent from CallMissed, which are billed as rentals.
:::steps
## Get your Plivo credentials
In the [Plivo Console](https://console.plivo.com/), open **Account → Keys & Credentials** and copy your **Auth ID** and **Auth Token**. The account must be allowed to manage Zentrunk trunks and to list phone numbers.
## Enter the details in CallMissed
Open **Phone numbers → Bring your own telephony → Connect**, choose **Plivo**, and paste the Auth ID and Auth Token. They are verified against Plivo before they are stored.
## We provision the trunks
Zentrunk splits the two directions, so CallMissed creates **both** an inbound trunk and an outbound trunk on your Plivo account, and points the inbound trunk's destination at our SIP endpoint.
## Import your numbers
Your Plivo voice numbers are listed for import. Note the format difference: **Plivo returns numbers in E.164 without a leading `+`** (for example `918080247309`, not `+918080247309`). CallMissed normalises them to `+E.164` on import, so they appear the same way as every other number in the dashboard.
Finally, attach each imported number to the inbound trunk in the Plivo Console. Plivo binds a number to a trunk on their side, so this last step is done in their console, not ours.
:::
## Custom SIP
Use this for any provider not listed above that can terminate a SIP trunk. You supply the trunk details yourself, and you enter the numbers manually because there is no account API for us to read them from.
**Fields you supply**
| Field | Example | Notes |
|---|---|---|
| **Termination host** | `sip.example.com` | The outbound SIP host, as a bare hostname. No `sip:` or `sips:` prefix, no path, no `;transport=` parameter. An explicit `:port` is accepted if your provider needs one. |
| **Transport** | `tcp` | One of `auto`, `udp`, `tcp`, `tls`. Defaults to `tcp`. Match what the provider's trunk actually accepts. |
| **SIP username** | `acme-outbound` | The digest username we authenticate outbound calls with. |
| **SIP password** | your trunk password | Stored encrypted, never shown again. |
| **Phone numbers** | `+911140848000` | The `+E.164` numbers to accept inbound calls on. Every entry must carry the leading `+`. |
> **Outbound calls authenticate with the SIP username and password, not with an IP allowlist.** We cannot guarantee a static egress IP for allowlisting, so an IP-only trunk cannot be authorised. Ask your provider to enable digest (username and password) authentication on the trunk. If they cannot, write to `support@callmissed.com` before you start.
**Inbound.** After the connection is created, CallMissed shows you the SIP endpoint to route to. Set that as the inbound destination on your provider's trunk, then confirm each number is attached to that trunk on their side. Only the numbers you entered are accepted.
## Connecting in the dashboard
The wizard is the same for every self-serve provider.
:::steps
## Open the Phone numbers page
In the [Dashboard](https://console.callmissed.com), go to **Phone numbers**. The **Bring your own telephony** card sits next to the rent-a-number card. Choose **Connect**.
## Pick a provider
Select your provider from the list. Providers marked *Set up by our team* show a contact panel instead of a credential form.
## Step 1. Get credentials
The panel tells you exactly where the credentials live in that provider's console, with the field names they use. Fetch them in another tab.
## Step 2. Enter details
Paste the credentials, and for Custom SIP the termination host, transport, and number list. Optionally give the connection a **label** (for example "Twilio, prod account") so two accounts on the same provider are easy to tell apart.
Submitting verifies the credentials with your provider. If verification fails, the connection stays unconnected and shows the reason. Nothing partial is left behind.
## Step 3. Configure and import numbers
CallMissed provisions the trunk, then lists the numbers it found on your account. Select the ones to import. For Custom SIP, this step confirms the numbers you typed in.
:::
A connection moves through **pending → provisioning → active**. If provisioning fails it lands in **error** with a short, readable reason on the connection card. Fix the cause at your provider and reconnect. A connection you no longer want can be **disabled**.
## After connecting
- **Imported numbers appear alongside rented ones.** They show up on the Phone numbers page and in the number list of the [Telephony API](/docs/telephony-api), tagged with the provider they came from.
- **Assign an agent the same way.** Link a voice-agent bot to the number exactly as you would for a rented number. See [Voice Calling](/docs/voice) for building the bot.
- **Per-number call settings still apply.** Greeting, language, voice, STT and TTS models, system prompt, tools, and maximum call duration are all set per number and override the linked bot on that number's calls. The full list is in [Per-number call overrides](/docs/telephony-api).
- **Disconnecting only affects CallMissed.** Removing a provider connection removes its numbers from CallMissed. It does **not** release or cancel anything at your provider, and it does not change your contract with them. Point the number's routing back at your own application before you disconnect, or inbound calls will go nowhere.
## Security
- **Credentials are encrypted at rest** before they reach the database. They are decrypted only in memory, only when a call to your provider needs them.
- **They are never returned by the API.** No response body, log line, or webhook payload contains a provider secret. A connection exposes only whether credentials are present, a masked account identifier, and the non-secret connection facts (trunk ids, SIP address, transport).
- **Scoped per tenant.** A connection belongs to one tenant and is only ever readable inside it.
- **To rotate a credential**, change it at your provider, then connect the provider again in CallMissed with the new pair. Verification runs against the new credential before it replaces the old one.
If you believe a credential has leaked, revoke it at your provider first, then reconnect. Revoking at the provider takes effect immediately, whatever is stored on our side.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Inbound calls ring, then drop or hit voicemail. The agent never picks up. | The number's call routing still points at your provider's old app, IVR, or trunk. | Point the number (or its trunk) at the SIP endpoint shown on the connection, and confirm the number is attached to that trunk on the provider's side. |
| One number fails while others on the same account work. | That number was never imported, or it is attached to a different trunk. | Import it, then attach it to the trunk CallMissed provisioned. |
| Outbound calls fail immediately with an authentication error. | Wrong SIP username or password, or the provider's trunk expects IP-based authentication. | Re-enter the SIP credentials. If the trunk is IP-authenticated, ask your provider to enable digest authentication, since outbound uses username and password. |
| Outbound calls time out instead of failing fast. | Wrong termination host, or the wrong transport (for example `tls` on a trunk that only accepts `udp`). | Check the host is a bare hostname with no `sip:` prefix and no parameters, and set the transport to what your provider documents for the trunk. |
| A number is missing from the import list. | The API credentials cannot list numbers, the number is not voice-capable, or it lives in a subaccount the credentials do not cover. | Use credentials for the account that actually holds the number, and confirm the number has the voice capability. |
| A number imported but shows a different format than expected. | Plivo returns numbers without a leading `+`. | Nothing to do. CallMissed normalises to `+E.164` on import. If you enter numbers manually, always include the `+` and the country code. |
| The connection sits in **error**. | Credential verification or trunk provisioning failed upstream. | Read the reason on the connection card, fix it at the provider, and reconnect. Credentials are re-verified on every connect. |
Still stuck, or on a provider marked *Set up by our team*? Write to `support@callmissed.com` with your provider, the connection label, and the number you are testing.
---
### Migrate from Twilio
URL: /docs/migrate-from-twilio
> Point an existing Twilio Programmable Voice integration at CallMissed by changing only the base URL and the credentials. Same path, same HTTP Basic scheme, same form parameters, same Call object and error envelope.
## Overview
If you already place calls through Twilio Programmable Voice, you can move to CallMissed by changing **two things**: the **base URL** and the **credentials**. The path shape, the HTTP Basic scheme, the `application/x-www-form-urlencoded` body with TitleCase parameters, the JSON Call object, the status enum, the list envelope and the error envelope are all Twilio's — your existing SDK or HTTP client keeps working.
```diff
- https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls.json
+ https://api.callmissed.com/2010-04-01/Accounts/{AccountSid}/Calls.json
- -u "ACxxxxxxxx:your_auth_token" # Twilio SID : auth token
+ -u "any:cm_your_api_key" # CallMissed key as the password
```
> **`{AccountSid}` is accepted, ignored, and never trusted.** Keep your old `AC…` sid in the path — every response echoes your *authenticated* CallMissed account sid, and every query is scoped to your tenant, so an arbitrary path sid can never reach another tenant's data. The only credential is your CallMissed API key.
**Authentication.** Both forms work:
- `Authorization: Basic base64(:cm_your_api_key)` — what a Twilio client sends (`AccountSid:AuthToken`). The **password** carries the key; a key in the username slot (`-u "cm_...:"`) is accepted too.
- `Authorization: Bearer cm_your_api_key` — the native form.
API-key callers need the `telephony:read` scope for fetch/list and `telephony:write` to place a call; placing a call additionally requires an owner/admin role when called with a JWT.
> **Availability.** Telephony is India-only and enabled per tenant. When it is not enabled, these routes are unmounted and return `404`. See [Telephony API](/docs/telephony-api) for the native lifecycle (KYC, buying a number, linking an agent).
## Place a call
```
POST /2010-04-01/Accounts/{AccountSid}/Calls.json
Content-Type: application/x-www-form-urlencoded
```
`201 Created` on success, matching Twilio.
| Parameter | Maps to | Notes |
|-----------|---------|-------|
| `To` | destination | E.164, e.g. `+919876543210` |
| `From` | one of your active numbers | Must be an **active** number on your account (matched with or without a leading `+`) |
| `ApplicationSid` | the agent that handles the call | A CallMissed **agent** is the analogue of a Twilio Application. Accepts an `AP` sid or a bare agent UUID. Omitted → the from-number's bound agent |
| `CallMissedReason` | spoken context | *(added — not a Twilio param)* a reason the agent can reference |
| `CallMissedVariables` | template values | *(added)* a JSON object of `{{token}}` values rendered into the agent's greeting/prompt |
:::tabs
```bash [cURL]
curl -X POST \
https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json \
-u "any:cm_your_api_key" \
--data-urlencode "To=+919876543210" \
--data-urlencode "From=+911140000000" \
--data-urlencode "ApplicationSid=AP0123456789abcdef0123456789abcdef"
```
```python [Python]
import httpx
httpx.post(
"https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json",
auth=("any", "cm_your_api_key"),
data={
"To": "+919876543210",
"From": "+911140000000",
"ApplicationSid": "AP0123456789abcdef0123456789abcdef",
},
)
```
```javascript [Node.js]
const body = new URLSearchParams({
To: "+919876543210",
From: "+911140000000",
ApplicationSid: "AP0123456789abcdef0123456789abcdef",
});
await fetch(
"https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json",
{
method: "POST",
headers: {
Authorization: "Basic " + btoa("any:cm_your_api_key"),
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
);
```
:::
### The Call object
Twilio's snake_case Call object, with a few honest CallMissed additions:
```json
{
"sid": "CA0f1e2d...",
"account_sid": "AC9a8b7c...",
"to": "+919876543210",
"from": "+911140000000",
"status": "queued",
"start_time": "Thu, 24 Aug 2023 05:01:45 +0000",
"end_time": null,
"duration": null,
"price": null,
"price_unit": "credits",
"direction": "outbound-api",
"date_created": "Thu, 24 Aug 2023 05:01:45 +0000",
"date_updated": "Thu, 24 Aug 2023 05:01:45 +0000",
"uri": "/2010-04-01/Accounts/AC9a8b7c.../Calls/CA0f1e2d....json",
"api_version": "2010-04-01",
"callmissed_call_id": "…",
"callmissed_agent_sid": "AP…"
}
```
Fidelity details that match Twilio exactly:
- **`duration` and `price` are strings**, not numbers, and stay `null` until the call is billed. `price` is negative (an amount debited).
- **Dates are RFC 2822** (`"Thu, 24 Aug 2023 05:01:45 +0000"`), not ISO 8601.
- **`price_unit` is `"credits"`** — CallMissed bills in credits, not a currency, so the field says so rather than pretending to be `"USD"`. Your upstream carrier cost is never exposed.
- **`status`** is exactly `queued`, `ringing`, `in-progress`, `canceled`, `completed`, `busy`, `failed`, `no-answer`.
## Fetch and list
```bash
# One call
curl https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls/CA0f1e2d....json \
-u "any:cm_your_api_key"
# List (filters: To, From, Status; paging: Page, PageSize)
curl "https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json?Status=completed&PageSize=50" \
-u "any:cm_your_api_key"
```
The list envelope is Twilio's, and the array key is the lower-cased resource name — **`calls`** — with `page`, `page_size`, `uri`, `first_page_uri`, `next_page_uri` and `previous_page_uri`.
## Parameters that are rejected, not ignored
Silently ignoring a parameter would connect the call and then behave differently from what you asked — worse than refusing it. These return `400` with a Twilio-shaped error that names the parameter:
- **`Url` / `Twiml` / `Method` / `Fallback*`** — there is no TwiML interpreter. CallMissed calls are agent-driven; the behaviour comes from `ApplicationSid` (the agent), not a markup document.
- **`Record` / `RecordingStatusCallback*`** — per-call recording is not controllable through this API.
- **`MachineDetection*` / `AsyncAmd*`** — no answering-machine detection.
- **`SendDigits`** — no post-answer DTMF injection.
- **`Timeout` / `TimeLimit`** — no per-call ring/duration override (the agent's configured max duration applies).
- **`StatusCallback` / `StatusCallbackEvent` / `StatusCallbackMethod`** — per-call status callbacks are not delivered. Subscribe instead to the `call.started` / `call.completed` / `call.failed` [webhook events](/docs/webhooks) at `/api/v1/webhooks`.
## Error envelope
Twilio's shape, unchanged:
```json
{
"status": 400,
"message": "The 'From' number +911140000000 is not an active phone number on this account.",
"code": 21210,
"more_info": "https://www.twilio.com/docs/errors/21210"
}
```
Where a real Twilio error code fits, it is used (`21201`, `21211`, `21212`, `21213`, `21210`, `21217`, `20003`, `20404`, `20429`), and `more_info` points at `twilio.com`. Where no Twilio code fits, a CallMissed code in the `61000–61999` range is used, and its `more_info` points at `docs.callmissed.com` — never at a `twilio.com` page that would describe something unrelated.
## When to use the native API instead
For new builds, the [Telephony API](/docs/telephony-api) exposes CallMissed's full lifecycle — India KYC, buying numbers, linking agents, richer call records — with your `cm_` key directly. This Twilio-compat surface exists to make an *existing* Programmable Voice integration a two-line migration.
---
### Telephony API
URL: /docs/telephony-api
> Complete India KYC, rent Indian phone numbers, link them to a voice-agent bot, place outbound PSTN calls, and fetch recordings — all with your cm_ key.
## Overview
The Telephony API is a full lifecycle for **CallMissed Numbers**: submit an India KYC (compliance) application, wait for it to be accepted, search available Indian numbers, **buy** one (a paid action that draws your real credit balance), manage it, and place outbound **PSTN** calls answered by your AI voice agent.
**Base path:** `https://api.callmissed.com/api/v1/telephony`
> **The journey is ordered.** You cannot buy a number until you hold an **accepted** KYC application, and you cannot place a call until you own an **active** number. Follow the flow below top to bottom.
:::flow
icon:app | Submit KYC | Upload your business documents and details once
icon:gateway | CallMissed | Reviews the application; poll or sync until it is `accepted`
icon:done | Buy & call | Search a number, buy it (paid), link a bot, place calls
:::
**Authentication.** Every endpoint accepts both a **JWT** (`Authorization: Bearer `) and an **API key** (`Authorization: Bearer cm_`). API-key callers need the `telephony:read` scope for search/list/get and `telephony:write` for buy, release, patch, KYC submit/sync, and originating calls. Money-affecting and destructive actions (buy, release, patch, KYC submit, originate call) additionally require an **owner/admin** role when called with a JWT.
> **Availability.** Telephony is **India-only** and enabled per tenant. When the feature is not enabled for your tenant, the routes are unmounted and every call returns `404`.
## 1. Submit KYC (Compliance)
Every rented Indian number must be backed by an **accepted** KYC application. Submission is a **multipart form** carrying your business details plus the two **mandatory** documents:
- **Registration certificate** — Certificate of Incorporation (CIN) or Udyam certificate
- **GST certificate**
Files must be **PDF, JPEG, or PNG**, up to **5 MB each**. The legal business name must match **exactly** on both documents or the application is rejected upstream.
`POST /compliance` · scope `telephony:write` (owner/admin for JWT)
**Form fields:**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `alias` | string (1–128) | Yes | A label for this application |
| `business_name` | string (1–100) | Yes | Legal name, exactly as printed on **both** documents |
| `registration_number` | string (1–64) | Yes | CIN or Udyam number |
| `email` | string (3–254) | Yes | Business contact email |
| `address_line1` | string (1–255) | Yes | |
| `address_line2` | string (0–255) | No | |
| `city` | string (1–100) | Yes | |
| `state` | string (1–100) | Yes | |
| `postal_code` | string (1–16) | Yes | |
| `registration_cert` | file | Yes | COI or Udyam certificate (PDF/JPEG/PNG, ≤ 5 MB) |
| `gst_cert` | file | Yes | GST certificate (PDF/JPEG/PNG, ≤ 5 MB) |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/telephony/compliance \
-H "Authorization: Bearer cm_your_api_key" \
-F 'alias=Acme India KYC' \
-F 'business_name=ACME TECHNOLOGIES PRIVATE LIMITED' \
-F 'registration_number=U72900KA2020PTC000000' \
-F 'email=compliance@acme.in' \
-F 'address_line1=123 MG Road' \
-F 'address_line2=Suite 400' \
-F 'city=Bengaluru' \
-F 'state=Karnataka' \
-F 'postal_code=560001' \
-F 'registration_cert=@certificate-of-incorporation.pdf' \
-F 'gst_cert=@gst-certificate.pdf'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/telephony"
headers = {"Authorization": "Bearer cm_your_api_key"}
data = {
"alias": "Acme India KYC",
"business_name": "ACME TECHNOLOGIES PRIVATE LIMITED",
"registration_number": "U72900KA2020PTC000000",
"email": "compliance@acme.in",
"address_line1": "123 MG Road",
"address_line2": "Suite 400",
"city": "Bengaluru",
"state": "Karnataka",
"postal_code": "560001",
}
files = {
"registration_cert": ("coi.pdf", open("coi.pdf", "rb"), "application/pdf"),
"gst_cert": ("gst.pdf", open("gst.pdf", "rb"), "application/pdf"),
}
resp = httpx.post(f"{BASE}/compliance", headers=headers, data=data, files=files)
application = resp.json()
print(application["id"], application["status"]) # e.g. "...", "submitted"
```
:::
**Response (200 OK)** — a compliance application:
```json
{
"id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e",
"tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"alias": "Acme India KYC",
"country_iso": "IN",
"number_type": "local",
"user_type": "business",
"status": "submitted",
"rejection_reason": null,
"business_name": "ACME TECHNOLOGIES PRIVATE LIMITED",
"registration_number": "U72900KA2020PTC000000",
"created_at": "2026-04-19T12:00:00Z"
}
```
**Status codes**
| Code | Meaning |
|------|---------|
| `200` | Application created and submitted |
| `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin |
| `413` | A document exceeds the 5 MB limit |
| `422` | A document is missing, empty, or not a PDF/JPEG/PNG |
| `404` | Telephony not enabled for your tenant |
## 2. Check KYC Status
Applications move through `draft` → `submitted` → `accepted` / `rejected`. Only an **`accepted`** application can back a number purchase.
| Endpoint | Scope | Purpose |
|----------|-------|---------|
| `GET /compliance` | `telephony:read` | List your applications |
| `GET /compliance/{application_id}` | `telephony:read` | Get one application + status |
| `POST /compliance/{application_id}/sync` | `telephony:write` | Refresh status from the carrier |
`GET /compliance` accepts `limit` (1–200, default 50) and `offset` (≥ 0). It returns an array of applications, newest first. `POST /compliance/{application_id}/sync` pulls the latest status and, if rejected, populates `rejection_reason`.
```bash
# List applications
curl https://api.callmissed.com/api/v1/telephony/compliance \
-H "Authorization: Bearer cm_your_api_key"
# Refresh one application's status
curl -X POST https://api.callmissed.com/api/v1/telephony/compliance/{application_id}/sync \
-H "Authorization: Bearer cm_your_api_key"
```
Once `status` is `accepted`, the application carries a **compliance reference** you pass as `compliance_application_id` when buying a number (step 4). A `GET`/`sync` on an application you do not own returns `404`.
## 3. Search Available Numbers
Search for Indian numbers before buying. Rates are returned **after** your tenant markup — `rental_credits` is what you will actually be charged per month.
`GET /numbers/search` · scope `telephony:read`
**Query parameters**
| Param | Values | Default |
|-------|--------|---------|
| `country_iso` | 2-letter ISO (`IN`) | `IN` |
| `type` | `local` / `mobile` / `tollfree` | — |
| `pattern` | digit substring to match (max 32 chars) | — |
| `limit` | 1–20 | 20 |
```bash
curl "https://api.callmissed.com/api/v1/telephony/numbers/search?country_iso=IN&type=local&pattern=80802&limit=10" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)** — an array of search hits:
```json
[
{
"number": "+918080247309",
"number_type": "local",
"country": "IN",
"region": "Mumbai",
"monthly_rental_rate_usd": 2.5,
"rental_credits": 250,
"voice_enabled": true,
"sms_enabled": false
}
]
```
## 4. Buy a Number
Rent one of the searched numbers. This is a **paid action** — it draws your **real (paid) credit balance**. The signup bonus does **not** cover a number rental; if your paid balance is short, you get a `402` telling you to top up.
`POST /numbers` · scope `telephony:write` (owner/admin for JWT)
**Request body**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `e164` | string | Yes | The number to buy, e.g. `+918080247309` |
| `compliance_application_id` | string | Yes | The compliance reference from your **accepted** KYC application |
The purchase runs in a strict, money-safe order: it verifies your KYC application is accepted (else `409`), confirms the number is still available and prices it live (else `422`), deducts the rental credits, and only then rents the number. If the rent fails, the credits are refunded automatically.
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/telephony/numbers \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"e164": "+918080247309",
"compliance_application_id": "your-accepted-compliance-reference"
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/telephony"
headers = {"Authorization": "Bearer cm_your_api_key"}
resp = httpx.post(
f"{BASE}/numbers",
headers=headers,
json={
"e164": "+918080247309",
"compliance_application_id": "your-accepted-compliance-reference",
},
)
if resp.status_code == 402:
print("Top up your paid balance before buying a number")
elif resp.status_code == 409:
print("Your KYC application is not accepted yet")
else:
number = resp.json()
print(number["id"], number["status"]) # e.g. "...", "active"
```
:::
**Response (200 OK)** — the rented number:
```json
{
"id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"e164": "+918080247309",
"country_iso": "IN",
"number_type": "local",
"status": "active",
"bot_id": null,
"alias": null,
"monthly_rental_rate_usd": 2.5,
"rental_credits": 250,
"added_on": "2026-04-19",
"renewal_date": "2026-05-19",
"config": null,
"metadata": null,
"created_at": "2026-04-19T12:00:00Z"
}
```
**Status codes**
| Code | Meaning |
|------|---------|
| `200` | Number rented and active |
| `402` | Insufficient **real** balance — top up to buy |
| `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin |
| `409` | KYC not accepted, or you already hold this number |
| `422` | Number no longer available, or `e164` is malformed |
| `404` | Telephony not enabled for your tenant |
## 5. Manage Numbers
| Endpoint | Scope | Purpose |
|----------|-------|---------|
| `GET /numbers` | `telephony:read` | List your rented numbers |
| `GET /numbers/{number_id}` | `telephony:read` | Get one number |
| `PATCH /numbers/{number_id}` | `telephony:write` | Update alias / linked bot / per-number call config |
| `DELETE /numbers/{number_id}?confirm=true` | `telephony:write` | Release a number (permanent) |
`GET /numbers` accepts `status` (e.g. `active`), `limit` (1–200, default 50), and `offset` (≥ 0). A number moves through `pending` → `active` → `suspended` (unpaid) → `released`.
```bash
# List your rented numbers
curl https://api.callmissed.com/api/v1/telephony/numbers \
-H "Authorization: Bearer cm_your_api_key"
# Get one
curl https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \
-H "Authorization: Bearer cm_your_api_key"
# Update the alias and link a voice-agent bot
curl -X PATCH https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"alias": "Support line", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"}'
```
### Per-number call overrides
The optional `config` object holds per-number call-handling overrides that **win over the linked bot's config on this number's calls** — so two numbers can share one bot yet greet and speak differently. The object **replaces** the stored overrides on every write (send the full set each time; `{}` clears every override). Unknown keys return `422`.
| Key | Type | Notes |
|-----|------|-------|
| `voice_model` | string | Voice LLM model id |
| `voice` | string | Voice / speaker id |
| `language` | string | e.g. `hi-IN` |
| `stt_model` | string | Speech-to-text model id |
| `tts_model` | string | Text-to-speech model id |
| `tts_provider` | string | TTS provider id |
| `tts_engine` | string | TTS engine id |
| `greeting` | string | Opening line spoken on the call |
| `system_prompt` | string | Overrides the bot's persona for this number |
| `max_call_duration_seconds` | integer | 30–14400 |
| `tools` | array of strings | Up to 20 agent tool names |
```bash
curl -X PATCH https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"config": {"greeting": "Namaste! Aap Support line par pahunche hain.", "language": "hi-IN", "max_call_duration_seconds": 600}}'
```
### Release a number
```bash
curl -X DELETE "https://api.callmissed.com/api/v1/telephony/numbers/{number_id}?confirm=true" \
-H "Authorization: Bearer cm_your_api_key"
```
`confirm=true` is **required** — releasing a number is permanent, stops its monthly rental charge, and is **not refunded**. Returns `204 No Content` on success, `400` if `confirm` is omitted, and `404` if the number is not found or not owned by your tenant.
## 6. Place a Call
Originate an outbound PSTN call from one of your **active** numbers. Link a `bot_id` to have your AI voice agent handle the call; if you omit it, the number's persistently bound bot is used.
`POST /calls` · scope `telephony:write` (owner/admin for JWT)
**Request body**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `from_number_id` | UUID | Yes | An active number you own |
| `to_e164` | string | Yes | The destination, e.g. `+919000000000` |
| `bot_id` | UUID | No | Voice-agent bot to answer the call |
| `reason` | string (≤ 500) | No | Plain-language purpose; spoken on the outbound greeting |
```bash
curl -X POST https://api.callmissed.com/api/v1/telephony/calls \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"to_e164": "+919000000000",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"reason": "Confirming your appointment for tomorrow"
}'
```
**Response (200 OK)** — the created call (status advances via webhooks):
```json
{
"id": "5e6a7b8c-9d0e-1f2a-3b4c-5d6e7f8a9b0c",
"tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"voice_session_id": "7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c",
"direction": "outbound",
"status": "initiated",
"remote_e164": "+919000000000",
"bill_duration_seconds": null,
"billed_duration_seconds": null,
"cost_credits": null,
"hangup_cause_code": null,
"hangup_source": null,
"recording_id": null,
"metadata": null,
"created_at": "2026-04-19T12:00:00Z"
}
```
**Status codes**
| Code | Meaning |
|------|---------|
| `200` | Call created and dialing |
| `402` | Insufficient credits to reserve the call |
| `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin |
| `404` | `from_number_id` not found/active, or an explicit `bot_id` not found |
| `422` | `to_e164` is malformed |
| `429` | Concurrent-call limit reached (up to 10 live calls per tenant) |
## 7. List & Fetch Calls
| Endpoint | Scope | Purpose |
|----------|-------|---------|
| `GET /calls` | `telephony:read` | List your calls |
| `GET /calls/{call_id}` | `telephony:read` | Get one call |
| `GET /calls/{call_id}/recording` | `telephony:read` | Signed recording URL |
**`GET /calls` query parameters**
| Param | Values | Default |
|-------|--------|---------|
| `direction` | `inbound` / `outbound` | — |
| `status` | `initiated` / `ringing` / `in_progress` / `completed` / `failed` / `no_answer` / `busy` | — |
| `limit` | 1–200 | 50 |
| `offset` | ≥ 0 | 0 |
```bash
curl "https://api.callmissed.com/api/v1/telephony/calls?direction=outbound&status=completed&limit=50&offset=0" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns an array of calls, newest first. `GET /calls/{call_id}` fetches a single call (`404` if not owned).
### Recordings
If a call was recorded, fetch a short-lived signed URL for its audio:
```bash
curl https://api.callmissed.com/api/v1/telephony/calls/{call_id}/recording \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK):**
```json
{ "url": "https://media.callmissed.com/recordings/....?token=..." }
```
The URL is time-limited — fetch it on demand rather than storing it. Returns `404` if the call has no recording, and `503` if recording storage is temporarily unavailable.
## Scopes
| Scope | Grants |
|-------|--------|
| `telephony:read` | Search numbers, list/get numbers, list/get compliance applications, list/get calls, fetch recording URLs |
| `telephony:write` | Buy/release/update numbers, submit & sync compliance applications, originate calls |
Buy, release, patch, KYC submit, and originate-call also require an **owner/admin** role when called with a JWT (an API key carrying `telephony:write` is sufficient on its own).
## Billing
| Charge | When |
|--------|------|
| **Number rental** | Monthly, in credits, per active number (`rental_credits`). Paid from your **real** balance — not the signup bonus. Renews on `renewal_date`. |
| **Call usage** | Reserved when a call is placed, then settled to the real cost from the call record after the call completes. |
Releasing a number stops its monthly rental charge (no refund for the current period). Ensure sufficient credits before buying numbers or placing calls, or those calls return `402`.
## Webhooks
Telephony call-lifecycle and recording-ready events are delivered to the endpoints you configure via the [Webhooks API](/docs/webhooks). Payloads are HMAC-SHA256 signed — verify the `X-CallMissed-Signature` header exactly as shown on the [Webhooks](/docs/webhooks) page before trusting a payload.
---
### Twilio Voice Setup
URL: /docs/twilio-setup
> Connect a Twilio voice number to CallMissed so an AI agent answers inbound calls in real time.
Connect a [Twilio](https://www.twilio.com/) voice number so an AI agent answers inbound calls — transcribing the caller, generating a reply, and speaking it back with barge-in support.
Twilio connects over **SIP trunking**. CallMissed provisions a trunk against your Twilio account and points it at a per-tenant SIP endpoint; inbound calls land in a room where a voice agent is dispatched to answer them.
## Set it up
The full walkthrough lives in **[Bring Your Own Telephony → Twilio](/docs/bring-your-own-telephony)**: which credentials to copy, what we provision on your account, and how to import your existing numbers.
You will need:
- A **Twilio account** (a trial account works for testing).
- A **voice-capable number** in the [Twilio Console](https://console.twilio.com/) (**Phone Numbers → Manage → Buy a number**, with the *Voice* capability).
- A CallMissed account with the **owner** or **admin** role.
Prefer not to manage a carrier account at all? [Rent a number from us](/docs/telephony-api) instead — KYC and provisioning are handled over the API with your `cm_` key.
## Create the agent that answers
```bash
curl -X POST https://api.callmissed.com/api/v1/bots \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Reception Agent",
"type": "inbound_call",
"system_prompt": "You are the front-desk agent for Acme Clinic. Be concise and friendly."
}'
```
Pick the STT, LLM and TTS models on the agent's **Voice** page in the dashboard, then tune the speech knobs your chosen TTS exposes on its **Speech** page. The same agent works over a phone call and over WebRTC, so anything you tune once applies to both.
## How the call runs
:::flow
icon:phone | Caller | Dials your Twilio number
icon:gateway | Twilio | Routes the call over your SIP trunk to CallMissed
icon:stt | STT | Transcribes the caller's audio in real time
icon:llm | LLM | Generates the reply from the bot's system prompt + conversation history
icon:tts | TTS | Synthesizes speech — playback starts before generation finishes
icon:done | Caller | Hears the AI agent respond, and can interrupt it
:::
The exact STT, LLM and TTS in that chain are whichever you selected on the agent — the defaults are Indian-language-first, and every option is listed under [Models](/docs/models).
## Not the TwiML webhook
:::warning
**Do not point your number at `/api/v1/webhooks/twilio/voice`.**
Earlier versions of this page told you to set that URL as the *A call comes in*
webhook. It returns TwiML that opens a media stream to
`wss://api.callmissed.com/ws/call/{call_id}`, and **that streaming pipeline was
never completed** — the socket accepts audio and discards it.
A number wired that way never works. The endpoint returns `503` unless a Twilio
auth token is configured; where one is, the call used to play a hold message and
then stay silent. It now says the number is not set up and hangs up.
If you configured it from the old instructions, that is why your test calls never
connected. Switch to the SIP setup linked above.
:::
> **Tip:** For browser/mobile WebRTC agents (no phone number required) use the [Voice Agent](/docs/voice-agent) and [Voice Sessions API](/docs/voice-sessions-api) instead. [Voice Calling](/docs/voice) compares the telephony paths.
---
### Voice Calling
URL: /docs/voice
> How an AI agent answers a PSTN call, and which integration path to use.
An AI voice agent answers an inbound phone call, transcribes the caller, generates a reply and speaks it back — with barge-in, so the caller can interrupt mid-sentence.
There are two ways to get a phone number onto an agent. Both run the same voice pipeline.
## Choose a path
**You want us to supply the number.** Complete KYC, rent an Indian number, and bind it to a bot — all over the API with your `cm_` key. See [Telephony API](/docs/telephony-api).
**You already own numbers.** Connect a carrier account you control (Twilio, Plivo, or any SIP provider), import your existing numbers, and point them at an agent. See [Bring Your Own Telephony](/docs/bring-your-own-telephony).
Moving an existing deployment across? [Migrate from Twilio](/docs/migrate-from-twilio) covers the number-by-number cutover.
## How a call actually runs
Both paths converge on the same flow:
```
Inbound PSTN call
→ your carrier's SIP trunk
→ CallMissed SIP endpoint (per-tenant inbound trunk)
→ a room is created and a voice agent is dispatched into it
→ the agent runs STT → LLM → TTS on the live audio
→ speech is streamed back to the caller
```
The agent pipeline is the same one WebRTC voice sessions use, so an agent you have already tuned in the dashboard behaves identically on a phone call. Turn-taking, interruption handling and the model stack all carry over.
Speaking style is per agent, not per call: pick the STT, LLM and TTS on the agent's **Voice** page, and tune the speech knobs your chosen TTS exposes on its **Speech** page.
## Outbound calling
Outbound is available through the [Telephony API](/docs/telephony-api) — place a call, attach an agent, and fetch the recording afterwards.
## Legacy: the TwiML media-stream route
:::warning
**Not implemented — do not build against this.**
Earlier versions of these docs described a Twilio TwiML route that opened
`wss://api.callmissed.com/ws/call/{call_id}` and ran a streaming
STT → LLM → TTS pipeline over it. **That pipeline was never completed.** The
WebSocket accepts audio and discards it: nothing is transcribed, no reply is
generated, and no audio is sent back.
A number pointed at `/api/v1/webhooks/twilio/voice` therefore never works. What
the caller hears depends on the deployment: the endpoint returns `503` unless a
Twilio auth token is configured, and where one is, the call used to play a hold
message and then stay silent for its whole duration. It now says the number is
not set up and hangs up, so the failure is at least audible.
It is documented here only so that anyone who wired it up from the old
instructions knows why their calls never connected. Use one of the two supported
paths above instead.
:::
---
### Changelog
URL: /docs/changelog
> Latest updates, new features, and improvements to the CallMissed API.
## August 2026
### Managed Voice Agent — speech-to-speech over one WebSocket
- **Managed Voice Agent** — a full speech-to-speech pipeline behind a single WebSocket. Stream microphone audio in, get synthesized speech and conversation events back; speech recognition, the language model, text-to-speech, turn-taking and interruption handling are all run and tuned for you. No WebRTC and no client SDK. Two protocols on the same host and the same engine: `wss://api.callmissed.com/v2/voice/agent` (CallMissed-native) and `wss://api.callmissed.com/v1/agent/converse` (**Deepgram Voice Agent compatible** — an existing Deepgram integration can repoint its URL and work unchanged). Supports in-call tool calling, live model/prompt/voice updates, and sessions up to 2 hours. See [Managed Voice Agent](/docs/managed-voice-agent).
- **Voice model catalogue** — `GET /api/v1/voice/models` lists every selectable speech-to-text, language and text-to-speech model with a **measured** latency verdict (`eligible`, `too_slow`, `unsupported`, `unmeasured`) plus its p50, sample count and budget. Any eligible combination is valid. Models too slow to hold a conversation are not offered — they are listed with the reason, rather than silently disappearing or being offered with a caveat. Verdicts come from real production traffic, not vendor claims, and a model with too few samples reads `unmeasured` rather than being assumed fast.
### Embeddings, usage API, CRM, support desk and voice-agent operations
- **Embeddings** — `POST /v1/embeddings`, OpenAI-compatible. `text-embedding-3-small` (1536 dims, $0.02 / 1M input tokens) and `text-embedding-3-large` (3072 dims, $0.13 / 1M). Batches of up to 128 inputs, optional `dimensions` shortening and `base64` output. Both are free-plan callable, taking the **free tier to 27 models across five categories**. Gated by the key's `llm` permission. See [Embeddings](/docs/embeddings).
- **Usage API** — `GET /v1/usage/summary`, `/logs` and `/logs.csv` return your own metering rows for the last 90 days, filterable by service, model, key, `session_id` and `trace_id`. Scope `usage:read`. See [Usage API](/docs/usage-api).
- **Gateway tooling** — server-side [prompt management](/docs/gateway-prompts) with versions, labels, presets and free rendering; [response cache](/docs/gateway-cache) stats and purge; and [bring your own provider key](/docs/provider-keys) with liveness verification and a write-only secret.
- **CRM** — [companies](/docs/crm-companies), [notes and tasks](/docs/crm-notes-tasks), [deals and pipelines](/docs/crm-deals), [custom fields and saved views](/docs/crm-custom-fields), [search, bulk and CSV](/docs/crm-import-export), and [lead scoring with a unified timeline](/docs/crm-lead-scores).
- **Support desk** — [tickets](/docs/support-tickets) with server-managed lifecycle stamps, [SLA policies](/docs/support-sla) with business hours and live breach reporting, [macros, tags and routing rules](/docs/support-ops) with a dry-run evaluator, and [CSAT/NPS surveys](/docs/csat) with a public, token-authenticated response surface.
- **Voice-agent operations** — [eval suites](/docs/voice-evals) (up to 50 cases per run, credit-charged), [A/B experiments](/docs/voice-experiments) with deterministic assignment, and [agent squads](/docs/voice-squads) with handoff simulation and credit-charged agent drafting.
- **WhatsApp** — [Flows](/docs/whatsapp-flows) (create, publish, read submissions) and [catalog orders](/docs/whatsapp-orders).
### New models — Cartesia Ink STT
- **`ink-whisper`** — Cartesia's fastest and most affordable STT at $0.18 / hour, across **100 languages** including Hindi, Urdu and Tamil. Better accuracy than baseline Whisper, and dynamic chunking that cuts hallucination during pauses and silence. Works for both file transcription and voice sessions. See [Speech to Text](/docs/speech-to-text#cartesia-ink-models).
- **`ink-2`** — Cartesia's top-ranked STT for voice agents at $0.54 / hour: 8% WER on AppTek's 14-accent call-centre benchmark, against 10% for Deepgram Flux and 12% for ElevenLabs. Self-detects turns, so no separate turn detector is needed. Two limits: it is **English only**, and it is **voice-session only** — the file transcription endpoint returns `400` and points you to `ink-whisper`. See [Speech to Text](/docs/speech-to-text#cartesia-ink-models).
### New models — conversational Indic LLM, Saaras V4 STT, Flux TTS
- **`sarvam-105b-conversations`** — 105B MoE tuned for conversation and voice. 128K context, tool calling, streaming, hybrid thinking. Free-tier, same $0.35 in / $0.35 out per 1M as `sarvam-105b`. See [Indic Models](/docs/models-indic).
- **`saaras:v4`** — Sarvam STT with five output modes (transcribe, translate, verbatim, transliterate, code-mix) across 24 languages. Free-tier at $0.30 / hour. See [Speech to Text](/docs/speech-to-text).
- **Deepgram Flux TTS** — streaming-first TTS built for voice agents: turn-based synthesis with prosody carried across turns. 36 English voices including `priya` (Indian-accented English, the default). English only, no expressive controls. Available only through the managed Voice Agent (`tts_engine: "flux"`), billed inside the per-minute voice rate. See [Voices](/docs/tts-voices).
- **Free tier** — now 27 models (11 LLM, 4 STT, 4 TTS, 6 image, 2 embedding).
### Model catalog update — retired models
- **Retired LLM IDs** — the following model IDs are no longer served: `openai/gpt-5.4-pro`, `openai/gpt-5.4`, `openai/gpt-5.4-mini`, `openai/gpt-5.4-nano`, `anthropic/claude-opus-4.6`, `anthropic/claude-sonnet-4.6`, `anthropic/claude-haiku-4.5`, `x-ai/grok-4.20`, `qwen/qwen3.5-plus`, `qwen/qwen3.5-flash`, `mistralai/mistral-small-2603`, and the `auto` auto-router.
- **Migration** — use the first-party flagships (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `grok-4.3`) or the direct-routed free tier (`kimi-k2.6`, `kimi-k2.7-code`, `glm-5.2`, `gpt-oss-120b`, `mistral-small-3.1`). See [Models](/docs/models).
- **Free tier** — now 24 models (11 LLM). The `auto` free auto-router is retired; pick a free model explicitly.
- **Endpoints unchanged** — `POST /v1/chat/completions` and the Anthropic-compatible `POST /v1/messages` both continue to work and accept every current catalog ID.
## June 2026
### v1.6.0 — WebRTC Voice, Image Generation & Web Search
- **WebRTC voice sessions** — `POST /v1/voice/sessions` returns a connection token + URL; CallMissed handles the STT→LLM→TTS pipeline. List, fetch, fetch transcript (`json | txt | srt`), and end sessions under `/v1/voice/sessions`. A public, capped browser demo lives at `POST /v1/voice/demo`. The legacy `/ws/voice-agent` WebSocket still works for backward compatibility. See [Voice Session API](/docs/voice-sessions-api).
- **Image Generation API** — `POST /v1/images/generations` (OpenAI-compatible). Free models include `flux-2-klein-9b`, `flux-2-dev`, `lucid-origin`, `phoenix-1.0`, `sdxl-lightning`, `dreamshaper-8-lcm`; paid models include `flux-2-pro`, `flux-1.1-pro`, `nano-banana-2`, and `nano-banana-pro`. See [Image Generation](/docs/image-generation).
- **Web Search API** — `POST /v1/search` defaults to Serper web search (Exa, Firecrawl also available); flat 1 credit per query. See [Web Search](/docs/web-search).
- **Knowledge RAG** — vector knowledge sources at `/api/v1/knowledge/sources` (ingest text, URL, or PDF; chunked + embedded) with semantic search at `POST /api/v1/knowledge/search`.
## May 2026
### v1.5.0 — First-Party Models, Account Security & WhatsApp Platform
- **First-party models** — deployments callable by bare ID: `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `grok-4.3`, `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash`, plus first-party STT (`whisper`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize`) and TTS (`gpt-4o-mini-tts`).
- **More STT/TTS** — `whisper-large-v3-turbo` (99 langs), `nova-3` (diarization), `aura-2-en` / `aura-2-es`, and `melotts` — all free-tier.
- **Kimi K2.6** — `kimi-k2.6` added to the direct-routed free tier alongside `kimi-k2.5`.
- **TOTP 2FA & passkeys** — two-factor auth (authenticator apps + backup codes) and passkeys for dashboard sign-in. Active sessions can be reviewed and revoked from the dashboard.
- **WhatsApp platform** — Embedded Signup onboarding, message templates, broadcast campaigns, and delivery analytics under `/api/v1/whatsapp/*`. See [WhatsApp API](/docs/whatsapp-api).
- **Billing surfaces** — coupon redemption, downloadable PDF invoices, and a credit ledger broken down by transaction type, all in the dashboard.
- **Audit log** — a sensitive-action audit feed in the dashboard.
## April 2026
### v1.4.0 — Anthropic API Compatibility & Audio Translation
- **Anthropic Messages API** — New `POST /v1/messages` endpoint. Use the Anthropic SDK with CallMissed by changing only the `base_url`. Full streaming support with Anthropic SSE lifecycle (`message_start`, `content_block_delta`, `message_stop`).
- **Dual auth headers** — Anthropic endpoint accepts both `x-api-key` and `Authorization: Bearer` headers
- **Model aliasing** — A bare model name on the Anthropic endpoint resolves against the CallMissed catalog
- **Audio Translation** — New `POST /v1/audio/translations` endpoint. Translate audio in 24 languages to English text. OpenAI SDK compatible (`client.audio.translations.create()`)
- **Token counting** — `POST /v1/messages/count_tokens` for input token estimation
- **Anthropic rate limit headers** — `anthropic-ratelimit-requests-limit`, `anthropic-ratelimit-requests-remaining`, etc.
### v1.3.0 — Voice Agent & Ultra-Low-Latency Pipeline
- **Voice Agent WebSocket** — Real-time STT→LLM→TTS pipeline over `/ws/voice-agent`. PCM audio in, streaming MP3 out. LLM and TTS run concurrently for minimum latency.
- **PCM AudioWorklet capture** — Raw PCM s16le at 16kHz, no container overhead
- **Streaming MP3 playback** — MediaSource API appends and plays chunks as they arrive
- **Profile management** — Save and update user profile from the dashboard
### v1.2.0 — Security, Google OAuth & Plan Enforcement
- **Sign in with Google** — Google sign-in for the dashboard. Auto-creates the organisation and user, and links to an existing account by email.
- **OTP Authentication** — Email-based OTP for passwordless login and password reset
- **Plan limit enforcement** — Server-side usage caps per plan tier (free/starter/pro/enterprise). API returns `429 quota_exceeded` when limits reached. Usage headers (`X-RateLimit-*`, `X-Usage-Warning`) on every response.
- **Per-API-key rate limiting** — 60 req/min per key
- **Security hardening** — across the API surface
- **Model catalog update** — OpenAI gpt-5.4 family, Anthropic Claude 4.6, Google Gemini 3.1, xAI Grok 4.20, Qwen 3.5, Mistral Small
- **Knowledge Base file upload** — Upload PDF, DOCX, TXT files (max 20 MB) with auto text extraction
- **Bot deployment verification** — Verify WhatsApp/Twilio channel connectivity from the dashboard
- **Settings verification** — Verify WhatsApp, Twilio, and Indic LLM API connectivity
- **Contact form** — Public `POST /api/v1/contact` endpoint with email notifications
- **Dual-domain support** — `.com` and `.in` TLDs for all apps
### v1.1.0 — Platform Playground & SEO
- **Playground rebuild** — LLM (streaming + non-streaming), STT (file upload + mic), TTS (37 voices across 11 Indian languages), Voice Agent demo
- **Call Analytics API** — Upload audio files for batch STT with diarization and LLM-powered analysis
- **SEO pages** — 6 product pages, legal pages, company pages on the landing site
- **Sitemaps** — All 4 apps have sitemap.ts for SEO
### v1.0.0 — Initial Release
- **Chat Completion API** — OpenAI-compatible endpoint with streaming, tool calls, and function calling
- **Speech to Text** — `saaras:v3` with 22 Indic language support
- **Text to Speech** — `bulbul:v3` (37 voices across 11 Indian languages)
- **WhatsApp Bot** — Full WhatsApp Business API integration
- **Voice Calling** — Twilio-based inbound voice with WebSocket streaming
- **Multi-tenant** — Complete tenant isolation with role-based access
- **API Keys** — Scoped API keys with usage tracking
- **Webhook Delivery** — Outbound webhooks with retry and HMAC signing
- **Analytics Dashboard** — Real-time conversation and usage analytics
- **Model catalog** — LLM, STT, TTS and image models from one OpenAI-compatible endpoint
---
### Error Codes
URL: /docs/errors
> Every HTTP status and error code the CallMissed API returns, what causes it, and how to recover.
## Error Format
All errors return a JSON body with a stable machine-readable `code` and a human message. We never leak upstream provider errors or stack traces to clients.
```json
{
"error": {
"code": "insufficient_credits",
"message": "Your credit balance is too low to complete this request.",
"type": "billing_error"
}
}
```
The OpenAI-compatible endpoints (`/v1/*`) return the standard OpenAI error envelope so existing SDK error handling works unchanged.
## HTTP Status Codes
| Status | Meaning | Typical cause |
| --- | --- | --- |
| `200` | OK | Success |
| `400` | Bad Request | Malformed JSON, invalid parameter value |
| `401` | Unauthorized | Missing/invalid `Authorization` header or expired token |
| `402` | Payment Required | Insufficient credits or monthly budget cap reached |
| `403` | Forbidden | Key lacks the required scope/permission, or domain not allowlisted |
| `404` | Not Found | Resource ID does not exist or belongs to another tenant |
| `409` | Conflict | Duplicate resource, or replayed `Idempotency-Key` with a different body |
| `422` | Unprocessable Entity | Schema validation failed (bad enum, out-of-range number) |
| `429` | Too Many Requests | Rate limit exceeded — back off and retry |
| `500` | Internal Server Error | Unexpected server error — safe to retry once |
| `501` | Not Implemented | Endpoint exists but the feature is not yet live (e.g. embeddings) |
| `503` | Service Unavailable | Upstream provider temporarily unavailable |
## Common Error Codes
| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_api_key` | 401 | The `cm_` key is unknown or revoked |
| `token_expired` | 401 | JWT access token expired — refresh it |
| `insufficient_credits` | 402 | Top up credits to continue |
| `budget_exceeded` | 402 | Monthly credit budget cap reached |
| `permission_denied` | 403 | Key is missing the required service permission |
| `search_provider_not_allowed` | 403 | Key's allowed web-search providers excludes the requested provider |
| `domain_not_allowed` | 403 | Request origin is not in the key's domain allowlist |
| `not_found` | 404 | Resource does not exist in your tenant |
| `rate_limit_exceeded` | 429 | Slow down — see `Retry-After` |
| `provider_error` | 503 | Upstream model/provider failed |
## Retrying
- On **429**, honor the `Retry-After` header (seconds) and use exponential backoff.
- On **500/503**, retry once or twice with jittered backoff. To make a mutating request safely retryable, send an `Idempotency-Key` header — replays with the same key and body return the original result instead of duplicating the action.
- On **402/403**, do **not** retry — fix the underlying credit/permission issue first.
---
### Glossary
URL: /docs/glossary
> Definitions for the core CallMissed concepts and terminology used throughout these docs.
## Platform
| Term | Definition |
| --- | --- |
| **Tenant** | Your organization. All users, bots, keys, and data are isolated per tenant. |
| **Bot** | A configured AI agent (WhatsApp, inbound/outbound call, IVR) with a system prompt and optional knowledge base. |
| **Channel** | The surface a bot runs on — WhatsApp or voice (telephony, WebRTC, or a direct WebSocket). |
| **Conversation** | A thread of messages between an end user and a bot on a channel. |
| **API Key** | A secret prefixed `cm_` used for server-to-server auth, with scopes, domain locks, and per-key limits. |
| **Permission** | A service an API key may call — `llm`, `stt`, `tts`, `search`, `image`, or `*`. Enforced on the inference endpoints; default `*`. |
| **Scope** | A platform resource an API key may access — e.g. `bots:read`, `conversations:write`, `knowledge:read`, `webhooks:write`, `whatsapp:write`. Defaults to empty (no resource access). |
| **Webhook** | An HTTPS endpoint CallMissed calls on events; payloads are HMAC-SHA256 signed. |
| **Idempotency-Key** | A header that makes a mutating request safely retryable — replays return the original result. |
## Billing
| Term | Definition |
| --- | --- |
| **Credit** | The universal billing unit. **1 credit = ₹1.** Every API call deducts credits based on usage. |
| **Plan** | Your subscription tier — free, starter, pro, or enterprise — which sets limits and model access. |
| **Budget cap** | An optional monthly credit limit; requests over the cap are rejected with `budget_exceeded`. |
| **Credit pack** | A purchasable bundle of credits for top-ups. |
## AI Services
| Term | Definition |
| --- | --- |
| **LLM** | Large Language Model — powers chat completions and the Anthropic Messages API. |
| **STT** | Speech-to-Text — transcription, translation, real-time, and batch. |
| **TTS** | Text-to-Speech — voice synthesis. |
| **RAG** | Retrieval-Augmented Generation — semantic search over ingested knowledge passed as context. |
| **Diarization** | Labeling who spoke when in a transcript (speaker separation). |
| **Voice Agent** | A real-time STT→LLM→TTS pipeline. Two transports: a raw WebSocket ([Managed Voice Agent](/docs/managed-voice-agent), no SDK) or WebRTC ([Voice Session API](/docs/voice-sessions-api)). |
| **OpenAI-compatible** | Our `/v1` endpoints accept the same request shapes as the OpenAI API — change only the base URL and key. |
---
### Pricing
URL: /docs/pricing
> Simple, transparent pricing. Pay only for what you use.
## Overview
Visit our [Pricing Page](https://callmissed.com/pricing) for detailed plan comparisons and per-API pricing.
For API-specific pricing and rate limits, see the [Credits & Rate Limits](/docs/credits-rate-limits) page.
For enterprise pricing, [talk to us](/docs/talk-to-us).
### Plan Limits
Each plan tier has monthly **call caps** that are enforced server-side — LLM, STT, TTS, and image generation:
| Resource | Free | Starter | Pro | Enterprise |
|----------|------|---------|-----|------------|
| LLM calls | 100 | 5,000 | 50,000 | No cap |
| STT calls | 50 | 2,500 | 25,000 | No cap |
| TTS calls | 50 | 2,500 | 25,000 | No cap |
| Image generations | 50 | 500 | 5,000 | No cap |
When you exceed one of these call caps, the API returns a `429` error with `code: "quota_exceeded"`. Every API response includes usage headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `X-Usage-Warning` at 80% and 95% usage.
Your actual spend is always governed by your credit balance and any monthly budget cap you set — the call caps above are an additional guardrail on top of that.
#### Included allowances
Every plan also comes with the following allowances. These are shown on your plan for reference and are **not** enforced as hard caps — going past one does not block an API request. Only the monthly call caps above, your credit balance, your monthly budget cap, your per-key request rate, and model access are enforced.
| Allowance | Free | Starter | Pro | Enterprise |
|-----------|------|---------|-----|------------|
| Conversations | 50 | 1,000 | 10,000 | No cap |
| Storage | 100 MB | 1 GB | 10 GB | Unlimited |
| Team members | 2 | 5 | 20 | Unlimited |
**Enterprise ($200/mo)** grants 26,000 bonus credits/month, the highest rate limit (10,000 req/min), and priority support. It has no monthly call quota ("No cap") — usage is metered pay-as-you-go from your credits at the same per-model rates as every other plan. Need more than Enterprise? [Talk to us](/docs/talk-to-us) for a custom volume deal.
---
### Talk to Us
URL: /docs/talk-to-us
> Get in touch with the CallMissed team for support, enterprise inquiries, or feedback.
## Support
For general support and questions:
- **Email**: support@callmissed.com (support), sales@callmissed.com (sales), karan@callmissed.com (careers, legal)
- **WhatsApp / Call**: [+91 80802 47309](https://wa.me/918080247309)
## Enterprise
Need custom rate limits, dedicated infrastructure, or volume pricing?
- **Email**: sales@callmissed.com
- We'll set up a call to discuss your requirements
## Community
- **LinkedIn**: [linkedin.com/company/callmissed](https://www.linkedin.com/company/callmissed) — product updates and announcements
- **Instagram**: [@callmissed.in](https://www.instagram.com/callmissed.in) — behind the scenes
- **Facebook**: [facebook.com/callmissed](https://www.facebook.com/share/1CzfT8bf78/)
---
### Facebook & Instagram API
URL: /docs/social-api
> Publish posts, moderate comments and reply to DMs on a connected Facebook Page and Instagram professional account — one API, both channels, no account id in the path.
The Social API is the programmatic surface for a connected **Facebook Page** and
**Instagram professional account**. One API key publishes posts, reads and
moderates comments, and replies to direct messages across both channels.
You never put an account id in the path. Connect a Page or an Instagram account
once, then call `POST /api/v1/facebook/posts` — the account is resolved from your
workspace server-side. This page covers the base path, authentication, that
resolution model, and the error shapes every other Social page depends on.
**Base URL:** `https://api.callmissed.com`
**Base path:** `/api/v1/facebook` and `/api/v1/instagram`
| Area | Page |
|---|---|
| Publish posts, photos, reels, carousels, stories | [Publishing](/docs/social-publish) |
| Read, reply to, hide and delete comments | [Comments](/docs/social-comments) |
| Read inbox threads and reply to DMs | [Messaging](/docs/social-messaging) |
| Generate a caption, hashtags and an image from a topic | [Post Studio](/docs/social-posts) |
## Connecting an account
Connecting a Facebook Page or an Instagram professional account is a one-time
setup done from your dashboard — you sign in with Facebook or Instagram and grant
CallMissed permission to act on the asset. The **first** account you connect on a
channel becomes that channel's **default**, so from the moment it is connected
every endpoint below works without naming it.
An Instagram account must be a **Business or Creator** account with content
publishing granted. A personal account cannot publish through the API.
## Authentication
Every endpoint accepts either a `cm_` API key or a dashboard session:
```
Authorization: Bearer cm_your_api_key
```
API keys are checked against three scopes. These are the **same scope names** used
across the messaging channels — a Facebook or Instagram call is authorized by the
`whatsapp:*` scope family, not a separate `facebook:*` or `instagram:*` scope.
| Scope | Grants |
|---|---|
| `whatsapp:read` | List connected accounts, list comments and their replies, read inbox threads and messages |
| `whatsapp:write` | Complete a connection (`/onboarding/exchange`) and change which account is the default |
| `whatsapp:send` | Publish posts, reply to and hide/delete comments, send DMs, read the Instagram publishing quota |
A key without the scope gets `403`. Add scopes under the key's **Permissions**
section in your dashboard.
## How an account is chosen
Every call is scoped to your workspace, and to exactly one connected account. You
do not have to say which one:
1. **One connected account on that channel** — it is used. Nothing to pass.
2. **Several, one marked default** — the default is used.
3. **Several, no default** — the call is refused with `409` and a message asking
you to name one. Nothing is published or modified.
To override the choice on a single call, pass the optional **`?account=`** query
parameter. It accepts **either** identifier, so the id you already have works
without translation:
- the account's CallMissed id (a UUID), or
- Meta's own id — `page_id` for a Page, `ig_user_id` for an Instagram account.
```bash
# Uses the workspace's only (or default) Page
curl -X POST https://api.callmissed.com/api/v1/facebook/posts \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{ "message": "Open till 9 tonight." }'
# Publishes as one specific Page — Meta's page_id
curl -X POST "https://api.callmissed.com/api/v1/facebook/posts?account=102938475610293" \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{ "message": "Open till 9 tonight." }'
```
`?account=` is accepted on every publishing and comment endpoint. Messaging is
the exception: a DM send names its account in the **body** instead, because the
value you need is already on the thread you are replying to — see
[Messaging](/docs/social-messaging).
### List connected accounts
`GET /api/v1/facebook/pages` · `GET /api/v1/instagram/accounts` — needs
`whatsapp:read`.
Every account your workspace has connected on that channel, newest first. Access
tokens are never returned. `is_default` tells you which account a call with no
`?account=` will act as.
```json
[
{
"id": "3f9a2c71-5d8e-4b16-9f03-7c1a2e5b84d0",
"page_id": "102938475610293",
"name": "Kalyani Motors",
"bot_id": null,
"connected_user_id": "7788990011223344",
"is_active": true,
"is_default": true,
"created_at": "2026-08-20T11:04:18.912004+00:00"
}
]
```
The Instagram shape carries `ig_user_id`, `username` and `token_expires_at`
instead of `page_id`, `name` and `connected_user_id`. An Instagram connection
carries an expiry — `token_expires_at` is that deadline. Watch it: once it passes,
calls on that account start returning `401` and the account has to be reconnected
from your dashboard. Reconnecting is the same one-time flow and keeps the
account's id, so nothing in your integration changes.
### Set the default account
`PATCH /api/v1/facebook/pages/{page_ref}/default`
`PATCH /api/v1/instagram/accounts/{account_ref}/default` — needs `whatsapp:write`.
Nominates the account that answers every call that names none. `page_ref` /
`account_ref` is the CallMissed id **or** Meta's id, the same as `?account=`.
Setting a default clears it from the previous holder, so a workspace has at most
one per channel. Returns the updated account in the shape above.
This is what turns a multi-account workspace from "every call needs `?account=`"
back into "most calls need nothing".
### Turn AI replies on or off for an account
`PATCH /api/v1/facebook/pages/{page_ref}/bot`
`PATCH /api/v1/instagram/accounts/{account_ref}/bot` — needs `whatsapp:write`.
Attaches the agent that auto-answers that account's DMs, or clears it. `page_ref`
/ `account_ref` is the CallMissed id **or** Meta's id, the same as `?account=`.
The body is the agent to attach; send `null` to turn AI off:
```json
{ "bot_id": "b7c1e2a4-9d3f-4a80-8e21-5f6c0a1b2d3e" }
```
Once an agent is attached, incoming DMs on that account are answered from the
agent's configured behaviour. Sending `{ "bot_id": null }` stops that — the
account keeps receiving DMs, they just aren't answered automatically. Returns the
updated account in the shape above, with `bot_id` reflecting the change.
The account must be connected: attaching an agent to a disconnected account
returns `409` and asks you to reconnect it first.
### Legacy: the account id in the path
The older form — `POST /api/v1/facebook/{page_uuid}/posts`,
`GET /api/v1/instagram/{ig_account_uuid}/media/{media_id}/comments`, and their
siblings — still works and is not being removed. Same bodies, same responses,
same scopes. New integrations should use the shorter paths on this site: they need
no id at all in the common case, and one query parameter in the multi-account
case.
## Errors
Errors are returned as a JSON body with a `detail` string. Upstream Meta error
text and numeric codes are **never** echoed back — a failure is mapped to a clean
HTTP status with a message safe to show an operator.
```json
{ "detail": "This Page's connection has expired. Reconnect it to keep posting." }
```
These are the statuses you will actually hit across the Social API:
| HTTP | Meaning |
|---|---|
| `400` | The request was rejected before any Meta call — a closed messaging window, a missing access token, or a body that failed local validation. |
| `401` | The connected account's access token has expired or been revoked. Reconnect the Page or account. |
| `403` | The account lacks the permission for this action (publishing, engagement/moderation), or the key lacks the scope. |
| `404` | No connected account answers this call — your workspace has none on that channel, or the `?account=` value does not exist or is not yours (identical response for both). |
| `409` | Either the account is **ambiguous** (several connected, none marked default — name one with `?account=` or set a default), or the resolved account is **disconnected** / its token is unavailable, or the upstream state is ambiguous (e.g. a possible duplicate). |
| `422` | The body failed validation — a bad post type, an unreachable media URL, a caption over the limit, or an out-of-range schedule. |
| `429` | Meta is rate-limiting this account. Wait a few minutes and retry. |
| `502` | The upstream call failed. |
| `503` | The channel is temporarily unavailable. |
| `504` | An ambiguous upstream timeout — the action **may or may not** have completed. Check the account before retrying rather than assuming it failed. |
Two details worth designing around:
- **A missing account and someone else's account return the same `404`**, with the
same message. The API is not an id oracle, so you cannot use it to probe for
accounts you do not own.
- **`409` is the one to handle in code**, because it is the only status that a
correct, well-formed request can hit purely because a second account got
connected. Read `detail`: an ambiguity asks you to name an account, a
disconnection asks you to reconnect.
## Billing
Organic publishing, comment moderation and messaging on Facebook and Instagram
are **not metered** — Meta does not charge for them, and neither do we. No credits
are reserved or deducted on these endpoints. (The [Post Studio](/docs/social-posts),
which generates an image and a caption with AI, is metered for that generation —
see its page.)
---
### Comments
URL: /docs/social-comments
> Read, reply to, hide and delete comments on Facebook Page posts and Instagram media.
Read and moderate comments on a connected Facebook Page's posts and an Instagram
account's media. Reading needs `whatsapp:read`; replying, hiding and deleting need
`whatsapp:send`. None of it is metered.
No account id goes in the path — the Page or Instagram account is resolved from
your workspace, and `?account=` overrides it on any endpoint here. See
[how an account is chosen](/docs/social-api#how-an-account-is-chosen).
Comment lists are **cursor-paginated**: a response carries an `after` cursor; pass
it back as the `after` query parameter to get the next page. There is no offset —
an offset would skip comments as new ones arrive. `limit` is 1–100 (CallMissed's
own bound; default 25).
## Facebook: list comments on a post
`GET /api/v1/facebook/posts/{post_id}/comments`
| Query | Type | Description |
|---|---|---|
| `filter` | string | `toplevel` (top-level comments) or `stream` (the flattened thread). |
| `summary` | boolean | Include Meta's `total_count` (default `true`). |
| `limit` | integer | Page size, 1–100 (default 25). |
| `after` | string | The cursor from a previous response. |
| `account` | string | Optional. Which connected Page to read as — a CallMissed id or Meta's `page_id`. |
```bash [cURL]
curl "https://api.callmissed.com/api/v1/facebook/posts/102938475610293_890123456789012/comments?filter=toplevel&limit=50" \
-H "Authorization: Bearer cm_your_key"
```
```json
{
"data": [
{
"id": "1122334455667788_9988776655",
"message": "Do you deliver to Pune?",
"author": { "id": "7788990011223344", "name": "Ritu Sharma" },
"created_time": "2026-08-24T09:14:02+0000",
"like_count": 3,
"comment_count": 1,
"parent_id": null,
"can_comment": true
}
],
"after": "QVFIUmxr...",
"total_count": 42
}
```
`author` is omitted when Meta withholds the commenter's identity (a permissions
state, not an error) — treat it as optional. `total_count` is `null` when `summary`
is false or Meta omits it.
## Facebook: list replies to a comment
`GET /api/v1/facebook/comments/{comment_id}/replies`
On Facebook a reply is a comment one level down. `parent_id` carries the id of the
comment being replied to. Same `limit`/`after`/`account` paging as above; no
`filter` (it is meaningless one level down).
## Instagram: list comments on media
`GET /api/v1/instagram/media/{media_id}/comments`
| Query | Type | Description |
|---|---|---|
| `limit` | integer | Page size, 1–100 (default 25). |
| `after` | string | The cursor from a previous response. |
| `account` | string | Optional. Which connected account to read as — a CallMissed id or Meta's `ig_user_id`. |
```json
{
"data": [
{
"id": "17924118234567890",
"text": "Do you ship to Bengaluru?",
"timestamp": "2026-08-24T09:14:03+0000",
"username": "anita.makes",
"like_count": 3,
"hidden": false,
"parent_id": null,
"reply_count": 2
}
],
"after": "QVFIUmp0WFZ6..."
}
```
There is **no `total_count`** on Instagram — its comments edge documents no total,
so none is invented. Every field except `id` is nullable: reading another
commenter's `username` needs the comment-management permission, and Meta omits
what a token may not see. `reply_count` is a count; fetch the replies themselves
from the replies endpoint.
## Instagram: list replies to a comment
`GET /api/v1/instagram/comments/{comment_id}/replies`
On Instagram a reply lives on a **distinct** edge (unlike Facebook), so replies
are read here, not from the media-comments endpoint. Same response shape and
paging as the media comments.
## Reply to a comment
Facebook — `POST /api/v1/facebook/comments/{comment_id}/reply`
Instagram — `POST /api/v1/instagram/comments/{comment_id}/reply`
| Field | Type | Description |
|---|---|---|
| `message` | string | The reply text. Required. |
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/instagram/comments/17924118234567890/reply \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{ "message": "We do — 2 to 3 days across Karnataka." }'
```
```json
{ "id": "17924118301122334" }
```
## Hide or unhide a comment
Facebook — `POST /api/v1/facebook/comments/{comment_id}/hide`
Instagram — `POST /api/v1/instagram/comments/{comment_id}/hide`
Hiding removes a comment from public view without deleting it. Send `hidden`:
| Field | Type | Description |
|---|---|---|
| `hidden` | boolean | `true` to hide, `false` to unhide. Required. |
## Delete a comment
Facebook — `DELETE /api/v1/facebook/comments/{comment_id}`
Instagram — `DELETE /api/v1/instagram/comments/{comment_id}`
Permanently deletes a comment you have permission to remove. You can only delete
comments on your own posts/media, or your own comments — Meta enforces the rest.
Irreversible; there is no undo, so hide first if you are unsure.
Every endpoint on this page also exists in the legacy account-id-in-path form
(`GET /api/v1/facebook/{page_uuid}/posts/{post_id}/comments` and siblings), which
still works — see [legacy paths](/docs/social-api#legacy-the-account-id-in-the-path).
---
### Messaging
URL: /docs/social-messaging
> Read Messenger and Instagram DM threads and reply as the Page or account.
Read your Facebook Messenger and Instagram Direct inbox threads and reply into
them. Reading needs `whatsapp:read`; sending needs `whatsapp:send`. Messaging on
both channels is **not metered**.
No account id goes in the path anywhere on this page. Reading is scoped to your
whole workspace, and a send names its account in the **body** — with a value you
already hold, because it comes off the thread you are replying to.
One rule shapes this whole surface: **a conversation must be started by the
customer.** You can never message someone who has not messaged your Page or
account first — there is no thread to reply into, and the send is refused with a
`409`.
## The messaging window
Meta only lets a business reply for a limited time after the customer's last
message. CallMissed enforces this **before** any upstream call, measured from the
thread's last inbound message:
| Time since last inbound | What happens |
|---|---|
| Within 24 hours | The reply sends normally. |
| 24 hours – 7 days | The reply sends as a **human-agent** tagged message. This uses Meta's Human Agent feature, which needs its own App Review approval — until that's granted, Meta may still reject the tagged send (surfaced as `502`). |
| Over 7 days | The reply is refused locally with `400`. The customer must message again to reopen the window. |
## List inbox threads
Facebook — `GET /api/v1/facebook/conversations`
Instagram — `GET /api/v1/instagram/conversations`
The most recent 200 threads, newest first. There is no pagination and no cursor —
this is a live inbox view, not an archive export. Message bodies are **not**
included; fetch them per thread from the messages endpoint.
Threads are returned for **every** connected account on the channel, so a
workspace with two Pages sees both inboxes in one call. `account_ref` tells you
which account each thread belongs to, and it is the value you send back when you
reply.
```json
[
{
"id": "7f3c1e64-2b8a-4d19-9c02-5a1b6e4f8d20",
"channel": "facebook",
"account_ref": "102938475610293",
"contact_id": "6284719203847561",
"contact_name": "Priya Sharma",
"last_inbound_at": "2026-08-24T09:14:02.481930+00:00",
"last_message_at": "2026-08-24T09:15:37.902144+00:00"
}
]
```
`contact_id` is the recipient id you send to (a Page-scoped id on Facebook, an
Instagram-scoped id on Instagram). `contact_name` can be `null`.
## List messages in a thread
Facebook — `GET /api/v1/facebook/conversations/{conversation_id}/messages`
Instagram — `GET /api/v1/instagram/conversations/{conversation_id}/messages`
The messages in one thread, oldest first (up to 500). `conversation_id` is the
`id` from the thread list.
```json
[
{
"id": "3a5e77c1-9b40-4f8e-a2d6-11c8ee4b7f39",
"channel": "facebook",
"direction": "inbound",
"external_id": "m_AbCdEf1234567890",
"message_type": "text",
"text": "Hi, is the store open tomorrow?",
"status": null,
"created_at": "2026-08-24T09:14:02.481930+00:00"
},
{
"id": "9c02b418-77de-4a55-b6f1-2d9e0a3c8b7e",
"channel": "facebook",
"direction": "outbound",
"external_id": "m_ZyXwVu0987654321",
"message_type": "text",
"text": "Yes, we are open 10am to 8pm tomorrow.",
"status": "sent",
"created_at": "2026-08-24T09:15:37.902144+00:00"
}
]
```
An unknown `conversation_id`, or one from another workspace, returns an **empty
array** (`[]`), not a `404` — so `[]` can mean "no such thread" as well as "no
messages yet". `status` is `null` on inbound messages. `message_type` is `text`,
an attachment type, `reaction` (Instagram), or `postback` (a button tap).
## Send a message
Facebook — `POST /api/v1/facebook/messages`
Instagram — `POST /api/v1/instagram/messages`
A text reply into an existing thread. All three fields are required, and all three
come straight off the thread you are replying to — there is no `?account=` hint
here because the body already carries the account.
**Facebook**
| Field | Type | Description |
|---|---|---|
| `page_id` | string | The connected Page's Meta id — the thread's `account_ref`. |
| `recipient_id` | string | The recipient's id — the thread's `contact_id`. |
| `text` | string | The message text. |
**Instagram**
| Field | Type | Description |
|---|---|---|
| `ig_user_id` | string | The connected Instagram account's Meta id — the thread's `account_ref`. |
| `recipient_id` | string | The recipient's id — the thread's `contact_id`. |
| `text` | string | The message text. |
An `account_ref` that is not connected to your workspace returns `404`, so a send
is authorized against your own accounts rather than trusted from the request.
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/instagram/messages \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{ "ig_user_id": "17841400000000000", "recipient_id": "1234567890123456", "text": "Yes, we deliver across Pune in 2 days." }'
```
On Instagram a long message is split on word boundaries into multiple sends (the
per-message limit is 1,000 bytes of UTF-8), and the response reports how many
went out. Only the parts Meta actually accepted are recorded, so a partial send is
visible rather than reported as whole:
```json
{ "status": "sent", "sent": 2, "message_ids": ["aWdfZG1fMTo...QUFB", "aWdfZG1fMTo...QkJC"] }
```
Facebook returns a single `message_id` (which can be `null` if Meta omitted it):
```json
{ "status": "sent", "message_id": "m_ZyXwVu0987654321" }
```
Text only — there is no attachment, template or quick-reply parameter on these
endpoints. A send fails with `409` when no thread exists for the recipient (the
window was never opened), `400` when the window has closed, `404` when the account
is not one of yours, and `502` when the upstream send fails.
---
### Publishing
URL: /docs/social-publish
> Publish text, links, photos, reels, carousels and stories to a Facebook Page and Instagram professional account.
Publish organic content to a connected Facebook Page or Instagram professional
account. All publishing endpoints need the `whatsapp:send` scope, and none is
metered — Meta does not charge for organic publishing.
No account id goes in the path. The Page or Instagram account is resolved from
your workspace: its only connected account, or the one marked default. Pass
`?account=` (a CallMissed id or Meta's `page_id` / `ig_user_id`) to publish as a
specific account — see [how an account is chosen](/docs/social-api#how-an-account-is-chosen).
Media you publish must be at a **publicly reachable URL**: Meta fetches the file
from its own servers, so a signed or expiring link that Meta cannot read fails as
a `422`. The public image URL returned by the [Post Studio](/docs/social-posts) is
exactly this shape.
## Facebook: publish a post
`POST /api/v1/facebook/posts`
A text post, a link post, or a scheduled version of either. Supply `message`,
`link`, or both — a body with neither is a `422`.
| Field | Type | Description |
|---|---|---|
| `message` | string | Post body text. Optional if `link` is set. |
| `link` | string | A URL to attach as a link post. Optional if `message` is set. |
| `scheduled_publish_time` | integer | A UNIX timestamp. When present, the post is created **unpublished and scheduled** instead of going live now. Must be **10 minutes to 75 days** out, or `422`. |
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/facebook/posts \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{ "message": "Winter service special is on now.", "link": "https://example.com/book" }'
```
```json
{ "id": "102938475610293_890123456789012", "post_id": null, "video_id": null }
```
## Facebook: publish a photo or multi-photo post
`POST /api/v1/facebook/photos`
Supply `url` for a single photo, or `urls` for a **multi-photo** post (2–30). A
single-URL `urls` list falls through to the single-photo path.
| Field | Type | Description |
|---|---|---|
| `url` | string | One publicly reachable photo URL. |
| `urls` | string[] | 2–30 photo URLs for a single multi-photo post. |
| `caption` | string | Caption for a single photo. |
| `message` | string | Feed text for a multi-photo post. |
The 30-URL cap is CallMissed's own bound (each URL is an upload call), not Meta's.
The response carries both the photo `id` and the resulting feed `post_id`.
```json
{ "id": "10160000000000000", "post_id": "102938475610293_890123456789012" }
```
## Facebook: publish a reel
`POST /api/v1/facebook/reels`
Publish a video reel from a publicly reachable `video_url`.
| Field | Type | Description |
|---|---|---|
| `video_url` | string | Publicly reachable video URL. Required. |
| `description` | string | Reel caption. |
| `scheduled_publish_time` | integer | A UNIX timestamp to schedule the reel. Reels have their **own** schedule window — up to **29 days** out. |
## Instagram: publish a post
`POST /api/v1/instagram/posts`
One endpoint for every Instagram post shape. The `type` field picks it:
| `type` | What it makes | Media field(s) |
|---|---|---|
| `image` | A single feed image | `image_url` |
| `video` | A single feed video | `video_url` |
| `reel` | A reel | `video_url` (+ reel-only options) |
| `story` | An image or video story | exactly one of `image_url` / `video_url` |
| `carousel` | A 2–10 item carousel | `items[]` |
| Field | Type | Description |
|---|---|---|
| `type` | string | `image`, `video`, `reel`, `story` or `carousel`. Required. |
| `image_url` | string | Image URL. Required for `image`; one of the two for `story`. |
| `video_url` | string | Video URL. Required for `video`/`reel`; one of the two for `story`. |
| `caption` | string | Caption, up to 2,200 characters. Not used for stories or carousel children. |
| `alt_text` | string | Accessibility alt text; images only. |
| `location_id` | string | A location tag id for the post. |
| `items` | array | Carousel children (2–10), each with one of `image_url`/`video_url` and optional `alt_text`. Required for `carousel`. |
| `cover_url` | string | Reel-only cover image URL. |
| `share_to_feed` | boolean | Reel-only: also show the reel in the main feed. |
| `thumb_offset` | integer | Video thumbnail offset (≥ 0). |
Caption length (≤ 2,200) and carousel size (2–10) are validated **before** any
upstream call, so a bad body fails fast without leaving orphaned containers. A
reel cannot be a carousel child (use a `video` child instead). A story takes no
caption.
```bash [cURL]
# A 3-image carousel
curl -X POST https://api.callmissed.com/api/v1/instagram/posts \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"type": "carousel",
"caption": "Three from this week'"'"'s firing.",
"items": [
{ "image_url": "https://media.callmissed.com/a.jpg", "alt_text": "A speckled mug" },
{ "image_url": "https://media.callmissed.com/b.jpg" },
{ "image_url": "https://media.callmissed.com/c.jpg" }
]
}'
```
### The two-step finish for a slow Instagram post
Instagram publishing is a multi-step upstream flow: a container is created, Meta
processes the media, then the container is published. This endpoint waits a short
budget (about 27 seconds) for that to finish. If the media is still processing
when the budget runs out, it returns the `container_id` with status
`IN_PROGRESS` instead of hanging — **do not re-post** (that risks a duplicate).
Finish it with the two routes below.
```json
{ "media_id": "17895695668004550", "container_id": "17889455560051444", "status": "PUBLISHED" }
```
**Check a container's status** — `GET /api/v1/instagram/posts/{container_id}`
Poll this until the status is `FINISHED`, then publish. A container **expires 24
hours** after creation.
**Publish a finished container** — `POST /api/v1/instagram/posts/{container_id}/publish`
Publishes a container whose media has finished processing, returning the live
`media_id`.
Both take the same optional `?account=` hint. Use the **same** account you created
the container with — a container belongs to the account that made it, so resolving
to a different one fails upstream.
## Instagram: check the publishing quota
`GET /api/v1/instagram/publishing_limit`
Instagram caps how many posts an account can publish in a rolling 24-hour window.
Read the **live** quota rather than assuming a number (Meta's own docs quote
different limits on different pages). A carousel counts as **one** post against
the quota. Needs `whatsapp:send`, like the publishing calls it protects.
```json
{ "quota_usage": 12, "config": { "quota_total": 100 } }
```
## Scheduling windows at a glance
| Surface | Schedule window |
|---|---|
| Facebook feed post (`/posts`) | 10 minutes – 75 days |
| Facebook reel (`/reels`) | up to 29 days |
| Instagram | No native scheduling — publish at the time you want to post |
A Facebook `504` on publish is **ambiguous** — the post may or may not have gone
live. Check the Page before retrying; the API never auto-retries a publish.
The legacy `POST /api/v1/facebook/{page_uuid}/posts` form of every endpoint on
this page still works — see [legacy paths](/docs/social-api#legacy-the-account-id-in-the-path).
---
### Speech to Text
URL: /docs/speech-to-text
> Transcribe audio to text across 45 STT models — Indic-first saaras, Cartesia Ink, and Deepgram Nova.
:::cards
/docs/stt-realtime | Real-time STT | play | Stream audio for live transcription
/docs/stt-translation | Translation | settings | Transcribe and translate in one call
/docs/models-indic | Indic Models | mic | saaras:v3 and other Indic STT models
:::
## Overview
Transcribe an audio file into text with any of **45 speech-to-text models**. The default, `saaras:v3`, covers 22 Indian languages plus English with automatic language detection — but the `model` field takes any file-transcription STT id, so you can pick per request.
**Endpoint:** `POST /v1/audio/transcriptions`
### Picking a model
| If you need | Use | Why |
|---|---|---|
| Indian languages, or code-mixed Hinglish | `saaras:v3` or `saaras:v4` | Purpose-built for Indic phonetics; v4 adds 24 languages and serves all five modes |
| The widest language coverage | `ink-whisper` | 100 languages, and cheaper per hour than the Indic models |
| English call-centre audio | `deepgram-nova-3`, or `nova-3` on the free tier | Strong on accented and noisy telephony English |
| Turn detection built into the model | `ink-2` or `deepgram-flux-general-en` | Voice sessions only — see the note below |
Four models are on the **free tier**: `saaras:v3`, `saaras:v4`, `nova-3` and
`whisper-large-v3-turbo`.
:::note
Some models appear under two ids at different prices — `nova-3` and
`deepgram-nova-3` are the same underlying model, but only `nova-3` is on the free
tier. If you are on the free plan, use the id listed above; picking the other
spelling of the same model will bill you. Full per-model pricing:
[Models](/docs/models).
:::
### How transcription works
:::flow
icon:app | Your app | Upload an audio file (WAV/MP3) to `POST /v1/audio/transcriptions`
icon:gateway | CallMissed gateway | Validate the key, resolve `model`, detect language (or use `language`), apply `mode`
icon:stt | Your chosen model | Run speech recognition — defaults to `saaras:v3` if `model` is omitted
icon:done | Your app | Receive `text` (plus word timestamps in `verbose_json`)
:::
> **Tip:** Leave `model` unset to get `saaras:v3`, and leave `language` unset to let it auto-detect. Set `mode=translate` to get English text out of any supported language in a single call.
:::warning
**Streaming-only models cannot transcribe files.** `ink-2` and the Deepgram Flux
models do turn detection as part of the model, which only makes sense on a live
stream. Sending one here returns a 400 naming the file-transcription
alternative rather than silently substituting a different model — see
[Cartesia Ink models](#cartesia-ink-models).
:::
## Basic Usage
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1"
)
with open("audio.wav", "rb") as f:
response = client.audio.transcriptions.create(
model="saaras:v3",
file=f
)
print(response.text)
```
```javascript [JavaScript]
import OpenAI from "openai";
import fs from "fs";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const response = await client.audio.transcriptions.create({
model: "saaras:v3",
file: fs.createReadStream("audio.wav"),
});
console.log(response.text);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/audio/transcriptions \
-H "Authorization: Bearer cm_your_key" \
-F file=@audio.wav \
-F model=saaras:v3
```
:::
## Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `model` | string | `saaras:v3`, `saaras:v4`, `ink-whisper`, or any other file-transcription STT model ID. `ink-2` is **not** valid here — see [Cartesia Ink models](#cartesia-ink-models) |
| `file` | file | Audio file (WAV, MP3, etc.) |
| `language` | string | Language code (auto-detected if omitted) |
| `mode` | string | Output mode — see below |
| `response_format` | string | `json`, `text`, or `verbose_json` |
| `timestamp_granularities[]` | array | `["word"]` for word-level timestamps (OpenAI-compatible) |
## Output Modes
| Mode | Description |
|------|-------------|
| `transcribe` | Standard transcription (default) |
| `translate` | Transcribe and translate to English |
| `verbatim` | Exact transcription including filler words |
| `translit` | Transliteration to Latin script |
| `codemix` | Code-mixed output (Indic + English) |
`saaras:v4` serves all five modes on one model across 24 languages, and is free-tier like `saaras:v3`:
```bash
curl -X POST https://api.callmissed.com/v1/audio/transcriptions \
-H "Authorization: Bearer cm_your_key" \
-F file=@audio.wav \
-F model=saaras:v4 \
-F mode=codemix
```
## Cartesia Ink models
Two Cartesia STT models, and they are **not interchangeable** — one transcribes
files, the other only runs on a live voice session.
| Model | Price | Languages | File transcription | Voice sessions |
|-------|-------|-----------|--------------------|----------------|
| `ink-whisper` | $0.18 / hr | 100 (incl. Hindi, Urdu, Tamil) | Yes | Yes |
| `ink-2` | $0.54 / hr | English only (`en`) | **No** | Yes |
### `ink-whisper` — the cheapest 100-language option
Cartesia's fastest and most affordable STT, with better accuracy than baseline
Whisper. Its dynamic chunking cuts hallucination during pauses and silence, so
audio with dead air transcribes cleanly instead of inventing text to fill gaps.
```bash
curl -X POST https://api.callmissed.com/v1/audio/transcriptions \
-H "Authorization: Bearer cm_your_key" \
-F file=@audio.wav \
-F model=ink-whisper \
-F language=hi
```
### `ink-2` — voice agents only
Cartesia's top-ranked STT for voice agents: **8% WER** on AppTek's 14-accent
call-centre benchmark, against 10% for Deepgram Flux and 12% for ElevenLabs. It
also self-detects turns, so a voice agent needs no separate turn detector on top.
Two limits decide whether you can use it at all:
**1. It cannot transcribe files.** `ink-2` is streaming-only. POSTing it to
`/v1/audio/transcriptions` returns `400` rather than quietly substituting a
different model:
```json
{
"detail": "ink-2 is a streaming-only model and is not available for file transcription. Use ink-whisper here, or ink-2 on a voice session."
}
```
Select it on a [voice session](/docs/voice-agent) or the
[Managed Voice Agent](/docs/managed-voice-agent) instead.
**2. It is English only.** The model accepts `en` and nothing else. Sending it
Hindi (or any other language) does **not** raise an upstream error — it silently
produces poor output. Our agent logs a warning and transcribes as `en`. For
non-English speech use `ink-whisper` (100 languages) or an Indic model such as
`saaras:v3` / `saaras:v4`.
## Deepgram feature parameters
When you select a Deepgram model (`deepgram-nova-3`, `deepgram-nova-2`, `deepgram-flux-general-en`, etc.), these extra form fields are accepted. They are ignored for non-Deepgram models. Model-restricted features are dropped automatically when the chosen model doesn't support them.
| Parameter | Type | Description |
|-----------|------|-------------|
| `diarize` | boolean | Label each speaker (`[Speaker 0]`, `[Speaker 1]`, …) |
| `utterances` | boolean | Segment the transcript into utterances |
| `utt_split` | number | Silence gap (seconds) used to split utterances |
| `paragraphs` | boolean | Split the transcript into paragraphs |
| `numerals` | boolean | Write numbers as digits (e.g. "five" → "5") |
| `measurements` | boolean | Abbreviate measurement units (English) |
| `dictation` | boolean | Convert spoken "comma"/"period" to punctuation (English) |
| `profanity_filter` | boolean | Mask recognized profanity with `****` |
| `filler_words` | boolean | Keep "uh"/"um" (Nova / Nova-2 / Nova-3) |
| `multichannel` | boolean | Transcribe each audio channel independently |
| `detect_entities` | boolean | Tag entities like names and locations (English) |
| `detect_language` | string | `true` to auto-detect, or repeat with codes to restrict |
| `redact` | string | `pci`, `pii`, `phi`, `numbers`, or a specific entity type (repeatable) |
| `keyterm` | string | Boost recognition of a term/phrase (Nova-3 + Flux; repeatable) |
| `keywords` | string | `keyword:intensifier` boost/suppress (Nova-2 / legacy; repeatable) |
| `search` | string | Phonetically search the audio for a term (repeatable) |
| `replace` | string | `find:replacement` substitution (repeatable) |
### Dialects & locales
Deepgram models accept locale-specific language codes so you can pin a dialect for best accuracy. Pass the code in the `language` field. Each model's exact dialect list is published in the `dialects` array on `GET /v1/models`. Examples:
- **English:** `en-US`, `en-GB`, `en-IN`, `en-AU`, `en-NZ`, `en-CA`, `en-IE`
- **Spanish:** `es`, `es-419` (Latin America)
- **Portuguese:** `pt-BR`, `pt-PT`
- **Chinese:** `zh-CN`, `zh-TW`, `zh-HK` (Cantonese)
- **Multilingual code-switching:** `multi` (Nova-3, Nova-2, Flux multilingual)
---
### Real-time STT
URL: /docs/stt-realtime
> Real-time speech-to-text transcription via WebSocket.
## Overview
Real-time STT is available through the **Voice Agent WebSocket** pipeline. Audio is streamed as PCM s16le 16kHz mono, and transcripts are returned in real-time as the user speaks.
There is no standalone real-time STT WebSocket endpoint — real-time transcription is part of the full Voice Agent pipeline (STT → LLM → TTS).
For file-based transcription, use the [Speech to Text](/docs/speech-to-text) REST API.
## Via Voice Agent
Connect to `WS /ws/voice-agent`, send audio chunks, and receive `transcript` messages:
```json
{"type": "transcript", "text": "Hello, how are you?", "is_final": true}
```
## Authentication
Pass your API key as a WebSocket subprotocol: `Sec-WebSocket-Protocol: token, cm_your_key`. That is a request header, so the key stays out of access logs and proxy history, which a query string does not. In the browser the constructor's second argument sets it, and the order matters: the literal `token` first, then the key.
```javascript
new WebSocket(url, ["token", "cm_your_key"]);
```
Clients that can set headers may send `Authorization: Bearer cm_your_key` instead.
The `?key=cm_your_key` query parameter is **deprecated**. It still works so existing integrations keep connecting, but prefer the subprotocol for anything new.
## Example
:::tabs
```javascript [JavaScript]
const ws = new WebSocket(
"wss://api.callmissed.com/ws/voice-agent",
["token", "cm_your_key"]
);
ws.onopen = () => {
// Send configuration
ws.send(JSON.stringify({
type: "config",
bot_id: "your-bot-id",
stt_language: "hi-IN",
tts_voice: "shubh",
}));
// Stream audio from microphone
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
recorder.ondataavailable = (e) => ws.send(e.data);
recorder.start(250); // send chunks every 250ms
});
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
const msg = JSON.parse(event.data);
if (msg.type === "transcript") {
console.log("User said:", msg.text);
} else if (msg.type === "llm_token") {
process.stdout.write(msg.token);
}
} else {
// Binary data = TTS audio chunk (MP3)
playAudio(event.data);
}
};
```
```python [Python]
import asyncio
import websockets
import json
async def realtime_stt():
uri = "wss://api.callmissed.com/ws/voice-agent"
async with websockets.connect(
uri, subprotocols=["token", "cm_your_key"]
) as ws:
# Send config
await ws.send(json.dumps({
"type": "config",
"bot_id": "your-bot-id",
"stt_language": "hi-IN",
}))
# Send audio file as chunks
with open("recording.wav", "rb") as f:
while chunk := f.read(16000): # 0.5s chunks at 16kHz
await ws.send(chunk)
await asyncio.sleep(0.25)
# Listen for transcripts
async for message in ws:
if isinstance(message, str):
data = json.loads(message)
if data["type"] == "transcript":
print(f"Transcript: {data['text']}")
asyncio.run(realtime_stt())
```
:::
See the [Voice Agent](/docs/voice-agent) page for the full WebSocket protocol and all message types.
---
### Audio Translation
URL: /docs/stt-translation
> Translate audio in any supported language to English text. OpenAI-compatible endpoint.
## Overview
Translates speech in any of 23 supported languages to **English text**. OpenAI-compatible `/v1/audio/translations`.
Unlike [Speech to Text](/docs/speech-to-text) (which transcribes in the original language), this endpoint always outputs English.
**Endpoint:** `POST /v1/audio/translations`
**Supported input languages (23):** Hindi, Bengali, Tamil, Telugu, Kannada, Malayalam, Marathi, Gujarati, Punjabi, Odia, Assamese, Urdu, Nepali, Konkani, Kashmiri, Sindhi, Sanskrit, Santali, Manipuri, Bodo, Maithili, Dogri, English. Omit `language` to auto-detect.
## Basic Usage
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1"
)
# Translate Hindi audio to English text
with open("hindi_audio.wav", "rb") as f:
translation = client.audio.translations.create(
model="saaras:v3",
file=f,
)
print(translation.text)
```
```javascript [JavaScript]
import OpenAI from "openai";
import fs from "fs";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const translation = await client.audio.translations.create({
model: "saaras:v3",
file: fs.createReadStream("hindi_audio.wav"),
});
console.log(translation.text);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/audio/translations \
-H "Authorization: Bearer cm_your_key" \
-F model=saaras:v3 \
-F file=@hindi_audio.wav
```
:::
**Response:**
```json
{"text": "Hello, how are you? I wanted to discuss the project."}
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file` | file | Yes | Audio file (WAV, MP3, AAC, OGG, FLAC, WebM, M4A) |
| `model` | string | No | Model ID (default: `saaras:v3`; `saaras:v4` also translates to English) |
| `response_format` | string | No | `json` (default), `text`, or `verbose_json` |
| `temperature` | float | No | Sampling temperature |
| `prompt` | string | No | Prompt to guide transcription style |
## Response Formats
### json (default)
```json
{"text": "Hello, how are you?"}
```
### text
Returns plain text with no JSON wrapping.
### verbose_json
```json
{
"task": "translate",
"language": "en",
"duration": 4.52,
"text": "Hello, how are you?",
"segments": [],
"words": []
}
```
> **Tip:** For transcription in the original language (not translated), use [Speech to Text](/docs/speech-to-text) instead. For output modes like transliteration or code-mixing, use the `mode` parameter on the transcription endpoint.
---
### Text to Speech
URL: /docs/text-to-speech
> Convert text to natural-sounding speech with our Indic TTS.
## Overview
The Text to Speech API converts text into audio — Indic languages, 37 voices across 11 languages.
**Endpoint:** `POST /v1/audio/speech`
:::flow
icon:app | Your app | Send text + a `voice` and `language` to `POST /v1/audio/speech`
icon:tts | bulbul:v3 | Synthesize speech in the chosen voice and `response_format`
icon:done | Your app | Receive the audio stream and play or save it
:::
## Basic Usage
:::tabs
```python [Python]
from openai import OpenAI
client = OpenAI(
api_key="cm_your_key",
base_url="https://api.callmissed.com/v1"
)
response = client.audio.speech.create(
model="bulbul:v3",
voice="shubh",
input="Namaste, kaise hain aap?"
)
response.stream_to_file("speech.mp3")
```
```javascript [JavaScript]
import OpenAI from "openai";
import fs from "fs";
const client = new OpenAI({
apiKey: "cm_your_key",
baseURL: "https://api.callmissed.com/v1",
});
const response = await client.audio.speech.create({
model: "bulbul:v3",
voice: "shubh",
input: "Namaste, kaise hain aap?",
});
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync("speech.mp3", buffer);
```
```bash [cURL]
curl -X POST https://api.callmissed.com/v1/audio/speech \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model": "bulbul:v3", "input": "Namaste, kaise hain aap?", "voice": "shubh"}' \
--output speech.mp3
```
:::
## Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `model` | string | `bulbul:v3`, `sonic-3.6`, `gnani-timbre-v2.0`, `deepgram-aura-2`, `deepgram-aura-1`, `aura-2-en`, `aura-2-es`, `gpt-4o-mini-tts`, `melotts` — see [Models](/docs/models) |
| `input` | string | Text to synthesize |
| `voice` | string | Voice ID — default `shubh` for `bulbul:v3`, `skylar` for `sonic-3.6` (see [Voices](/docs/tts-voices)) |
| `language` | string | Language code (e.g. `hi-IN`, `ta-IN`; `sonic-3.6` takes base codes like `en`, `hi`) |
| `speed` | number | Speech speed (default 1.0). `bulbul:v3` 0.5–2.0, `gpt-4o-mini-tts` 0.25–4.0, `deepgram-aura-2`/`-1` 0.7–1.5, `sonic-3.6` 0.6–1.5, `gnani-timbre-v2.0` 0.85–1.15. Not supported by `aura-2-en`, `aura-2-es`, `melotts` |
| `speech_sample_rate` | integer | 8000, 16000, 22050, 24000, or 48000 Hz |
| `response_format` | string | Output format — see below |
| `temperature` | number | Expressiveness, 0.01–2.0. `bulbul:v3` only — higher is more expressive, lower is more consistent. Defaults to 0.9 (warmer than the model's flat default) |
| `instructions` | string | Natural-language delivery direction — tone, emotion, accent, pacing. `gpt-4o-mini-tts` only. Max 2000 chars. Example: `"Speak slowly and warmly, like you're reassuring someone."` |
| `humanize` | boolean | Default `true`. Shapes your text for natural speech before synthesis — strips markdown and emoji, speaks URLs and emails as words, groups long digit runs into readable chunks. Set `false` to synthesize your text byte-for-byte |
| `stream` | boolean | Deepgram Aura-2 only — stream audio as it's generated (lower time-to-first-byte) |
## Audio Formats
Supported values for `response_format`: `mp3`, `opus`, `aac`, `flac`, `wav`, `pcm`
## Making speech sound human
Naturalness comes from three places, in order of impact:
**1. The text you send.** Every engine sounds more human when the input reads like speech rather than like a screen. `humanize` (on by default) handles the mechanical part — markdown, emoji, `https://callmissed.com` → "callmissed dot com", `2039123456` → `203.912.3456`. Beyond that, write short sentences and use contractions; if an LLM generates your text, tell it that its output will be spoken aloud.
**2. Expressiveness parameters**, where the model supports them:
```bash
# bulbul:v3 — temperature is its expressiveness control
curl -X POST https://api.callmissed.com/v1/audio/speech \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model": "bulbul:v3", "input": "Bilkul, main abhi check karta hoon.", "voice": "shubh", "temperature": 1.1}' \
--output speech.mp3
# gpt-4o-mini-tts — direct the performance in plain language
curl -X POST https://api.callmissed.com/v1/audio/speech \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o-mini-tts", "input": "Your order shipped this morning.", "voice": "nova", "instructions": "Cheerful and upbeat, like sharing good news with a friend."}' \
--output speech.mp3
```
**3. Pauses in the text.** `deepgram-aura-2`, `deepgram-aura-1`, `aura-2-en` and `aura-2-es` read pause cues written into your text: `...` gives a longer natural pause, a comma or period gives a short one, and `um`/`uh` render as natural hesitation.
```json
{"model": "deepgram-aura-2", "input": "Let me pull that up... okay, found it."}
```
`bulbul:v3` and `gnani-timbre-v2.0` do not support pause markup or SSML — they would speak the dots aloud. Use sentence length and real punctuation for rhythm on those models.
## Choosing an expressive voice
| Want | Use |
|------|-----|
| Most natural conversational speech | `sonic-3.6` — 44 languages, native-quality Hindi, sub-90ms first audio |
| Direct the emotion in words | `gpt-4o-mini-tts` with `instructions` |
| Indian languages, warm delivery | `bulbul:v3` with `temperature` 0.9–1.2 |
| Pauses and hesitation in text | `deepgram-aura-2` (90 voices, many tagged expressive/cheerful) |
| Lowest cost | `melotts` — no expressive controls; rely on `humanize` |
## Streaming (Deepgram Aura-2)
For `deepgram-aura-2` and `deepgram-aura-1`, set `"stream": true` to receive audio frames as they're synthesized over Deepgram's low-latency WebSocket, relayed to you as a chunked HTTP response. Ideal for real-time playback where you want the first audio bytes as fast as possible.
Streaming supports **raw encodings only** — `response_format` must be `linear16` (or `pcm`/`wav`), `mulaw`, or `alaw`. Compressed formats (`mp3`, `opus`, `aac`, `flac`) are not WebSocket-streamable; if you request one with `stream:true`, the full audio is returned in one buffered response instead.
```bash [cURL]
curl -N -X POST https://api.callmissed.com/v1/audio/speech \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"model": "deepgram-aura-2", "voice": "thalia", "input": "Streaming hello.", "response_format": "linear16", "stream": true}' \
--output speech.raw
```
Billing is identical to the non-streaming path (per character). Other providers ignore `stream` and return the full audio in one response.
---
### Voices
URL: /docs/tts-voices
> Available voices for text-to-speech synthesis.
## Indic Voices
**bulbul:v3** provides **37 voices** spanning 11 Indian languages (`bn-IN`, `en-IN`, `gu-IN`, `hi-IN`, `kn-IN`, `ml-IN`, `mr-IN`, `od-IN`, `pa-IN`, `ta-IN`, `te-IN`). Pass the voice ID as the `voice` parameter and the target `language`. The default voice is `shubh`; an unrecognized voice falls back to `shubh`.
```text
shubh · aditya · ritu · priya · neha · rahul · pooja · rohan · simran · kavya
amit · dev · ishita · shreya · ratan · varun · manan · sumit · roopa · kabir
aayan · ashutosh · advait · anand · tanya · tarun · sunny · mani · gokul · vijay
shruti · suhani · mohit · kavitha · rehan · soham · rupali
```
Preview every voice in the [Playground](https://platform.callmissed.com/playground/tts).
**gnani-timbre-v2.0** provides **73 voices** across English, Hindi and other Indian languages with context-aware tone for telephony-grade delivery. The default voice is `Nalini`; an unrecognized voice falls back to the default.
```text
Nalini · Bhavna · Yashvi · Urmila · Jwala · Chitra · Ambuja · Deepak · Roopesh · Vikrant
Hemraj · Jalaj · Omkar · Aarohi · Bhavini · Charvi · Eishani · Falguni · Gauri · Iravati
Janaki · Kamakshi · Madhuri · Radhika · Shweta · Tanvi · Vidya · Wamika · Yamini · Abhimanyu
Chirag · Deven · Farhan · Jatin · Kartik · Kaveri · Trupti · Devika · Pranav · Shlok
Girish · Asmita · Trisha · Brinda · Vedika · Noopur · Oviya · Parvati · Suhana · Lehara
Lavanya · Yukti · Varuni · Saanvi · Kavin · Hansika · Reshma · Riyaan · Zahira · Ishaan
Kirra · Dhruva · Damini · Urvashi · Falak · Veera · Lalita · Nayana · Gaurav · Harshit
Mehuli · Zayan · Poorvi
```
## Cartesia Voices
**sonic-3.6** (Cartesia Sonic 3.6) speaks 44 languages with native-quality Hindi and Hinglish. Pass a featured handle (`skylar`, default) or any public Cartesia voice UUID as `voice`, plus a base ISO `language` code (`en`, `hi`). An unrecognized non-UUID handle falls back to `skylar`.
The live public library is paginated — do not treat the 16 featured aliases as the full set:
```bash
curl "https://api.callmissed.com/api/v1/models/sonic-3.6/voices?q=hindi&limit=50"
curl "https://api.callmissed.com/v1/audio/voices?model=sonic-3.6&q=skylar" \
-H "Authorization: Bearer cm_YOUR_KEY"
```
`GET /api/v1/models/sonic-3.6/voices/{id}/preview` streams Cartesia's own pre-recorded sample (no synthesis, no credit charge). Query params: `q`, `language`, `gender` (`masculine` / `feminine` / `gender_neutral`), `limit` (1–100), `starting_after`.
If the upstream library is briefly unreachable, the first page answers `200` with the featured aliases below and an extra `"degraded": true` field instead of failing, so a voice picker still renders and every returned voice remains usable for synthesis. The field is **absent** on a normal response — treat its presence as "this is the short list, retry later for the full library". A request carrying `starting_after` is not degraded: pagination returns `502` so you keep the page you already have.
Featured aliases (stable handles, also valid UUIDs in the library):
```text
skylar · daniel · jacqueline · katie · cathy · caroline · ronald · carson · jameson
gemma · archie · riya · arushi · siya · parvati · kabir
```
`riya`, `arushi`, `siya`, `parvati`, and `kabir` are native Hindi speakers (pair with `"language": "hi"`). Browse the full library in the [console Voices page](https://console.callmissed.com/voice/voices), the [Playground](https://platform.callmissed.com/playground/tts), or [Talk](https://callmissed.com/talk).
> **Other TTS providers** also expose voices via the same `POST /v1/audio/speech` endpoint — **aura-2-en** (40 English voices, default `luna`), **aura-2-es** (10 Spanish voices), **deepgram-aura-2** (90 voices across English, Spanish, German, French, Dutch, Italian, and Japanese via the direct Deepgram API, default `thalia`), **deepgram-aura-1** (12 legacy English voices via the direct Deepgram API at half the Aura-2 rate, default `asteria`), and **gpt-4o-mini-tts** (`alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`). See [Credits & Rate Limits](/docs/credits-rate-limits) for per-model pricing.
## Flux TTS Voices (managed Voice Agent only)
Deepgram Flux TTS is a voice-agent-first model. It is **not** available on the `POST /v1/audio/speech` endpoint — it is offered only through the managed Voice Agent (see the Voice Sessions API), selectable with `tts_engine: "flux"`, where synthesis is turn-based, prosody carries across turns, and it is billed inside the per-minute voice rate. It provides **36 English voices** (American, British, Indian, Irish, Australian, Singaporean and Filipino accents); the default is `priya`, an Indian-accented English voice, and an unrecognized voice falls back to the default.
```text
alexis · bruce · cole · drew · haley · heather
jack · marcus · priya · rufus · sharon
```
English only — a multilingual voice set is planned for a later release. The model exposes no expressive/emotion/style controls and does not interpret SSML.
---
### Managed Voice Agent
URL: /docs/managed-voice-agent
> Stream audio over a WebSocket and get a speaking agent back. Two protocols: CallMissed-native and Deepgram Voice Agent compatible.
## Overview
The Managed Voice Agent is a full speech-to-speech pipeline behind a single
WebSocket. You stream microphone audio in; you get synthesized speech and
conversation events back. Speech recognition, the language model, text-to-speech,
turn-taking and interruption handling are all run and tuned for you.
Unlike the [Voice Session API](/docs/voice-sessions-api), there is no WebRTC and
no client SDK to install — a plain WebSocket and raw PCM is the whole integration.
Two wire protocols are served, backed by the same engine:
| Endpoint | Protocol |
| --- | --- |
| `wss://api.callmissed.com/v2/voice/agent` | CallMissed-native |
| `wss://api.callmissed.com/v1/agent/converse` | Deepgram Voice Agent compatible |
If you already have an integration written against Deepgram's Voice Agent API,
point it at the second URL and it will work unchanged.
Both live on `api.callmissed.com`, the same host as the rest of the API — there
is no separate hostname to allowlist.
## Authentication
Both endpoints take an API key with `stt`, `tts` and `llm` permissions.
```http
Authorization: Token cm_your_api_key
```
Browsers cannot set headers on a WebSocket, so the subprotocol form is also
accepted:
```js
new WebSocket(url, ["token", "cm_your_api_key"])
```
Plan limits, the concurrent-session cap and your credit balance are all checked
before the socket is accepted, so an over-limit connection fails at the
handshake rather than mid-conversation.
## Session flow
1. Connect. The server sends `Welcome` with a `request_id`.
2. Send `Settings` as your first message.
3. Wait for `SettingsApplied`, then start streaming audio.
4. Send raw PCM as **binary** frames; receive synthesized PCM as binary frames
and events as JSON on the same socket.
```json
{ "type": "Welcome", "request_id": "fc553ec9-5874-49ca-a47c-b670d525a4b1" }
```
## Settings (native)
The native shape is organised around what you actually choose: one model id per
role.
```json
{
"type": "Settings",
"audio": {
"input": { "encoding": "linear16", "sample_rate": 24000 },
"output": { "encoding": "linear16", "sample_rate": 24000 }
},
"agent": {
"prompt": "You are a concise support agent for an Indian retail brand.",
"greeting": "Hi, how can I help?",
"language": "en-IN",
"llm": { "model": "gpt-oss-120b", "temperature": 0.4 },
"stt": { "model": "saaras:v3" },
"tts": { "model": "bulbul:v3", "voice": "shubh" }
},
"tags": ["support"]
}
```
Every other client message — updates, injection, tool responses, keepalive — is
identical across both protocols. Switching protocols means rewriting one message.
### Audio format
`linear16` (raw 16-bit PCM, little-endian, mono) in both directions, at
`8000`, `16000`, `24000`, `32000` or `48000` Hz. Output is raw frames with no
container: a WAV or OGG header would be read as audio by most telephony
consumers.
An unsupported encoding, sample rate or container is rejected at `Settings`
time rather than accepted and quietly changed, so you find out at the handshake
instead of hearing the wrong thing on a call.
## Choosing models
Call [`GET /api/v1/voice/models`](#list-available-models) for the models you can
use, each with its measured latency. Any eligible speech-to-text × language model
× text-to-speech combination is valid.
Models are offered based on **measured** performance from real traffic, not on
vendor claims. A model that is too slow to hold a conversation is not offered —
it is listed with the reason, so you can see why rather than wondering where it
went.
Two speech-to-text models are streaming-capable here that you cannot use for file
transcription in the same way:
- **`ink-2`** ($0.54 / hr) — Cartesia's top-ranked voice-agent STT: 8% WER on
AppTek's 14-accent call-centre benchmark, vs 10% Deepgram Flux and 12%
ElevenLabs. It self-detects turns. **English only** — set
`"language": "en"`. Sending non-English audio does not error; it just
transcribes badly. This is the only surface `ink-2` runs on: the file
transcription endpoint rejects it with a `400`.
- **`ink-whisper`** ($0.18 / hr) — 100 languages including Hindi, Urdu and Tamil.
Cartesia's cheapest STT, with dynamic chunking that reduces hallucination
across pauses. Use this instead of `ink-2` for any non-English call.
### List available models
```bash
curl https://api.callmissed.com/api/v1/voice/models \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"turn_budget_ms": 1000,
"llm": [
{
"id": "gpt-oss-120b",
"label": "GPT-OSS 120B",
"eligible": true,
"verdict": "eligible",
"p50_ms": 180.0,
"samples": 240,
"budget_ms": 400,
"reason": "p50 180ms within the 400ms budget",
"languages": ["en"],
"voices": []
}
],
"stt": [],
"tts": []
}
```
| `verdict` | Meaning |
| --- | --- |
| `eligible` | Measured within its stage budget. Selectable. |
| `too_slow` | Measured over budget. Not offered; `reason` says by how much. |
| `unsupported` | Structurally unavailable (e.g. under maintenance). |
| `unmeasured` | Not enough measured turns yet to give a verdict. |
`p50_ms` is `null` when a model has no measurement. It is never reported as `0`
— zero would read as "instant" for a stage that was simply never measured.
## Server events
| Event | Meaning |
| --- | --- |
| `Welcome` | Socket open, carries `request_id`. |
| `SettingsApplied` | Configuration accepted; start streaming. |
| `ConversationText` | A finished turn — `role` is `user` or `assistant`. |
| `UserStartedSpeaking` | **Barge-in.** Stop playback and clear your buffer. |
| `AgentThinking` | The model is working. |
| `AgentStartedSpeaking` | First audio of a turn; carries latency numbers. |
| `AgentAudioDone` | Last audio chunk *sent* for this turn. |
| `FunctionCallRequest` | Run a tool and reply. |
| `LatencyReport` | Per-stage timings for the turn. |
| `Warning` | Non-fatal; the session continues. |
| `Error` | Fatal; reconnect. |
`UserStartedSpeaking` is the **only** barge-in signal — there is no separate
flush message. When you receive it, stop playback and discard whatever audio you
have buffered. If you don't, the caller keeps hearing the interrupted sentence
for as long as your playback buffer is deep, which is the most common reason
interruption appears not to work.
`AgentAudioDone` means the last chunk was **sent**, not that the caller heard it.
Your own playback buffer may still be draining.
Latency fields are omitted when a stage was not measured, rather than reported
as `0`.
## Tool calling
Declare tools in `Settings`, then answer requests over the same socket.
```json
{
"type": "FunctionCallRequest",
"functions": [
{
"id": "fc_01H...",
"name": "lookup_order",
"arguments": "{\"order_id\":\"A-1042\"}",
"client_side": true
}
]
}
```
Reply with the result. `arguments` is a JSON **string**, not an object.
```json
{
"type": "FunctionCallResponse",
"id": "fc_01H...",
"name": "lookup_order",
"content": "{\"status\":\"shipped\",\"eta\":\"2 days\"}"
}
```
A tool that does not answer within 30 seconds does not hang the call — the turn
continues and a `Warning` is emitted.
Server-side execution (a `functions[].endpoint`) is **not** supported. Declaring
one returns a `Warning` and the call is dispatched to your client instead, so an
unsupported mode is never silently ignored.
## Updating a live session
| Message | Effect |
| --- | --- |
| `UpdatePrompt` | Append to the system prompt. |
| `UpdateThink` | Switch the language model. |
| `UpdateListen` | Switch speech recognition. |
| `UpdateSpeak` | Switch voice or text-to-speech model. |
| `InjectAgentMessage` | Make the agent say something now. |
| `InjectUserMessage` | Inject text as if the caller said it. |
| `KeepAlive` | Hold an idle socket open. |
A model id the service cannot serve keeps the current model and returns a
`Warning`; it is never swapped for a different one behind your back.
## Latency
The fast path targets **sub-1s** from end of your speech to first audio back.
That budget is the sum of three serial stages:
| Stage | Budget |
| --- | --- |
| Turn detection | ~300 ms |
| Language model, first token | 400 ms |
| Text-to-speech, first byte | 300 ms |
`LatencyReport` gives you the real numbers per turn, so you can measure rather
than take our word for it.
Sub-200ms end-to-end voice-to-voice is not achievable with a speech-to-text →
language model → speech pipeline, by anyone. Detecting that you stopped speaking
alone costs more than that. Treat sub-second as the realistic target and measure
the rest with `LatencyReport`.
## Limits
| Limit | Value |
| --- | --- |
| Maximum session length | 2 hours |
| Tool response timeout | 30 seconds |
| Concurrent sessions | Per plan |
Usage is billed per turn across speech recognition, the language model and
text-to-speech. If your balance runs out mid-session you receive an `Error`
frame and the socket closes, rather than the call continuing unbilled.
---
### Voice Agent
URL: /docs/voice-agent
> Real-time voice agents over WebRTC with one selected speech-to-speech or STT-to-LLM-to-TTS stack.
:::cards
/docs/voice-sessions-api | Voice Sessions API | key | Create sessions and generate connection tokens
/docs/voice-sdk | Voice SDK | package | Client SDK for browser and mobile WebRTC
/docs/stt-realtime | Real-time STT | mic | Streaming speech-to-text over WebSocket
/docs/text-to-speech | Text to Speech | volume2 | Indic TTS for agent responses
:::
## Overview
The Voice Agent streams conversations over **WebRTC**. Choose one configuration for the call:
- **CallMissed-managed pipeline:** select one speech-recognition model, one language model, and one speech-generation model and voice.
- **Deepgram-managed pipeline:** select a `deepgram-voice-*` model and its supported recognition and voice settings.
- **Native speech-to-speech:** select a GPT Realtime or Nova Sonic model and voice.
An omitted `llm_model` selects `deepgram-voice-open-ai-gpt-5.4-nano`. Calls do not switch to another model or provider on failure. Same-provider transient retries remain; if the selected stack cannot run, the session fails or ends instead of substituting another stack. Retired `voice_fallbacks` settings are no longer used.
## Architecture
You create a session over REST and receive a connection URL + token. Your client connects with the `livekit-client` SDK; the CallMissed voice agent joins automatically and handles the speech pipeline. Audio flows over WebRTC.
This is the WebRTC path. For a plain WebSocket you stream raw audio to — no client SDK, no media hop — see the [Managed Voice Agent](/docs/managed-voice-agent), which runs the same tuned pipeline over `wss://api.callmissed.com`.
:::flow
icon:app | Browser (livekit-client SDK) | Captures mic audio and streams it over WebRTC
icon:server | Connection | WebRTC transport that connects your client to the voice agent
icon:bot | CallMissed voice agent | Runs the selected speech-to-speech model or STT → LLM → TTS pipeline
icon:done | Browser | Receives synthesized speech back over WebRTC and plays it
:::
### One conversational turn
With Nova Sonic selected, every turn stays in one speech-to-speech model. With a cascaded model selected (or when the speech-to-speech model is unavailable), every turn streams through STT, LLM, and TTS concurrently to minimize time-to-first-audio:
:::flow
icon:stt | STT | Streams partial transcripts as the user speaks, finalizes on end-of-speech
icon:llm | LLM | Generates the reply at high throughput and pushes sentence chunks downstream
icon:tts | TTS | Synthesizes each sentence chunk as it arrives — playback starts before generation finishes
:::
## Quickstart
**1. Create a session:**
```bash
curl -X POST https://api.callmissed.com/v1/voice/sessions \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "You are a helpful assistant.",
"voice": "shubh",
"language": "en-IN",
"llm_model": "kimi-k2.5"
}'
```
**Response:**
```json
{
"id": "uuid",
"ws_url": "wss://…",
"token": "eyJhbGciOi...",
"status": "created"
}
```
Read `ws_url` from this response and pass it straight to the client — it is issued per session. Do not hardcode it.
**2. Connect with the client SDK:**
```javascript
import { Room, RoomEvent, Track } from "livekit-client";
const room = new Room();
room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === Track.Kind.Audio) {
const el = track.attach();
document.body.appendChild(el);
}
});
room.on(RoomEvent.TranscriptionReceived, (segments, participant) => {
for (const seg of segments) {
if (seg.final) {
const who = participant?.isLocal ? "You" : "Agent";
console.log(who + ": " + seg.text);
}
}
});
await room.connect(session.ws_url, session.token);
await room.localParticipant.setMicrophoneEnabled(true);
```
The agent joins automatically, greets the user, and responds to speech.
## Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `system_prompt` | string | "You are a helpful voice assistant..." | System prompt for LLM |
| `voice` | string | `shubh` | TTS voice ID (37 voices available) |
| `language` | string | `en-IN` | Language code for STT and TTS |
| `llm_model` | string | resolved server side | One supported voice model. Unavailable selections are not replaced. |
| `tts_provider` | string | *plan-dependent* | Legacy TTS selector; use `tts_model` for per-model selection. Omit it and the server picks by plan: paid plans (starter, pro, enterprise) default to Cartesia `sonic-3.6`, the free plan to Sarvam `bulbul:v3`. An explicit value is always honoured. |
| `max_duration_seconds` | int | 1800 | Max session duration (30-3600) |
## Features
- **Interruption handling** — speak while the agent is talking and it stops immediately, listens to you
- **STT-based turn detection** — server-side VAD detects speech start/end with low-latency (~50ms) endpointing
- **Preemptive generation** — LLM starts generating before STT fully confirms the transcript
- **Streaming pipeline** — each stage streams to the next, no buffering between stages
- **Session management** — REST API for creating, listing, deleting sessions and retrieving transcripts
- **Per-model pricing** — usage tracked and billed per model ($0.81/$4.05 per 1M tokens)
## Legacy WebSocket
The direct WebSocket endpoint is still available for backward compatibility:
```
WS /ws/voice-agent
Sec-WebSocket-Protocol: token, cm_your_api_key
```
Authenticate with the subprotocol header. It is a request header, so the key stays out of access logs and proxy history, which a query string does not. In the browser, pass it as the constructor's second argument, with the literal `token` first and the key second:
```javascript
new WebSocket("wss://api.callmissed.com/ws/voice-agent", [
"token",
"cm_your_api_key",
]);
```
Clients that can set headers may send `Authorization: Bearer cm_your_api_key` instead. The `?key=cm_your_api_key` query parameter is **deprecated** and still accepted for existing integrations.
Send a config message after connecting, then stream PCM audio. This is a direct-WebSocket pipeline, separate from the WebRTC path above. See the [Session API](/docs/voice-sessions-api) for the recommended WebRTC approach.
---
### Agent Evals
URL: /docs/voice-evals
> Regression-test a voice agent against scripted personas with pass/fail assertions, and read the transcript of every case.
## Overview
An **eval suite** is a regression test for one voice agent. Each **case** in the suite gives a simulated caller a persona and an opening line, lets the conversation run for up to a fixed number of turns, and then checks the transcript against **success criteria**.
Running a suite produces a **run** — a pass count plus the full transcript and per-assertion result for every case. Use it before promoting a prompt change, exactly as you would a test suite.
> **Running a suite calls models and costs credits.** Everything else on this page is free. See [Billing](#billing).
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List/get suites, list cases, list/get runs | `evals:read` |
| Create/update/delete suites and cases, **run a suite** | `evals:write` |
## Limits
| Thing | Limit |
| --- | --- |
| Cases executed per run | **50** |
| Turns per case | `1..20`, default `6` |
| Success criteria per case | 20 |
| Suite name | 255 characters |
| Persona | 4,000 characters |
| Opening line | 2,000 characters |
A suite may **store** more than 50 cases; the cap is on what one run executes.
---
## Suites
```json
{
"id": "aa10…",
"bot_id": "b1f2…",
"name": "Booking flow — regression",
"description": "Covers the happy path plus three refusals.",
"scorecard_id": "sc33…",
"is_active": true,
"created_at": "2026-08-12T09:00:00Z",
"updated_at": "2026-08-12T09:00:00Z"
}
```
Attaching a `scorecard_id` adds a graded score on top of the pass/fail assertions.
### GET `/api/v1/voice/evals`
Newest first. Filters: `bot_id`, `is_active`. `limit` `1..200` (default `50`), `offset` `0..100000`.
### POST `/api/v1/voice/evals`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `bot_id` | `UUID` | Yes | Must be your agent |
| `name` | `string` | Yes | 1–255 characters, unique per agent |
| `description` | `string` | No | At most 500 characters |
| `scorecard_id` | `UUID` | No | Must be your scorecard |
| `is_active` | `boolean` | No | Default `true` |
Returns `201`. `409 A suite named '…' already exists for this bot` on a duplicate.
### GET / PATCH / DELETE `/api/v1/voice/evals/{suite_id}`
`DELETE` returns `204` and cascades the suite's cases **and its run history**.
---
## Cases
```json
{
"id": "bb20…",
"suite_id": "aa10…",
"name": "Caller wants a Saturday slot",
"persona": "An impatient customer in Pune who only has Saturdays free and dislikes being put on hold.",
"opening": "Hi, can I move my appointment to Saturday?",
"max_turns": 6,
"success_criteria": [
{ "type": "contains", "value": "Saturday", "role": "agent" },
{ "type": "tool_called", "value": "reschedule_appointment" },
{ "type": "max_turns_under", "value": 5 }
],
"position": 0,
"created_at": "2026-08-12T09:05:00Z",
"updated_at": "2026-08-12T09:05:00Z"
}
```
### Success criteria
| `type` | `value` | Passes when |
| --- | --- | --- |
| `contains` | text, at most 500 characters | The transcript contains the text |
| `not_contains` | text | The transcript does not contain it |
| `regex` | pattern, at most 200 characters | The pattern matches |
| `tool_called` | tool name | The agent invoked that tool |
| `max_turns_under` | integer `1..20` | The conversation finished in fewer turns |
| `ends_with_handoff` | omitted | The call ended in a handoff to a human |
Optional per criterion: `role` (`agent` — the default, `caller`, or `any`) and `case_sensitive` for the text types.
Design the criteria as assertions about **outcomes**, not exact wording: `tool_called` and `ends_with_handoff` survive a prompt rewrite, `contains` on a whole sentence will not.
### GET `/api/v1/voice/evals/{suite_id}/cases`
Ordered by `position`, then oldest first. `limit` `1..200` (default `100`), `offset` `0..100000`.
### POST `/api/v1/voice/evals/{suite_id}/cases`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters |
| `persona` | `string` | Yes | 1–4,000 characters |
| `opening` | `string` | Yes | 1–2,000 characters |
| `max_turns` | `integer` | No | `1 <= n <= 20`, default `6` |
| `success_criteria` | `object[]` | No | At most 20 |
| `position` | `integer` | No | `0 <= position <= 10000`, default `0` |
### PATCH / DELETE `/api/v1/voice/evals/cases/{case_id}`
Note the path: cases are addressed directly, **not** under their suite.
---
## Running a suite
### POST `/api/v1/voice/evals/{suite_id}/run`
No body. Returns `201` with the run and every case result.
```bash
curl -X POST https://api.callmissed.com/api/v1/voice/evals/aa10…/run \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"id": "run77…",
"suite_id": "aa10…",
"status": "completed",
"started_at": "2026-08-17T08:00:00Z",
"finished_at": "2026-08-17T08:01:44Z",
"total_cases": 12,
"passed_cases": 11,
"model": "kimi-k2.6",
"cost_credits": 3.812,
"created_at": "2026-08-17T08:00:00Z",
"results": [
{
"id": "res01…",
"run_id": "run77…",
"case_id": "bb20…",
"passed": true,
"transcript": [
{ "role": "caller", "content": "Hi, can I move my appointment to Saturday?" },
{ "role": "agent", "content": "Of course — I can move it to Saturday." }
],
"assertions": [
{ "type": "contains", "value": "Saturday", "passed": true }
],
"score": 0.92,
"error": null,
"created_at": "2026-08-17T08:00:12Z"
}
]
}
```
The call is **synchronous** — it returns when every case has finished, so allow a generous client timeout for a large suite.
### Nothing is charged before the work starts
Checks run in this order, and a failure at any step costs nothing and writes nothing:
1. Suite and agent loaded and confirmed yours.
2. Cases fetched and the 50-case cap checked.
3. Scorecard loaded, if attached.
4. Credit balance checked.
5. Only then does any model run.
| Status | Detail |
| --- | --- |
| `402` | `Insufficient credits to run an eval suite. Add credits to use this feature.` |
| `409` | `This suite has no cases to run.` |
| `422` | `A run executes at most 50 cases. Split this suite.` |
| `404` | `Eval suite not found` / `Bot not found` / `Scorecard not found` |
An over-cap suite is **rejected, not truncated** — a silently-shortened run would report a green result it did not earn.
## Runs
### GET `/api/v1/voice/evals/runs`
Newest first. Filter by `suite_id`. `limit` `1..100` (default `25`), `offset` `0..100000`.
### GET `/api/v1/voice/evals/runs/{run_id}`
The run plus its case results, oldest first, **capped at 50 results** — the same bound as a run.
## Billing
Only `POST /{suite_id}/run` charges. The cost is the agent model's usage across every case, plus the scoring model when a scorecard is attached, deducted after the run completes and visible in [usage logs](/docs/usage-api) as `service: "llm"`.
Cost scales with `cases × (max_turns × 2 + 1)` model calls, so trimming `max_turns` is the cheapest lever. A run that completes but whose deduction fails is still returned to you in full.
## Errors
| Status | When |
| --- | --- |
| `402` | Credit balance exhausted at the pre-run gate |
| `403` | Key is missing `evals:read` / `evals:write` |
| `404` | Suite, case, run, agent or scorecard not in your tenant |
| `409` | Duplicate suite name, or an empty suite |
| `422` | Blank name/persona/opening, over 20 criteria, an unknown criterion type, or over 50 cases in a run |
---
### A/B Experiments
URL: /docs/voice-experiments
> Split voice traffic across agent variants, assign callers deterministically, and read per-arm results against a chosen metric.
## Overview
An **experiment** compares variants of one voice agent on a single metric. Each variant is an **arm**: a bot version, or a set of overrides (system prompt, voice, model, timing). Exactly one arm is the **control**.
`traffic_split` decides what share of callers each arm gets. `POST /assign` buckets a caller into an arm deterministically, and `GET /results` reports the metric per arm.
Nothing on this page consumes credits — the calls the experiment configures are billed as normal voice usage.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List/get experiments, read results | `experiments:read` |
| Create, edit arms, start/stop/conclude, **assign** | `experiments:write` |
`assign` needs the **write** scope — it records a durable assignment.
## Lifecycle
```
draft ──start──▶ running ──stop──▶ stopped ──conclude──▶ concluded
│ ▲
└──────────────conclude────────────────┘
```
| Status | What it means |
| --- | --- |
| `draft` | Being configured. The metric can still be changed |
| `running` | Assigning traffic. Arms are frozen except for renaming |
| `stopped` | Not assigning. Arms can be edited again, and it can restart |
| `concluded` | Terminal and **immutable** — a winner is recorded and nothing can change |
Stop before editing an arm; conclude only when you are done for good.
## Metrics
| `metric` | Label | Better |
| --- | --- | --- |
| `goal_completed` | Goal completion rate | Higher |
| `avg_score` | Average scorecard total | Higher |
| `handoff_rate` | Human-handoff rate | Lower |
| `completion_rate` | Call completion rate | Higher |
| `avg_duration_seconds` | Average call duration | Lower |
The deciding metric can only be changed while the experiment is a `draft` — picking the winner after seeing the data is exactly what that rule prevents.
## The experiment object
```json
{
"id": "ex10…",
"tenant_id": "a0b1…",
"bot_id": "b1f2…",
"name": "Shorter opening line",
"hypothesis": "A one-sentence greeting raises goal completion.",
"status": "running",
"traffic_split": { "arm-a-id": 50, "arm-b-id": 50 },
"metric": "goal_completed",
"winner_arm_id": null,
"started_at": "2026-08-14T09:00:00Z",
"stopped_at": null,
"created_at": "2026-08-13T09:00:00Z",
"updated_at": "2026-08-14T09:00:00Z",
"arms": []
}
```
`GET` and every mutating call return the detail shape, with `arms` populated oldest first.
## GET `/api/v1/voice/experiments`
Newest first. Filters: `bot_id`, `status`. `limit` `1..200` (default `50`), `offset` `0..100000`.
## POST `/api/v1/voice/experiments`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `bot_id` | `UUID` | Yes | Must be your agent |
| `name` | `string` | Yes | At most 255 characters, not blank, unique per agent |
| `metric` | `string` | Yes | One of the five metrics |
| `hypothesis` | `string` | No | At most 500 characters |
Created as `draft` with an empty `traffic_split`. Returns `201`.
## Arms
```json
{
"id": "arm-b-id",
"tenant_id": "a0b1…",
"experiment_id": "ex10…",
"name": "short-greeting",
"bot_version_number": 12,
"overrides": {
"system_prompt": "Greet in one sentence, then ask how you can help.",
"voice": "anushka",
"timing": { "interrupt_sensitivity": 0.6, "silence_timeout_ms": 2000 }
},
"is_control": false,
"created_at": "2026-08-13T09:05:00Z"
}
```
### POST `/api/v1/voice/experiments/{experiment_id}/arms`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | At most 64 characters, unique within the experiment |
| `bot_version_number` | `integer` | No | `1 <= n <= 1000000` |
| `overrides` | `object` | No | See below |
| `is_control` | `boolean` | No | Default `false`. Only one arm may be the control |
**At most 6 arms per experiment.**
### `overrides`
Unknown keys are rejected with `422` rather than ignored.
| Field | Type | Constraints |
| --- | --- | --- |
| `system_prompt` | `string` | At most 8,000 characters |
| `voice` | `string` | At most 64 characters |
| `model` | `string` | At most 100 characters |
| `timing` | `object` | See the timing table |
| `node_timing` | `object` | `{ node_id: timing }`, at most 100 entries, node ids at most 64 characters |
#### Timing fields
| Field | Type | Range |
| --- | --- | --- |
| `allow_interruptions` | `boolean` | |
| `interrupt_sensitivity` | `number` | `0.0`–`1.0` |
| `resume_delay_ms` | `integer` | `0`–`5000` |
| `silence_timeout_ms` | `integer` | `500`–`30000` |
| `max_node_duration_ms` | `integer` | `1000`–`600000` |
### PATCH / DELETE `/api/v1/voice/experiments/arms/{arm_id}`
Arms are addressed directly, not under their experiment. While the experiment is `running` you may change only `name` — anything else returns `409 Stop the experiment before changing an arm's configuration`, because a mid-flight change would silently mix two configurations into one arm's numbers.
Deleting an arm also removes it from `traffic_split` in the same transaction.
## Traffic split
`traffic_split` maps every arm id to a whole-number percentage.
| Rule | Error when broken |
| --- | --- |
| Must name every arm, and only arms of this experiment | `traffic_split is missing arm(s): …` / `…names arm(s) that do not belong…` |
| Percentages are whole numbers in `0..100` | `traffic_split percentages must be whole numbers` |
| Must sum to exactly 100 | `traffic_split must sum to 100 (got 90)` |
| Exactly one arm is the control | `exactly one arm must be the control (found 0)` |
Set it with `PATCH /{experiment_id}`, or pass it on start.
## Start, stop, conclude
### POST `/api/v1/voice/experiments/{experiment_id}/start`
Optional body `{ "traffic_split": { … } }`; falls back to the stored split. Needs **at least two arms** — `422 An experiment needs at least two arms to compare`.
### POST `/api/v1/voice/experiments/{experiment_id}/stop`
No body. `409 This experiment is not running` if it was not.
### POST `/api/v1/voice/experiments/{experiment_id}/conclude`
| Field | Type | Required |
| --- | --- | --- |
| `winner_arm_id` | `UUID` | Yes — must be an arm of this experiment |
After this the experiment is immutable. A `draft` cannot be concluded — `409 Start the experiment before concluding it`.
## Assignment
### POST `/api/v1/voice/experiments/{experiment_id}/assign`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `voice_session_id` | `UUID` | Conditional | Must be your session |
| `key` | `string` | Conditional | At most 128 characters |
Send at least one. When both are present the session id is the bucketing key.
```bash
curl -X POST https://api.callmissed.com/api/v1/voice/experiments/ex10…/assign \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "voice_session_id": "vs99…" }'
```
```json
{
"experiment_id": "ex10…",
"arm_id": "arm-b-id",
"arm_name": "short-greeting",
"voice_session_id": "vs99…",
"assignment_id": "as55…",
"created": true,
"assigned_at": "2026-08-17T08:20:00Z"
}
```
Bucketing is **deterministic** — the same key always lands in the same arm for the same split, so a returning caller keeps their variant.
Assignment by session is **idempotent**: a repeat call returns the existing row with `created: false`, and a concurrent double-call re-reads the winner rather than creating two.
> A **key-only** call writes nothing. `assignment_id` comes back `null` and the result is a preview of which arm that key maps to. Use it to plan; use `voice_session_id` to record.
`409 This experiment is not running; no traffic is assigned` outside the running state.
## Results
### GET `/api/v1/voice/experiments/{experiment_id}/results`
No parameters.
```json
{
"experiment_id": "ex10…",
"status": "running",
"metric": "goal_completed",
"metric_label": "Goal completion rate",
"higher_is_better": true,
"min_sample_per_arm": 30,
"total_assignments": 412,
"sufficient_data": true,
"leader_arm_id": "arm-b-id",
"winner_arm_id": null,
"verdict": "short-greeting is ahead on goal completion rate",
"arms": [
{ "arm_id": "arm-a-id", "name": "control", "is_control": true, "sample_size": 205, "metric_value": 0.61 },
{ "arm_id": "arm-b-id", "name": "short-greeting", "is_control": false, "sample_size": 207, "metric_value": 0.68 }
]
}
```
| Field | Notes |
| --- | --- |
| `sufficient_data` | `false` while any arm has fewer than **30** assignments, or fewer than two arms have a value |
| `leader_arm_id` | Currently ahead on the metric. Not a verdict |
| `winner_arm_id` | Only set once you conclude |
> There is deliberately **no p-value or significance field**. `sufficient_data` is a floor, not a test — treat `leader_arm_id` as a signal to keep running, and decide the winner yourself.
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `experiments:read` / `experiments:write` |
| `404` | Experiment, arm, agent or voice session not in your tenant |
| `409` | Concluded and immutable, already running / not running, or an arm edit while running |
| `422` | Over 6 arms, a second control, fewer than two arms on start, a `traffic_split` that does not add up, or a winner that is not an arm of the experiment |
---
### Voice Client Libraries
URL: /docs/voice-sdk
> Which packages you actually install to build a voice client, and how to wire them to the Voice Session API.
## What you install
There is no CallMissed-branded voice package on PyPI or npm. Voice is two plain pieces: a JSON REST call to create the session, and a standard WebRTC client in the browser to carry the audio.
| Layer | What to use | Why |
|-------|-------------|-----|
| Create a session (server side) | Any HTTP client: `httpx`, `requests`, `fetch`, `curl` | `POST /v1/voice/sessions` is a plain JSON endpoint |
| Browser audio | `livekit-client` (npm) | The create response hands you a WebRTC URL and token that this package consumes |
| Transcripts and session records | Any HTTP client | Plain JSON `GET` endpoints |
For the text LLM, speech-to-text and text-to-speech APIs there is likewise no bespoke package: those surfaces are OpenAI and Anthropic compatible, so you use the official `openai` or `anthropic` SDK with our base URL. See [Libraries & SDKs](/docs/sdks).
**Authentication:** every REST call on this page takes `Authorization: Bearer cm_your_api_key`. To create a voice session the key needs the `stt`, `tts` and `llm` permissions; a key missing any of them gets `403`.
## Step 1: create the session
Mint the session on your server, never in the browser, so your API key is never shipped to a client.
```bash
curl -X POST https://api.callmissed.com/v1/voice/sessions \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "You are a helpful assistant. Keep replies to one or two sentences.",
"greeting": "Hi, how can I help?",
"voice": "shubh",
"language": "en-IN",
"max_duration_seconds": 1800
}'
```
```python
import httpx
async with httpx.AsyncClient() as client:
r = await client.post(
"https://api.callmissed.com/v1/voice/sessions",
headers={"Authorization": "Bearer cm_your_api_key"},
json={
"system_prompt": "You are a helpful assistant.",
"greeting": "Hi, how can I help?",
"voice": "shubh",
"language": "en-IN",
},
)
r.raise_for_status()
session = r.json()
# Hand session["ws_url"] and session["token"] to your browser client.
print(session["id"], session["ws_url"])
```
```typescript
// Server-side route handler. Returns only ws_url + token to the browser.
const res = await fetch("https://api.callmissed.com/v1/voice/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLMISSED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
system_prompt: "You are a helpful assistant.",
greeting: "Hi, how can I help?",
voice: "shubh",
language: "en-IN",
}),
});
const session = await res.json();
return Response.json({ wsUrl: session.ws_url, token: session.token });
```
### Fields used above
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `system_prompt` | string | a concise built-in assistant prompt | Max 4096 characters |
| `greeting` | string | agent decides its own opener | Max 500 characters. The exact first line the agent speaks |
| `voice` | string | `shubh` | Max 50 characters |
| `language` | string | `en-IN` | Max 10 characters, BCP-47 |
| `llm_model` | string | resolved server side | Omit it to take the platform default voice stack |
| `max_duration_seconds` | int | `1800` | Between 30 and 3600. Hard ceiling for one active call |
| `variables` | object | none | Values for `{{token}}` placeholders in the greeting and prompt |
| `metadata` | object | none | Arbitrary JSON stored with the session |
`bot_id`, `webhook_url`, `tts_provider`, `tts_model`, `stt_model` and `tts_engine` are also accepted. Each call uses one selected stack without automatic model or provider substitution. The [Voice Session API](/docs/voice-sessions-api) page is the full reference for the request body, the other endpoints and the webhook events.
## Step 2: what the response gives you
```json
{
"id": "7c2b9e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"tenant_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"bot_id": null,
"status": "created",
"config": { "system_prompt": "You are a helpful assistant.", "voice": "shubh", "language": "en-IN" },
"ws_url": "wss://…",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"started_at": null,
"ended_at": null,
"duration_seconds": null,
"turn_count": 0,
"total_audio_seconds": 0,
"end_reason": null,
"metadata": null,
"created_at": "2026-04-19T12:00:00Z",
"analysis": null
}
```
The three fields your client needs:
| Field | Use |
|-------|-----|
| `ws_url` | The media server URL, issued per session. Read it from the response and pass it through; do not hardcode it |
| `token` | The connection credential. Returned once at creation and never fetchable again. It expires one hour after issue |
| `id` | The session id, for fetching the transcript afterwards |
`analysis` is `null` at creation and is populated later, once post-call analysis has run.
## Step 3: connect the browser
The transport package is the third-party `livekit-client`:
```bash
npm install livekit-client
```
```javascript
import { Room, RoomEvent, Track } from "livekit-client";
// wsUrl + token come from your own server route (step 1).
const { wsUrl, token } = await fetch("/api/voice/session").then((r) => r.json());
const room = new Room();
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
document.body.appendChild(track.attach());
}
});
room.on(RoomEvent.TranscriptionReceived, (segments, participant) => {
for (const seg of segments) {
if (!seg.final) continue;
const who = participant?.isLocal ? "You" : "Agent";
console.log(`${who}: ${seg.text}`);
}
});
await room.connect(wsUrl, token);
await room.localParticipant.setMicrophoneEnabled(true);
```
The agent joins the room on its own and runs the speech pipeline. Speech boundaries are detected server side, and interruptions are handled for you: talk while the agent is speaking and it stops and listens.
Call `room.disconnect()` to end the call from the client.
## Streaming audio from a server, not a browser
`livekit-client` is a browser package. If the thing holding the microphone is a Python, Go or Node process rather than a browser tab, do not reach for a WebRTC client at all: use the [Managed Voice Agent](/docs/managed-voice-agent) instead. It takes raw audio over a single plain WebSocket with no client SDK on your side.
## Step 4: read the transcript
```bash
curl "https://api.callmissed.com/v1/voice/sessions/{id}/transcript?format=json" \
-H "Authorization: Bearer cm_your_api_key"
```
`format` accepts `json` (the default, a structured turn list), `txt` (alternating plain text) or `srt` (subtitles).
---
### Voice Session API
URL: /docs/voice-sessions-api
> REST API for creating and managing WebRTC voice agent sessions.
## Overview
The Voice Session API provides a two-step flow for voice agent interactions:
1. **Create a session** via REST — returns a connection URL + JWT
2. **Connect over WebRTC** — stream audio with the `livekit-client` SDK; the agent joins automatically and handles STT → LLM → TTS
Audio flows over WebRTC; on this API the REST endpoints handle session metadata, token issuance, usage tracking and transcript storage.
Each session runs one selected voice stack. There is no automatic model/provider failover, and `voice_fallbacks` is no longer a request field. Same-provider transient retries remain supported. Select another model explicitly if the requested stack is unavailable.
If you would rather stream audio straight to us over a plain WebSocket — no WebRTC and no client SDK — use the [Managed Voice Agent](/docs/managed-voice-agent) instead. This page covers the WebRTC session API, which remains the right choice for browser calls with adaptive bitrate.
**Authentication:** All REST endpoints accept both **JWT** (`Authorization: Bearer `) and **API key** (`Authorization: Bearer cm_`). API keys must have `stt`, `tts`, and `llm` permissions to create a session.
## Create Session
```bash
curl -X POST https://api.callmissed.com/v1/voice/sessions \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "You are a helpful assistant.",
"voice": "shubh",
"language": "en-IN",
"llm_model": "kimi-k2.5",
"tts_provider": "sarvam",
"max_duration_seconds": 300,
"webhook_url": "https://your-app.com/webhooks/voice"
}'
```
**Response (201 Created):**
```json
{
"id": "7c2b9e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"tenant_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"bot_id": null,
"status": "created",
"config": {
"system_prompt": "You are a helpful assistant.",
"voice": "shubh",
"language": "en-IN",
"llm_model": "kimi-k2.5",
"tts_provider": "sarvam",
"max_duration_seconds": 300,
"room": "voice-"
},
"ws_url": "wss://…",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"started_at": null,
"ended_at": null,
"duration_seconds": null,
"turn_count": 0,
"total_audio_seconds": 0,
"end_reason": null,
"metadata": null,
"created_at": "2026-04-19T12:00:00Z"
}
```
- `ws_url` is the **media server** URL — not the CallMissed API. It is issued per session; read it from the response and pass it straight to the client, do not hardcode it.
- `token` is the **connection JWT** (not an opaque `vs_*` string). TTL is **1 hour**.
- The token is returned **once** on creation and is not fetchable again.
## Request Body
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `bot_id` | uuid | — | Optional bot to load prompt/knowledge from |
| `system_prompt` | string | "You are a helpful voice assistant..." | Max 4096 chars. Overrides bot's prompt if both set |
| `voice` | string | `shubh` | TTS voice ID (37 voices) |
| `language` | string | `en-IN` | BCP-47 language for STT + TTS |
| `llm_model` | string | `kimi-k2.5` | Any catalog LLM (`sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.6`, `gpt-5.6-luna`, …). `kimi-k2.5-fast` is under maintenance. |
| `tts_provider` | string | *plan-dependent* | `sarvam`, `elevenlabs` or `cartesia`. Omit it and the server picks by plan: paid plans (starter, pro, enterprise) default to Cartesia `sonic-3.6`, the free plan to Sarvam `bulbul:v3`. An explicit value is always honoured. |
| `max_duration_seconds` | int | `1800` | 30–3600 |
| `webhook_url` | string | — | Receives session events (see below) |
| `metadata` | object | — | Arbitrary JSON stored with the session |
## Connect the client
Use `ws_url` + `token` returned by create. **Do not** try to open a WebSocket to the CallMissed API — use the `livekit-client` SDK:
```bash
npm install livekit-client
```
```javascript
import { Room, RoomEvent, Track } from "livekit-client";
const session = await fetch("https://api.callmissed.com/v1/voice/sessions", {
method: "POST",
headers: {
"Authorization": "Bearer cm_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
system_prompt: "You are a helpful assistant.",
voice: "shubh",
language: "en-IN",
llm_model: "kimi-k2.5",
}),
}).then(r => r.json());
const room = new Room();
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
document.body.appendChild(track.attach());
}
});
room.on(RoomEvent.TranscriptionReceived, (segments, participant) => {
for (const seg of segments) {
if (!seg.final) continue;
const who = participant?.isLocal ? "You" : "Agent";
console.log(who + ": " + seg.text);
}
});
await room.connect(session.ws_url, session.token);
await room.localParticipant.setMicrophoneEnabled(true);
```
The agent joins the room, greets the user, listens for speech, and responds. Server-side VAD detects speech boundaries; interruptions are handled automatically (speak while the agent is talking and it stops and listens).
## List Sessions
```bash
curl "https://api.callmissed.com/v1/voice/sessions?status=completed&limit=50&offset=0" \
-H "Authorization: Bearer cm_your_api_key"
```
**Query parameters:**
| Param | Values | Default |
|-------|--------|---------|
| `status` | `created` / `active` / `completed` / `failed` / `timeout` | — |
| `limit` | 1–200 | 50 |
| `offset` | ≥ 0 | 0 |
Returns an array of `VoiceSessionOut` objects (same shape as Get Session).
## Get Session
```bash
curl https://api.callmissed.com/v1/voice/sessions/{id} \
-H "Authorization: Bearer cm_your_api_key"
```
Response contains everything from the create response **except** `ws_url` and `token` — those are issued once at creation.
## Get Transcript
```bash
curl "https://api.callmissed.com/v1/voice/sessions/{id}/transcript?format=json" \
-H "Authorization: Bearer cm_your_api_key"
```
**`format` query param** — defaults to `json`:
| Format | Content-Type | Shape |
|--------|--------------|-------|
| `json` | application/json | Array of turns: `turn_index`, `user_transcript`, `agent_response`, `interrupted`, `stt_ms`, `first_token_ms`, `first_audio_ms`, `total_ms`, `llm_model`, `created_at` |
| `txt` | text/plain | Human-readable alternating `User:` / `Agent:` lines |
| `srt` | application/x-subrip | SubRip subtitles with timing derived from per-turn durations |
## Delete Session
```bash
curl -X DELETE https://api.callmissed.com/v1/voice/sessions/{id} \
-H "Authorization: Bearer cm_your_api_key"
```
Returns `204 No Content`. Sessions in `created` or `active` state are marked `completed` with `end_reason = "api_delete"`; already-finished sessions are left unchanged.
## Limits
| Limit | Value | Behavior on exceed |
|-------|-------|--------------------|
| Session create rate | 10 / minute / tenant | HTTP 429 |
| Concurrent active sessions (free) | 1 | HTTP 429 |
| Concurrent active sessions (starter) | 5 | HTTP 429 |
| Concurrent active sessions (pro) | 20 | HTTP 429 |
| Concurrent active sessions (enterprise) | unlimited | — |
| Minimum credit balance to create | server-configured | HTTP 402 |
| Max session duration | 3600s (capped by `max_duration_seconds`) | session auto-ends |
| Connection token TTL | 3600s (1 hour) | reconnect requires a new session |
## Webhook Events
If `webhook_url` is set on session creation, the following events are delivered as `POST` requests with JSON body and HMAC-SHA256 signature:
| Event | When |
|-------|------|
| `voice_session.started` | Session created (token issued) |
| `voice_session.ended` | Session marked completed (normal finish or `DELETE`) |
| `voice_session.failed` | Session entered failed state |
**Delivery headers:**
| Header | Value |
|--------|-------|
| `X-CallMissed-Event` | Event name (e.g. `voice_session.started`) |
| `X-CallMissed-Delivery` | Delivery UUID (unique per attempt batch) |
| `X-CallMissed-Signature` | `sha256=` HMAC of the raw body using your webhook secret |
**Verify the signature:**
```python
import hmac, hashlib
raw_body = await request.body() # bytes — do not re-serialize
received = request.headers["X-CallMissed-Signature"] # e.g. "sha256=abc123..."
expected = "sha256=" + hmac.new(
webhook_secret.encode(), raw_body, hashlib.sha256
).hexdigest()
assert hmac.compare_digest(expected, received)
```
```javascript
import crypto from "node:crypto";
const received = req.headers["x-callmissed-signature"]; // "sha256=..."
const expected = "sha256=" + crypto
.createHmac("sha256", webhookSecret)
.update(rawBody) // raw Buffer / string
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).send("invalid signature");
}
```
---
### Agent Squads
URL: /docs/voice-squads
> Group specialist voice agents behind one entry point, control handoffs with a policy, dry-run the routing decision, and draft a new agent from a description.
## Overview
A **squad** is several specialist voice agents behind one entry point. The entry agent answers, and hands off to a member when the caller's need matches that member's role. A **handoff policy** bounds how far that can go, so a call cannot bounce between agents forever.
Two extras sit alongside the roster:
- `POST /{squad_id}/simulate-handoff` — a pure dry run that shows which member *would* be picked and why.
- `POST /author/draft` — describe an agent in prose and get a complete configuration proposal back. **This one costs credits.**
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List/get squads, list members, **simulate a handoff** | `squads:read` |
| Create/update/delete squads and members, **draft an agent** | `squads:write` |
## Limits
| Thing | Limit |
| --- | --- |
| Members per squad | **12**. Past that, use a call flow |
| Handoffs per call | `0..5`, default `3` |
| Role name | 64 characters |
| Member description | 2,000 characters |
---
## Squads
```json
{
"id": "sq10…",
"tenant_id": "a0b1…",
"name": "Support desk",
"description": "Front line plus billing and technical specialists.",
"entry_bot_id": "b1f2…",
"handoff_policy": {
"max_handoffs": 3,
"allow_return_to_previous": false,
"min_score": 1,
"fallback_role": "generalist"
},
"is_active": true,
"created_at": "2026-08-11T09:00:00Z",
"updated_at": "2026-08-11T09:00:00Z",
"members": []
}
```
### Handoff policy
| Field | Type | Default | Constraints |
| --- | --- | --- | --- |
| `max_handoffs` | `integer` | `3` | `0 <= n <= 5`. `0` disables handoffs entirely |
| `allow_return_to_previous` | `boolean` | `false` | Leaving this `false` is what stops two agents ping-ponging a caller |
| `min_score` | `integer` | `1` | `1 <= n <= 20`. The match strength a member must reach to be handed to |
| `fallback_role` | `string \| null` | `null` | Role to use when nothing scores high enough |
Unknown keys inside the policy are rejected with `422`.
### GET `/api/v1/voice/squads`
Newest first. Filter by `is_active`. `limit` `1..200` (default `50`), `offset` `0..100000`.
### POST `/api/v1/voice/squads`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters, unique per tenant |
| `description` | `string` | No | At most 500 characters |
| `entry_bot_id` | `UUID` | Yes | The agent that answers the call |
| `entry_role` | `string` | No | 1–64 characters, default `entry` |
| `entry_description` | `string` | No | At most 2,000 characters |
| `handoff_policy` | `object` | No | Omit for the defaults above |
| `is_active` | `boolean` | No | Default `true` |
Creating a squad **automatically enrols the entry agent as the first member** at `position: 0` — you do not add it yourself.
Returns `201` with the squad and its roster.
### GET / PATCH / DELETE `/api/v1/voice/squads/{squad_id}`
`PATCH` takes `name`, `description`, `entry_bot_id`, `handoff_policy` (an explicit `null` clears it back to defaults) and `is_active`.
`entry_bot_id` must point at an agent **already in the squad** — `422 entry_bot_id must be a bot that is already a member of this squad`. Add the member first, then promote it.
`DELETE` returns `204` and cascades the roster.
---
## Members
```json
{
"id": "mb20…",
"tenant_id": "a0b1…",
"squad_id": "sq10…",
"bot_id": "b7c8…",
"role": "billing",
"description": "Handles invoices, refunds and payment failures.",
"position": 1,
"created_at": "2026-08-11T09:02:00Z"
}
```
`role` and `description` are what the routing engine matches a caller's utterance against — write the description as the things this agent handles, in the caller's words.
### GET `/api/v1/voice/squads/{squad_id}/members`
Ordered by `position`, then oldest first. **No pagination** — the 12-member cap bounds it.
### POST `/api/v1/voice/squads/{squad_id}/members`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `bot_id` | `UUID` | Yes | Must be your agent, not already in this squad |
| `role` | `string` | Yes | 1–64 characters, not blank |
| `description` | `string` | No | At most 2,000 characters |
| `position` | `integer` | No | `0 <= position <= 1000`, default `0` |
`422 A squad holds at most 12 agents. Past that, use a call flow.` · `409 That agent is already in this squad`.
### PATCH / DELETE `/api/v1/voice/squads/members/{member_id}`
`PATCH` takes `role`, `description` (explicit `null` clears it) and `position`. `bot_id` is not editable — remove the member and add the other agent.
Removing the entry agent returns `409 This agent answers the call for the squad. Point entry_bot_id at another member before removing it.`
---
## Simulating a handoff
### POST `/api/v1/voice/squads/{squad_id}/simulate-handoff`
Requires `squads:read`. Pure and read-only: no model call, no credits, nothing written.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `utterance` | `string` | Yes | 1–2,000 characters |
| `handoffs_used` | `integer` | No | `0 <= n <= 100`, default `0` |
| `current_member_id` | `UUID` | No | Who is handling the call now |
| `previous_member_id` | `UUID` | No | Who handled it before — used for the ping-pong check |
```bash
curl -X POST https://api.callmissed.com/api/v1/voice/squads/sq10…/simulate-handoff \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "utterance": "my card was charged twice", "handoffs_used": 1, "current_member_id": "mb01…" }'
```
```json
{
"squad_id": "sq10…",
"handoff": true,
"target_member_id": "mb20…",
"target_bot_id": "b7c8…",
"target_role": "billing",
"reason": "matched billing on 'charged'",
"blocked_by": null,
"score": 3,
"handoffs_used": 1,
"max_handoffs": 3,
"scores": [
{ "member_id": "mb20…", "role": "billing", "score": 3, "eligible": true },
{ "member_id": "mb30…", "role": "technical", "score": 0, "eligible": true }
]
}
```
`scores` shows every member's match strength, so a wrong route is debuggable: if the right agent scored `0`, its description is missing the words callers actually use.
### Why a handoff was blocked
| `blocked_by` | Meaning |
| --- | --- |
| `no_members` | The squad has no one to hand to |
| `max_handoffs` | The policy's handoff budget is spent |
| `ping_pong` | The target is the previous member and returns are disallowed |
| `already_current` | The best match is already handling the call |
| `no_match` | Nothing reached `min_score` |
| `unknown_role` | The configured fallback role matches no member |
---
## Drafting an agent
### POST `/api/v1/voice/squads/author/draft`
Requires `squads:write`. **Charges credits.** It creates nothing — you get a proposal to review and then submit yourself via the bots API.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `description` | `string` | Yes | 20–4,000 characters, not blank |
| `bot_type` | `string` | No | `inbound_call` (default), `outbound_call`, `ivr`, `whatsapp` or `whatsapp_voice` |
```bash
curl -X POST https://api.callmissed.com/api/v1/voice/squads/author/draft \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"description": "An agent for a Pune dental clinic that books, moves and cancels appointments in Hindi and English, and escalates anything about pain to a human.",
"bot_type": "inbound_call"
}'
```
```json
{
"draft": {
"name": "Clinic Reception",
"bot_type": "inbound_call",
"system_prompt": "You are the receptionist for a dental clinic in Pune…",
"objective": "Book, reschedule or cancel appointments; escalate pain reports.",
"response_guidelines": "Keep replies under two sentences…",
"conversation_script": "",
"first_message": "Namaste, thanks for calling. How can I help?",
"tools": ["book_appointment", "cancel_appointment", "handoff_to_human"],
"voice_model": "…",
"tts_model": "…",
"stt_model": "…",
"voice": "anushka",
"language": "hi-IN"
},
"dropped_tools": ["send_invoice"],
"model": "…"
}
```
`dropped_tools` lists tools the draft asked for that are not in your tool registry — they were removed so the configuration is valid on submission. Check this list: a dropped tool usually means the capability you described is not wired up yet.
At most 12 tools are proposed, de-duplicated and validated against the registry.
| Status | Detail | Note |
| --- | --- | --- |
| `402` | `Not enough credits to draft an agent. Top up to continue.` | Checked **before** any model runs — costs nothing |
| `422` | `Could not draft an agent: …` | The model returned an unusable draft. **This attempt is still billed** — the work was done |
| `502` | `The agent drafting service is unavailable.` | Retry |
Cost appears in [usage logs](/docs/usage-api) as `service: "llm"`.
## Errors
| Status | When |
| --- | --- |
| `402` | Credit balance exhausted before drafting |
| `403` | Key is missing `squads:read` / `squads:write` |
| `404` | Squad, member or agent not in your tenant |
| `409` | Duplicate squad or role name, agent already a member, or removing the entry agent |
| `422` | Over 12 members, a blank name/role, an unknown key in `handoff_policy`, or an `entry_bot_id` that is not a member |
---
### Migrate from Meta Cloud API
URL: /docs/migrate-from-meta
> Point an existing WhatsApp Cloud API integration at CallMissed by changing only the host and the token. Same path, same request bodies, same response and error envelopes.
## Overview
If you already send WhatsApp messages through Meta's Cloud API (directly, or through a BSP that mirrors it), you can move to CallMissed by changing **two things**: the **host** and the **token**. The path, the request bodies, the success envelope and the error envelope are Meta's own — your existing code keeps working.
```diff
- https://graph.facebook.com/v25.0/{phone-number-id}/messages
+ https://api.callmissed.com/api/v1/whatsapp/{phone-number-id}/messages
- Authorization: Bearer EAAG... # Meta access token
+ Authorization: Bearer cm_your_api_key # CallMissed API key
```
Your **phone number ID** stays in the path. CallMissed maps it to your registered number and scopes everything to your tenant. API-key callers need the `whatsapp:send` scope.
> **This is a compatibility surface, not a separate product.** Everything that is a *policy* rather than a wire format — tenant isolation, credit pre-flight, template category resolution, error mapping — is the same code path as the native WhatsApp API, so behaviour and billing are identical. New integrations can use the [native WhatsApp API](/docs/whatsapp-api); this page is for moving an existing Cloud API codebase with minimal edits.
## Send a message
```
POST /api/v1/whatsapp/{phone-number-id}/messages
Authorization: Bearer cm_your_api_key
Content-Type: application/json
```
The request body is Meta's, verbatim. `messaging_product` must be `"whatsapp"`. Supported `type` values: `text`, `template`, `image`, `audio`, `video`, `document`, `sticker`, `interactive`, `location`, `contacts`, `reaction`.
:::tabs
```bash [cURL]
curl -X POST \
https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "919876543210",
"type": "text",
"text": { "body": "Hello from CallMissed" }
}'
```
```python [Python]
import httpx
httpx.post(
"https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages",
headers={"Authorization": "Bearer cm_your_api_key"},
json={
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "919876543210",
"type": "text",
"text": {"body": "Hello from CallMissed"},
},
)
```
```javascript [Node.js]
await fetch(
"https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages",
{
method: "POST",
headers: {
Authorization: "Bearer cm_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
recipient_type: "individual",
to: "919876543210",
type: "text",
text: { body: "Hello from CallMissed" },
}),
},
);
```
:::
### Success response
Meta's success shape, unchanged:
```json
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "919876543210", "wa_id": "919876543210" }],
"messages": [{ "id": "wamid.HBgL..." }]
}
```
## Two deliberate differences
Everything matches Meta except these two, and both exist so a Meta-written client keeps working correctly:
1. **A text body over 4096 characters is rejected, not split.** The native CallMissed endpoint splits a long body across several messages and returns every id in `wamids`. Meta hard-rejects instead, and a Meta-written client has no `wamids` field to read — so this surface matches Meta's rejection with error code **`100`** and the message *"Param text.body must be at most 4096 characters long."*
2. **Meta's numeric error code is preserved.** A migrated integration branches on `error.code` (e.g. `131047` → fall back to a template, `130429` → back off). So this surface returns the real code — exactly what Meta would have told you.
## Error envelope
Errors use Meta's shape (not CallMissed's usual `{"detail": "..."}`), and the HTTP status matches Meta:
```json
{
"error": {
"message": "(#131047) Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"type": "OAuthException",
"code": 131047,
"error_data": {
"messaging_product": "whatsapp",
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number."
},
"fbtrace_id": "A1b2C3..."
}
}
```
The 24-hour-window error (`131047`) comes back as a real code and as HTTP `400`, so your existing "send a template instead" branch fires unchanged.
## What is unchanged on your side
- **Your seven existing `/messages/*` calls, if any, still work** and still return CallMissed's `{"detail": "..."}` shape. This compat surface is additive — it does not replace them.
- **Inbound messages and delivery statuses** still arrive through your [webhook subscriptions](/docs/whatsapp-api). This page covers sending; receiving is unchanged.
- **Templates, media, interactive, location, contacts and reactions** all take Meta's payloads for those types.
## When to use the native API instead
If you are building fresh, the [native WhatsApp API](/docs/whatsapp-api) and [Sending Messages](/docs/whatsapp-messages) give you CallMissed's own richer response shape and helpers. The Meta-compat surface exists purely to make an *existing* Cloud API integration a two-line migration.
---
### WhatsApp API
URL: /docs/whatsapp-api
> Base path, authentication scopes, error shapes, connected accounts and numbers, inbound webhook events, and delivery analytics.
The WhatsApp API is the programmatic surface for a connected **WhatsApp Business Account (WABA)**. This page covers the parts every other WhatsApp page depends on: authentication, how you pick a sending number, what an error looks like, how to read your connected accounts and numbers, what CallMissed pushes to your own webhook, and the delivery analytics.
**Base URL:** `https://api.callmissed.com`
**Base path:** `/api/v1/whatsapp`
| Area | Page |
|---|---|
| Connect a WABA and register a number | [Business Setup](/docs/whatsapp-setup) |
| Send text, template, media, interactive, location, reaction, contacts | [Sending Messages](/docs/whatsapp-messages) |
| Create, list, delete and sync templates | [Message Templates](/docs/whatsapp-templates) |
| Bulk template sends | [Campaigns](/docs/whatsapp-campaigns) |
| Voice calls over WhatsApp | [Calling](/docs/whatsapp-calling) |
## Authentication
Every endpoint accepts either a `cm_` API key or a dashboard JWT:
```
Authorization: Bearer cm_your_api_key
```
API keys are checked against three WhatsApp scopes. A JWT session is authorized by role instead, so scopes do not apply to it.
| Scope | Grants |
|---|---|
| `whatsapp:read` | List accounts, numbers, templates, campaigns, calls, webhook events, analytics; resolve and download media |
| `whatsapp:write` | Onboard and manage numbers, link bots, template create/delete/sync, campaign create/launch/cancel, upload media, calling settings |
| `whatsapp:send` | Send any message type, mark as read, request call permission, place and terminate calls |
A key without the scope gets `403`:
```json
{
"detail": "API key missing required scope: whatsapp:send. Add it under the key's 'Permissions' section in your dashboard."
}
```
Add scopes when you create the key. See [API Keys](/docs/keys).
## Choosing the sending number
Every endpoint that acts on a specific number needs to know which one. Supply **exactly one** of these. They are interchangeable, and both are always tenant-scoped, so you can only ever act on a number your workspace owns.
| Field | Type | Where it comes from |
|---|---|---|
| `phone_id` | UUID | The `id` field from `GET /phone_numbers` (CallMissed's id) |
| `phone_number_id` | string, max 64 | Meta's `phone_number_id` for the same number |
On send endpoints they go in the JSON body. On `GET` endpoints they are query parameters. On `POST /media` they are multipart form fields.
Omitting both returns `400`:
```json
{ "detail": "Either phone_id (UUID) or phone_number_id (Meta) is required" }
```
## Error shape
Every error is a single JSON object with a `detail` string:
```json
{ "detail": "The 24-hour customer service window is closed. Send a template message instead, or wait for the user to message you." }
```
The one exception is request-body validation, where `detail` is an array of field-level errors instead of a string:
```json
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "to"],
"msg": "String should have at least 5 characters",
"input": "+91"
}
]
}
```
Upstream WhatsApp errors are never echoed verbatim. They are mapped to a short, actionable message and a status code that tells you whether to retry.
### Platform errors
These come from CallMissed before any WhatsApp call is made.
| Code | Meaning | Fix |
|---|---|---|
| `400` | Neither `phone_id` nor `phone_number_id` supplied, or a variant-specific field is missing | Add the missing field |
| `401` | Missing, malformed or expired credentials | Check the `Authorization` header |
| `402` | Not enough credits to pay for the send or campaign, or a workspace budget cap would be exceeded. Nothing was sent and nothing was charged | Top up, or raise the cap |
| `403` | API key is missing the required WhatsApp scope, or the action needs an owner or admin login | Add the scope, or sign in as an owner or admin |
| `404` | The number, template, campaign or call does not exist on your workspace | Verify the id |
| `409` | The number is disconnected, or has no stored access token | Reconnect the number |
| `422` | Request body failed validation | Read the `loc` path in `detail` |
| `429` | Rate limit exceeded. Media upload is limited more tightly than other calls | Back off and retry |
| `503` | Stored credentials could not be decrypted on this server | Contact support |
A `404` is deliberately identical whether the resource does not exist or belongs to another workspace, so the API cannot be used to probe for ids.
### Running out of credits
Sends, campaign launches and outbound calls are checked against your balance **before** WhatsApp is called, because the actual charge lands after delivery and there is nothing to un-send. The refusal is a `402` that tells you the shortfall:
```json
{
"detail": "Not enough credits to send this message, so nothing was sent and nothing was charged. It needs at least 7.51 credits and you have 2.00 spendable (balance 12.00, 10.00 held for running campaigns) -- short by 5.51. Top up your balance and try again."
}
```
A separate `402` covers a self-imposed monthly budget cap, where the fix is raising the cap rather than topping up. Credits held for a running campaign or an in-flight call are reserved, not spent, and are released when the work settles.
### WhatsApp errors
Errors returned by Meta are translated. These are the mappings you will actually hit:
| Code | When | Retryable |
|---|---|---|
| `400` | Media MIME type does not match the file, or the request was rejected outright | No, fix the payload |
| `401` | The number's Meta access token is invalid or expired | No, reconnect the number |
| `403` | The app lacks advanced access for this WABA | No, contact support |
| `409` | Number is not registered on the WhatsApp Business Platform, was recently deleted, or calling is not enabled on it | No, finish the setup step named in `detail` |
| `413` | Media file exceeds 100 MB | No, shrink the file |
| `422` | 24-hour window closed, display name not yet approved by Meta, no call permission from the user, or a generic WhatsApp rejection | No, follow the instruction in `detail` |
| `429` | Per-user-pair send rate limit, or too many registration attempts in a short window | Yes, with backoff |
| `502` | WhatsApp returned a server error | Yes |
| `503` | The WhatsApp integration is not configured on this server | No, contact support |
## Accounts
A WABA is the Meta-side container for your numbers and templates.
### List accounts
`GET /api/v1/whatsapp/accounts` · scope `whatsapp:read`
No parameters. Returns every WABA on your workspace, newest first.
```bash
curl https://api.callmissed.com/api/v1/whatsapp/accounts \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"business_id": "441329482726",
"name": "Acme Coffee",
"currency": "INR",
"review_status": "APPROVED",
"account_status": "ACTIVE",
"account_restriction_reason": null,
"payment_setup_complete": true,
"is_active": true,
"created_at": "2026-04-19T12:00:00Z"
}
]
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | CallMissed's account id, used as `account_id` on template endpoints |
| `waba_id` | string | Meta's WABA id |
| `business_id` | string, nullable | Meta business id |
| `name` | string, nullable | WABA display name, filled in from Meta |
| `currency` | string, nullable | ISO 4217, the currency Meta bills the WABA in |
| `review_status` | string | Meta's business verification state |
| `account_status` | string | `ACTIVE` while healthy |
| `account_restriction_reason` | string, nullable | Set when Meta restricts the account, null otherwise |
| `payment_setup_complete` | boolean | `false` means Meta has no payment method on the WABA and sends will fail |
| `is_active` | boolean | Whether the account is live on your workspace |
| `created_at` | datetime | ISO 8601 UTC |
### Delete an account
`DELETE /api/v1/whatsapp/accounts/{account_id}` · scope `whatsapp:write`
Permanently removes the WABA and everything under it: phone numbers, templates, campaigns and call records all cascade. Conversation history is preserved. CallMissed first tries to deregister each number and unsubscribe from the WABA's webhooks upstream, then deletes locally whether or not that succeeded.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/accounts/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{ "ok": true, "error": null }
```
`error` is a short string when the upstream cleanup partly failed. The local delete still happened.
## Phone numbers
### List numbers
`GET /api/v1/whatsapp/phone_numbers` · scope `whatsapp:read`
No parameters. Newest first.
```bash
curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"phone_number_id": "1234567890",
"display_phone_number": "+91 80802 47309",
"verified_name": "Acme Coffee",
"code_verification_status": "VERIFIED",
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"throughput_level": "STANDARD",
"registration_status": "REGISTERED",
"registration_error": null,
"name_status": "APPROVED",
"ai_autoreply_enabled": true,
"is_active": true,
"created_at": "2026-04-19T12:00:00Z"
}
]
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | Use as `phone_id` anywhere a sending number is needed |
| `account_id` | UUID | The owning WABA |
| `bot_id` | UUID, nullable | The linked agent. `null` means no auto-reply |
| `phone_number_id` | string | Meta's id for the number |
| `display_phone_number` | string | Human-readable number |
| `verified_name` | string, nullable | The business name shown to customers |
| `code_verification_status` | string | Meta's number verification state |
| `quality_rating` | string | `GREEN`, `YELLOW`, `RED` or `UNKNOWN` |
| `messaging_limit_tier` | string | Meta's 24-hour send cap tier, for example `TIER_1K` |
| `throughput_level` | string | Meta's throughput class, `STANDARD` by default |
| `registration_status` | string | `PENDING`, `REGISTERED`, `FAILED` or `DEREGISTERED` |
| `registration_error` | string, nullable | Why registration failed |
| `name_status` | string | Display-name approval. `APPROVED` or `AVAILABLE_WITHOUT_REVIEW` means sends work. Anything else blocks free-form sends |
| `ai_autoreply_enabled` | boolean | Master AI auto-reply switch for the number |
| `is_active` | boolean | `false` after a disconnect |
| `created_at` | datetime | ISO 8601 UTC |
### Get one number
`GET /api/v1/whatsapp/phone_numbers/{phone_id}` · scope `whatsapp:read`
Same object as a list element. `404` if the number is not on your workspace.
```bash
curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d \
-H "Authorization: Bearer cm_your_api_key"
```
### Refresh metadata from Meta
`POST /api/v1/whatsapp/phone_numbers/{phone_id}/refresh` · scope `whatsapp:write`
No body. Re-pulls live values from Meta and updates `display_phone_number`, `verified_name`, `code_verification_status`, `quality_rating`, `messaging_limit_tier`, `throughput_level` and `name_status`. Use it when a freshly onboarded number is still missing its display number, or after Meta approves a display name or raises your tier.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/refresh \
-H "Authorization: Bearer cm_your_api_key"
```
Returns the updated phone-number object. `400` if the number has no stored token (reconnect it), `422` if Meta rejected the read, `502` if Meta failed.
### Link or unlink a bot
`POST /api/v1/whatsapp/phone_numbers/{phone_id}/link-bot` · scope `whatsapp:write`
This is what turns a number into an AI agent. Without a link the number stores inbound messages and never replies.
| Field | Type | Required | Notes |
|---|---|---|---|
| `bot_id` | UUID or null | Yes | The bot to link. Pass `null` to unlink |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }'
```
Returns the updated phone-number object. `404` if the bot does not belong to your workspace.
### Pause or resume auto-reply
`POST /api/v1/whatsapp/phone_numbers/{phone_id}/autoreply` · scope `whatsapp:write`
Master switch for the whole number. Set `false` and the agent stays silent on every conversation on that number until you set it back. Messages are still received and stored.
| Field | Type | Required | Notes |
|---|---|---|---|
| `enabled` | boolean | Yes | `false` pauses the agent for this number |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/autoreply \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'
```
Returns the updated phone-number object.
### Conversational automation
In-chat guidance on a number: **ice breakers**, the tappable openers a customer sees before they have said anything, and **commands**, the slash-command hints that appear while they type. Both are per number.
`GET /api/v1/whatsapp/phone_numbers/{phone_id}/conversational_automation` · scope `whatsapp:read`
```bash
curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/conversational_automation \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"enable_welcome_message": true,
"commands": [
{ "command_name": "track", "command_description": "Track your latest order" },
{ "command_name": "invoice", "command_description": "Get an invoice by order id" }
],
"prompts": ["Track my order", "Talk to a human", "Store timings"]
}
```
A number that has never been configured returns empty lists rather than an error.
`POST /api/v1/whatsapp/phone_numbers/{phone_id}/conversational_automation` · scope `whatsapp:write`
| Field | Type | Required | Notes |
|---|---|---|---|
| `commands` | array of objects, max 30 | No | Each is `{ "command_name": string (1-32), "command_description": string (1-256) }` |
| `prompts` | array of strings, max 4 | No | The ice breakers. Each max 80 characters |
| `enable_welcome_message` | boolean | No | Show a welcome message on a brand-new chat. Forwarded to WhatsApp only when you set it explicitly, and never defaulted |
Every field is optional and updates are partial, so posting only `commands` leaves your ice breakers untouched, and vice versa. A field you do send replaces the whole list, so send the full set you want, not just the additions.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/conversational_automation \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"prompts": ["Track my order", "Talk to a human", "Store timings"],
"commands": [
{ "command_name": "track", "command_description": "Track your latest order" },
{ "command_name": "invoice", "command_description": "Get an invoice by order id" }
],
"enable_welcome_message": true
}'
```
Returns the configuration as WhatsApp reports it after the update, in the same shape as the read. `422` when a cap is exceeded: more than 30 commands, more than 4 ice breakers, an ice breaker over 80 characters, a `command_name` over 32, or a `command_description` over 256.
A tapped ice breaker or command arrives as an ordinary inbound text message, so your agent answers it with no extra wiring. Point the copy at things the agent can actually do.
### Disconnect a number
`POST /api/v1/whatsapp/phone_numbers/{phone_id}/disconnect` · scope `whatsapp:write`
No body. Reversible teardown: CallMissed tries to deregister the number and unsubscribe from the WABA's webhooks, then always marks the local row `is_active: false` with `registration_status: "DEREGISTERED"`, even if the upstream calls failed. Reconnect later by onboarding the number again.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/disconnect \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{ "ok": true, "error": null }
```
### Delete a number
`DELETE /api/v1/whatsapp/phone_numbers/{phone_id}` · scope `whatsapp:write`
Permanent, unlike disconnect. Best-effort deregister upstream first, then the row is removed regardless. Campaigns and call records that reference the number cascade. Conversation history is preserved.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{ "ok": true, "error": null }
```
## Raw webhook events
`GET /api/v1/whatsapp/webhook_events` · scope `whatsapp:read`
An audit peek at the raw events Meta delivered for your WABAs, newest first. Useful for debugging "did that message actually arrive". This is **not** the way to consume inbound messages, see [Inbound events you receive](#inbound-events-you-receive) for that.
| Param | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer, 1 to 100 | 20 | Max rows |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/webhook_events?limit=20" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"id": "b7d4e2f1-0a3c-4d5e-8f6a-9b0c1d2e3f4a",
"received_at": "2026-04-19T12:04:11Z",
"signature_valid": true,
"event_type": "messages",
"waba_id": "102290129340398",
"processed": true,
"process_error": null,
"sender_wa_id": "919000000000",
"body_preview": "Where is my order AC-10294?"
}
]
```
Full raw payloads are deliberately not exposed here, because they carry customer message bodies and phone numbers. `event_type` mirrors Meta's webhook field name, for example `messages`, `message_template_status_update`, `account_update`, `phone_number_quality_update` or `calls`.
## The Meta-facing webhook
Meta delivers every event for your WABA to one CallMissed endpoint:
```
https://api.callmissed.com/api/v1/webhooks/whatsapp
```
**You do not configure this.** Connecting a number subscribes the CallMissed app to your WABA's webhooks, and this URL is already registered on Meta's side for every live WABA. It is documented here so you recognise it in Meta's dashboard, not because you need to set it.
`GET` is Meta's one-time verification handshake. It echoes `hub.challenge` as a plain-text body when the verify token matches, and returns `403` otherwise.
`POST` is the event receiver. Every request is authenticated by `X-Hub-Signature-256`, an HMAC-SHA256 of the raw body keyed on the app secret. Verification is unconditional: an unsigned or mis-signed request is archived for audit and then rejected with `403`, because a forged `delivered` status would otherwise drive billing. A valid request is archived, acknowledged immediately, and processed in the background, so a slow model never causes Meta to retry.
```json
{ "status": "ok" }
```
The bodies Meta posts here are its own webhook payloads (`messages`, `statuses`, `message_template_status_update`, `account_update`, `phone_number_quality_update`, `calls` and so on). You read what arrived through [`GET /webhook_events`](#raw-webhook-events), and you consume the messages themselves through your own subscription below.
## Inbound events you receive
You never poll for inbound messages. Register an HTTPS endpoint and CallMissed pushes to it.
### Subscribe
```bash
curl -X POST https://api.callmissed.com/api/v1/webhooks \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/callmissed",
"events": ["message.received"]
}'
```
`message.received` is the event the WhatsApp channel emits. See [Webhooks](/docs/webhooks) for the full catalogue of event types across the platform, delivery retries and replay.
### The request you receive
```
POST /hooks/callmissed HTTP/1.1
Content-Type: application/json
X-CallMissed-Event: message.received
X-CallMissed-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-CallMissed-Delivery: 4c8d2e10-6b7a-4f3d-9e21-0a5b6c7d8e9f
```
```json
{
"event": "message.received",
"data": {
"conversation_id": "2f6c9a11-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"channel": "whatsapp",
"from": "919000000000",
"message_id": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=",
"type": "text",
"text": "Where is my order AC-10294?"
},
"timestamp": "2026-04-19T12:04:11.512340+00:00"
}
```
| Field | Type | Notes |
|---|---|---|
| `event` | string | Always `message.received` for this subscription |
| `timestamp` | string | ISO 8601 UTC, when the event was dispatched |
| `data.conversation_id` | UUID | The CallMissed conversation thread |
| `data.bot_id` | UUID | The bot that owns the conversation |
| `data.channel` | string | `whatsapp` |
| `data.from` | string | The customer's WhatsApp id, digits only, no `+` |
| `data.message_id` | string | Meta's `wamid` for the inbound message |
| `data.type` | string | WhatsApp message type, for example `text`, `image`, `audio`, `interactive`, `button`, `location` |
| `data.text` | string, nullable | Body text. Null for non-text types |
### Verify the signature
`X-CallMissed-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the **raw request body**, keyed with the webhook's secret. Compare in constant time and reject a mismatch.
```python
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", header or "")
```
```javascript
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return (
header?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))
);
}
```
Return `2xx` quickly. Do your own work after acknowledging.
## Analytics
Three read-only aggregations over the last N days. All require `whatsapp:read`, and `days` is bounded to 1 to 90.
### Delivery funnel
`GET /api/v1/whatsapp/analytics/funnel` · scope `whatsapp:read`
| Param | Type | Default | Notes |
|---|---|---|---|
| `days` | integer, 1 to 90 | 7 | Window size |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/analytics/funnel?days=7" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"days": 7,
"inbound": 412,
"outbound": 508,
"delivered": 341,
"read": 146,
"replied": 96,
"delivery_rate": 0.671,
"read_rate": 0.428
}
```
| Field | Type | Notes |
|---|---|---|
| `days` | integer | Echoes the window |
| `inbound` | integer | Inbound message events from customers |
| `outbound` | integer | Messages you sent: conversation replies plus campaign sends |
| `delivered` | integer | Real delivered acks, from message status plus campaign delivery counters |
| `read` | integer | Real read acks, from the same two sources |
| `replied` | integer | Conversations you sent at least one agent reply in during the window |
| `delivery_rate` | float | `delivered / outbound`, 3 decimals, clamped to `[0, 1]` |
| `read_rate` | float | `read / delivered`, 3 decimals, clamped to `[0, 1]` |
The counts come from real delivery data, not from a ratio. Where a figure genuinely cannot be sourced it stays `0` rather than being estimated. If no WABA is connected, every counter is `0`.
### Event time series
`GET /api/v1/whatsapp/analytics/timeseries` · scope `whatsapp:read`
One row per day and event type, ready to stack in a chart.
| Param | Type | Default | Notes |
|---|---|---|---|
| `days` | integer, 1 to 90 | 14 | Window size |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/analytics/timeseries?days=14" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"days": 14,
"points": [
{ "date": "2026-04-18", "event_type": "messages", "count": 63 },
{ "date": "2026-04-18", "event_type": "message_template_status_update", "count": 2 },
{ "date": "2026-04-19", "event_type": "messages", "count": 71 }
]
}
```
| Field | Type | Notes |
|---|---|---|
| `points[].date` | string | `YYYY-MM-DD` in UTC |
| `points[].event_type` | string | Meta webhook field name, or `unknown` |
| `points[].count` | integer | Events that day |
### Cost breakdown
`GET /api/v1/whatsapp/analytics/costs` · scope `whatsapp:read`
| Param | Type | Default | Notes |
|---|---|---|---|
| `days` | integer, 1 to 90 | 30 | Window size |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/analytics/costs?days=30" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"days": 30,
"llm_tokens": 1284300,
"llm_credits": 998.4412,
"whatsapp_message_credits": 512.0,
"whatsapp_call_credits": 43.75,
"total_credits": 1554.1912,
"ledger_source": "wa_usage_events"
}
```
| Field | Type | Notes |
|---|---|---|
| `days` | integer | Echoes the window |
| `llm_tokens` | integer | Tokens the agent consumed. Context only, it does not drive any credit figure |
| `llm_credits` | float | Real LLM spend, from the usage ledger |
| `whatsapp_message_credits` | float | Real WhatsApp message spend, priced per delivered message |
| `whatsapp_call_credits` | float | Real WhatsApp call spend |
| `total_credits` | float | Sum of the three credit figures |
| `ledger_source` | string | How the figures were sourced, so you can tell a per-event ledger match from an aggregate |
Every credit figure here traces to a per-event billing record, so it reconciles with what was actually deducted. For the wallet balance and the platform-wide usage feed, see [Credits & Rate Limits](/docs/credits-rate-limits).
---
### Calling
URL: /docs/whatsapp-calling
> Voice calls over WhatsApp: enable calling, request permission, place a business-initiated call answered by your agent, and read call logs with transcripts.
WhatsApp Calling lets a customer call your business number, and lets you call them, over WhatsApp itself rather than the phone network. Calls are answered by the same agent that handles the number's messages, with the same voice, model and system prompt, so a call produces a transcript and per-turn AI cost exactly like any other voice session.
All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`.
## Before you can call
Three things have to be true, and each has its own failure code:
1. **Calling is enabled on the number.** Turn it on with [call settings](#call-settings), or in WhatsApp Manager. Otherwise sends fail with `409` telling you calling is not enabled.
2. **The number's messaging limit is 2000 or above.** WhatsApp requires it. Below that you get `409`.
3. **The customer granted call permission.** Inbound calls need nothing, but a business-initiated call is permission-gated and returns `403` without one.
## Call settings
### Read settings
`GET /api/v1/whatsapp/calling/settings` · scope `whatsapp:read`
| Query param | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The number |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/calling/settings?phone_number_id=1234567890" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns WhatsApp's own settings object for the number, unchanged.
### Update settings
`POST /api/v1/whatsapp/calling/settings` · scope `whatsapp:write`
Send only the fields you want to change. At least one is required, or you get `400` with `"No calling settings provided"`.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The number |
| `status` | string | No | `ENABLED` or `DISABLED`. The master switch for calling on this number |
| `call_icon_visibility` | string, max 32 | No | Where WhatsApp shows the call button |
| `callback_permission_status` | string | No | `ENABLED` or `DISABLED` |
| `call_hours` | object | No | Your calling hours, forwarded to WhatsApp unchanged |
| `sip` | object | No | SIP configuration, forwarded unchanged |
| `audio` | object | No | Audio configuration, forwarded unchanged |
| `voicemail` | object | No | Voicemail configuration, forwarded unchanged |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/settings \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"status": "ENABLED",
"callback_permission_status": "ENABLED"
}'
```
The nested objects are passed to WhatsApp exactly as you send them and validated there, so any option WhatsApp supports works without waiting on a CallMissed release. Returns WhatsApp's response.
## Call permission
WhatsApp requires an explicit grant from the customer before a business may call them. A grant is `temporary` or `permanent`; temporary grants expire, so re-check before relying on one.
### Check one user's permission
`GET /api/v1/whatsapp/calling/permissions` · scope `whatsapp:read`
| Query param | Type | Required | Notes |
|---|---|---|---|
| `user` | string, 5 to 20 chars | Yes | The customer's WhatsApp id |
| `phone_id` / `phone_number_id` | UUID / string | One of | Your number |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/calling/permissions?phone_number_id=1234567890&user=919000000000" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns WhatsApp's permission object, which carries a `permission.status` of `no_permission`, `temporary` or `permanent`. The result is also recorded locally, so a number that has granted permission shows up in [allowed numbers](#list-allowed-numbers) even before you call it.
### Ask for permission
`POST /api/v1/whatsapp/calling/permission-request` · scope `whatsapp:send`
Sends the customer an interactive message asking them to allow calls. It only works inside an open 24-hour customer service window. Outside it, send an approved `call_permission_request` template instead.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | Your number |
| `to` | string, 5 to 20 chars | Yes | The customer in E.164 |
| `body_text` | string, 1 to 1024 chars | Yes | Why you want to call |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/permission-request \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"body_text": "Can we call you about order AC-10294? It will take about two minutes."
}'
```
**Response (200 OK)**
```json
{ "wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQjE1RDNBOEY0RTVCOTAxMgA=" }
```
The customer's answer arrives as an inbound event, and their permission state is reflected on the next permission check.
### List allowed numbers
`GET /api/v1/whatsapp/calling/allowed-numbers` · scope `whatsapp:read`
Numbers that have granted call permission, so you can pick one and dial. WhatsApp exposes no bulk lookup, so this is derived from the permission state recorded whenever you check permission, request it, or place a call. Deduplicated per number, most recent first.
| Query param | Type | Default | Notes |
|---|---|---|---|
| `phone_id` | UUID | none | Restrict to one of your numbers |
| `limit` | integer, 1 to 500 | 100 | Max rows |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/calling/allowed-numbers?limit=100" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"to_number": "919000000000",
"permission_status": "permanent",
"last_call_at": "2026-04-19T12:31:08Z"
}
]
```
| Field | Type | Notes |
|---|---|---|
| `to_number` | string | The customer's number |
| `permission_status` | string | `temporary` or `permanent` |
| `last_call_at` | datetime, nullable | When we last saw this number |
A `temporary` grant expires. Re-check with the permissions endpoint before relying on one.
## Place a call
`POST /api/v1/whatsapp/calling/initiate` · scope `whatsapp:send`
Places a business-initiated call. Permission is verified first, the credits are held, the media bridge is provisioned, and the agent picks up when the customer answers.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The number to call from |
| `to` | string, 5 to 20 chars | Yes | The customer in E.164 |
| `reason` | string, max 512 | No | Your own note on why the call was placed. Stored on the call log, not sent to WhatsApp |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/initiate \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"reason": "Delivery address could not be verified for AC-10294"
}'
```
**Response (200 OK)**
```json
{
"id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012",
"session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"status": "INITIATED"
}
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | The CallMissed call row |
| `session_id` | UUID | The linked voice session, where the transcript and AI cost land |
| `status` | string | Always `INITIATED` at this point |
WhatsApp's own `call_id` does not exist yet. It is minted moments later as the call is placed, and appears on the call log once the handshake completes. Correlate by `session_id` until then.
**Failures**
| Code | Meaning |
|---|---|
| `402` | Not enough credits to cover the call. Nothing was placed |
| `403` | The customer has not granted call permission. Send a permission request first |
| `404` | The calling number is not on your workspace |
| `409` | Calling is not enabled on the number, or its messaging limit is below 2000 |
| `503` | The calling media bridge is unavailable right now |
### The credit hold
The network leg is charged when the call ends, so an unfundable call cannot be undone once placed. Before the call is provisioned, its worst-case cost is held: the agent's own maximum call duration, priced at the recipient's regional per-minute rate. A shortfall returns `402` and nothing is placed, no room is created and WhatsApp is never asked to dial.
Over-holding is self-correcting. When the call ends, the real charge settles and the remainder is released. Inbound calls are not held at all, because WhatsApp does not charge for user-initiated calls.
## Call logs
### List calls
`GET /api/v1/whatsapp/calling/calls` · scope `whatsapp:read`
Most recent first.
| Query param | Type | Default | Notes |
|---|---|---|---|
| `phone_id` | UUID | none | Restrict to one of your numbers |
| `limit` | integer, 1 to 200 | 50 | Max rows |
| `offset` | integer, 0 to 100000 | 0 | Pagination offset |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/calling/calls?limit=50&offset=0" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012",
"call_id": "wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=",
"direction": "BUSINESS_INITIATED",
"status": "COMPLETED",
"from_wa_id": "1234567890",
"to_number": "919000000000",
"duration_seconds": 96,
"cost_credits": 4.8,
"voice_session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"permission_status": "permanent",
"created_at": "2026-04-19T12:31:08Z",
"ai_cost_credits": 2.1374
}
]
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | The CallMissed call row |
| `call_id` | string | WhatsApp's call id. Use it on the detail and terminate endpoints |
| `direction` | string | `BUSINESS_INITIATED` or `USER_INITIATED` |
| `status` | string | Lifecycle state, for example `INITIATED`, `RINGING`, `ACCEPTED`, `COMPLETED`, `TERMINATED`, `FAILED`. Stored as WhatsApp reports it, so new values can appear |
| `from_wa_id` | string, nullable | The calling side |
| `to_number` | string, nullable | The called side |
| `duration_seconds` | integer, nullable | Call length. Falls back to the voice session's duration when the call was ended by the agent |
| `cost_credits` | float, nullable | The WhatsApp network leg only. Zero for inbound calls, which WhatsApp does not charge for |
| `voice_session_id` | UUID, nullable | The linked voice session |
| `permission_status` | string, nullable | The permission snapshot when the call was placed |
| `created_at` | datetime, nullable | ISO 8601 UTC |
| `ai_cost_credits` | float, nullable | Speech, model and voice cost for the call. Separate from `cost_credits` |
Permission-check marker rows are excluded, so this list is real calls only.
### Get one call with its transcript
`GET /api/v1/whatsapp/calling/calls/{call_id}` · scope `whatsapp:read`
`{call_id}` is WhatsApp's call id. Only `A-Z a-z 0-9 _ . : = -` are accepted in the path, up to 128 characters.
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/calling/calls/wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012",
"call_id": "wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=",
"direction": "BUSINESS_INITIATED",
"status": "COMPLETED",
"from_wa_id": "1234567890",
"to_number": "919000000000",
"duration_seconds": 96,
"cost_credits": 4.8,
"voice_session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"permission_status": "permanent",
"created_at": "2026-04-19T12:31:08Z",
"ai_cost_credits": 2.1374,
"transcript": [
{
"turn_index": 0,
"user_transcript": null,
"agent_response": "Hi, this is Acme Coffee calling about order AC-10294.",
"interrupted": false
},
{
"turn_index": 1,
"user_transcript": "Yes, go ahead.",
"agent_response": "We could not verify the delivery address. Is flat 4B still correct?",
"interrupted": false
}
]
}
```
Every field from the list response, plus:
| Field | Type | Notes |
|---|---|---|
| `transcript[].turn_index` | integer | Turn order, starting at 0 |
| `transcript[].user_transcript` | string, nullable | What the customer said |
| `transcript[].agent_response` | string, nullable | What the agent said |
| `transcript[].interrupted` | boolean | Whether the customer spoke over the agent |
`transcript` is empty until the agent has persisted turns, so it is normally empty while a call is still running. `404` if the call is not on your workspace.
### Terminate a live call
`POST /api/v1/whatsapp/calling/calls/{call_id}/terminate` · scope `whatsapp:send`
No body. Hangs up on WhatsApp's side and immediately marks the local row `TERMINATED`, so your call log updates without waiting for the webhook.
```bash
curl -X POST "https://api.callmissed.com/api/v1/whatsapp/calling/calls/wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=/terminate" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{ "success": true }
```
`404` if the call is not on your workspace.
---
### Campaigns
URL: /docs/whatsapp-campaigns
> Bulk template sends: create a campaign, upload recipients with per-recipient variables, launch it, and track delivery.
A campaign sends one approved template to many recipients, each with their own variable values, throttled so WhatsApp does not rate-limit you. It is the right tool for an order-status blast, a restock notice or a renewal reminder. For a single send, use [`POST /messages/template`](/docs/whatsapp-messages#send-a-template-message) instead.
All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`.
## Lifecycle
:::flow
icon:gateway | Create | `POST /campaigns` returns a campaign in `draft`
icon:user | Add recipients | `POST /campaigns/{id}/recipients` in batches of up to 10,000
icon:llm | Launch | `POST /campaigns/{id}/launch` prices the list, holds the credits, and starts the worker
icon:done | Track | `GET /campaigns/{id}` returns live counters and a recipient sample
:::
**Campaign statuses:** `draft`, `scheduled`, `running`, `completed`, `cancelled`, `failed`.
**Recipient statuses:** `pending`, `sent`, `delivered`, `read`, `failed`, `skipped`.
Recipients can only be added while the campaign is `draft`. Once it is `running` the worker is already claiming rows.
## Create a campaign
`POST /api/v1/whatsapp/campaigns` · scope `whatsapp:write`
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_number_id` | UUID | Yes | The **CallMissed** phone id (the `id` from `GET /phone_numbers`), not Meta's id |
| `name` | string, 1 to 255 chars | Yes | Your label for the campaign |
| `template_name` | string, 1 to 255 chars | Yes | An approved template's name |
| `template_language` | string, 2 to 16 chars | No | Template locale. Default `en` |
| `template_components` | array of objects | No | The component **shape**, with `{{N}}` placeholders left in. Default empty |
| `scheduled_at` | datetime | No | When you intend to run it. Recorded on the row; launching is still an explicit call |
`template_components` is a shape, not a finished payload. Leave the `{{1}}`, `{{2}}` tokens in the parameter text and the worker substitutes each recipient's `variables` before sending. Non-text parameters, such as a header image, are passed through untouched.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"name": "April restock notice",
"template_name": "back_in_stock",
"template_language": "en_US",
"template_components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "{{1}}" },
{ "type": "text", "text": "{{2}}" }
]
}
]
}'
```
**Response (201 Created)**
```json
{
"id": "6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"name": "April restock notice",
"template_name": "back_in_stock",
"template_language": "en_US",
"status": "draft",
"scheduled_at": null,
"started_at": null,
"completed_at": null,
"total": 0,
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0,
"created_at": "2026-04-19T12:00:00Z"
}
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | The campaign id |
| `account_id` | UUID | The WABA it sends from |
| `phone_number_id` | UUID | The sending number |
| `status` | string | Campaign status |
| `scheduled_at` / `started_at` / `completed_at` | datetime, nullable | Timestamps, ISO 8601 UTC |
| `total` | integer | Recipients added |
| `sent` / `delivered` / `read` / `failed` | integer | Live counters, updated by the worker and by delivery webhooks |
`404` with `"phone_number_id not found"` if the number is not on your workspace.
## Add recipients
`POST /api/v1/whatsapp/campaigns/{campaign_id}/recipients` · scope `whatsapp:write`
Up to 10,000 per call. Paginate for larger lists.
| Field | Type | Required | Notes |
|---|---|---|---|
| `recipients` | array, max 10000 | Yes | The batch |
| `recipients[].to_phone` | string, 8 to 32 chars | Yes | Any format. Non-digits are stripped, and the result must be 8 to 15 digits |
| `recipients[].variables` | object of string to string | No | Values keyed by placeholder number, so `{"1": "Priya"}` fills `{{1}}` |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/recipients \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{ "to_phone": "+91 90000 00000", "variables": { "1": "Priya", "2": "Ethiopia Guji" } },
{ "to_phone": "919000000001", "variables": { "1": "Arun", "2": "Colombia Huila" } },
{ "to_phone": "12", "variables": { "1": "Broken" } }
]
}'
```
**Response (200 OK)**
```json
{
"inserted": 2,
"skipped_invalid": 1,
"skipped_duplicate": 0,
"total_now": 2
}
```
| Field | Type | Notes |
|---|---|---|
| `inserted` | integer | Recipients added |
| `skipped_invalid` | integer | Numbers that were not 8 to 15 digits after stripping |
| `skipped_duplicate` | integer | Numbers already on the campaign, or repeated inside the batch |
| `total_now` | integer | The campaign's recipient total after this call |
Bad rows are counted and skipped rather than failing the batch, so a 10,000-row paste with a few broken cells still lands the good ones. Adding to a campaign that is not `draft` returns `409` with `Cannot add recipients to a campaign in status=running`.
## Launch
`POST /api/v1/whatsapp/campaigns/{campaign_id}/launch` · scope `whatsapp:write`
No body. Flips the campaign to `running` and starts the send worker.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/launch \
-H "Authorization: Bearer cm_your_api_key"
```
Returns the campaign object with `status: "running"` and `started_at` set.
### The credit hold
Before anything is sent, the whole pending recipient list is priced against the real rate card, per recipient, using the template's category and each number's region. Those credits are then **held**, so a campaign launched a second later cannot spend them.
If the balance will not cover it the launch is refused with `402` and the campaign stays `draft`, retryable after a top-up. Nothing was sent and nothing was charged.
```json
{
"detail": "Not enough credits to launch this campaign. It needs about 8631.40 credits for 1200 recipients and you are short by 431.40. Top up your balance and try again."
}
```
Pricing varies by more than tenfold across markets, so a mixed India and Germany list is priced per recipient rather than at a blended rate. If the campaign's template has not been synced locally, it is priced as `MARKETING`, the most expensive category, so a campaign can never start underfunded.
**Failures**
| Code | Meaning |
|---|---|
| `400` | The campaign has no pending recipients to send to |
| `402` | Not enough credits for the priced recipient list. The campaign stays `draft` |
| `404` | No such campaign on your workspace |
| `409` | The campaign is not `draft` or `scheduled`, for example it is already `running` |
Concurrent launch calls are serialised, so a double-click cannot start two workers and double-send.
## Cancel
`POST /api/v1/whatsapp/campaigns/{campaign_id}/cancel` · scope `whatsapp:write`
No body. Works from `draft`, `scheduled` or `running`. A running worker notices within one send, so a few in-flight messages may still go out.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/cancel \
-H "Authorization: Bearer cm_your_api_key"
```
Returns the campaign object with `status: "cancelled"` and `completed_at` set. `409` from any other status, for example one already `completed`.
## List campaigns
`GET /api/v1/whatsapp/campaigns` · scope `whatsapp:read`
| Param | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer, 1 to 100 | 50 | Max rows, newest first |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/campaigns?limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns an array of campaign objects.
## Get one campaign
`GET /api/v1/whatsapp/campaigns/{campaign_id}` · scope `whatsapp:read`
The campaign object plus a sample of up to 50 recent recipient rows, most recently updated first. This is the progress endpoint: poll it while a campaign runs.
```bash
curl https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"id": "6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"name": "April restock notice",
"template_name": "back_in_stock",
"template_language": "en_US",
"status": "running",
"scheduled_at": null,
"started_at": "2026-04-19T12:05:02Z",
"completed_at": null,
"total": 1200,
"sent": 418,
"delivered": 402,
"read": 191,
"failed": 3,
"created_at": "2026-04-19T12:00:00Z",
"recipients_sample": [
{
"id": "aa11bb22-cc33-4d44-8e55-6f7788990011",
"to_phone": "919000000000",
"status": "delivered",
"wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSN0MyRDFBOEY0RTVCOTAxMgA=",
"error": null,
"sent_at": "2026-04-19T12:05:44Z",
"last_status_at": "2026-04-19T12:05:51Z"
},
{
"id": "bb22cc33-dd44-4e55-9f66-7788990011aa",
"to_phone": "919000000002",
"status": "failed",
"wamid": null,
"error": "Request rejected by Meta - check the recipient and payload.",
"sent_at": null,
"last_status_at": "2026-04-19T12:05:47Z"
}
]
}
```
| Field | Type | Notes |
|---|---|---|
| `recipients_sample[].id` | UUID | Recipient row id |
| `recipients_sample[].to_phone` | string | Normalised to digits only |
| `recipients_sample[].status` | string | `pending`, `sent`, `delivered`, `read`, `failed` or `skipped` |
| `recipients_sample[].wamid` | string, nullable | Meta's message id once sent |
| `recipients_sample[].error` | string, nullable | Why this recipient failed |
| `recipients_sample[].sent_at` | datetime, nullable | When the send left |
| `recipients_sample[].last_status_at` | datetime | Last status change |
The sample is capped at 50 rows and is not paginated. Use the counters on the campaign itself for totals.
---
### Flows
URL: /docs/whatsapp-flows
> Build native in-chat forms — create a Flow from its screen JSON, publish it, and read the submissions customers send back.
## Overview
A **Flow** is a multi-screen form WhatsApp renders **inside the conversation** — no browser, no link-out. Customers pick dates, confirm an address or answer a survey without leaving the chat, and the submission comes back to you as structured JSON.
Typical uses: cash-on-delivery confirmation, address capture, lead qualification, appointment booking, and post-conversation surveys.
## Lifecycle
```
create (DRAFT) ──▶ publish (PUBLISHED) ──▶ send ──▶ read responses
```
1. **Create** with a `flow_json` screen document and one or more categories. It starts as `DRAFT`.
2. **Publish** it. One way, and after publishing the screen document is frozen — a change means a new Flow.
3. **Send** it with [`POST /api/v1/whatsapp/messages/interactive`](/docs/whatsapp-messages#send-an-interactive-message) using `interactive_type: "flow"`. That is the billed, window-aware send path for every interactive message.
4. **Read** the submissions here.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List, get, read responses | `wa_flows:read` |
| Create, publish, delete | `wa_flows:write` |
Your tenant also needs a connected WhatsApp number and Business Account. Without one you get `409 No connected WhatsApp number for tenant`.
## Statuses
`DRAFT`, `PUBLISHED`, `DEPRECATED`, `BLOCKED`, `THROTTLED`. Only the first two are ever set from this API; the rest can appear when WhatsApp changes a Flow's state on its side.
## Categories
Every Flow declares 1–8 categories: `SIGN_UP`, `SIGN_IN`, `APPOINTMENT_BOOKING`, `LEAD_GENERATION`, `CONTACT_US`, `CUSTOMER_SUPPORT`, `SURVEY`, `OTHER`.
## The flow object
```json
{
"id": "f1a2…",
"tenant_id": "a0b1…",
"flow_id": "1122334455667788",
"name": "Appointment booking",
"categories": ["APPOINTMENT_BOOKING"],
"status": "PUBLISHED",
"flow_json": { "version": "7.0", "screens": [] },
"endpoint_uri": null,
"created_at": "2026-08-09T09:00:00Z",
"updated_at": "2026-08-09T09:30:00Z"
}
```
> There are **two ids**. `id` is the CallMissed record and is what every path parameter on this page takes. `flow_id` is WhatsApp's id — that is the one you pass to the send endpoint.
`endpoint_uri` decides the Flow's kind:
| `endpoint_uri` | Kind | Behaviour |
| --- | --- | --- |
| `null` | **Static** | Every screen is defined up front in `flow_json` |
| Set | **Endpoint-backed** | Screens are served from your endpoint at runtime via `data_exchange` |
Start static. It needs no server, no encryption key and no runtime availability on your side.
## GET `/api/v1/commerce/flows`
Newest first.
| Parameter | Type | Constraints |
| --- | --- | --- |
| `status` | `string` | One of the five statuses |
| `limit` | `integer` | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | `0 <= offset <= 100000`, default `0` |
## POST `/api/v1/commerce/flows`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters, not blank |
| `categories` | `string[]` | Yes | 1–8 entries from the category list |
| `flow_json` | `object` | Yes | The screen document. At most 10 MB serialised |
| `endpoint_uri` | `string` | No | At most 512 characters. Omit for a static Flow |
```bash
curl -X POST https://api.callmissed.com/api/v1/commerce/flows \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Appointment booking",
"categories": ["APPOINTMENT_BOOKING"],
"flow_json": { "version": "7.0", "screens": [] }
}'
```
Returns `201` with `status: "DRAFT"`.
The Flow is created at WhatsApp **first**, then mirrored locally — so a rejected screen document never leaves a phantom record behind. WhatsApp's validation message is passed through verbatim, which is what you want when a screen definition is malformed.
## GET `/api/v1/commerce/flows/{flow_uuid}`
One Flow by its CallMissed `id`. `404 Flow not found`.
## POST `/api/v1/commerce/flows/{flow_uuid}/publish`
No body. Moves `DRAFT` to `PUBLISHED`.
```bash
curl -X POST https://api.callmissed.com/api/v1/commerce/flows/f1a2…/publish \
-H "Authorization: Bearer cm_your_api_key"
```
One way, and irreversible. Publishing freezes the screen document — iterate while the Flow is still a draft. WhatsApp validates the whole document at this point, so this is where a structural mistake surfaces.
## DELETE `/api/v1/commerce/flows/{flow_uuid}`
Returns `204`.
WhatsApp refuses to delete a `PUBLISHED` Flow and its refusal is passed through. If the Flow is already gone upstream, the local record is still cleared, so a stale mirror can always be tidied.
## Responses
A customer's submission arrives on your inbound webhook and is recorded here, correlated by the `flow_token` you set when sending. Recording is idempotent per WhatsApp message id, so a webhook redelivery never doubles a submission.
### GET `/api/v1/commerce/flows/responses`
Newest first.
| Parameter | Type | Constraints |
| --- | --- | --- |
| `flow_id` | `string` | WhatsApp's flow id, at most 64 characters |
| `flow_token` | `string` | At most 128 characters — the identifier you sent |
| `contact_id` | `UUID` | |
| `limit` | `integer` | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | `0 <= offset <= 100000`, default `0` |
```json
[
{
"id": "r9c8…",
"tenant_id": "a0b1…",
"flow_id": "1122334455667788",
"flow_token": "booking-4471",
"wa_message_id": "wamid.HBg…",
"contact_id": "4411…",
"conversation_id": "c0ff…",
"response": { "date": "2026-08-22", "slot": "10:30", "branch": "Kothrud" },
"created_at": "2026-08-17T07:41:00Z"
}
]
```
`response` is the screen data the customer submitted, exactly as your `flow_json` defined the field names.
Filtering by your own `flow_token` is the reliable way to tie a submission back to the order, booking or ticket you sent it for — set it to something meaningful when you send.
### GET `/api/v1/commerce/flows/responses/{response_id}`
One submission by its CallMissed id. `404 Flow response not found`.
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `wa_flows:read` / `wa_flows:write` |
| `404` | Flow or response not in your tenant |
| `409` | No connected WhatsApp number or Business Account for your tenant |
| `422` | Blank name, no categories, an unknown category, or a `flow_json` over 10 MB |
| `502` | WhatsApp accepted the call but returned no flow id |
Errors originating at WhatsApp keep their status code and message, so a validation failure reads the same as it would against the Cloud API directly.
Creating, publishing, deleting and reading Flows do not consume credits. Sending a Flow message is billed on the [interactive message endpoint](/docs/whatsapp-messages#send-an-interactive-message).
---
### Sending Messages
URL: /docs/whatsapp-messages
> Every WhatsApp send endpoint: text, template, media, interactive, location, reaction, contact cards, read receipts, and media upload and download.
Every send endpoint lives under `https://api.callmissed.com/api/v1/whatsapp`, takes a JSON body, needs the `whatsapp:send` scope (media upload needs `whatsapp:write`, media reads need `whatsapp:read`), and identifies the sending number with `phone_id` or `phone_number_id`. See [WhatsApp API](/docs/whatsapp-api#choosing-the-sending-number) for the selector and the shared error shape.
> **Free-form sends need an open window.** Text, media, interactive and location sends only work inside the 24-hour customer service window that opens when the customer last messaged you. Outside it, send an approved [template](/docs/whatsapp-templates). A closed-window send returns `422`.
## The common response
Every send endpoint returns the same object:
```json
{
"wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA=",
"wamids": ["wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA="],
"contacts": [
{ "input": "+919000000000", "wa_id": "919000000000" }
]
}
```
| Field | Type | Notes |
|---|---|---|
| `wamid` | string | Meta's id for the first message. Keep it to correlate delivery and read status |
| `wamids` | array of strings | Every id the request produced, in send order. More than one when a long text was split across messages |
| `contacts` | array | WhatsApp's resolution of the recipient. Empty when WhatsApp returns none |
Track delivery against `wamids`, not `wamid`: WhatsApp reports status per message, so a split reply produces several status events.
Sends are persisted into the matching conversation thread, so anything you send over the API shows up in the dashboard inbox alongside the agent's own replies. Reactions are the exception, since a reaction is a property of the message it targets rather than a bubble of its own.
## Before WhatsApp is called
Two gates run on every send, before any request reaches Meta.
**Tenant scope.** The sending number is resolved against your workspace. A number you do not own returns `404`, identically to one that does not exist.
**Credit check.** The charge for a WhatsApp message lands after Meta delivers it, so a send you cannot pay for cannot be undone. Sends are therefore priced up front, against the same rate card the delivery charge uses, and refused with `402` when the balance will not cover them. Nothing is sent and nothing is charged.
```json
{
"detail": "Not enough credits to send this message, so nothing was sent and nothing was charged. It needs at least 7.51 credits and you have 2.00 spendable (balance 12.00, 10.00 held for running campaigns) -- short by 5.51. Top up your balance and try again."
}
```
Template sends are priced from the recipient's region and the template's category, which differ by more than tenfold across markets, so the quoted figure is specific to the message you tried to send. Reactions are not credit-gated, because they are not billed.
## Send a text message
`POST /api/v1/whatsapp/messages` · scope `whatsapp:send`
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` | UUID | One of | CallMissed's number id |
| `phone_number_id` | string, max 64 | One of | Meta's number id |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164, for example `+919000000000` |
| `text` | string, 1 to 65536 chars | Yes | Message body. WhatsApp caps a single message at 4096 characters, so a longer body is split across several messages and every id comes back in `wamids` |
| `preview_url` | boolean | No | Render a link preview for the first URL. Default `false` |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"text": "Your order AC-10294 shipped this morning. Track it at https://acme.example.com/t/AC-10294",
"preview_url": true
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/whatsapp"
headers = {"Authorization": "Bearer cm_your_api_key"}
resp = httpx.post(
f"{BASE}/messages",
headers=headers,
json={
"phone_number_id": "1234567890",
"to": "+919000000000",
"text": "Your order AC-10294 shipped this morning.",
"preview_url": False,
},
)
resp.raise_for_status()
print(resp.json()["wamid"])
```
```javascript [Node]
const res = await fetch("https://api.callmissed.com/api/v1/whatsapp/messages", {
method: "POST",
headers: {
Authorization: "Bearer cm_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
phone_number_id: "1234567890",
to: "+919000000000",
text: "Your order AC-10294 shipped this morning.",
}),
});
if (!res.ok) throw new Error((await res.json()).detail);
const { wamid } = await res.json();
```
:::
**Failures**
| Code | Meaning |
|---|---|
| `400` | Neither `phone_id` nor `phone_number_id` was supplied |
| `401` | The number's Meta token is invalid or expired. Reconnect the number |
| `402` | Not enough credits, or a workspace budget cap would be exceeded. Nothing was sent |
| `403` | API key is missing `whatsapp:send` |
| `404` | The sending number is not on your workspace |
| `409` | The number is disconnected, or is not registered on the WhatsApp Business Platform |
| `422` | The 24-hour window is closed, the display name is not approved yet, or WhatsApp rejected the payload |
| `429` | Per-user-pair send rate limit. Retry with backoff |
## Send a template message
`POST /api/v1/whatsapp/messages/template` · scope `whatsapp:send`
The only way to message someone outside the 24-hour window. The template must already be `APPROVED`.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `template_name` | string, 1 to 512 chars | Yes | The approved template's name |
| `language_code` | string, max 12 | No | Template locale. Default `en_US` |
| `components` | array of objects | No | Header, body and button variable values, passed to WhatsApp unchanged |
`components` follows WhatsApp's own shape, so any combination WhatsApp supports works: header media, body variables, URL button suffixes. Authentication templates (one-time codes) are sent the same way, with the code as a body or button parameter.
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/template \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"template_name": "order_shipped",
"language_code": "en_US",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Priya" },
{ "type": "text", "text": "AC-10294" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "AC-10294" }]
}
]
}'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/whatsapp"
headers = {"Authorization": "Bearer cm_your_api_key"}
resp = httpx.post(
f"{BASE}/messages/template",
headers=headers,
json={
"phone_number_id": "1234567890",
"to": "+919000000000",
"template_name": "order_shipped",
"language_code": "en_US",
"components": [
{
"type": "body",
"parameters": [
{"type": "text", "text": "Priya"},
{"type": "text", "text": "AC-10294"},
],
}
],
},
)
resp.raise_for_status()
print(resp.json()["wamid"])
```
:::
Returns the common send response. `422` if the template name or locale does not resolve to an approved template on the WABA, and `402` if the priced send exceeds your spendable balance.
## Send media
`POST /api/v1/whatsapp/messages/media` · scope `whatsapp:send`
Reference the file by an uploaded `media_id` (recommended, reusable for 30 days) or by a public `link` that WhatsApp fetches and caches briefly.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `kind` | enum | Yes | `image`, `audio`, `video`, `document` or `sticker` |
| `media_id` | string, max 64 | One of | From [upload media](#upload-media) |
| `link` | string, max 2048 | One of | A public URL to the file |
| `caption` | string, max 1024 | No | Honoured for `image`, `video` and `document` only, ignored otherwise |
| `filename` | string, max 255 | No | Display filename for documents |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/media \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"kind": "document",
"media_id": "1079235482913746",
"filename": "invoice-AC-10294.pdf",
"caption": "Your invoice"
}'
```
**Failures**
| Code | Meaning |
|---|---|
| `400` | The MIME type does not match the file. Check the extension and `Content-Type` |
| `413` | The file exceeds 100 MB |
| `422` | The 24-hour window is closed, or WhatsApp rejected the media |
## Send an interactive message
`POST /api/v1/whatsapp/messages/interactive` · scope `whatsapp:send`
Reply buttons, a list menu, a call-to-action URL button, or a Flow. `interactive_type` selects the variant.
**Common fields**
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `interactive_type` | enum | Yes | `button`, `list`, `cta_url` or `flow` |
| `body_text` | string, 1 to 1024 chars | Yes | The main message body |
| `footer_text` | string, max 60 | No | Small footer line |
| `header` | object | No | Header block, passed through to WhatsApp. For `list` only its `text` is used |
**Variant fields**
| `interactive_type` | Required | Shape |
|---|---|---|
| `button` | `buttons` | 1 to 3 objects, each `{ "id": string (1-256), "title": string (1-20) }` |
| `list` | `button_text`, `sections` | `button_text` max 20. Each section is `{ "title": string (1-24), "rows": [{ "id": string (1-200), "title": string (1-24), "description"?: string (max 72) }] }`, at least one row per section |
| `cta_url` | `button_text`, `button_url` | `button_url` max 2048 |
| `flow` | `flow_cta`, and exactly one of `flow_id` / `flow_name` | The flow fields below |
**Flow fields** (`interactive_type: "flow"` only)
| Field | Type | Required | Notes |
|---|---|---|---|
| `flow_cta` | string, 1 to 30 chars | Yes | The button label that opens the flow. Emojis are not supported |
| `flow_id` | string, max 64 | Exactly one of | The published flow's id |
| `flow_name` | string, max 200 | Exactly one of | The flow's name. Cannot be combined with `flow_id` |
| `flow_action` | enum | No | `navigate` (default) or `data_exchange` |
| `flow_action_payload` | object | For `navigate` | Must carry `screen`, the first screen to open. On `data_exchange` the first screen comes from your endpoint's response instead |
| `flow_token` | string, max 512 | No | Your own identifier for this flow session, echoed back to you with the customer's submission |
| `flow_mode` | enum | No | `published` (default) or `draft`, to send an unpublished flow while you are still building it |
:::tabs
```bash [Buttons]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"interactive_type": "button",
"body_text": "Your order is out for delivery. Is someone home to receive it?",
"footer_text": "Acme Coffee",
"buttons": [
{ "id": "home_yes", "title": "Yes, deliver" },
{ "id": "home_no", "title": "Reschedule" }
]
}'
```
```bash [List]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"interactive_type": "list",
"body_text": "What would you like help with?",
"button_text": "Pick a topic",
"sections": [
{
"title": "Orders",
"rows": [
{ "id": "track", "title": "Track an order", "description": "Live delivery status" },
{ "id": "return", "title": "Start a return" }
]
},
{
"title": "Account",
"rows": [{ "id": "invoice", "title": "Get an invoice" }]
}
]
}'
```
```bash [CTA URL]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"interactive_type": "cta_url",
"body_text": "Your invoice is ready.",
"button_text": "View invoice",
"button_url": "https://acme.example.com/invoices/AC-10294"
}'
```
```bash [Flow]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"interactive_type": "flow",
"body_text": "Book your tasting session in a few taps.",
"footer_text": "Acme Coffee",
"flow_id": "1122334455667788",
"flow_cta": "Book a slot",
"flow_action": "navigate",
"flow_action_payload": { "screen": "PICK_DATE" },
"flow_token": "booking-4471"
}'
```
:::
Returns the common send response. The customer's tap arrives back on your webhook as an inbound message with `type: "interactive"` or `type: "button"`. A completed flow arrives as an interactive reply carrying your `flow_token` alongside the screen data the customer submitted, so use `flow_token` to tie the submission back to the order, booking or ticket you sent it for.
**Failures**
| Code | Meaning |
|---|---|
| `400` | `buttons` missing for `button`, or `button_text` and `sections` missing for `list`, or `button_text` and `button_url` missing for `cta_url`, or for `flow`: `flow_cta` missing, neither or both of `flow_id` / `flow_name` supplied, or `flow_action_payload.screen` missing while `flow_action` is `navigate` |
| `422` | The 24-hour window is closed, or WhatsApp rejected the layout |
## Send an order details message
`POST /api/v1/whatsapp/messages/order_details` · scope `whatsapp:send`
An itemised bill the customer can pay from the chat with UPI. Needs a payment configuration on the WABA first, see [WhatsApp Payments](/docs/whatsapp-payments). India and UPI only: any other `payment_type` returns `501`.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `reference_id` | string, max 35 | Yes | Your order reference. Letters, digits, `_`, `-` and `.` only, and unique per order details message. This is the key an [order status](#send-an-order-status-update) update quotes to settle the bill |
| `goods_type` | string | Yes | `digital-goods` or `physical-goods` |
| `payment_configuration` | string, 1 to 60 chars | Yes | The `configuration_name` of the payment configuration to charge into |
| `total_amount` | object | Yes | `{ "value": integer, "offset": 100 }` |
| `order` | object | Yes | The line items and money breakdown, below |
| `body_text` | string, 1 to 1024 chars | Yes | The message body above the bill |
| `footer_text` | string, max 60 | No | Small footer line |
| `header` | object | No | Image header, passed through to WhatsApp |
| `beneficiaries` | array of objects | For shipped physical goods | India addresses only, see the shape below |
| `preferred_payment_methods` | array of objects | No | At most one, `[{ "method": "gpay" }]`. One of `gpay`, `phonepe`, `paytm`, `amazonpay`, `cred`, `mobikwik` |
| `payment_type` | string | No | Default `upi`. Anything else returns `501` |
| `currency` | string | No | Default `INR`, the only accepted value |
**Money is integer minor units.** Every amount is `{ "value": …, "offset": 100 }`, where `value` is paise and `offset` must be `100`, so ₹499.00 is `{ "value": 49900, "offset": 100 }`. Floats are not accepted, because binary floating point cannot represent decimal currency exactly and this is a bill.
**The `order` object**
| Field | Type | Required | Notes |
|---|---|---|---|
| `items` | array, at least 1 | Yes | Each item is `{ "name": string (1-60), "amount": Amount, "quantity": integer >= 1 }`, plus optional `sale_amount`, `retailer_id`, `image: { "link": … }`, `country_of_origin`, `importer_name`, `importer_address` |
| `subtotal` | object | Yes | Amount. Must equal the sum of the line items |
| `tax` | object | Yes | Amount, with an optional `description` (max 60) |
| `shipping` | object | No | Amount |
| `discount` | object | No | Amount |
| `catalog_id` | string | No | When the items come from a catalog. Cannot be combined with a custom item `image` |
| `expiration` | object | No | `{ "timestamp": …, "description": string (max 120) }`. `timestamp` is UTC epoch seconds and must be at least 300 seconds in the future |
| `type` | string | No | Only `quick_pay` is accepted, which shows a single "Pay Now" button |
| `status` | string | No | Only `pending` is accepted on an order details message |
`total_amount.value` must equal `subtotal + tax + shipping - discount`. Using a custom item `image` limits the order to 10 items.
**The `beneficiaries` shape** — required for shipped physical goods, and India-only:
| Field | Type | Notes |
|---|---|---|
| `name` | string, 1 to 200 | |
| `address_line1` | string, 1 to 100 | `address_line2` optional, same cap |
| `city` / `state` / `country` | string | `country` must be `India` |
| `postal_code` | string | A 6-digit PIN code |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/order_details \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"reference_id": "AC-10294",
"goods_type": "physical-goods",
"payment_configuration": "acme-upi",
"body_text": "Here is your order. Pay with any UPI app to confirm it.",
"footer_text": "Acme Coffee",
"total_amount": { "value": 61800, "offset": 100 },
"order": {
"type": "quick_pay",
"status": "pending",
"items": [
{
"name": "Ratnagiri Dark Roast 500g",
"amount": { "value": 55000, "offset": 100 },
"quantity": 1
}
],
"subtotal": { "value": 55000, "offset": 100 },
"tax": { "value": 6800, "offset": 100, "description": "GST 12%" },
"expiration": { "timestamp": 1776000000, "description": "Pay within 30 minutes" }
},
"preferred_payment_methods": [{ "method": "gpay" }]
}'
```
Returns the common send response.
> **Always follow up with an order status update.** The customer's order screen keeps showing "Order pending" until you send one, so an order that was paid still looks unpaid.
**Failures**
| Code | Meaning |
|---|---|
| `400` | A money rule failed (`offset` not `100`, total does not equal subtotal plus tax plus shipping minus discount, subtotal does not equal the line items), an invalid `reference_id` charset, an unknown `goods_type` or `status`, more than one `preferred_payment_methods` entry, or an unlisted payment app |
| `402` | Not enough credits. Nothing was sent |
| `422` | The 24-hour window is closed, or WhatsApp rejected the order |
| `501` | `payment_type` is not `upi`. Only India and UPI are supported |
## Send an order status update
`POST /api/v1/whatsapp/messages/order_status` · scope `whatsapp:send`
The update that settles a bill. It moves the customer's order screen off "Order pending" and updates the buttons on the original order details message. Send one on every transaction update.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `reference_id` | string, max 35 | Yes | The same reference you sent the order details message with |
| `status` | enum | Yes | `pending`, `processing`, `partially-shipped`, `shipped`, `completed` or `canceled`. `partially_shipped` and `cancelled` are accepted and normalised |
| `body_text` | string, 1 to 1024 chars | Yes | The message body |
| `description` | string, max 120 | No | A line of detail under the status |
| `footer_text` | string, max 60 | No | Small footer line |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/order_status \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"reference_id": "AC-10294",
"status": "shipped",
"body_text": "Your order is on its way and should arrive by Thursday.",
"description": "Picked up by the courier this morning",
"footer_text": "Acme Coffee"
}'
```
Returns the common send response. `400` for an unknown `status` or a `reference_id` outside the allowed charset, and `422` when the window is closed or WhatsApp rejected the update.
## Send a location
`POST /api/v1/whatsapp/messages/location` · scope `whatsapp:send`
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `latitude` | float, -90 to 90 | Yes | |
| `longitude` | float, -180 to 180 | Yes | |
| `name` | string, max 200 | No | Location label |
| `address` | string, max 300 | No | Street address shown under the name |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/location \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"latitude": 19.076,
"longitude": 72.8777,
"name": "Acme Coffee Bandra",
"address": "Linking Road, Bandra West, Mumbai 400050"
}'
```
## Send a reaction
`POST /api/v1/whatsapp/messages/reaction` · scope `whatsapp:send`
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `message_id` | string, 1 to 128 chars | Yes | The `wamid` of the message to react to |
| `emoji` | string, max 8 | No | The emoji. An empty string removes an existing reaction. Default empty |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/reaction \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"message_id": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=",
"emoji": "👍"
}'
```
## Send contact cards
`POST /api/v1/whatsapp/messages/contacts` · scope `whatsapp:send`
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `to` | string, 5 to 20 chars | Yes | Recipient in E.164 |
| `contacts` | array of objects, 1 to 10 | Yes | WhatsApp contact objects. Each needs a `name` block with at least `formatted_name`, or `first_name` plus `last_name` |
| `context_message_id` | string, max 128 | No | `wamid` of the inbound message this replies to |
| `biz_opaque_callback_data` | string, max 256 | No | Opaque string echoed back on status events for your own correlation |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/contacts \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"contacts": [
{
"name": { "formatted_name": "Acme Support", "first_name": "Acme", "last_name": "Support" },
"phones": [{ "phone": "+918080247309", "type": "WORK", "wa_id": "918080247309" }],
"emails": [{ "email": "support@acme.example.com", "type": "WORK" }]
}
],
"biz_opaque_callback_data": "escalation-4471"
}'
```
## Mark a message as read
`POST /api/v1/whatsapp/messages/{message_id}/read` · scope `whatsapp:send`
`{message_id}` is the `wamid` of the inbound message. Shows blue ticks, and optionally a typing bubble while you prepare a reply.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The sending number |
| `typing_indicator` | boolean | No | Show a typing bubble. Default `false` |
```bash
curl -X POST "https://api.callmissed.com/api/v1/whatsapp/messages/wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=/read" \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "phone_number_id": "1234567890", "typing_indicator": true }'
```
**Response (200 OK)**
```json
{ "success": true }
```
The agent does this automatically for messages it answers.
## Media
### Upload media
`POST /api/v1/whatsapp/media` · scope `whatsapp:write`
Multipart upload. The MIME type is read from the file part's `Content-Type`, so set it explicitly, and it is validated against WhatsApp's allowlist before the request reaches Meta. The returned `media_id` is reusable for 30 days and is scoped to the number you uploaded it against. Maximum upload size is 100 MB, and this path is rate limited more tightly than the rest of the API.
| Form field | Type | Required | Notes |
|---|---|---|---|
| `file` | file | Yes | The media file. Must carry a `Content-Type` |
| `phone_id` | UUID | One of | CallMissed's number id |
| `phone_number_id` | string | One of | Meta's number id |
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/media \
-H "Authorization: Bearer cm_your_api_key" \
-F 'phone_number_id=1234567890' \
-F 'file=@invoice-AC-10294.pdf;type=application/pdf'
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/whatsapp"
headers = {"Authorization": "Bearer cm_your_api_key"}
with open("invoice-AC-10294.pdf", "rb") as fh:
resp = httpx.post(
f"{BASE}/media",
headers=headers,
data={"phone_number_id": "1234567890"},
files={"file": ("invoice-AC-10294.pdf", fh, "application/pdf")},
)
resp.raise_for_status()
media_id = resp.json()["media_id"]
```
:::
**Response (200 OK)**
```json
{
"media_id": "1079235482913746",
"mime_type": "application/pdf",
"size_bytes": 84213
}
```
Pass `media_id` to [send media](#send-media).
**Failures**
| Code | Meaning |
|---|---|
| `400` | No `Content-Type` on the file part, or the MIME type does not match the bytes |
| `413` | The file exceeds 100 MB |
### Resolve inbound media to a URL
`GET /api/v1/whatsapp/media/{media_id}` · scope `whatsapp:read`
Turns a media id into a temporary download URL. Mostly used for **inbound** media, where the webhook gives you a media id and you want the file. The URL is valid for about five minutes and requires WhatsApp's own auth, so fetch it immediately or use [the content proxy](#stream-inbound-media-bytes) instead.
| Query param | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The owning number |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/media/1079235482913746?phone_number_id=1234567890" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1079235482913746",
"mime_type": "image/jpeg",
"sha256": "b1946ac92492d2347c6235b4d2611184a3e0f5b1c2d3e4f5a6b7c8d9e0f1a2b3",
"file_size": 84213
}
```
| Field | Type | Notes |
|---|---|---|
| `url` | string | Short-lived download URL |
| `mime_type` | string | Falls back to `application/octet-stream` |
| `sha256` | string, nullable | Checksum, when WhatsApp provides one |
| `file_size` | integer, nullable | Bytes, when WhatsApp provides it |
### Stream inbound media bytes
`GET /api/v1/whatsapp/media/{media_id}/content` · scope `whatsapp:read`
Streams the raw file back through CallMissed with the upstream content type, so your browser or mobile client can render inbound images without handling short-lived URLs or WhatsApp credentials. Responses carry `Cache-Control: private, max-age=300`.
| Query param | Type | Required | Notes |
|---|---|---|---|
| `phone_id` / `phone_number_id` | UUID / string | One of | The owning number |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/media/1079235482913746/content?phone_number_id=1234567890" \
-H "Authorization: Bearer cm_your_api_key" \
--output inbound-image.jpg
```
Returns the file bytes on `200`, or `404` when the media id no longer resolves.
---
### Orders
URL: /docs/whatsapp-orders
> Read the orders customers place from your WhatsApp catalog — filter by status, contact or date, and fetch line items.
## Overview
When a customer builds a cart from your WhatsApp catalog and sends it, the order is recorded against your tenant. These endpoints read those orders and their line items.
Orders are created by the customer's action on WhatsApp — there is no create endpoint here.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
Reading orders requires `wa_commerce:read`.
```json
{ "detail": "API key missing required scope: wa_commerce:read. Add it under the key's 'Permissions' section in your dashboard." }
```
## Statuses
`pending`, `processing`, `partially_shipped`, `shipped`, `completed`, `canceled`.
`completed` and `canceled` are terminal.
## The order object
```json
{
"id": "o1a2…",
"tenant_id": "a0b1…",
"conversation_id": "c0ff…",
"contact_id": "4411…",
"wa_message_id": "wamid.HBg…",
"catalog_id": "998877665544",
"reference_id": "ORD-4471",
"status": "pending",
"currency": "INR",
"subtotal": 2499.0,
"note": "Please deliver after 6pm",
"created_at": "2026-08-17T07:20:00Z",
"updated_at": "2026-08-17T07:20:00Z"
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `wa_message_id` | `string` | The WhatsApp message that carried the cart |
| `reference_id` | `string \| null` | Your own bill reference, once one has been attached |
| `subtotal` | `number` | Sum of the line items, in `currency` |
| `note` | `string \| null` | Free-text the customer typed with the order |
## GET `/api/v1/commerce/orders`
Newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `status` | `string` | No | One of the six statuses |
| `contact_id` | `UUID` | No | One customer's orders |
| `created_from` | `datetime` | No | Inclusive lower bound on `created_at` |
| `created_to` | `datetime` | No | Inclusive upper bound on `created_at` |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/commerce/orders?status=pending&limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
An unknown status returns `422 status must be one of: pending, processing, partially_shipped, shipped, completed, canceled`.
## GET `/api/v1/commerce/orders/{order_id}`
The order plus its line items, oldest first.
```json
{
"id": "o1a2…",
"status": "pending",
"currency": "INR",
"subtotal": 2499.0,
"items": [
{
"id": "i9b8…",
"product_retailer_id": "SKU-114",
"quantity": 2,
"item_price": 999.0,
"currency": "INR",
"created_at": "2026-08-17T07:20:00Z"
},
{
"id": "i7c6…",
"product_retailer_id": "SKU-220",
"quantity": 1,
"item_price": 501.0,
"currency": "INR",
"created_at": "2026-08-17T07:20:00Z"
}
]
}
```
`product_retailer_id` is your own SKU as it appears in the catalog — join on it to look the product up in your system.
`404 Order not found` for an unknown id or another tenant's order.
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `wa_commerce:read` |
| `404` | `Order not found` |
| `422` | Unknown `status` value |
Reading orders does not consume credits.
---
### Payments
URL: /docs/whatsapp-payments
> Take UPI payments inside a WhatsApp chat: create and manage payment configurations on a WABA, then send order details and order status messages.
WhatsApp Payments lets a customer pay an itemised bill from the chat itself with any UPI app. Two pieces: a **payment configuration** on the WABA that says where the money lands, and the two [order messages](/docs/whatsapp-messages#send-an-order-details-message) that bill the customer and then settle the order.
All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`.
> **India and UPI only.** These endpoints implement the India flow, with `payment_type: "upi"` and `INR`. Any other `payment_type` on a send returns `501`, because other regions use a different request shape rather than a variation of this one.
## How it fits together
:::flow
icon:gateway | Configure | `POST /payment_configurations` registers a UPI VPA or a payment gateway on the WABA
icon:user | Link | For a gateway, the merchant opens the returned `oauth_url` to finish linking. A VPA is usable immediately
icon:send | Bill | `POST /messages/order_details` sends the itemised bill. The customer pays in their UPI app
icon:done | Settle | `POST /messages/order_status` moves the order off "Order pending"
:::
## Choosing the WABA
A payment configuration belongs to a **WhatsApp Business Account**, not to a phone number, so these endpoints take the same account selector as [templates](/docs/whatsapp-templates#choosing-the-waba). Supply exactly one:
| Field | Type | Where it comes from |
|---|---|---|
| `account_id` | UUID | The `id` from `GET /accounts` |
| `waba_id` | string, max 64 | Meta's WABA id |
The account is always resolved against your workspace, so naming a WABA you do not own returns the same `404` as one that does not exist.
The two order sends are per-**number** instead, and take `phone_id` or `phone_number_id` like every other send.
## Providers
`provider_name` picks how the money is collected.
| `provider_name` | What it is | Ready when |
|---|---|---|
| `upi_vpa` | A UPI VPA handle you own, linked directly | Immediately |
| `razorpay` | Payment gateway | After the merchant completes the OAuth link |
| `payu` | Payment gateway | After the merchant completes the OAuth link |
| `zaakpay` | Payment gateway | After the merchant completes the OAuth link |
A gateway configuration exists as soon as you create it but **cannot take a payment** until the merchant visits the `oauth_url` the create returns. Until then, an order details message quoting it will not be payable.
## Create a payment configuration
`POST /api/v1/whatsapp/payment_configurations` · scope `whatsapp:write`
| Field | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA to configure |
| `configuration_name` | string, 1 to 60 chars | Yes | The name you quote as `payment_configuration` when sending an order |
| `provider_name` | string, 1 to 32 chars | Yes | One of the providers above |
| `merchant_vpa` | string, max 256 | For `upi_vpa` | The VPA handle to collect into |
| `merchant_category_code` | string, max 32 | No | Your MCC |
| `purpose_code` | string, max 32 | No | Purpose code, where your provider requires one |
| `redirect_url` | string, max 2048 | No | Where to send the merchant after they finish the OAuth link |
:::tabs
```bash [UPI VPA]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"configuration_name": "acme-upi",
"provider_name": "upi_vpa",
"merchant_vpa": "acmecoffee@okhdfcbank"
}'
```
```bash [Gateway]
curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"configuration_name": "acme-razorpay",
"provider_name": "razorpay",
"redirect_url": "https://acme.example.com/payments/linked"
}'
```
:::
**Response (200 OK)**
```json
{
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"configuration_name": "acme-razorpay",
"success": true,
"oauth_url": "https://business.example.com/payments/link?token=...",
"expiration": 1776000000
}
```
| Field | Type | Notes |
|---|---|---|
| `account_id` | UUID | The WABA's CallMissed id |
| `waba_id` | string | Meta's WABA id |
| `configuration_name` | string | Echoes the name you created |
| `success` | boolean | Whether the configuration was created |
| `oauth_url` | string, nullable | Present for a gateway provider only. The merchant must visit it to finish linking |
| `expiration` | integer, nullable | When that link stops working |
## List payment configurations
`GET /api/v1/whatsapp/payment_configurations` · scope `whatsapp:read`
Read live, with no cached fallback, so you never see a status we stored earlier and never refreshed.
| Query param | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA to read |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/payment_configurations?waba_id=102290129340398" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"payment_configurations": [
{
"configuration_name": "acme-upi",
"status": "Active",
"provider_name": "upi_vpa",
"provider_mid": null,
"merchant_vpa": "acmecoffee@okhdfcbank",
"merchant_category_code": { "code": "5814", "description": "Restaurants" },
"purpose_code": null,
"created_timestamp": 1774000000,
"updated_timestamp": 1774000000
}
]
}
```
### The payment configuration object
| Field | Type | Notes |
|---|---|---|
| `configuration_name` | string | The name you quote when sending an order |
| `status` | string, nullable | `Active`, `Needs_Connecting` or `Needs_Testing`. Only `Active` can take a payment |
| `provider_name` | string, nullable | The provider it was created with |
| `provider_mid` | string, nullable | The gateway's merchant id, where the provider issues one |
| `merchant_vpa` | string, nullable | The VPA handle, for a `upi_vpa` configuration |
| `merchant_category_code` | string or object, nullable | Reported either as a plain code or as `{ code, description }` |
| `purpose_code` | string or object, nullable | Same, when set |
| `created_timestamp` / `updated_timestamp` | integer, nullable | Epoch seconds |
Fields are broadly optional because the read endpoints and the status webhook each report a different subset.
## Get one payment configuration
`GET /api/v1/whatsapp/payment_configurations/{configuration_name}` · scope `whatsapp:read`
| Query param | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA to read |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-upi?waba_id=102290129340398" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns a single [payment configuration object](#the-payment-configuration-object), or `404` when no configuration on that WABA carries the name.
Poll this after creating a gateway configuration to see it move to `Active` once the merchant has finished linking.
## Regenerate the OAuth link
`POST /api/v1/whatsapp/payment_configurations/{configuration_name}/oauth_link` · scope `whatsapp:write`
The link a gateway create returns expires. This is how a merchant who never finished linking, or whose link went stale, gets a fresh one without recreating the configuration.
| Field | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA |
| `redirect_url` | string | No | Where to send the merchant afterwards |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-razorpay/oauth_link \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"redirect_url": "https://acme.example.com/payments/linked"
}'
```
**Response (200 OK)**
```json
{
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"configuration_name": "acme-razorpay",
"oauth_url": "https://business.example.com/payments/link?token=...",
"expiration": 1776000000
}
```
Only meaningful for a gateway provider. A `upi_vpa` configuration has nothing to link.
## Delete a payment configuration
`DELETE /api/v1/whatsapp/payment_configurations/{configuration_name}` · scope `whatsapp:write`
| Query param | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA |
```bash
curl -X DELETE "https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-razorpay?waba_id=102290129340398" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"configuration_name": "acme-razorpay",
"success": true
}
```
> **Stop sending first.** Make sure no new order messages quote this configuration before you unlink it, or those bills will have nowhere to collect into.
## Billing a customer
The two sends live with the rest of the send reference:
- [Send an order details message](/docs/whatsapp-messages#send-an-order-details-message) — the itemised bill, with the money rules and the full `order` shape
- [Send an order status update](/docs/whatsapp-messages#send-an-order-status-update) — the update that settles it
Both need the `whatsapp:send` scope and both are window-limited like any other free-form send. Tie the two together with `reference_id`: it is unique per order details message, and quoting it on an order status update is what moves that specific order off "Order pending".
---
### Business Setup
URL: /docs/whatsapp-setup
> Connect a Meta WhatsApp Business Account to CallMissed, register the number, link an agent, and let AI write the first system prompt and templates.
Connecting a number binds your **WhatsApp Business Account (WABA)** to CallMissed, subscribes CallMissed to the WABA's webhooks, and registers the number on the WhatsApp Cloud API. After that, inbound messages flow to your agent and you can send from the API.
There are two ways to connect, and one thing you never have to do: **you do not configure a webhook in Meta**. Connecting subscribes the CallMissed app to your WABA automatically. See [the Meta-facing webhook](/docs/whatsapp-api#the-meta-facing-webhook) if you want to know what that endpoint is.
## Prerequisites
- A **Meta Business account** with a verified business.
- A **WhatsApp Business Account** and a phone number in [WhatsApp Manager](https://business.facebook.com/wa/manage/). The number must not be tied to a personal WhatsApp app.
- A payment method on the WABA in WhatsApp Manager. Until Meta has one, sends fail.
- A CallMissed workspace. The manual path additionally needs the **owner** or **admin** role.
## Option 1: connect from the dashboard
Go to **Settings → Integrations → WhatsApp** in the [dashboard](https://console.callmissed.com) and follow Embedded Signup. Meta's popup handles the account selection and consent, and CallMissed does the rest: exchanging the authorisation code, subscribing to webhooks, and registering the number with a two-step verification PIN.
This is the recommended path. It is also the only path that registers a brand-new number for you.
## Option 2: connect an existing WABA over the API
Use this when the number was set up outside Embedded Signup, for example registered directly in the Meta dashboard or migrated from another provider.
`POST /api/v1/whatsapp/onboarding/manual`
**Owner or admin dashboard login only.** This endpoint accepts a long-lived business token in the body, so an API key cannot call it: even a key with `whatsapp:write` gets `403`. Authenticate with a dashboard session JWT.
| Field | Type | Required | Notes |
|---|---|---|---|
| `waba_id` | string, 1 to 64 chars | Yes | Meta's WABA id |
| `phone_number_id` | string, 1 to 64 chars | Yes | Meta's phone number id, not the phone number itself |
| `access_token` | string, 20 to 4096 chars | Yes | A long-lived business token. A System User token is strongly recommended, since 24-hour tokens break delivery when they expire |
| `business_id` | string, max 64 | No | Meta business id |
| `bot_id` | UUID | No | Link an agent to the number in the same call |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/onboarding/manual \
-H "Authorization: Bearer eyJhbGciOi...your-dashboard-session-jwt" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"phone_number_id": "1234567890",
"business_id": "441329482726",
"access_token": "EAAG...long-lived-system-user-token",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
}'
```
The token is encrypted at rest. Meta's `register` step is skipped, because a number provisioned outside Embedded Signup is already registered and calling it again would fail and burn your registration quota. Webhook subscription is still attempted, so events flow.
**Response (200 OK)**
```json
{
"account": {
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"waba_id": "102290129340398",
"business_id": "441329482726",
"name": "Acme Coffee",
"currency": "INR",
"review_status": "APPROVED",
"account_status": "ACTIVE",
"account_restriction_reason": null,
"payment_setup_complete": true,
"is_active": true,
"created_at": "2026-04-19T12:00:00Z"
},
"phone_number": {
"id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"phone_number_id": "1234567890",
"display_phone_number": "+91 80802 47309",
"verified_name": "Acme Coffee",
"code_verification_status": "VERIFIED",
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"throughput_level": "STANDARD",
"registration_status": "REGISTERED",
"registration_error": null,
"name_status": "APPROVED",
"ai_autoreply_enabled": true,
"is_active": true,
"created_at": "2026-04-19T12:00:00Z"
},
"fully_provisioned": true,
"onboarding_error": null
}
```
| Field | Type | Notes |
|---|---|---|
| `account` | object | The connected WABA |
| `phone_number` | object | The connected number. Its `id` is the `phone_id` you use everywhere else |
| `fully_provisioned` | boolean | `false` means the rows exist but a setup step did not complete. The connection is recoverable, so retry rather than starting over |
| `onboarding_error` | string, nullable | A short reason when `fully_provisioned` is `false` |
**Failures**
| Code | Meaning |
|---|---|
| `403` | Not an owner or admin, or called with an API key instead of a dashboard session |
| `404` | The `bot_id` does not belong to your workspace |
| `409` | The WABA or number is already connected to a different workspace |
| `422` | Meta rejected the token or the ids |
## Completing Embedded Signup yourself
If you are building your own Embedded Signup flow rather than using the dashboard, post Meta's callback data to:
`POST /api/v1/whatsapp/onboarding/exchange` · scope `whatsapp:write`
| Field | Type | Required | Notes |
|---|---|---|---|
| `code` | string, 1 to 2048 chars | Yes | The exchangeable code from Meta's login callback. It expires in about 30 seconds, so post it immediately |
| `waba_id` | string, 1 to 64 chars | Yes | From the signup event data |
| `phone_number_id` | string, 1 to 64 chars | Yes | From the signup event data |
| `business_id` | string, max 64 | No | From the signup event data |
| `bot_id` | UUID | No | Link an agent in the same call |
| `data_localization_region` | string, exactly 2 chars | No | ISO 3166-1 alpha-2 for data-at-rest residency, from Meta's supported list |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/onboarding/exchange \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"code": "AQBx-hBsH...code-from-meta",
"waba_id": "102290129340398",
"phone_number_id": "1234567890",
"business_id": "441329482726",
"data_localization_region": "IN"
}'
```
CallMissed exchanges the code for a business token, saves the account and number, subscribes to the WABA's webhooks, and registers the number with a two-step verification PIN it generates and stores. Returns the same object as the manual path.
The rows are saved before registration is attempted, so a failure at the last step leaves a recoverable connection rather than losing the WABA association. Check `fully_provisioned` and retry if it is `false`.
> **Reconnecting a number keeps its original PIN.** WhatsApp has no way to disable two-step verification, so a reconnect reuses the stored PIN. If it cannot be read, you get `409` asking you to reset the PIN in WhatsApp Manager first. Guessing would burn Meta's limit of registration attempts.
## Link an agent
A connected number stores inbound messages but stays silent until an agent is linked.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }'
```
See [Phone numbers](/docs/whatsapp-api#phone-numbers) for linking, pausing auto-reply, refreshing metadata from Meta, disconnecting and deleting.
## Let AI write the first setup
Two endpoints turn a description of your business into a working agent. Both are billed to your workspace.
### Bootstrap the whole number
`POST /api/v1/whatsapp/ai/bootstrap` · scope `whatsapp:write`
Generates a system prompt, a persona, a welcome message and a set of starter templates from a plain-language description, and optionally applies them to the number's agent.
| Field | Type | Required | Notes |
|---|---|---|---|
| `phone_number_id` | UUID | Yes | The **CallMissed** phone id, from `GET /phone_numbers` |
| `company_description` | string, 10 to 2000 chars | Yes | What the business does and how it wants to sound |
| `mode` | enum | No | `suggest`, `auto_apply` or `autonomous`. Default `auto_apply` |
| `language` | string, 2 to 8 chars | No | Default `en` |
`suggest` changes nothing and returns the plan for review. `auto_apply` and `autonomous` write the system prompt and create the templates.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/bootstrap \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d",
"company_description": "Acme Coffee is an Indian D2C roastery. We sell single-origin beans by subscription, ship in 2 to 3 days, and get asked about order status, roast dates and returns.",
"mode": "suggest",
"language": "en"
}'
```
**Response (200 OK)**
```json
{
"bootstrap_id": null,
"mode": "suggest",
"applied": false,
"system_prompt": "You are the support agent for Acme Coffee, an Indian D2C roastery...",
"persona": {
"name": "Acme Coffee Support",
"tone": "warm and concise",
"signature": "Acme Coffee"
},
"welcome_message": "Hi, this is Acme Coffee. Ask me about an order, a roast date, or a return.",
"templates": [
{
"name": "order_shipped",
"category": "UTILITY",
"body": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days.",
"applied": false,
"template_id": null
}
]
}
```
| Field | Type | Notes |
|---|---|---|
| `bootstrap_id` | UUID, nullable | Present when the plan was applied. Pass it to undo |
| `mode` | string | Echoes the requested mode |
| `applied` | boolean | Whether the plan was written to the agent |
| `system_prompt` | string | The generated prompt |
| `persona` | object | `name`, `tone` and `signature` |
| `welcome_message` | string | Suggested opening line |
| `templates[]` | array | Each with `name`, `category`, `body`, `applied` and `template_id` when created |
`400` when the plan cannot be applied, for example the number does not exist. `422` when the model cannot produce a usable plan.
### Undo a bootstrap
`POST /api/v1/whatsapp/ai/bootstrap/{bootstrap_id}/undo` · scope `whatsapp:write`
No body. Restores the agent's previous system prompt and removes the still-pending templates the bootstrap created, within a 7-day window. Templates Meta has already approved are kept, because deleting them would break sends already relying on them.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/bootstrap/7b3c9d10-2e4f-4a5b-8c6d-9e0f1a2b3c4d/undo \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
{
"undone": true,
"reverted_at": "2026-04-19T12:44:03+00:00",
"reason": "ok"
}
```
| Field | Type | Notes |
|---|---|---|
| `undone` | boolean | Whether anything was reverted |
| `reverted_at` | string, nullable | ISO 8601 timestamp of the revert, `null` when nothing was reverted |
| `reason` | string | `ok` on success. Otherwise `bootstrap_id not found` or `undo window expired` |
A refusal is still a `200`: read `undone`, not the status code.
### Write or improve a system prompt
`POST /api/v1/whatsapp/ai/build_system_prompt` · scope `whatsapp:read`
Read-only. Returns markdown you review and save on the agent yourself.
| Field | Type | Required | Notes |
|---|---|---|---|
| `intent` | string, 10 to 8000 chars | Yes | The business and what the agent should do |
| `language` | string, 2 to 8 chars | No | Default `en` |
| `tone` | string, max 40 | No | For example `friendly`, `formal` |
| `existing_prompt` | string, max 8000 | No | Set this to improve an existing prompt instead of starting fresh |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/build_system_prompt \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"intent": "Support agent for Acme Coffee. Handle order status, roast dates and returns. Escalate anything about a refund over 5000 rupees to a human.",
"tone": "warm and concise"
}'
```
**Response (200 OK)**
```json
{
"system_prompt": "## Role\nYou are the support agent for Acme Coffee...\n\n## Rules\n- Answer in under 60 words..."
}
```
`422` when the model cannot produce a usable prompt. Shorten or clarify the intent and retry.
To draft message templates the same way, see [Draft a template with AI](/docs/whatsapp-templates#draft-a-template-with-ai).
## Verify it works
Message your business number from a personal WhatsApp account. Then:
1. `GET /api/v1/whatsapp/webhook_events` shows the raw event arriving with `signature_valid: true`.
2. Your own webhook subscription receives a `message.received` event. See [Inbound events](/docs/whatsapp-api#inbound-events-you-receive).
3. The agent replies, and the thread appears in the dashboard inbox.
If the message arrives but nothing replies, walk the [auto-reply checklist](/docs/whatsapp#when-the-bot-replies). The usual cause is a number that was never linked to a bot.
> **Going to production.** While your number is in Meta's test mode you can only message numbers you have added as recipients. Submit your business for verification and request production access in the Meta dashboard to message any customer who opts in. Sends also fail until Meta approves a display name for the number, which shows as `name_status` on the phone-number object.
---
### Message Templates
URL: /docs/whatsapp-templates
> Create, list, delete and sync WhatsApp message templates, including authentication templates and the AI drafting endpoint.
A message template is pre-approved copy you can send **outside** the 24-hour customer service window. Order updates, delivery notices, reminders and one-time codes are all template sends. Templates are created on WhatsApp, reviewed by Meta, and mirrored locally so you can list and filter them without a Meta round trip.
All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`.
## Lifecycle
:::flow
icon:gateway | Create | `POST /templates` validates the copy locally, then submits it to WhatsApp
icon:llm | Review | Meta reviews it. The template sits at `PENDING`
icon:done | Approved | A status webhook flips it to `APPROVED` and it becomes sendable
:::
Statuses you will see: `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`, `DISABLED`, `IN_APPEAL`. Only `APPROVED` templates can be sent. A rejected template carries a `rejection_reason`.
## Choosing the WABA
Template endpoints act on a WhatsApp Business Account rather than a phone number. Supply **exactly one**:
| Field | Type | Where it comes from |
|---|---|---|
| `account_id` | UUID | The `id` from `GET /accounts` |
| `waba_id` | string, max 64 | Meta's WABA id |
Omitting both returns `400` with `"Either account_id (UUID) or waba_id (Meta) is required"`. On `GET /templates` these are optional filters instead.
## Create a template
`POST /api/v1/whatsapp/templates` · scope `whatsapp:write`
| Field | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA to create under |
| `name` | string, 1 to 512 chars | Yes | Must match `^[a-z0-9_]+$`: lowercase letters, digits and underscores only |
| `category` | string | Yes | `MARKETING`, `UTILITY` or `AUTHENTICATION` |
| `language` | string, 2 to 12 chars | Yes | Locale, for example `en_US`, `hi`, `es_MX` |
| `components` | array of objects, at least 1 | Yes | Header, body, footer and button spec. Must include a `BODY` |
| `parameter_format` | string | No | `POSITIONAL` or `NAMED`, selecting the variable syntax |
| `allow_category_change` | boolean | No | Let Meta re-categorise the template. Defaults to on, so only send this to opt out |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"name": "order_shipped",
"category": "UTILITY",
"language": "en_US",
"components": [
{ "type": "HEADER", "format": "TEXT", "text": "Your order is on its way" },
{
"type": "BODY",
"text": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days.",
"example": { "body_text": [["Priya", "AC-10294"]] }
},
{ "type": "FOOTER", "text": "Acme Coffee" }
]
}'
```
`components` is forwarded to WhatsApp unchanged, so any component type WhatsApp supports works, including button blocks. `BODY`, `FOOTER`, text `HEADER` and the three marketing formats below ([carousel](#carousel-templates), [limited-time offer](#limited-time-offer-templates), [coupon code](#coupon-code-templates)) are checked locally first; everything else is validated by Meta.
**Response (200 OK)**
```json
{
"template_id": "1234567890123456",
"status": "PENDING",
"template": {
"id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"template_id": "1234567890123456",
"name": "order_shipped",
"language": "en_US",
"category": "UTILITY",
"status": "PENDING",
"quality_score": "UNKNOWN",
"rejection_reason": null,
"components": [],
"last_meta_synced_at": null,
"created_at": "2026-04-19T12:00:00Z",
"updated_at": "2026-04-19T12:00:00Z"
}
}
```
| Field | Type | Notes |
|---|---|---|
| `template_id` | string, nullable | Meta's template id |
| `status` | string | Initial lifecycle state, typically `PENDING` |
| `template` | object | The mirrored row, described in [the template object](#the-template-object) |
The local row is written only after WhatsApp accepts the create, so a rejection leaves nothing behind.
### Validation before submission
Copy is checked locally first, so a guaranteed rejection does not cost a Meta round trip. Each of these returns `400` with the reason:
| Rule | Message you get |
|---|---|
| Name outside `^[a-z0-9_]+$` | `template name must match ^[a-z0-9_]+$ (lowercase letters, digits, and underscores only)` |
| No `BODY` component | `A BODY component is required.` |
| Empty `BODY` text | `The BODY component requires non-empty text.` |
| `BODY` over 1024 characters | `BODY text exceeds 1024 characters.` |
| `FOOTER` over 60 characters | `FOOTER text exceeds 60 characters.` |
| `{{N}}` variables with no example | `A component with {{N}} variables requires an 'example.body_text' array.` |
| Example count does not match the variable count | `The example provides 1 value(s) but the text has 2 {{N}} variable(s).` |
| An `example` on a component with no variables | `Omit the 'example' object on a component with no {{N}} variables -- Meta rejects an empty example.` |
`example.body_text` is an **array of arrays**: one inner array holding a sample value per variable. A text `HEADER` with variables uses `example.header_text`, a flat array.
### Authentication templates
One-time-code templates have a different body shape. Meta owns the verification copy, so you must **not** send `BODY` text:
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"name": "acme_login_code",
"category": "AUTHENTICATION",
"language": "en_US",
"components": [
{ "type": "BODY", "add_security_recommendation": true }
]
}'
```
The only body option is the boolean `add_security_recommendation`, and there is no `example` because there is no sender-supplied variable in the body. Sending `BODY` text on an `AUTHENTICATION` template returns `400` telling you the verification-code copy is fixed by Meta, and a non-boolean `add_security_recommendation` returns `400` as well. Any additional button or footer options come from Meta's authentication-template reference and are passed through unchanged.
Once approved, send the code through [`POST /messages/template`](/docs/whatsapp-messages#send-a-template-message), passing it as the body or button parameter.
### Carousel templates
A carousel pairs a normal message `BODY` with a swipeable row of cards, each with its own media header and buttons. Add a `CAROUSEL` component alongside the `BODY`.
Carousels are **`MARKETING` only**. Under any other category the create returns `400` naming the format.
| Rule | Detail |
|---|---|
| `cards` | 2 to 10. The count is fixed at creation: an approved template can only send the number of cards it was created with |
| Card `HEADER` | Required on every card, and always media. `format` is `IMAGE` or `VIDEO` |
| Card header media | `example.header_handle` must be a non-empty array holding an uploaded media handle |
| Card `BODY` | Optional, but if one card has it every card must. Text max 160 characters, far shorter than the 1024-character message body. Variables need an `example` object |
| Card `BUTTONS` | Optional, at most 2 per card, of type `QUICK_REPLY`, `URL` or `PHONE_NUMBER` |
| Uniform structure | Every card must carry the same components in the same order, and the same button types. Cards render at a shared height, so a body or button on one card is required on all |
| Top-level `BODY` | Still required, alongside the `CAROUSEL` component |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"name": "summer_blends_carousel",
"category": "MARKETING",
"language": "en_US",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, our cold brew blends are 20% off this week.",
"example": { "body_text": [["Priya"]] }
},
{
"type": "CAROUSEL",
"cards": [
{
"components": [
{
"type": "HEADER",
"format": "IMAGE",
"example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ1"] }
},
{ "type": "BODY", "text": "Ratnagiri Dark Roast, notes of cocoa and dried fig." },
{
"type": "BUTTONS",
"buttons": [
{ "type": "QUICK_REPLY", "text": "Send me a sample" },
{ "type": "URL", "text": "Shop now", "url": "https://acme.example.com/dark-roast" }
]
}
]
},
{
"components": [
{
"type": "HEADER",
"format": "IMAGE",
"example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ2"] }
},
{ "type": "BODY", "text": "Chikmagalur Medium Roast, bright and citrus-forward." },
{
"type": "BUTTONS",
"buttons": [
{ "type": "QUICK_REPLY", "text": "Send me a sample" },
{ "type": "URL", "text": "Shop now", "url": "https://acme.example.com/medium-roast" }
]
}
]
}
]
}
]
}'
```
Every rule above is checked before submission, and the `400` names the card index and the field, so you do not have to reverse-engineer a generic rejection.
### Limited-time offer templates
A limited-time offer adds an offer banner with an optional countdown. Add a `LIMITED_TIME_OFFER` component. `MARKETING` only.
| Rule | Detail |
|---|---|
| `limited_time_offer` | Required object: `{ "text": string (max 16), "has_expiration": boolean }`. `text` is the offer label |
| `BODY` | Max 600 characters on this format, stricter than the usual 1024 |
| `HEADER` | Optional, but when present must be `IMAGE` or `VIDEO`. A text header is not supported |
| `FOOTER` | Not supported at all. Sending one returns `400` |
| `BUTTONS` | Only `COPY_CODE` and `URL`. When both are present the `COPY_CODE` button must be declared first, because it is fixed at button index 0 and the URL button at index 1 |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"name": "monsoon_offer",
"category": "MARKETING",
"language": "en_US",
"components": [
{
"type": "HEADER",
"format": "IMAGE",
"example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ1"] }
},
{
"type": "BODY",
"text": "Hi {{1}}, take 20% off your next bag of coffee.",
"example": { "body_text": [["Priya"]] }
},
{
"type": "LIMITED_TIME_OFFER",
"limited_time_offer": { "text": "20% off", "has_expiration": true }
},
{
"type": "BUTTONS",
"buttons": [
{ "type": "COPY_CODE", "example": "MONSOON20" },
{ "type": "URL", "text": "Shop now", "url": "https://acme.example.com/shop" }
]
}
]
}'
```
`has_expiration: true` renders a countdown, whose expiry is supplied per send as a component parameter on [`POST /messages/template`](/docs/whatsapp-messages#send-a-template-message). `components` is forwarded to WhatsApp unchanged on a template send, so the parameter shape is WhatsApp's own.
### Coupon code templates
A `COPY_CODE` button gives the customer a one-tap copy of a discount code. It works on its own marketing template, and is also the button an LTO template uses. `MARKETING` only.
| Rule | Detail |
|---|---|
| Button shape | `{ "type": "COPY_CODE", "example": "" }`. The button's label is fixed, so there is no `text` to set |
| `example` | Required, a sample coupon code, max 20 characters. The same cap applies to the code you pass at send time |
| Count | At most one `COPY_CODE` button per template |
| Companions | A `QUICK_REPLY` button may accompany it |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"waba_id": "102290129340398",
"name": "welcome_coupon",
"category": "MARKETING",
"language": "en_US",
"components": [
{
"type": "BODY",
"text": "Welcome to Acme, {{1}}. Here is 15% off your first order.",
"example": { "body_text": [["Priya"]] }
},
{
"type": "BUTTONS",
"buttons": [
{ "type": "COPY_CODE", "example": "WELCOME15" },
{ "type": "QUICK_REPLY", "text": "Browse blends" }
]
}
]
}'
```
The `example` is a sample for review, not the code you ship. The real code goes in a `coupon_code` button parameter per send, capped at the same 20 characters, so one approved template can issue a different code to every customer.
An `AUTHENTICATION` template's `{ "type": "OTP", "otp_type": "COPY_CODE" }` button is a different component on a different template family and is not subject to these rules.
## List templates
`GET /api/v1/whatsapp/templates` · scope `whatsapp:read`
Reads the local mirror, newest updated first. All parameters are optional filters.
| Param | Type | Default | Notes |
|---|---|---|---|
| `account_id` | UUID | none | Filter to one connected account |
| `waba_id` | string, max 64 | none | Filter by Meta WABA id |
| `status` | string | none | `APPROVED`, `PENDING`, `REJECTED`, `PAUSED`, `DISABLED`, `IN_APPEAL`. Case-insensitive |
| `category` | string | none | `MARKETING`, `UTILITY` or `AUTHENTICATION`. An unknown value returns `400` |
| `language` | string, max 12 | none | Filter by locale |
| `limit` | integer, 1 to 500 | 100 | Max rows |
```bash
curl "https://api.callmissed.com/api/v1/whatsapp/templates?status=APPROVED&limit=100" \
-H "Authorization: Bearer cm_your_api_key"
```
**Response (200 OK)**
```json
[
{
"id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e",
"account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"template_id": "1234567890123456",
"name": "order_shipped",
"language": "en_US",
"category": "UTILITY",
"status": "APPROVED",
"quality_score": "GREEN",
"rejection_reason": null,
"components": [
{ "type": "BODY", "text": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days." },
{ "type": "FOOTER", "text": "Acme Coffee" }
],
"last_meta_synced_at": "2026-04-19T13:02:44Z",
"created_at": "2026-04-19T12:00:00Z",
"updated_at": "2026-04-19T13:02:44Z"
}
]
```
The mirror is kept current by status webhooks and an hourly reconciliation sweep. For up-to-the-second consistency, call [sync](#sync-from-whatsapp) first.
### The template object
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | CallMissed's id. Use it on get and delete |
| `account_id` | UUID | The owning WABA |
| `template_id` | string, nullable | Meta's template id. Null if Meta never confirmed the create |
| `name` | string | Template name |
| `language` | string | Locale |
| `category` | string | `MARKETING`, `UTILITY` or `AUTHENTICATION` |
| `status` | string | Lifecycle state |
| `quality_score` | string | Meta's quality signal, for example `GREEN` or `UNKNOWN` |
| `rejection_reason` | string, nullable | Why Meta rejected it |
| `components` | array of objects | The approved component spec |
| `last_meta_synced_at` | datetime, nullable | Last reconciliation against Meta |
| `created_at` / `updated_at` | datetime | ISO 8601 UTC |
## Get one template
`GET /api/v1/whatsapp/templates/{template_uuid}` · scope `whatsapp:read`
`{template_uuid}` is the `id` field, not Meta's `template_id`. Returns the template object, or `404` if it is not on your workspace.
```bash
curl https://api.callmissed.com/api/v1/whatsapp/templates/3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e \
-H "Authorization: Bearer cm_your_api_key"
```
## Delete a template
`DELETE /api/v1/whatsapp/templates/{template_uuid}` · scope `whatsapp:write`
Deletes on WhatsApp and drops the local row. Returns `204 No Content` with an empty body.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/templates/3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e \
-H "Authorization: Bearer cm_your_api_key"
```
If the template was already deleted in WhatsApp Manager, the local row is cleaned up anyway. A template that never got a Meta id is simply dropped locally.
> **Deleting an approved template starts a 30-day cooldown** before the same **name** can be reused. Reusing it sooner fails at create time.
## Sync from WhatsApp
`POST /api/v1/whatsapp/templates/sync` · scope `whatsapp:write`
Pulls every template for a WABA from WhatsApp and upserts the local mirror. An hourly sweep does this automatically, so call it when you have just edited templates in WhatsApp Manager and want them reflected immediately.
| Field | Type | Required | Notes |
|---|---|---|---|
| `account_id` / `waba_id` | UUID / string | One of | The WABA to reconcile |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates/sync \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "waba_id": "102290129340398" }'
```
**Response (200 OK)**
```json
{ "waba_id": "102290129340398", "fetched": 12, "inserted": 2, "updated": 10 }
```
| Field | Type | Notes |
|---|---|---|
| `waba_id` | string | The WABA that was reconciled |
| `fetched` | integer | Templates WhatsApp returned |
| `inserted` | integer | New local rows |
| `updated` | integer | Existing rows refreshed |
Templates WhatsApp no longer returns are not deleted by this call. The background sweep owns that.
## Draft a template with AI
`POST /api/v1/whatsapp/ai/draft_template` · scope `whatsapp:read`
Turns a plain-language intent into a Meta-compliant draft, with an approval-risk assessment. It is read-only: nothing is submitted to WhatsApp, so review the draft and then post it to [create](#create-a-template) yourself. The generation is billed to your workspace.
| Field | Type | Required | Notes |
|---|---|---|---|
| `intent` | string, 10 to 1000 chars | Yes | What the template should say and when it is sent |
| `language` | string, 2 to 8 chars | No | Default `en` |
| `category` | string | No | Force `UTILITY`, `MARKETING` or `AUTHENTICATION`. Omit to let the model choose |
| `emojis` | boolean | No | Allow emojis in the body. Default `false`, which is safest for approval |
| `include_header` | boolean | No | Allow a header line. Default `true` |
| `include_footer` | boolean | No | Allow a footer line. Default `true` |
| `tone` | string, max 40 | No | For example `friendly`, `formal`, `concise` |
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/draft_template \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"intent": "Tell a customer their coffee subscription renews in three days and they can skip or change the blend before then.",
"language": "en",
"category": "UTILITY",
"tone": "friendly"
}'
```
**Response (200 OK)**
```json
{
"name": "subscription_renewal_reminder",
"category": "UTILITY",
"language": "en",
"body": "Hi {{1}}, your Acme coffee subscription renews on {{2}}. Reply SKIP to pause this delivery or CHANGE to pick a different blend.",
"header_text": "Your subscription renews soon",
"footer_text": "Acme Coffee",
"components": [
{ "type": "HEADER", "format": "TEXT", "text": "Your subscription renews soon" },
{
"type": "BODY",
"text": "Hi {{1}}, your Acme coffee subscription renews on {{2}}. Reply SKIP to pause this delivery or CHANGE to pick a different blend.",
"example": { "body_text": [["Priya", "22 April"]] }
},
{ "type": "FOOTER", "text": "Acme Coffee" }
],
"approval_risk": "low",
"rejection_risks": [],
"compliance_notes": "Transactional reminder tied to an existing subscription, so UTILITY is the correct category."
}
```
| Field | Type | Notes |
|---|---|---|
| `name` | string | Suggested template name, already matching Meta's naming rules |
| `category` | string | The category chosen or forced |
| `language` | string | Echoes the requested locale |
| `body` / `header_text` / `footer_text` | string, nullable for header and footer | The drafted copy |
| `components` | array of objects | Ready to post to `POST /templates` as-is |
| `approval_risk` | string | The model's read on how likely Meta is to approve it |
| `rejection_risks` | array of strings | Specific things that could get it rejected. Empty when none were found |
| `compliance_notes` | string, nullable | Why the category and wording were chosen |
`422` when the model cannot produce a valid draft. Shorten or clarify the intent and retry.
---
### WhatsApp Bot
URL: /docs/whatsapp
> Run an AI agent on your WhatsApp Business number: connect a WABA, auto-reply to inbound messages, and send, template, campaign and call through one API.
CallMissed connects your **WhatsApp Business Account (WABA)** to an AI agent. Inbound messages land on a webhook, get stored as a conversation, and are answered by your bot's system prompt plus knowledge base. Everything the agent can do by itself you can also do programmatically over the REST API: send any WhatsApp message type, manage approved templates, run bulk template campaigns, place voice calls on WhatsApp, and read delivery analytics.
## What you get
| Capability | Where |
|---|---|
| AI auto-reply to inbound WhatsApp messages | Automatic once a bot is linked to a number |
| Send text, template, media, interactive, flow, location, reaction, contact cards | [Sending Messages](/docs/whatsapp-messages) |
| Create, list, delete and sync message templates, including carousel, limited-time offer and coupon formats | [Message Templates](/docs/whatsapp-templates) |
| Bulk template sends with per-recipient variables | [Campaigns](/docs/whatsapp-campaigns) |
| Take UPI payments in the chat with order details and order status messages | [Payments](/docs/whatsapp-payments) |
| Voice calls over WhatsApp, answered by the same agent | [Calling](/docs/whatsapp-calling) |
| Connected accounts, numbers, ice breakers and commands, delivery funnel, cost | [WhatsApp API](/docs/whatsapp-api) |
## Two ways in
**Dashboard.** Connect a number under **Settings → Integrations → WhatsApp**, create a bot, link the two, and the agent starts replying. Nothing to build.
**API.** Everything the dashboard does is an endpoint under `https://api.callmissed.com/api/v1/whatsapp`, authenticated with a `cm_` API key. Use it to embed WhatsApp into your own product, run campaigns from your backend, or ship a custom inbox.
The two share one data model. A number connected in the dashboard is immediately sendable from the API, and a message sent over the API appears in the dashboard conversation thread.
## Message flow
Meta posts every inbound event to a single CallMissed endpoint. You never configure that endpoint yourself: connecting a number subscribes the CallMissed app to your WABA's webhooks.
:::flow
icon:user | Customer | Sends a WhatsApp message to your business number
icon:gateway | Meta | POSTs the event to `/api/v1/webhooks/whatsapp` with an `X-Hub-Signature-256` header
icon:gateway | CallMissed | Verifies the signature, archives the raw event, and acknowledges with `200` immediately
icon:llm | Agent | Routes the number to its linked bot, stores the message, marks it read, and runs the LLM with the conversation history plus knowledge base
icon:done | Customer | Receives the reply through the WhatsApp Cloud API
:::
The acknowledgement is sent before the LLM runs, so a slow model never causes Meta to retry the event.
### When the bot replies
An inbound message is **always stored**. The agent only answers when all of these hold:
1. The number is **explicitly linked** to a bot (`POST /phone_numbers/{phone_id}/link-bot`). An unlinked number stores messages and stays silent.
2. The bot has a non-empty `system_prompt`.
3. AI auto-reply is on for the number (`ai_autoreply_enabled`, togglable per number).
4. AI auto-reply is on for that conversation (an agent can take over a single thread from the inbox without pausing the whole number).
5. The message is **text**, or an **image** when the bot's model supports vision. Audio, stickers, reactions, interactive replies and other types are stored but not auto-answered.
## Quickstart
Send your first message and get an AI reply in five calls. You need an API key with the `whatsapp:read`, `whatsapp:write` and `whatsapp:send` scopes, plus a connected number ([Business Setup](/docs/whatsapp-setup)).
:::steps
## Create the bot
```bash
curl -X POST https://api.callmissed.com/api/v1/bots \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Support",
"type": "whatsapp",
"system_prompt": "You are the support agent for Acme, an Indian D2C coffee brand. Answer in under 60 words. If asked about an order, ask for the order id first. Never invent a delivery date."
}'
```
The response carries the bot `id`. Keep it.
```json
{
"id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"tenant_id": "7f2e1d0c-9b8a-4756-b3c2-1a0f9e8d7c6b",
"name": "Acme Support",
"type": "whatsapp",
"system_prompt": "You are the support agent for Acme...",
"is_active": true
}
```
## Find your connected number
```bash
curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers \
-H "Authorization: Bearer cm_your_api_key"
```
Take `id` (CallMissed's `phone_id`) and `phone_number_id` (Meta's id) from the number you want to use.
## Link the bot to the number
Without this the bot never auto-replies.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }'
```
## Subscribe to inbound messages
Register your own HTTPS endpoint so every customer message is pushed to you.
```bash
curl -X POST https://api.callmissed.com/api/v1/webhooks \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/callmissed",
"events": ["message.received"]
}'
```
See [Inbound events](/docs/whatsapp-api#inbound-events-you-receive) for the exact payload and the signature header.
## Send a message
Free-form sends need an open 24-hour window (the customer messaged you within the last 24 hours). Outside it, send a template instead.
```bash
curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "1234567890",
"to": "+919000000000",
"text": "Thanks for reaching out. How can we help?"
}'
```
```json
{
"wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA=",
"contacts": [{ "input": "+919000000000", "wa_id": "919000000000" }]
}
```
:::
Now message your business number from a personal WhatsApp account. The message appears in the dashboard conversation, hits your webhook, and the agent answers.
## The 24-hour window
WhatsApp only allows free-form messages (text, media, interactive, location) inside the 24-hour customer service window that opens each time the user messages you. Outside it you must send an **approved template**.
A closed-window send returns `422` with an actionable message:
```json
{
"detail": "The 24-hour customer service window is closed. Send a template message instead, or wait for the user to message you."
}
```
Templates are never window-limited, which is why order updates, reminders and one-time codes are all template sends. See [Message Templates](/docs/whatsapp-templates).
## Bot configuration
A bot is channel-agnostic. What makes it a WhatsApp agent is the `link-bot` binding to a connected number, not its `config`. Credentials live on the connected number (encrypted at rest), so a linked bot needs no WhatsApp keys of its own.
```json
{
"name": "Acme Support",
"type": "whatsapp",
"system_prompt": "You are the support agent for Acme Coffee.",
"config": {
"model": "kimi-k2.5",
"language": "en"
}
}
```
Add product facts, FAQs and policies as [knowledge base](/docs/knowledge) entries. The agent calls a `search_knowledge_base` tool on demand and grounds its answer in what it retrieves.
## Where to next
:::cards
/docs/whatsapp-setup | Business Setup | Settings | Connect a WABA and register a number, with or without Embedded Signup.
/docs/whatsapp-api | WhatsApp API | Webhook | Auth, scopes, error shapes, accounts, numbers, analytics and inbound events.
/docs/whatsapp-messages | Sending Messages | Send | Every send endpoint plus media upload and download.
/docs/whatsapp-templates | Message Templates | FileText | Create, list, delete and sync approved templates.
/docs/whatsapp-campaigns | Campaigns | Megaphone | Bulk template sends with per-recipient variables.
/docs/whatsapp-payments | Payments | IndianRupee | UPI payment configurations, order details and order status messages.
/docs/whatsapp-calling | Calling | Phone | Voice calls over WhatsApp, answered by the same agent.
:::