' is not in the header (row N): …` | A mapped column does not exist. The message lists the header |
| `422 WhatsApp template not found` / `That WhatsApp template is not approved by Meta yet` | |
| `422 Choose a column for template variable(s): …` | A template placeholder has no column in `whatsapp_params` |
| `422 Template variable : column '' not in the header` | |
| `422 Choose the agent and the phone number to call from` | A `*_call` switch is on without `bot_id` and `from_number_id` |
| `422 Agent not found` / `Phone number not found or not active` | |
| `422 The calling window must start before it ends` | |
| `422 timezone_name must be an IANA zone like Asia/Kolkata` | |
### PATCH `/api/v1/sheet-automations/{automation_id}`
Accepts any field from the object table except `integration_id`, `spreadsheet_id` and `sheet` (the tab an automation watches cannot be changed; delete it and create a new one). Send only the fields you want to change. An empty string clears an optional column such as `payment_column`.
When the automation is (or stays) `enabled`, the result is validated exactly as on create and the same `422`/`409` errors apply. Sending `{"enabled": false}` pauses it without re-checking the sheet.
```bash
curl -X PATCH https://api.callmissed.com/api/v1/sheet-automations/{automation_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "prepaid_call": true, "call_window_end": 20 }'
```
Returns the updated automation. `404 Automation not found`.
### GET `/api/v1/sheet-automations/{automation_id}/orders`
The orders this automation has acted on, newest first. Rows that were already in the tab when it started are not listed.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/sheet-automations/{automation_id}/orders?limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"total": 1,
"items": [
{
"id": "0d1e…",
"order_key": "#1042",
"segment": "cod",
"phone_e164": "+919812345678",
"whatsapp_status": "sent",
"whatsapp_error": null,
"call_status": "done",
"call_attempts": 1,
"call_error": null,
"outcome": "confirmed",
"note": "Customer confirmed delivery address.",
"sheet_status": "Confirmed",
"sheet_error": null,
"created_at": "2026-09-25T08:03:00Z",
"updated_at": "2026-09-25T08:09:00Z"
}
]
}
```
| Field | Values |
| --- | --- |
| `segment` | `cod` or `prepaid` |
| `whatsapp_status` | `null` (not wanted), `pending`, `sending`, `sent`, `failed`, `skipped` |
| `call_status` | `null` (not wanted), `pending`, `calling`, `answered`, `done`, `failed` |
| `outcome` | `confirmed`, `cancelled`, `reschedule`, `unclear`, `no_answer` or `null` |
| `sheet_status` | The text last written to the status column, e.g. `Confirmed`, `No answer`, `Call scheduled` |
| `sheet_error` | Why the last write-back failed, if it did |
`404 Automation not found`.
### DELETE `/api/v1/sheet-automations/{automation_id}`
Stops the automation and deletes it with its order history. Returns `204`. `404 Automation not found`.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/sheet-automations/{automation_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## Errors
| Status | When |
| --- | --- |
| `400` | Unknown provider, or a Google Sheets-only endpoint called on another provider |
| `403` | Key is missing `integrations:read` / `integrations:write` |
| `404` | Integration, automation or Shopify integration is not in your account |
| `409` | Account already connected, tab already automated, or the stored Google credential needs reconnecting |
| `422` | Invalid or rejected credentials, an unreadable spreadsheet or tab, or an automation setting that does not match the sheet or your account |
Managing integrations and automations consumes no credits. The WhatsApp messages and calls an automation makes are billed like any other message or call.
---
### API Keys
URL: /docs/keys
> What an API key controls: service permissions, resource scopes, budget caps and logging.
> Keys are created, rotated and revoked in the **console** (Developer → API keys) — you cannot manage keys with a key, so there is no API for it. This page explains what the settings on a key actually do.
A key is shown in plaintext **once**, at creation. Store it immediately; if you lose it, the console can re-reveal it after an emailed one-time code, or you can rotate it.
## Service permissions
Permissions decide which AI services the key may call. A key without the matching permission gets `403 permission_denied`.
| Permission | Gates |
|------------|-------|
| `llm` | `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (+ `/anthropic/v1/messages`), `/v1/embeddings`, `/v1/batches` and `/v1/files` |
| `stt` | `/v1/audio/transcriptions`, `/v1/audio/translations` |
| `tts` | `/v1/audio/speech` |
| `search` | `/v1/search` |
| `image` | `/v1/images/generations`, `/v1/images/history` |
| `email` | The [Email API](/docs/email) |
| `*` | All services (the default for a new key) |
Voice sessions and the Managed Voice Agent sockets need **all three** of `stt`, `tts` and `llm`.
## Resource scopes
Scopes gate the platform resources the key may touch. They are independent of permissions and default to **empty = no resource access** — you opt in explicitly.
| Scope | Gates |
|-------|-------|
| `bots:read` / `bots:write` | View vs. create/update/delete agents |
| `conversations:read` / `conversations:write` | View vs. update conversations and handoffs |
| `knowledge:read` / `knowledge:write` | View vs. add/remove knowledge sources |
| `webhooks:write` | Manage webhook subscriptions and read their delivery log |
| `whatsapp:read` / `whatsapp:write` / `whatsapp:send` | Read vs. manage vs. send WhatsApp messaging |
| `*` | All resource scopes |
CRM, support desk, campaigns, telephony, integrations, gateway and voice-agent tooling each have their own pairs. The full list, with the page each one unlocks, is on [Authentication](/docs/authentication#resource-scopes).
A key created for plain inference — the common case — needs only service permissions. Leave scopes empty.
## Limits on a key
Each key carries its own guardrails, so a key handed to one service or environment cannot spend or reach beyond what you intended.
| Setting | What it does |
|---------|--------------|
| **Budget** | A credit cap for the key. Once spent, calls on that key fail with `402` until you raise it. Other keys keep working. |
| **RPM limit** | A per-key requests-per-minute ceiling, independent of your plan's overall quota. |
| **Allowed models** | Restricts the key to a named set of model ids, or `*` for everything your plan allows. |
| **Allowed search providers** | Restricts which providers `/v1/search` may use on this key. |
| **Domain allowlist** | Restricts which origins may send the key, for browser-side use. `*` allows any. |
## Logging
Logging is **off** by default on a new key and is opt-in per key:
- **Request logging** records metadata for each call — model, timing, token counts, credits spent.
- **Prompt logging** additionally stores message content. Enable it only when you need to inspect payloads.
## Expiry
A key can be given an expiry (1-365 days) when it is created. After that it gets `401`, and an `api_key.expired` [webhook](/docs/webhooks) fires. A key with no expiry set never expires.
## Rotating and revoking
Rotate a key in the console whenever it may have been exposed. Rotation issues a new secret for the **same** key: its id, name, permissions, scopes and budget carry over, and the old secret stops working within about a minute. There is no overlap window, so deploy the new secret straight away. Revocation likewise takes effect within about a minute; in-flight calls already authenticated are not retroactively cancelled. Because budgets, scopes and limits are per key, issuing one key per service or environment keeps a leak contained and makes usage attributable.
---
### Payment Requests
URL: /docs/payment-requests
> Track the payment links your agents send to customers through your own Razorpay account, and cap how much an agent may request.
## Overview
A **payment request** is one payment link an agent sent to one of your customers during a call or chat: an EMI instalment, an order payment, an invoice. The link is created with **your own Razorpay account**, so the money is paid into your account. It is never CallMissed credits and never passes through CallMissed.
Agents create payment requests with the `create_payment_link` tool once Razorpay is connected. On a WhatsApp chat the link is sent in the chat; elsewhere (for example on a phone call) Razorpay texts it to the customer's phone and emails it when an email is known. This API is read-only for the requests themselves: you list them, read one, and set the per-link limit.
Status changes arrive from Razorpay and are applied to the request, then delivered to you as `payment_request.*` [webhook events](/docs/webhooks).
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List, get, read settings | `integrations:read` |
| Update settings | `integrations:write` |
## Setup: connect Razorpay
1. In Razorpay Dashboard → Account & Settings → API Keys, create a key. Choose a webhook secret (at least 8 characters) and keep it for step 3.
2. Connect it with [`POST /api/v1/integrations`](/docs/integrations):
```bash
curl -X POST https://api.callmissed.com/api/v1/integrations \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"provider": "razorpay",
"name": "Collections",
"credentials": {
"key_id": "rzp_live_XXXXXXXXXXXXXX",
"key_secret": "your_key_secret",
"webhook_secret": "the_secret_you_chose"
}
}'
```
The key is checked with Razorpay before it is saved. A `rzp_test_…` key connects in test mode.
3. Call [`GET /api/v1/payment-requests/settings`](#get-apiv1payment-requestssettings). In Razorpay Dashboard → Webhooks, add a webhook whose URL is the returned `webhook_url`, whose secret is the one from step 1, and whose events are the returned `webhook_events`. Without this webhook, requests stay at `created` and no `payment_request.*` events are sent.
If more than one Razorpay integration is connected, the oldest one is used.
## Statuses
| `status` | Meaning |
| --- | --- |
| `creating` | Saved, link being created with Razorpay |
| `created` | Link created and sent; nothing paid yet |
| `partially_paid` | Part of the amount paid (`amount_paid_minor` is less than `amount_minor`) |
| `paid` | Paid in full. `paid_at` is set |
| `expired` | The link expired unpaid |
| `cancelled` | The link was cancelled |
| `failed` | Razorpay refused the link, or the result could not be confirmed. See `error` |
Statuses only move forward: a late or repeated event from Razorpay never moves a request back. `paid`, `expired` and `cancelled` are final. A `failed` request can still become `partially_paid` or `paid` if Razorpay reports a payment on its link.
## The payment request object
```json
{
"id": "5c7d…",
"provider": "razorpay",
"status": "paid",
"amount_minor": 149900,
"amount_paid_minor": 149900,
"currency": "INR",
"description": "EMI 3 of 12",
"external_reference": "LOAN-2291-03",
"short_url": "https://rzp.io/i/AbC123",
"customer_name": "Priya Sharma",
"customer_phone": "+919812345678",
"customer_email": null,
"contact_id": "4411…",
"bot_id": "c3d4…",
"channel": "voice",
"conversation_id": null,
"voice_session_id": "8e9f…",
"delivered_via": "sms",
"error": null,
"paid_at": "2026-09-26T10:42:00Z",
"created_at": "2026-09-26T10:31:00Z",
"updated_at": "2026-09-26T10:42:00Z"
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `provider` | `string` | `razorpay` |
| `amount_minor` | `integer` | Requested amount in the smallest currency unit (paise): `149900` = ₹1,499.00 |
| `amount_paid_minor` | `integer` | Paid so far, in paise |
| `currency` | `string` | `INR` |
| `description` | `string` | What the payment is for, as shown to the customer |
| `external_reference` | `string \| null` | Your own reference (invoice, EMI or order number), if the agent was given one |
| `short_url` | `string \| null` | The payment link. `null` until the link is created |
| `customer_name` / `customer_phone` / `customer_email` | `string \| null` | Who the link was sent to. Phone in E.164 |
| `contact_id` | `UUID \| null` | The CRM contact, when known. Status changes are also added as a note on this contact |
| `bot_id` | `UUID \| null` | The agent that created the request |
| `channel` | `string \| null` | Where the agent was, e.g. `voice` or `whatsapp` |
| `conversation_id` / `voice_session_id` | `UUID \| null` | The chat or call it came from |
| `delivered_via` | `string \| null` | Comma-separated channels the link was sent through: `whatsapp`, `sms`, `email` |
| `error` | `string \| null` | Why the request `failed`, safe to show |
| `paid_at` | `datetime \| null` | When it became `paid` |
## GET `/api/v1/payment-requests`
Returns an array of payment request objects, newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `status` | `string` | No | One of the seven statuses |
| `contact_id` | `UUID` | No | One contact's requests |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/payment-requests?status=paid&limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
An unknown `status` returns `422 status must be one of: creating, created, partially_paid, paid, expired, cancelled, failed`.
## GET `/api/v1/payment-requests/{request_id}`
One payment request. `404 Payment request not found`.
```bash
curl https://api.callmissed.com/api/v1/payment-requests/{request_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## GET `/api/v1/payment-requests/settings`
The connected Razorpay account, the per-link limit and the webhook to configure in Razorpay.
```bash
curl https://api.callmissed.com/api/v1/payment-requests/settings \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"connected": true,
"integration_id": "6a1e…",
"account": "rzp_live_XXXXXXXXXXXXXX",
"mode": "live",
"max_amount_inr": 10000,
"platform_max_amount_inr": 500000,
"webhook_url": "https://api.callmissed.com/api/v1/webhooks/razorpay/6a1e…",
"webhook_events": [
"payment_link.cancelled",
"payment_link.expired",
"payment_link.paid",
"payment_link.partially_paid"
]
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `connected` | `boolean` | `false` when no Razorpay integration is connected. `integration_id`, `account`, `mode` and `webhook_url` are then `null` |
| `account` | `string \| null` | The connected Razorpay key id |
| `mode` | `string \| null` | `live` for a `rzp_live_…` key, otherwise `test` |
| `max_amount_inr` | `integer` | The largest amount, in whole rupees, an agent may request in one link. Default `10000` |
| `platform_max_amount_inr` | `integer` | The highest value `max_amount_inr` can be set to: `500000` |
| `webhook_url` | `string \| null` | Paste this exactly as returned into Razorpay's webhook settings |
| `webhook_events` | `string[]` | The Razorpay events to enable on that webhook |
## PUT `/api/v1/payment-requests/settings`
Sets the largest amount an agent may request in one payment link. Larger collections are left to a person.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `max_amount_inr` | `integer` | Yes | Whole rupees, `1 <= max_amount_inr <= 500000` |
```bash
curl -X PUT https://api.callmissed.com/api/v1/payment-requests/settings \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "max_amount_inr": 25000 }'
```
Returns the settings object. `404 Connect Razorpay first` when no Razorpay integration is connected; `422` when the value is out of range.
## Webhook events
Subscribe a [webhook](/docs/webhooks) to these events to hear about payments as they happen:
| Event | When |
| --- | --- |
| `payment_request.partially_paid` | A part payment was made |
| `payment_request.paid` | Paid in full |
| `payment_request.expired` | The link expired |
| `payment_request.cancelled` | The link was cancelled |
The event's `data` object:
```json
{
"payment_request_id": "5c7d…",
"status": "paid",
"provider": "razorpay",
"amount": 1499.0,
"amount_paid": 1499.0,
"currency": "INR",
"description": "EMI 3 of 12",
"reference": "LOAN-2291-03",
"short_url": "https://rzp.io/i/AbC123",
"contact_id": "4411…",
"customer_phone": "+919812345678",
"customer_email": null,
"bot_id": "c3d4…",
"paid_at": "2026-09-26T10:42:00.481203+00:00"
}
```
Note the units: in event payloads `amount` and `amount_paid` are in **rupees**, while the API's `amount_minor` and `amount_paid_minor` are in **paise**. `reference` is the API's `external_reference`.
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `integrations:read` / `integrations:write` |
| `404` | Payment request not in your account, or settings updated before Razorpay is connected |
| `422` | Unknown `status` filter, or `max_amount_inr` outside `1`–`500000` |
Nothing on this page consumes credits.
---
### Bring Your Own Key
URL: /docs/provider-keys
> Store your own model-provider credentials, verify them, rotate them, and deactivate them — the secret is never returned.
## Overview
Bring Your Own Key (BYOK) lets you store your own credential for a model provider so inference runs on your account with that provider instead of ours.
The stored secret is **write-only**. No endpoint on this page returns it, and there is no reveal route. You can see the provider, an optional label, the last four characters, whether it is active, and when it was last verified — nothing more. If you lose the original secret, delete the record and add a new one.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List | `provider_keys:read` |
| Create, update, delete, verify | `provider_keys:write` |
## Supported providers
Provider ids are lowercase. The accepted set is intentionally small — only providers whose credentials the gateway can actually inject into inference — for example `google` and `sarvam`. An unsupported value returns `422` and lists the supported provider ids.
## Which requests use your key, and billing
A stored key is used for `POST /v1/chat/completions` (and `/v1/responses`, `/v1/messages` and Batch lines) when the model you call is served through that provider — for example a `google` key for the `gemini-*` chat models, and a `sarvam` key for the `sarvam-*` chat models.
- A completion served on your own key costs **0 credits**: you pay the provider directly. It still appears in your [usage logs](/docs/usage-api).
- With no active key for that provider, or if the stored key cannot be read, the request runs on CallMissed's credential and is billed normally.
- Your key is only ever sent to the provider it belongs to. If a [`models` fallback](/docs/chat-completion#model-substitution) is served by a different provider, that fallback runs on our credential and is billed normally.
## The uniqueness rule
A credential occupies one slot per `(provider, label)`. A record with no label is that provider's **default** slot. Adding a second key to an occupied slot returns `409`:
```json
{ "detail": "A default google key already exists. Delete it first to replace the secret." }
```
To hold several keys for one provider, give each a distinct `label` (for example `eu`, `us`, `batch`).
**Rotation is delete-then-create.** `PATCH` deliberately refuses a `key` field, so a secret can never be silently swapped under a record that other systems believe they know.
## GET `/api/v1/gateway/provider-keys`
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `provider` | `string` | No | At most 32 characters, case-insensitive, must be a supported provider |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `100` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```json
[
{
"id": "1a2b…",
"provider": "google",
"label": "eu",
"key_last4": "9f3a",
"is_active": true,
"last_verified_at": "2026-08-17T09:12:00Z",
"created_at": "2026-08-02T11:00:00Z",
"updated_at": "2026-08-17T09:12:00Z"
}
]
```
Newest first.
## POST `/api/v1/gateway/provider-keys`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `provider` | `string` | Yes | 1–32 characters, lowercased, must be supported |
| `key` | `string` | Yes | 8–512 characters, not blank. Write-only — stored encrypted, never returned |
| `label` | `string` | No | At most 128 characters. Blank is stored as no label (the default slot) |
Unknown fields are rejected with `422` rather than ignored, so a typo in a field name fails loudly instead of quietly dropping your secret.
```bash
curl -X POST https://api.callmissed.com/api/v1/gateway/provider-keys \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "provider": "google", "key": "AIza…", "label": "eu" }'
```
Returns `201` with the record — note the response has no `key` field.
## PATCH `/api/v1/gateway/provider-keys/{key_id}`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `label` | `string` | No | At most 128 characters |
| `is_active` | `boolean` | No | `false` takes the credential out of use without deleting it |
Sending `key` returns `422`. Moving a label into an occupied slot returns `409`.
Use `is_active: false` when you suspect a credential and want traffic to fall back immediately while you investigate.
## DELETE `/api/v1/gateway/provider-keys/{key_id}`
Returns `204`. `404 Provider key not found` for an unknown or another tenant's id.
## POST `/api/v1/gateway/provider-keys/{key_id}/verify`
Performs a live check against the provider. No request body.
```json
{
"id": "1a2b…",
"provider": "google",
"ok": true,
"detail": "Credential accepted by the provider.",
"last_verified_at": "2026-08-17T09:12:00Z"
}
```
`detail` is one of exactly two strings — `Credential accepted by the provider.` or `The provider rejected this credential or could not be reached.` The upstream status code and body are never passed through, so a verify call cannot be used to probe a provider. A successful check stamps `last_verified_at`.
| Status | Detail | Cause |
| --- | --- | --- |
| `400` | `Liveness verification is not available for {provider} keys.` | That provider has no cheap check |
| `404` | `Provider key not found` | Unknown or another tenant's id |
| `409` | `This stored credential can no longer be read. Delete and re-add it.` | The stored secret is unreadable — re-add it |
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `provider_keys:read` / `provider_keys:write` |
| `404` | Record not found in your tenant |
| `409` | Slot already occupied, or an unreadable stored credential |
| `422` | Unsupported provider, blank/short secret, an unknown field, or a `key` sent to `PATCH` |
Storing, verifying and deleting provider keys never consumes credits.
---
### Status API
URL: /docs/status-api
> Public service-health endpoints — current status, historical uptime, and the incident feed that power status.callmissed.com.
> These endpoints are **public** (no auth) and back [status.callmissed.com](https://status.callmissed.com). Use them to embed live status in your own dashboards or to gate automated jobs on platform health.
## Credential class: none
Send no `Authorization` header. A dashboard JWT or a `cm_` API key is accepted but ignored: these routes have no auth dependency and return identical data either way. They are **not** tenant-scoped, so nothing here reveals your account.
Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`; out-of-range query parameters are `422`.
The JSON endpoints share the same status vocabulary:
| `status` value | Meaning |
| --- | --- |
| `operational` | Healthy |
| `degraded` | Working with reduced quality |
| `down` | Not serving |
| `not_configured` | Not enabled on this deployment |
| `unknown` | No data recorded for that day (uptime only) |
---
## GET /api/v1/status
Live health snapshot. No parameters.
```bash
curl https://api.callmissed.com/api/v1/status
```
```json
{
"overall": "operational",
"checked_at": "2026-08-04T10:31:07.512004+00:00",
"services": [
{
"name": "CallMissed API",
"group": "infrastructure",
"status": "operational",
"description": "REST API, dashboard, webhooks, and API keys",
"latency_ms": 4
},
{
"name": "AI, speech & language",
"group": "ai",
"status": "operational",
"description": "Chat, speech-to-text, text-to-speech, and embeddings-compatible routes"
},
{
"name": "Billing & checkout",
"group": "payments",
"status": "operational",
"description": "Subscriptions and one-time payments"
},
{
"name": "WhatsApp Business",
"group": "channels",
"status": "operational",
"description": "Meta WhatsApp Cloud API"
},
{
"name": "Voice & SMS",
"group": "channels",
"status": "operational",
"description": "PSTN voice and SMS"
},
{
"name": "Transactional email",
"group": "notifications",
"status": "operational",
"description": "Account notifications and receipts"
}
],
"self_heal": [],
"interval_seconds": 30
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `overall` | `string` | `down` if any monitored row is down, else `degraded` if any is degraded, else `operational`. Rows with `not_configured` are ignored |
| `checked_at` | `string` | ISO-8601 UTC timestamp of this check |
| `services[].name` | `string` | Human label |
| `services[].group` | `string` | One of `infrastructure`, `ai`, `payments`, `channels`, `notifications` |
| `services[].status` | `string` | See the vocabulary above |
| `services[].description` | `string` | What the row covers |
| `services[].latency_ms` | `integer` | **Optional.** Present only where a live latency was measured |
| `services[].checked_at` | `string` | **Optional.** ISO-8601 UTC time this row was last checked |
| `services[].ci` | `object \| null` | **Optional.** Latest automated API check for the row: `status` (`operational` or `degraded`) and `checked_at` |
| `self_heal[]` | `array` | Automatic recoveries in the last 7 days, each with `at`, `component` (a `services[].name`), and `result` (`recovered` or `still_failing`) |
| `interval_seconds` | `integer` | How often the snapshot is refreshed |
To gate a job on platform health, poll this and require `overall == "operational"`. Other fields may appear in the response; treat only the ones above as stable.
## GET /api/v1/status/stream
The same payload as `GET /api/v1/status`, pushed as Server-Sent Events: one `status` event on connect and another on every change, with a `: ping` comment every 25 seconds to keep the connection open.
```bash
curl -N https://api.callmissed.com/api/v1/status/stream
```
```
event: status
data: {"overall":"operational","checked_at":"…","services":[…]}
```
Connections are capped: too many from one client returns `429`, and `503` means the stream is at capacity — fall back to polling `GET /api/v1/status`.
## GET /api/v1/status/uptime
Per-service daily uptime for a rolling window ending today.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `days` | `integer` | No | `1 <= days <= 90`, default `90` |
```bash
curl "https://api.callmissed.com/api/v1/status/uptime?days=30"
```
```json
{
"days": 30,
"services": [
{
"service_name": "CallMissed API",
"uptime_percent": 99.94,
"daily": [
{
"date": "2026-07-06",
"total_checks": 288,
"operational_checks": 288,
"degraded_checks": 0,
"down_checks": 0,
"worst_status": "operational"
},
{
"date": "2026-07-07",
"total_checks": 0,
"operational_checks": 0,
"degraded_checks": 0,
"down_checks": 0,
"worst_status": "unknown"
}
]
}
]
}
```
`daily` always contains exactly `days` entries, oldest first. Days with no recorded checks are returned as zero-filled placeholders with `worst_status: "unknown"` so a chart axis stays continuous.
`uptime_percent` counts a degraded check as half an operational one:
```
uptime_percent = round(((operational + 0.5 * degraded) / total) * 100, 2)
```
It is `0.0` when the window contains no checks at all. Services are sorted by name.
`422` when `days` is outside `1 .. 90`.
## GET /api/v1/status/incidents
Incidents overlapping the requested window, newest first. An incident is included when it started before now and either has not ended or ended inside the window.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `days` | `integer` | No | `1 <= days <= 90`, default `7` |
```bash
curl "https://api.callmissed.com/api/v1/status/incidents?days=7"
```
```json
{
"days": 7,
"incidents": [
{
"id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
"service_name": "CallMissed API",
"severity": "degraded",
"title": "Elevated latency on the CallMissed API",
"summary": "Requests were slower than normal for roughly 20 minutes.",
"started_at": "2026-08-01T04:12:00+00:00",
"ended_at": "2026-08-01T04:33:00+00:00",
"resolved": true
}
]
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` | UUID |
| `service_name` | `string` | Matches a `services[].name` from `/status` |
| `severity` | `string` | `degraded` or `down` |
| `title` | `string` | Generated from service and severity when no custom title was written |
| `summary` | `string \| null` | Optional detail |
| `started_at` | `string` | ISO-8601 UTC |
| `ended_at` | `string \| null` | `null` while the incident is ongoing |
| `resolved` | `boolean` | Whether the incident is closed |
`incidents` is an empty array when the window is clean. `422` when `days` is outside `1 .. 90`.
---
### Macros, Tags & Routing
URL: /docs/support-ops
> Canned replies with placeholders and side-effect actions, a tag vocabulary, and a first-match-wins routing engine with a dry-run evaluator.
## Overview
Three operational primitives sit behind the support desk:
- **Macros** — canned replies. A macro carries a body with `{{placeholder}}` variables and, optionally, `actions` that are applied *besides* sending the text: set a status, add tags, assign a user.
- **Tags** — your tenant's tag vocabulary for tickets: a name, a display colour and a description.
- **Routing rules** — an ordered, first-match-wins triage list. Each rule ANDs a set of conditions over a fixed six-field allowlist and names what should happen when it matches (assignee, priority, tags). Rules are applied when a ticket is created (see [creating a ticket](/docs/support-tickets)), and the dry run tells you which one would match without writing anything.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| Every list/read, plus `POST /routing-rules/evaluate` | `support_ops:read` |
| Every create, update, delete, plus `POST /macros/{id}/use` | `support_ops:write` |
`POST /routing-rules/evaluate` is a **dry run** — it writes nothing, so it needs only the read scope. `POST /macros/{id}/use` increments a counter, so it needs write.
---
# Macros
## The macro object
```json
{
"id": "9b8a…",
"tenant_id": "a0b1…",
"name": "Refund acknowledged",
"body": "Hi {{customer_name}}, your refund for order {{order_id}} is on its way.",
"actions": { "set_status": "pending", "add_tags": ["refund"], "assign_to": null },
"category": "billing",
"is_active": true,
"usage_count": 41,
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-16T14:00:00Z"
}
```
### `actions`
| Field | Type | Constraints |
| --- | --- | --- |
| `set_status` | `string \| null` | At most 32 characters, lowercase identifier |
| `add_tags` | `string[] \| null` | At most 20, each at most 64 characters, non-blank |
| `assign_to` | `UUID \| null` | Must be a user in your tenant |
`set_status` must match `^[a-z][a-z0-9_-]{0,31}$`; it is stored as written, not checked against the ticket status list, so use one of the [ticket statuses](/docs/support-tickets) if you intend to apply it to a ticket.
Unknown keys inside `actions` are **rejected** with `422` rather than ignored, so a typo cannot silently do nothing.
## GET `/api/v1/support/ops/macros`
Ordered by `usage_count` descending, then name — so the picker shows what your team actually uses.
| Parameter | Type | Constraints |
| --- | --- | --- |
| `category` | `string` | At most 64 characters, exact match |
| `is_active` | `boolean` | |
| `limit` | `integer` | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/support/ops/macros?category=billing&is_active=true" \
-H "Authorization: Bearer cm_your_api_key"
```
## POST `/api/v1/support/ops/macros`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters, not blank, unique per tenant |
| `body` | `string` | Yes | 1–20,000 characters, not blank |
| `actions` | `object` | No | See above |
| `category` | `string` | No | At most 64 characters |
| `is_active` | `boolean` | No | Default `true` |
```bash
curl -X POST https://api.callmissed.com/api/v1/support/ops/macros \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Refund acknowledged",
"body": "Hi {{customer_name}}, your refund for order {{order_id}} is on its way.",
"actions": { "set_status": "pending", "add_tags": ["refund"] },
"category": "billing"
}'
```
Returns `201` with the macro object. `409 A macro named '…' already exists` on a duplicate name; `404 Assignee not found in this tenant` when `actions.assign_to` is not one of your users.
## PATCH / DELETE `/api/v1/support/ops/macros/{macro_id}`
`PATCH` takes the same fields, all optional, and returns the updated macro; sending `actions: null` clears the actions. `DELETE` returns `204`. Both return `404 Macro not found` for an id outside your tenant.
```bash
curl -X PATCH https://api.callmissed.com/api/v1/support/ops/macros/{macro_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "is_active": false }'
curl -X DELETE https://api.callmissed.com/api/v1/support/ops/macros/{macro_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## POST `/api/v1/support/ops/macros/{macro_id}/use`
Renders the macro against a context and bumps `usage_count`.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `context` | `object` | No | Default `{}`. At most 50 keys; at most 16,000 characters serialised |
```bash
curl -X POST https://api.callmissed.com/api/v1/support/ops/macros/9b8a…/use \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "context": { "customer_name": "Asha", "order_id": "A-4419" } }'
```
```json
{
"id": "9b8a…",
"name": "Refund acknowledged",
"body": "Hi Asha, your refund for order A-4419 is on its way.",
"actions": { "set_status": "pending", "add_tags": ["refund"] },
"usage_count": 42
}
```
Placeholder syntax is `{{name}}` (surrounding spaces allowed), where the name starts with a letter or underscore and may contain letters, digits, `_` and `.`, up to 64 characters. Substituted values are truncated to 500 characters and are not re-scanned for placeholders.
> **Unknown or null-valued placeholders are left verbatim** — `{{order_id}}` stays in the text rather than becoming an empty gap. Check the rendered body before sending it to a customer, or supply every variable the macro declares.
Applying the returned `actions` is your job: this endpoint renders and counts, it does not mutate a ticket.
---
# Tags
```json
{
"id": "7f6e…",
"tenant_id": "a0b1…",
"name": "refund",
"color": "#c2410c",
"description": "Money going back to the customer",
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-01T10:00:00Z"
}
```
| Method | Path | Scope |
| --- | --- | --- |
| `GET` | `/api/v1/support/ops/tags` | `support_ops:read` |
| `POST` | `/api/v1/support/ops/tags` | `support_ops:write` |
| `PATCH` | `/api/v1/support/ops/tags/{tag_id}` | `support_ops:write` |
| `DELETE` | `/api/v1/support/ops/tags/{tag_id}` | `support_ops:write` |
`GET` is ordered by name and takes `limit` (`1..500`, default `100`) and `offset` (`0..100000`). There are no filters.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–64 characters, not blank, unique per tenant |
| `color` | `string` | No | A hex triplet such as `#c2410c` or `#c40`. Stored lowercase |
| `description` | `string` | No | At most 255 characters |
Anything other than a hex triplet returns `422 color must be a hex triplet like '#aabbcc'`. `PATCH` takes the same fields, all optional, and returns the updated tag; `DELETE` returns `204`. `404 Tag not found` for an id outside your tenant, `409 A tag named '…' already exists` on a duplicate name.
```bash
curl "https://api.callmissed.com/api/v1/support/ops/tags?limit=100" \
-H "Authorization: Bearer cm_your_api_key"
curl -X POST https://api.callmissed.com/api/v1/support/ops/tags \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "name": "refund", "color": "#c2410c", "description": "Money going back to the customer" }'
curl -X PATCH https://api.callmissed.com/api/v1/support/ops/tags/{tag_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "color": "#15803d" }'
curl -X DELETE https://api.callmissed.com/api/v1/support/ops/tags/{tag_id} \
-H "Authorization: Bearer cm_your_api_key"
```
---
# Routing rules
## Vocabulary
| Concept | Allowed values |
| --- | --- |
| Condition `field` | `channel`, `priority`, `status`, `subject_contains`, `contact_email_domain`, `tag` |
| Condition `operator` | `eq`, `neq`, `contains`, `in`, `is_set`, `is_not_set` |
| `assign_strategy` | `direct`, `round_robin` |
The field list is a **closed allowlist** — an unknown field is `422`, not a silently-false condition.
| Condition `field` | Read from (in a dry run) |
| --- | --- |
| `channel` | `channel` |
| `priority` | `priority` |
| `status` | `status` |
| `subject_contains` | `subject` |
| `contact_email_domain` | The part of `contact_email` after `@`, lowercased |
| `tag` | `tags` — `eq` means "carries this tag", `neq` "does not carry it", `contains` "some tag contains the text" |
All comparisons are case-insensitive and ignore surrounding whitespace.
### Condition value rules
| Operator | `value` |
| --- | --- |
| `in` | A non-empty array, at most 100 entries |
| `is_set` / `is_not_set` | Omitted |
| Everything else | A non-blank scalar (string at most 512 characters, number or boolean) |
## The rule object
```json
{
"id": "2c3d…",
"tenant_id": "a0b1…",
"name": "Enterprise urgent → Ravi",
"position": 0,
"conditions": [
{ "field": "priority", "operator": "eq", "value": "urgent" },
{ "field": "contact_email_domain", "operator": "in", "value": ["acme.com", "globex.com"] }
],
"assign_to_user_id": "b1f2…",
"assign_strategy": "direct",
"set_priority": "urgent",
"add_tags": ["enterprise"],
"is_active": true,
"created_at": "2026-08-05T09:00:00Z",
"updated_at": "2026-08-05T09:00:00Z"
}
```
All conditions on a rule are **ANDed**. Rules are evaluated in `position` order and the **first match wins** — `position` is the policy, so the list is never re-sorted for you.
## GET `/api/v1/support/ops/routing-rules`
Returns an array of rule objects in evaluation order (`position`, then creation time). Takes `is_active`, `limit` (`1..500`, default `100`) and `offset` (`0..100000`, default `0`).
```bash
curl "https://api.callmissed.com/api/v1/support/ops/routing-rules?is_active=true" \
-H "Authorization: Bearer cm_your_api_key"
```
## POST `/api/v1/support/ops/routing-rules`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters, unique per tenant |
| `conditions` | `object[]` | No | Default `[]`. **At most 20 per rule** |
| `position` | `integer` | No | `0 <= position <= 100000`, default `0` |
| `assign_to_user_id` | `UUID` | No | Must be a user in your tenant |
| `assign_strategy` | `string` | No | `direct` (default) or `round_robin` |
| `set_priority` | `string` | No | At most 16 characters, lowercase identifier |
| `add_tags` | `string[]` | No | At most 20, each at most 64 characters. De-duplicated |
| `is_active` | `boolean` | No | Default `true` |
A rule with an empty `conditions` array matches everything — use it deliberately as a final catch-all at the highest `position`.
`round_robin` is stored on the rule for your own assignment logic; the dry run below has no team roster to rotate through, so it returns the rule's `assign_to_user_id` for both strategies.
```bash
curl -X POST https://api.callmissed.com/api/v1/support/ops/routing-rules \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Enterprise urgent → Ravi",
"position": 0,
"conditions": [
{ "field": "priority", "operator": "eq", "value": "urgent" },
{ "field": "contact_email_domain", "operator": "in", "value": ["acme.com", "globex.com"] }
],
"assign_to_user_id": "b1f2c3d4-1111-2222-3333-444455556666",
"add_tags": ["enterprise"]
}'
```
Returns `201` with the rule object. `409 A routing rule named '…' already exists` on a duplicate name; `404 Assignee not found in this tenant` when `assign_to_user_id` is not one of your users.
## PATCH `/api/v1/support/ops/routing-rules/reorder`
Rewrites the whole evaluation order.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `rule_ids` | `UUID[]` | Yes | 1–500 entries. Must be **every** routing rule in your tenant, exactly once |
```bash
curl -X PATCH https://api.callmissed.com/api/v1/support/ops/routing-rules/reorder \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "rule_ids": ["2c3d…", "8e9f…", "4a5b…"] }'
```
Returns the rules in their new order. New `position` is the array index. Duplicate ids return `422 rule_ids must not contain duplicates`. A partial list returns `422 rule_ids must list every routing rule in this tenant exactly once` — reordering is all-or-nothing, so two concurrent partial reorders cannot interleave into a nonsense order.
## POST `/api/v1/support/ops/routing-rules/evaluate`
A **dry run**. Nothing is written; you get back what would happen.
| Field | Type | Constraints |
| --- | --- | --- |
| `channel` | `string` | At most 64 characters |
| `priority` | `string` | At most 32 characters |
| `status` | `string` | At most 32 characters |
| `subject` | `string` | At most 1,000 characters |
| `contact_email` | `string` | At most 320 characters |
| `tags` | `string[]` | At most 100 entries |
Every field is optional. **An absent fact fails any `eq`, `contains`, `in` or `is_set` condition that asks about it** (it satisfies `neq` and `is_not_set`) — so send the whole picture when testing.
```bash
curl -X POST https://api.callmissed.com/api/v1/support/ops/routing-rules/evaluate \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"priority": "urgent",
"subject": "Cannot check out",
"contact_email": "asha@acme.com",
"tags": ["vip"]
}'
```
```json
{
"matched": true,
"rule_id": "2c3d…",
"rule_name": "Enterprise urgent → Ravi",
"assign_to_user_id": "b1f2…",
"assign_strategy": "direct",
"set_priority": "urgent",
"add_tags": ["enterprise"]
}
```
Only `is_active: true` rules are considered. When nothing matches you get `{"matched": false, …, "add_tags": []}` — a miss, not an error.
The same decision is applied automatically when a ticket is created with `POST /api/v1/support/tickets`: the rule fills in the assignee only when the request names none, sets the priority only when the request sends none, and adds its tags to the request's. Tickets that already exist are not re-routed — change them with [`PATCH /api/v1/support/tickets/{ticket_id}`](/docs/support-tickets).
## PATCH / DELETE `/api/v1/support/ops/routing-rules/{rule_id}`
Same fields as create, all optional; sending `conditions` replaces the whole list. Returns the updated rule. `DELETE` returns `204`. `404 Routing rule not found` for an id outside your tenant.
```bash
curl -X PATCH https://api.callmissed.com/api/v1/support/ops/routing-rules/{rule_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "is_active": false }'
curl -X DELETE https://api.callmissed.com/api/v1/support/ops/routing-rules/{rule_id} \
-H "Authorization: Bearer cm_your_api_key"
```
---
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `support_ops:read` / `support_ops:write` |
| `404` | Macro, tag, rule or assignee not in your tenant |
| `409` | Duplicate macro, tag or rule name |
| `422` | Blank name or body, over 20 conditions, an unknown condition field or operator, a value that does not fit its operator, a bad colour, an unknown key in `actions`, an incomplete or duplicated `reorder` list |
Nothing on this page consumes credits.
---
### SLA Policies
URL: /docs/support-sla
> Define response and resolution targets with business hours, read a ticket's live SLA clock, and list what is breaching.
## Overview
An **SLA policy** promises two things about a ticket: how fast someone will respond, and how fast it will be resolved. A policy may be scoped to one priority, or left un-scoped as the catch-all.
Deadlines are computed against the ticket's server-managed [lifecycle stamps](/docs/support-tickets), so a policy cannot be gamed by editing a timestamp.
Breach is **computed on read, never stored**. That means the numbers are always current, and it has one consequence for pagination — see the note on `/breaches`.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List policies, ticket status, breaches | `sla:read` |
| Create, update, delete a policy | `sla:write` |
## Business hours
A policy with `business_hours` only burns its clock during those hours. Omit it and the clock runs continuously.
| Field | Type | Default | Constraints |
| --- | --- | --- | --- |
| `tz` | `string \| null` | Your tenant's timezone | At most 64 characters |
| `days` | `integer[]` | `[1,2,3,4,5]` | 1–7 entries, values `1`–`7` where Monday is `1` and Sunday is `7`. Stored sorted and de-duplicated |
| `start` | `string` | `"09:00"` | `HH:MM`, 24-hour |
| `end` | `string` | `"18:00"` | `HH:MM`, must be later than `start` |
```json
{ "tz": "Asia/Kolkata", "days": [1,2,3,4,5,6], "start": "10:00", "end": "19:00" }
```
## The policy object
```json
{
"id": "d1c2…",
"tenant_id": "a0b1…",
"name": "Urgent — 15 min first response",
"priority": "urgent",
"first_response_minutes": 15,
"resolution_minutes": 240,
"business_hours": null,
"is_active": true,
"created_at": "2026-08-10T08:00:00Z",
"updated_at": "2026-08-10T08:00:00Z"
}
```
## GET `/api/v1/support/sla/policies`
Returns an array of policy objects, newest first.
| Parameter | Type | Constraints |
| --- | --- | --- |
| `priority` | `string` | At most 16 characters, matched lowercase |
| `is_active` | `boolean` | |
| `limit` | `integer` | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/support/sla/policies?is_active=true" \
-H "Authorization: Bearer cm_your_api_key"
```
## POST `/api/v1/support/sla/policies`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `name` | `string` | Yes | 1–255 characters, not blank, unique per tenant |
| `priority` | `string` | No | At most 16 characters, must start with a letter. Omit for a catch-all policy |
| `first_response_minutes` | `integer` | No | `1 <= n <= 100000` |
| `resolution_minutes` | `integer` | No | `1 <= n <= 100000` |
| `business_hours` | `object` | No | See above |
| `is_active` | `boolean` | No | Default `true` |
**At least one of `first_response_minutes` / `resolution_minutes` must be set** — a policy that promises nothing is rejected with `422 set at least one of first_response_minutes / resolution_minutes`.
```bash
curl -X POST https://api.callmissed.com/api/v1/support/sla/policies \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Urgent — 15 min first response",
"priority": "urgent",
"first_response_minutes": 15,
"resolution_minutes": 240
}'
```
A duplicate name returns `409 An SLA policy named '…' already exists`.
## PATCH `/api/v1/support/sla/policies/{policy_id}`
All fields optional; only the fields you send are changed. Sending `business_hours: null` removes business hours (the clock then runs continuously); sending `priority: null` turns the policy into a catch-all. The "must promise something" rule is re-checked against the **merged** result, so you cannot clear both minute fields in two steps. Returns the updated policy.
```bash
curl -X PATCH https://api.callmissed.com/api/v1/support/sla/policies/{policy_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"first_response_minutes": 30,
"business_hours": { "tz": "Asia/Kolkata", "days": [1,2,3,4,5], "start": "09:00", "end": "18:00" }
}'
```
`404 SLA policy not found`; `409` on a name another policy already uses.
## DELETE `/api/v1/support/sla/policies/{policy_id}`
Returns `204`. `404 SLA policy not found`.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/support/sla/policies/{policy_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## GET `/api/v1/support/sla/status/{ticket_id}`
The live clock for one ticket.
```bash
curl https://api.callmissed.com/api/v1/support/sla/status/{ticket_id} \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"ticket_id": "e5d4…",
"policy_id": "d1c2…",
"policy_name": "Urgent — 15 min first response",
"first_response_due_at": "2026-08-17T06:25:00Z",
"first_response_breached": true,
"resolution_due_at": "2026-08-17T10:10:00Z",
"resolution_breached": false,
"minutes_remaining": 84
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `policy_id` | `UUID \| null` | `null` when no policy matched this ticket |
| `minutes_remaining` | `integer \| null` | Wall-clock minutes to the nearest still-running deadline. **Negative when overdue.** `null` when both clocks have stopped or no policy matched |
| `first_response_breached` / `resolution_breached` | `boolean` | A stopped clock is judged against its stamp: a ticket first answered after the deadline stays breached for good, and one answered in time never becomes breached later |
Policy selection: only active policies (`is_active: true`) are considered. A policy whose `priority` matches the ticket's priority (case-insensitive) wins; otherwise the oldest catch-all policy applies; otherwise nothing does and every due date is `null`.
`404 Ticket not found`.
## GET `/api/v1/support/sla/status`
The same answer as `/status/{ticket_id}`, for up to 200 tickets in one call — what a ticket list uses to show every row's clock.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `ticket_id` | `UUID` (repeatable) | Yes | 1–200 ids; duplicates are ignored |
```bash
curl "https://api.callmissed.com/api/v1/support/sla/status?ticket_id={id_1}&ticket_id={id_2}" \
-H "Authorization: Bearer cm_your_api_key"
```
Returns an array of status objects. Ids that are not tickets in your tenant are left out of the answer rather than reported as errors.
## GET `/api/v1/support/sla/breaches`
Tickets currently past a deadline, oldest first.
| Parameter | Type | Constraints |
| --- | --- | --- |
| `priority` | `string` | At most 16 characters, matched lowercase |
| `include_resolved` | `boolean` | Default `false`, which skips tickets that have a `resolved_at` stamp |
| `limit` | `integer` | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/support/sla/breaches?priority=urgent&limit=200" \
-H "Authorization: Bearer cm_your_api_key"
```
```json
[
{
"ticket_id": "e5d4…",
"subject": "Refund not received",
"status": "open",
"priority": "urgent",
"created_at": "2026-08-17T06:10:00Z",
"sla": {
"ticket_id": "e5d4…",
"policy_id": "d1c2…",
"policy_name": "Urgent — 15 min first response",
"first_response_due_at": "2026-08-17T06:25:00Z",
"first_response_breached": true,
"resolution_due_at": "2026-08-17T10:10:00Z",
"resolution_breached": false,
"minutes_remaining": -32
}
}
]
```
`sla` has the same shape as the `/status/{ticket_id}` response.
> **Pagination behaves differently here.** Because breach is computed rather than stored, `limit` and `offset` page over the **tickets scanned** (oldest first), not the breaches returned. A page can come back shorter than `limit`, or empty, and still have more behind it — the response does not tell you how many tickets were scanned. To sweep everything, advance `offset` by `limit` each call until `offset` passes your ticket count, rather than stopping at the first short or empty page.
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `sla:read` / `sla:write` |
| `404` | Policy or ticket not in your tenant |
| `409` | Duplicate policy name |
| `422` | Policy promises nothing, blank name, minutes outside `1..100000`, a `priority` that is not a short identifier, or `end` not later than `start` |
Nothing on this page consumes credits.
---
### Support Tickets
URL: /docs/support-tickets
> Create, filter, assign and move support tickets through their lifecycle, with server-managed response and resolution stamps.
## Overview
A **ticket** is one unit of support work. It can stand alone, or hang off a conversation and a contact so an agent sees the thread that produced it.
The lifecycle timestamps — `first_responded_at`, `resolved_at`, `closed_at`, `reopened_count` — are **server-managed**. You never send them; you change `status` and the API stamps the rest. That is what makes the [SLA endpoints](/docs/support-sla) and CSAT reporting trustworthy.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
| Operation | Scope |
| --- | --- |
| List, get, list comments | `support_tickets:read` |
| Create, update, assign, status, delete, add comment | `support_tickets:write` |
## Enumerations
| Field | Values |
| --- | --- |
| `status` | `open`, `pending`, `waiting_on_customer`, `resolved`, `closed` |
| `priority` | `low`, `normal`, `high`, `urgent` |
`resolved` and `closed` are the **terminal** statuses; the other three are **active**.
## The ticket object
```json
{
"id": "e5d4…",
"tenant_id": "a0b1…",
"conversation_id": "c0ff…",
"contact_id": "4411…",
"subject": "Refund not received",
"description": "Customer says the refund has not landed after 7 days.",
"status": "open",
"priority": "high",
"assignee_user_id": null,
"tags": ["billing", "refund"],
"first_responded_at": null,
"resolved_at": null,
"closed_at": null,
"reopened_count": 0,
"created_at": "2026-08-17T06:10:00Z",
"updated_at": "2026-08-17T06:10:00Z"
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `assignee_user_id` | `UUID \| null` | `null` means the ticket is in the unassigned queue |
| `tags` | `string[] \| null` | Trimmed, blanks dropped, de-duplicated, order preserved |
| `first_responded_at` | `datetime \| null` | Stamped **once**, the first time the ticket leaves `open`. Never re-stamped |
| `resolved_at` | `datetime \| null` | Stamped on the move to `resolved` |
| `closed_at` | `datetime \| null` | Stamped on the move to `closed` |
| `reopened_count` | `integer` | Incremented each time a terminal ticket returns to an active status |
## GET `/api/v1/support/tickets`
Returns an array of ticket objects, newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `status` | `string` | No | One of the five statuses |
| `priority` | `string` | No | One of the four priorities |
| `assignee_user_id` | `UUID` | No | One agent's queue |
| `contact_id` | `UUID` | No | |
| `conversation_id` | `UUID` | No | |
| `unassigned` | `boolean` | No | `true` = no assignee, `false` = has one. Cannot be combined with `assignee_user_id` |
| `q` | `string` | No | At most 255 characters. Case-insensitive substring on `subject` |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/api/v1/support/tickets?status=open&unassigned=true&limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
Sending both `unassigned=true` and `assignee_user_id` returns `422 unassigned=true cannot be combined with assignee_user_id` — the two contradict each other, so the API refuses rather than silently picking one.
## POST `/api/v1/support/tickets`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `subject` | `string` | Yes | 1–255 characters, not blank |
| `description` | `string` | No | At most 20,000 characters |
| `conversation_id` | `UUID` | No | Must exist in your tenant |
| `contact_id` | `UUID` | No | Must exist in your tenant |
| `assignee_user_id` | `UUID` | No | Must be a user in your tenant |
| `status` | `string` | No | Default `open` |
| `priority` | `string` | No | Default `normal` |
| `tags` | `string[]` | No | At most 20 tags, each at most 64 characters |
```bash
curl -X POST https://api.callmissed.com/api/v1/support/tickets \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"subject": "Refund not received",
"description": "Customer says the refund has not landed after 7 days.",
"conversation_id": "c0ffee00-1111-2222-3333-444455556666",
"priority": "high",
"tags": ["billing", "refund"]
}'
```
Returns `201`. Creating a ticket directly as `resolved` or `closed` stamps the matching timestamp immediately.
Your active [routing rules](/docs/support-ops) run on create, first match wins: a matching rule fills in `assignee_user_id` only when you sent none, sets `priority` only when you sent none, and adds its tags to yours. Values you send always win.
`404 Conversation not found` / `Contact not found` / `Assignee not found` when a linked id is not in your tenant.
## GET `/api/v1/support/tickets/{ticket_id}`
One ticket. `404 Ticket not found`.
```bash
curl https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## PATCH `/api/v1/support/tickets/{ticket_id}`
Accepts the same editable fields as create. The lifecycle timestamps and `reopened_count` are **not** accepted — a status change here runs the same transition rules as the dedicated status endpoint.
Every field is optional; only the fields you send are changed. Linked ids (`conversation_id`, `contact_id`, `assignee_user_id`) are checked against your tenant exactly as on create. Returns the updated ticket.
```bash
curl -X PATCH https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "priority": "urgent", "tags": ["billing", "escalated"] }'
```
`subject` sent as blank or `null` returns `422 subject must not be blank`. An explicit `null` for `status` or `priority` is ignored rather than written.
## POST `/api/v1/support/tickets/{ticket_id}/assign`
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `assignee_user_id` | `UUID \| null` | No | `null` (or omitted) unassigns and returns the ticket to the queue |
```bash
curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/assign \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "assignee_user_id": "b1f2c3d4-1111-2222-3333-444455556666" }'
```
Returns the updated ticket. `404 Assignee not found` when the user is not in your tenant.
## POST `/api/v1/support/tickets/{ticket_id}/status`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `status` | `string` | Yes | One of the five statuses |
```bash
curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/status \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "status": "resolved" }'
```
Returns the updated ticket. **Idempotent.** Sending the status the ticket already has returns it untouched — no re-stamp, no `reopened_count` increment. Safe to retry.
### Transition rules
| Move | Effect |
| --- | --- |
| Same status | No-op |
| → `resolved` | Stamps `resolved_at` if unset, clears `closed_at` |
| → `closed` | Stamps `closed_at`, leaves `resolved_at` alone |
| Terminal → active | Clears both stamps, `reopened_count += 1` |
| Leaving `open` for the first time | Stamps `first_responded_at` once |
A logged reply (see [comments](#post-apiv1supportticketsticket_idcomments)) also stamps `first_responded_at` if nothing has yet.
## GET `/api/v1/support/tickets/{ticket_id}/comments`
The ticket's thread — internal notes and logged replies — oldest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `limit` | `integer` | No | 1–200, default 100 |
| `offset` | `integer` | No | 0–100000, default 0 |
```bash
curl https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/comments \
-H "Authorization: Bearer cm_your_api_key"
```
```json
[
{
"id": "9a1c…",
"ticket_id": "e5d4…",
"author_user_id": "b1f2…",
"body": "Refund re-issued, reference RF-2210.",
"is_internal": false,
"created_at": "2026-08-17T07:02:00Z"
}
]
```
`author_user_id` is `null` for entries added with an API key. `404 Ticket not found`.
## POST `/api/v1/support/tickets/{ticket_id}/comments`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `body` | `string` | Yes | 1–20000 characters, not blank |
| `is_internal` | `boolean` | No | Default `true`. `true` is a team-only note; `false` records a reply the customer was given |
A comment is **recorded on the ticket only** — nothing is sent to the customer. The first entry with `is_internal: false` stamps `first_responded_at` if it is still unset; internal notes never do.
```bash
curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/comments \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "body": "Refund re-issued, reference RF-2210.", "is_internal": false }'
```
Returns `201` with the comment. `404 Ticket not found`, `422` for a blank body.
## DELETE `/api/v1/support/tickets/{ticket_id}`
Returns `204`. `404 Ticket not found`.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \
-H "Authorization: Bearer cm_your_api_key"
```
## Errors
| Status | When |
| --- | --- |
| `403` | Key is missing `support_tickets:read` / `support_tickets:write` |
| `404` | Ticket, conversation, contact or assignee is not in your tenant |
| `422` | Unknown status/priority, blank subject, more than 20 tags or a tag over 64 characters, or `unassigned` combined with `assignee_user_id` |
Nothing on this page consumes credits.
---
### Usage API
URL: /docs/usage-api
> Read your own metering data — spend summaries, per-request logs, and a CSV export you can drop into a warehouse.
## Overview
The Usage API returns the same metering rows that back the dashboard's usage charts: one record per billable API call, with the service, model, token counts, latency and the credits it cost you.
Use it to build an internal cost dashboard, attribute spend to a customer via `session_id`, or reconcile an invoice.
| Endpoint | Returns |
| --- | --- |
| `GET /v1/usage/summary` | Rolled-up totals, per-service and per-model breakdowns, a daily series |
| `GET /v1/usage/logs` | Individual request records, newest first |
| `GET /v1/usage/logs.csv` | The same records as a CSV download |
| `GET /v1/credits/balance` | Your current credit balance |
## Authentication
```
Authorization: Bearer cm_your_api_key
```
All three endpoints require the `usage:read` scope. A dashboard session also works.
```json
{ "detail": "API key missing required scope: usage:read. Add it under the key's 'Permissions' section in your dashboard." }
```
## Retention window
Usage history is queryable for the **last 90 days**. Any request for a wider or older window returns `422` — export to CSV on a schedule if you need to keep more.
## GET `/v1/usage/summary`
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `days` | `integer` | No | `1 <= days <= 90`, default `30` |
```bash
curl "https://api.callmissed.com/v1/usage/summary?days=7" \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"period_days": 7,
"totals": {
"total_requests": 18422,
"success": 18310,
"errors": 112,
"success_rate": 0.9939,
"total_cost_usd": 41.87,
"total_input_tokens": 9120334,
"total_output_tokens": 1844920,
"total_cache_read_tokens": 4210000,
"total_cache_creation_tokens": 120500,
"total_audio_seconds": 6120.5,
"avg_latency_ms": 812.4
},
"by_service": [
{ "service": "llm", "requests": 15980, "cost_usd": 38.11, "input_tokens": 9120334, "output_tokens": 1844920, "cache_read_tokens": 4210000, "cache_creation_tokens": 120500 },
{ "service": "tts", "requests": 1422, "cost_usd": 2.44, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0 }
],
"by_model": [
{ "model": "kimi-k2.6", "requests": 9120, "cost_usd": 12.30 }
],
"series": [
{ "date": "2026-08-11", "requests": 2610, "cost_usd": 5.98 }
]
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `totals.success_rate` | `number` | Fraction in `0..1`, not a percentage |
| `totals.total_cost_usd` | `number` | What **you** were charged, in US$ at the published rate (1 credit = ₹1, US$1 = ₹96, so credits = US$ × 96). Every `cost_usd` in this response uses the same unit |
| `totals.total_input_tokens` | `integer` | LLM prompt tokens that were **not** served from the prompt cache |
| `totals.total_cache_read_tokens` | `integer` | Prompt tokens served from the prompt cache, billed at the cache-read rate |
| `totals.total_cache_creation_tokens` | `integer` | Prompt tokens written to the prompt cache, billed at the cache-write rate |
| `by_service[].cache_read_tokens`, `by_service[].cache_creation_tokens` | `integer` | The same cache split for one service |
| `by_model` | `array` | Top 10 models by request volume |
| `series[].date` | `string` | `YYYY-MM-DD`, one row per day in the window |
A tenant with no traffic gets zeroed totals and empty arrays — never an error.
## GET `/v1/usage/logs`
Individual request records, newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `days` | `integer` | No | `1 <= days <= 90`, default `7`. Ignored when `start` or `end` is present |
| `start` | `datetime` | No | ISO-8601 UTC |
| `end` | `datetime` | No | ISO-8601 UTC |
| `service` | `string` | No | One of `llm`, `stt`, `tts`, `image`, `search`, `embedding`, `bot`, `whatsapp_message`, `whatsapp_call`, `telephony_call`, `voice_tier_call` (the flat per-minute charge for a call on a priced voice tier, billed instead of the per-component `stt`/`llm`/`tts` rows), `voice_platform_fee` (the ₹1 per-minute platform fee on a metered call, billed beside its `stt`/`llm`/`tts` rows) |
| `model` | `string` | No | At most 255 characters. Exact model id |
| `api_key_id` | `UUID` | No | Restrict to one key |
| `status` | `string` | No | `ok` or `error` (`error` means a status code of 400 or above) |
| `session_id` | `string` | No | At most 64 characters |
| `trace_id` | `string` | No | At most 64 characters |
| `limit` | `integer` | No | `1 <= limit <= 500`, default `100` |
| `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` |
```bash
curl "https://api.callmissed.com/v1/usage/logs?days=1&service=llm&status=error&limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"period": { "start": "2026-08-16T00:00:00Z", "end": "2026-08-17T00:00:00Z" },
"limit": 50,
"offset": 0,
"count": 2,
"logs": [
{
"id": "9a1f…",
"created_at": "2026-08-16T18:04:11Z",
"service": "llm",
"endpoint": "/v1/chat/completions",
"method": "POST",
"model": "kimi-k2.6",
"status_code": 429,
"latency_ms": 41,
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"audio_seconds": 0.0,
"cost_usd": 0.0,
"request_id": "req_01J…",
"api_key_id": "b1f2…",
"error_message": "rate limit exceeded",
"trace_id": null,
"session_id": "checkout-flow"
}
]
}
```
For LLM rows, `input_tokens` is the part of the prompt that was not cached;
`cache_read_tokens` (served from the prompt cache) and `cache_creation_tokens`
(written to it) are counted separately, so the whole prompt is the sum of the
three. Rows recorded before cache tracking was added show `0` for both cache fields.
`cost_usd` is your price for the call, in US$ at the published rate (1 credit = ₹1, US$1 = ₹96), the same unit as `/v1/usage/summary`. Failed requests are recorded with `cost_usd: 0.0` — an error is never billed.
### Attributing spend
On `POST /v1/messages`, put `trace_id` and/or `session_id` (each at most 64 characters) inside the `metadata` object, then filter here by `session_id` or `trace_id` to attribute spend to one of your own customers, tenants or workflows. Other `metadata` keys (up to 16, string/number/boolean values) are stored with the row and exported in the CSV's `metadata_json` column.
**This needs request logging on the key that makes the call.** `trace_id`, `session_id` and the rest of `metadata` are stored only when **Request logging** is turned on for that API key, and logging is off by default on a new key. On a key with logging off the request is still metered and billed, but the row keeps `trace_id`, `session_id` and `metadata_json` empty (as it does `model`, `latency_ms`, `request_id` and `error_message`), so filtering by `session_id` or `trace_id` will not find it. Request logging is a console setting: turn it on when you create the key, or later from the key's edit dialog under **Developer → API keys** in the [console](https://console.callmissed.com/developer/keys). It cannot be changed with an API key, and it applies only to requests made after you turn it on. See [Logging](/docs/keys#logging).
```json
{
"model": "kimi-k2.6",
"max_tokens": 512,
"metadata": { "session_id": "checkout-flow", "trace_id": "tr_8812", "feature": "summary" },
"messages": [{ "role": "user", "content": "Summarise this order history." }]
}
```
On `/v1/chat/completions` and `/v1/responses` the `metadata`, `trace_id` and `session_id` fields are accepted and validated but are **not yet recorded** on the usage row, so those rows show `null` for both ids. Attribute that traffic with a separate API key per customer and filter by `api_key_id`.
Text-to-speech rows (`POST /v1/audio/speech`) record the audio settings in `metadata` (the CSV's `metadata_json` column): `response_format` as you sent it, `speech_sample_rate` as you sent it (`null` when omitted), and `sample_rate_sent`, the rate passed to the model (`null` when the model's own default applied or the model takes no rate). Like the rest of `metadata`, they are stored only when request logging is on for the key.
## GET `/v1/usage/logs.csv`
Same filters as `/logs` **minus `limit` and `offset`**, capped at **5,000 rows** per download. Narrow the window or add filters if you need more.
```bash
curl "https://api.callmissed.com/v1/usage/logs.csv?days=30&service=llm" \
-H "Authorization: Bearer cm_your_api_key" \
-o usage.csv
```
Returns `text/csv` with `Content-Disposition: attachment; filename="usage-YYYYMMDD.csv"`. Header row:
```
id,created_at,service,endpoint,method,model,status_code,latency_ms,input_tokens,output_tokens,cache_read_tokens,cache_creation_tokens,audio_seconds,cost_usd,request_id,api_key_id,error_message[,trace_id][,session_id][,metadata_json]
```
`cost_usd` is in US$, as on `/logs`. `error_message` is truncated to 500 characters. Text cells are escaped so a spreadsheet cannot interpret a value as a formula.
## GET `/v1/credits/balance`
Your account's current credit balance. Any valid `cm_` key can call it — no scope or service permission is needed.
```bash
curl https://api.callmissed.com/v1/credits/balance \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{ "balance": 1843.25 }
```
`balance` is in credits (1 credit = ₹1). Errors use the OpenAI envelope, e.g. `401 invalid_api_key`.
## Errors
| Status | Detail | Cause |
| --- | --- | --- |
| `403` | `API key missing required scope: usage:read…` | Key lacks the scope |
| `422` | `` `start` must be earlier than `end`. `` | Inverted range |
| `422` | `Requested range exceeds the 90-day maximum.` | `start`/`end` span too wide |
| `422` | `Usage history is available for the last 90 days only.` | `start` older than the window |
| `422` | `Unknown service '…'. Valid: …` | Bad `service` value |
Reading usage never consumes credits.
---
### Webhooks
URL: /docs/webhooks
> Create outbound webhook subscriptions, fire test deliveries, and inspect or replay the delivery log.
> Subscribe an HTTPS endpoint to platform events. Every payload is signed with HMAC-SHA256 using the subscription's secret. Deliveries are logged, inspectable, and replayable.
## Credential class: dashboard JWT **or** a `cm_` key with `webhooks:write`
These routes accept either credential.
```
Authorization: Bearer
# or
Authorization: Bearer cm_your_api_key
```
| Caller | Requirement |
| --- | --- |
| `cm_` API key | Must carry the `webhooks:write` scope. Every endpoint on this page checks it, **including the read-only list and delivery endpoints** - there is no `webhooks:read` scope |
| Dashboard JWT | Every endpoint, **including the reads**, requires **owner or admin** (`403 Only owners/admins can manage webhooks` otherwise). Delivery payloads carry customer data, so the log is not open to every member |
Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`.
## Event types
Pass these in the `events` array. An unrecognised value returns `422` listing the valid set. Duplicates are dropped, and the array may hold at most as many entries as the catalogue below (31).
| Category | Events |
| --- | --- |
| Conversations & messages | `conversation.started`, `conversation.ended`, `message.received`, `message.sent`, `message.status` (a WhatsApp message you sent reached `sent`, `delivered`, `read` or `failed`, see [delivery status](/docs/whatsapp-api#delivery-status-and-proof-of-service)) |
| Voice sessions | `voice_session.started`, `voice_session.ended`, `voice_session.failed` |
| Telephony call lifecycle | `call.started`, `call.completed`, `call.failed`, `call.amd_detected` (answering-machine detection resolved on an outbound call), `call.handoff` (an [agent squad](/docs/voice-squads) member handed a live call to another member), `call.moderation_flagged` (caller or agent speech matched a [moderation](/docs/voice-moderation) category) |
| Post-call analysis | `voice_analysis.completed` |
| Outbound campaigns | `campaign.started`, `campaign.completed`, `campaign.failed` |
| Metric alerts | `voice_alert.triggered` |
| Billing | `budget.alert`, `budget.exceeded`, `credits.low` |
| Keys | `api_key.expired` |
| Invoices & payments | `invoice.created`, `payment.succeeded`, `payment.failed` |
| Customer payment links | `payment_request.partially_paid`, `payment_request.paid`, `payment_request.expired`, `payment_request.cancelled` — a payment link an agent sent to your customer changed status in your own connected Razorpay account |
| CRM quotes & invoices | `quote.accepted` (a customer accepted a [quote](/docs/crm-quotes) on its public page, or your team recorded the acceptance), `invoice.paid` (a [CRM invoice](/docs/crm-invoices) was fully paid, online through its payment link or by a recorded manual payment) |
### CRM quote and invoice events
Both are sent once the change is saved. Money is in minor units of the document's `currency`; ids are your own records.
| Event | `data` fields |
| --- | --- |
| `quote.accepted` | `quote_id`, `number`, `status` (`converted` when the quote was turned straight into an issued invoice, else `accepted`), `currency`, `total_minor`, `accepted_by_name`, `accepted_at`, `contact_id`, `deal_id`, `invoice_id` (the issued invoice, or `null`) |
| `invoice.paid` | `invoice_id`, `number`, `status` (`paid`), `currency`, `total_minor`, `amount_paid_minor`, `contact_id`, `deal_id`, `paid_at` |
### Squad handoff event
`call.handoff` is sent once per handoff, after it is saved, on phone and web calls alike. A subscription scoped to one agent receives it when that agent is the one the call was handed **to**.
| `data` field | Meaning |
| --- | --- |
| `session_id` | The voice session |
| `call_id` | The phone call, or `null` for a web or API session |
| `squad_id` | The squad the call runs in |
| `from_bot_id`, `from_role` | The member that handed the call over |
| `to_bot_id`, `to_role` | The member that has the call now |
| `reason` | The handing agent's one-line summary of what the caller needs. Empty when the agent or your workspace keeps no voice content (zero data retention) |
| `handoff_number` | `1` for the call's first handoff, then `2`, … |
| `at` | When the handoff happened (ISO 8601) |
### Voice session end reasons
A voice session fires exactly one of `voice_session.ended` or `voice_session.failed`, once, whichever way it ends: the agent or caller hanging up, a `DELETE`, the managed voice agent socket closing, or the session timing out. The event is sent only after the end has been saved. If two end signals arrive at the same moment, the first one decides `end_reason` and `duration_seconds`, and the later one changes nothing.
| `end_reason` | Session `status` | Event |
| --- | --- | --- |
| `agent_error`, `start_failed`, and the managed voice agent's start-up errors (`invalid_settings`, `concurrency_limit`, `model_unavailable`, `non_settings_message_before_settings`) | `failed` | `voice_session.failed` |
| `timeout` (ran past its maximum duration and nothing reported the end), `token_expired` (never connected) | `timeout` | `voice_session.failed` |
| Any other reason, for example `caller_hangup`, `participant_disconnect`, `client_disconnect`, `agent_closed`, `max_duration`, `no_input`, `handoff`, `transferred`, `budget_exhausted`, `api_delete` | `completed` | `voice_session.ended` |
## Delivery format
Every event is a `POST` with this JSON body:
```json
{
"event": "message.received",
"data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" },
"timestamp": "2026-08-04T09:12:00.512301+00:00"
}
```
and these headers:
```
Content-Type: application/json
X-CallMissed-Event: message.received
X-CallMissed-Delivery:
X-CallMissed-Signature: sha256=
X-CallMissed-Signature-Previous: sha256= (only for 24 hours after a secret rotation)
```
`X-CallMissed-Delivery` is the `id` you see in the delivery log; use it to de-duplicate, because a retry or a replay can deliver the same event more than once.
A response with a status below `300` counts as delivered. Anything else, or no response within 10 seconds, is retried: up to **5 attempts** in total, waiting 2, 4, 8 and 16 seconds between them. The delivery log records the attempt count, the last status code and the last error.
A Slack Incoming Webhook URL (`https://hooks.slack.com/services/...`) receives the event formatted as Slack message blocks instead of the JSON body above.
## Signing and verification
Every delivery, including the test delivery, carries the `X-CallMissed-Signature` header above.
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.
```python
import hashlib, hmac
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return any(
hmac.compare_digest(expected, headers.get(name, ""))
for name in ("X-CallMissed-Signature", "X-CallMissed-Signature-Previous")
)
```
The full `secret` is returned **only once**, in the `POST` create response (and, for a new secret, in the [rotate-secret](#rotating-the-secret) response). Every later read masks it as `first4 + "****" + last4`. Store it when you create the subscription.
### Rotating the secret
`POST /api/v1/webhooks/{webhook_id}/rotate-secret` replaces the secret. From then on `X-CallMissed-Signature` is signed with the **new** secret, and for **24 hours** every delivery also carries `X-CallMissed-Signature-Previous`, signed with the **old** one. A receiver that accepts either header (as in the snippet above) keeps verifying while you deploy the new secret. After 24 hours only the new secret signs. Rotating again during the window replaces the old secret with the one just retired.
## URL rules
The `url` is validated on create, on update, before a test delivery, and on every real delivery attempt. Endpoints resolving to private, loopback, link-local, or shared address space are rejected. Use a public HTTPS URL.
---
## GET /api/v1/webhooks
Lists your tenant's subscriptions, newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
```bash
curl https://api.callmissed.com/api/v1/webhooks \
-H "Authorization: Bearer cm_your_api_key"
```
```json
[
{
"id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff",
"url": "https://hooks.acme.com/callmissed",
"secret": "Xk4p****9dQz",
"events": ["conversation.started", "message.received"],
"is_active": true,
"bot_id": null,
"created_at": "2026-07-19T08:00:00Z"
}
]
```
`403` when a `cm_` key lacks `webhooks:write`, or a JWT caller is not owner/admin; `422` when `limit` is outside `1 .. 200`.
## POST /api/v1/webhooks
Creates a subscription and generates its secret. Returns `201`.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `url` | `string` | Yes | 1-2048 chars. Must pass the URL rules above |
| `events` | `string[]` | Yes | 1-30 entries, each from the event table |
| `bot_id` | `UUID \| null` | No | Scope the subscription to one agent. Omit for a workspace-wide subscription |
```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://hooks.acme.com/callmissed",
"events": ["conversation.started", "message.received", "payment.succeeded"],
"bot_id": null
}'
```
```json
{
"id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff",
"url": "https://hooks.acme.com/callmissed",
"secret": "Xk4pR7tYb2Lm8sNcVhJ3wQeZ1aFgD6uO9dQz",
"events": ["conversation.started", "message.received", "payment.succeeded"],
"is_active": true,
"bot_id": null,
"created_at": "2026-08-04T10:40:00Z"
}
```
This response is the **only** place the full `secret` appears.
| Status | Cause |
| --- | --- |
| `400` | The URL failed validation |
| `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin |
| `404` | `Agent not found` when `bot_id` is not an agent in your tenant |
| `422` | Unknown event type, empty `events`, or `url` over 2048 chars |
## PUT `/api/v1/webhooks/{webhook_id}`
Partial update. Every field is optional; omitted fields are unchanged. Returns the subscription with a **masked** secret.
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `url` | `string \| null` | No | 1-2048 chars. Re-validated when present |
| `events` | `string[] \| null` | No | Replaces the whole list. An empty array `[]` subscribes to **every** event |
| `is_active` | `boolean \| null` | No | Pause or resume deliveries |
| `bot_id` | `UUID \| null` | No | Re-scope to an agent in your tenant |
| `clear_bot_id` | `boolean` | No | Default `false`. Send `true` to widen a scoped subscription back to the whole workspace. Takes precedence over `bot_id` |
```bash
curl -X PUT https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"events": ["message.received"], "is_active": false}'
```
`404` `Webhook not found`; `404` `Agent not found` for a foreign `bot_id`; `403`, `400`, and `422` as for create.
## DELETE `/api/v1/webhooks/{webhook_id}`
Returns `204` with an empty body. Writes a `webhook.delete` audit event.
```bash
curl -X DELETE https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \
-H "Authorization: Bearer cm_your_api_key"
```
`403` for insufficient permission; `404` when the subscription is not in your tenant.
## POST `/api/v1/webhooks/{webhook_id}/rotate-secret`
Generates a new signing secret and returns it **once**. No request body. The previous secret keeps signing `X-CallMissed-Signature-Previous` until `previous_secret_expires_at` (24 hours), see [Rotating the secret](#rotating-the-secret). Writes a `webhook.secret.rotate` audit event.
```bash
curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/rotate-secret \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff",
"url": "https://hooks.acme.com/callmissed",
"secret": "Qm7tZ2pLw9XcV4bN1sRkY8eH3uJ6dF0aGq5o",
"events": ["conversation.started", "message.received"],
"is_active": true,
"bot_id": null,
"created_at": "2026-07-19T08:00:00Z",
"previous_secret_expires_at": "2026-10-18T09:30:00+00:00"
}
```
`403` when a `cm_` key lacks `webhooks:write`, or a JWT caller is not owner/admin; `404` when the subscription is not in your tenant.
## POST `/api/v1/webhooks/{webhook_id}/test`
Sends a real signed POST to the configured URL and reports the result. No request body. The stored URL is re-validated first, so a hostname repointed at a private address after creation is rejected.
The delivered payload:
```json
{
"event": "test",
"timestamp": "2026-08-04T10:45:12.004921+00:00",
"data": { "message": "This is a test webhook delivery from CallMissed" }
}
```
```bash
curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/test \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"success": true,
"status_code": 200,
"latency_ms": 214,
"error": null
}
```
`success` is true only for a 2xx from your endpoint. On a network failure the response is still `200` with `success: false`, `status_code: null`, and a short `error` such as `Connection failed` or `Request timed out`. The 10-second request timeout applies. The test is not retried and is not written to the delivery log.
`403` for insufficient permission; `404` when the subscription is not in your tenant.
## GET `/api/v1/webhooks/{webhook_id}/deliveries`
Delivery log for one subscription, newest first.
| Parameter | Type | Required | Constraints |
| --- | --- | --- | --- |
| `limit` | `integer` | No | `1 <= limit <= 200`, default `50` |
```bash
curl "https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries?limit=50" \
-H "Authorization: Bearer cm_your_api_key"
```
```json
[
{
"id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3",
"webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"event": "message.received",
"status_code": 500,
"attempts": 3,
"success": false,
"error": "HTTP 500",
"created_at": "2026-08-04T09:12:00Z",
"delivered_at": null
}
]
```
`status_code` and `error` are `null` when not applicable; `delivered_at` is `null` until a delivery succeeds. The payload body is **not** in this list response - fetch one delivery for that.
`403` when a `cm_` key lacks `webhooks:write`; `404` when the subscription is not in your tenant; `422` when `limit` is outside `1 .. 200`.
## GET `/api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}`
Full inspector view of one delivery, including the stored payload.
```bash
curl https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3 \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3",
"webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"event": "message.received",
"status_code": 500,
"attempts": 3,
"success": false,
"error": "HTTP 500",
"payload": {
"event": "message.received",
"data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" }
},
"created_at": "2026-08-04T09:12:00+00:00",
"delivered_at": null
}
```
| Status | Cause |
| --- | --- |
| `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin |
| `404` | `Webhook not found` or `Delivery not found` |
| `422` | Either path id is not a valid UUID |
## POST `/api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay`
Re-sends a past delivery's payload against the subscription's **current** URL and secret. No request body. A new delivery row is created; the original is preserved. Returns `202`.
```bash
curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3/replay \
-H "Authorization: Bearer cm_your_api_key"
```
```json
{
"delivery_id": "a7b8c9d0-e1f2-4304-8516-27a8b9c0d1e2",
"status": "queued"
}
```
`status: "queued"` means the replay was accepted, not that it succeeded. Poll the deliveries list for the new row's outcome. Writes a `webhook.delivery.replay` audit event.
| Status | Cause |
| --- | --- |
| `400` | `Webhook is inactive` - re-enable it with `PUT` first |
| `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin |
| `404` | `Webhook not found` or `Delivery not found` |
---
### Domains & Senders
URL: /docs/email-domains
> Register a sending domain, publish its DNS records, verify ownership, SPF and both DKIM keys, and understand the donotreply sender rule.
## Overview
Before you can send anything you register a domain you own, publish the DNS records we generate for it, and verify. Verification also registers the one sender username you may send from. Everything on this page uses the base path `https://api.callmissed.com/api/v1/email` and a `cm_` key with the **email** permission.
## Add & Verify a Domain
Register a domain, publish the returned DNS records at your DNS provider, then verify.
**`POST /api/v1/email/domains`** registers the domain and returns its records.
**`POST /api/v1/email/domains/{domain_id}/verify`** runs the checks.
| Create field | Type | Required | Notes |
|--------------|------|----------|-------|
| `domain` | string | Yes | The domain you own, 3–255 chars, for example `acme.com` |
:::tabs
```bash [cURL]
# 1. Add the domain - returns 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 the records, verify
curl -X POST https://api.callmissed.com/api/v1/email/domains/{domain_id}/verify \
-H "Authorization: Bearer cm_your_key"
```
```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:
httpx.post(f"{BASE}/domains/{created['domain']['id']}/verify", headers=h)
```
```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());
for (const rec of created.dns_records) {
console.log(rec.type, rec.host, rec.value); // publish these at your DNS host
}
// once published:
await fetch(`${BASE}/domains/${created.domain.id}/verify`, {
method: "POST",
headers: { Authorization: "Bearer cm_your_key" },
});
```
:::
The add response returns `domain` (a `DomainOut`), `dns_records` (the exact set to publish) and a `message` reading `"Publish these DNS records, then call verify."`. Use each record's `host` (already formatted the way DNS panels want it: `@` for the apex, a bare label for a subdomain) and its `value` verbatim.
| Record | Required | Purpose |
|--------|----------|---------|
| `TXT` (account ownership) | Yes, until the domain is verified | Published at `_callmissed-verify.your-domain` with a value starting `callmissed-domain-verification=`. Proves the domain belongs to **your** account |
| `TXT` (ownership) | Yes | A one-off token proving you control the domain |
| `TXT` (SPF) | Yes | Authorises our sending infrastructure to send for the domain |
| `CNAME` (DKIM) | Yes | Delegates the DKIM signing key for the domain to us |
| `CNAME` (DKIM2) | Yes | A second delegated DKIM key, used for key rotation |
| `MX` | No | Routes inbound mail to us (only needed to **receive**) |
DKIM is **delegated by `CNAME`**, not published as a `TXT` key. The exact host and value of every record are generated per domain, so read them from `dns_records` rather than hard-coding them. If your DNS panel refuses the `CNAME`, you almost certainly have a conflicting record at that name already.
**No DMARC record is generated.** DMARC is worth publishing and we recommend it, but you author `_dmarc.your-domain` yourself. It is not in the returned set and not part of verification.
Verification covers **five** checks (account ownership, domain ownership, SPF, DKIM and DKIM2) and a domain may send only once **all five** report verified. The account-ownership check appears in `checks` as `ownership`; a domain verified before this check existed keeps working without it. `POST /domains/{id}/verify` returns the refreshed `domain`, `verified` (a single boolean over all checks) and a `checks` array with one entry per check, so a partial pass tells you exactly which record has not propagated yet. Each check is a `CheckOut` carrying `name`, `status`, `detail`, and `found` (the values actually seen in DNS, empty when the check is served by the mail service rather than a DNS scan). Propagation is not instant; call verify again until `verified` is `true`.
A newly verified domain starts on a warm-up quota that rises automatically as it sends clean volume. See [Limits & Quotas](/docs/email-limits).
### Managing domains
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/email/domains` | Register a domain (`201`) and get its DNS records. A domain already on your account → `400` |
| `GET /api/v1/email/domains` | List your domains, newest first, as `DomainOut` |
| `GET /api/v1/email/domains/{id}/records` | Re-fetch a domain's DNS records at any time, the same set the create call returned |
| `GET /api/v1/email/domains/{id}/provider` | Detect the domain's DNS host from its nameservers and return a deep link to the right DNS-management page |
| `POST /api/v1/email/domains/{id}/verify` | Run verification and return the per-check states |
| `PATCH /api/v1/email/domains/{id}` | Toggle [open/click tracking and sending](#tracking-and-sending-toggles) |
| `DELETE /api/v1/email/domains/{id}` | Remove a domain (`204`). Sending from it stops immediately |
```bash
# List your domains, re-fetch records, look up the DNS host, then remove a domain
curl https://api.callmissed.com/api/v1/email/domains \
-H "Authorization: Bearer cm_your_key"
curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/records \
-H "Authorization: Bearer cm_your_key"
curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/provider \
-H "Authorization: Bearer cm_your_key"
curl -X DELETE https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key"
```
A domain id that isn't yours returns `404` with `{"detail": "Domain not found"}`.
### Tracking and sending toggles
**`PATCH /api/v1/email/domains/{id}`** sets per-domain behaviour. Needs a write key.
| Field | Type | Notes |
|-------|------|-------|
| `open_tracking` | boolean | Track opens on HTML mail from this domain. Also accepted as `track_opens` |
| `click_tracking` | boolean | Track link clicks on HTML mail from this domain. Also accepted as `track_clicks` |
| `sending` | boolean | `false` pauses sending from this domain; `true` resumes it |
Only the fields you send are applied, so an omitted toggle is left untouched. An unsupported field is a `422` rather than a silent no-op, so you always know whether a setting took effect.
```bash
# Turn on open + click tracking
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}'
# Pause sending from this domain
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 '{"sending": false}'
```
The response is `DomainSettingsOut`, the full domain including `track_opens` and `track_clicks`, so you can see what the call just set.
**Tracking.** Both toggles are off by default and independent. Once on, HTML sends from the domain get an open pixel and/or signed click-redirect links. Details of what gets rewritten are on [Send Email](/docs/email-send#open-and-click-tracking); read the numbers back from [engagement metrics](/docs/email-logs#engagement-metrics).
**Sending.** `sending: false` takes effect immediately: sends from the domain stop, and its `status` becomes `paused`. `sending: true` resumes it.
One important limit: **`sending: true` only resumes a domain you paused yourself.** A domain paused automatically for deliverability reasons (too many bounces or complaints for its volume) stays paused and returns `409`. That pause is a circuit breaker, and a breaker a caller can clear is only advice. Fix the underlying list quality, then contact support. `pause_reason` on `DomainOut` tells the two cases apart.
### Response objects
`DomainOut` returns `id`, `domain`, `status` (`pending` / `verified` / `failed` / `paused`), `dkim_selector`, `daily_quota`, `verified_at`, `last_checked_at`, `paused_at`, `pause_reason`, `sent_count`, `bounce_count`, `complaint_count`, `track_opens`, `track_clicks`, `created_at`.
`DomainSettingsOut`, returned by `PATCH /domains/{id}`, carries the same fields as `DomainOut`.
`DnsRecordOut` returns `type`, `name` (the full FQDN), `host` (the panel-ready name), `value`, `purpose`, `required`, and `priority` (MX only; `null` otherwise).
`DnsProviderOut` returns `detected`, `provider_id`, `provider_name`, `manage_url`, `domain_specific`, and `nameservers`.
### Errors on the domain routes
| Status | Body | Meaning |
|--------|------|---------|
| 400 | string `detail` | The domain is not a valid domain name, or is already registered on your account |
| 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header |
| 403 | string `detail` | The API key is read-only and this route writes |
| 404 | `{"detail": "Domain not found"}` | The domain id is not yours |
| 409 | string `detail` | `PATCH sending=true` on a domain paused automatically for deliverability reasons or never verified, or `sending=false` on a domain that is not verified |
| 409 | string `detail` | Add or verify a domain that is already verified on another account: `"This domain is verified on another account. Remove it there first, or contact support if you own it."` |
| 422 | schema array `detail` | An unsupported field in a `PATCH` body |
| 503 | `reason` nested under `detail` | `provider_unavailable` (older deployments may return `acs_unavailable`; match both): domain provisioning or verification is temporarily unavailable, retry the domain call |
| 503 | string `detail` | `"Domain onboarding is not configured"` |
Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes).
## Sender Addresses
A message may only be sent from an address whose local part is a **registered sender** on the verified domain. Verification registers exactly one, `donotreply`, so `donotreply@your-domain` works the moment the domain reports verified:
```json
{ "from": "Acme " }
```
The local part is matched case-insensitively, so `DoNotReply@acme.com` works too. The **display name is entirely yours**: `"Acme Billing "` is what a recipient's mail client shows first, so the mailbox name is rarely the part they read.
Any other local part on the domain has to be registered, and there are two ways to do it.
**1. Register it up front** with the senders endpoints below. This is the explicit route and the one to use when you know your sending addresses.
**2. Let the send path register it.** When a send is refused because the `from` local part is not a registered sender, the service registers that local part and retries the send once. Registration is eventually consistent across the mail service, so if the retries still land before it is live you get `503 sender_propagating`, which means the address is now registered and the send should simply be retried in a minute or two. Nothing else is required.
### Sender endpoints
| Endpoint | Purpose |
|----------|---------|
| `GET /api/v1/email/domains/{domain_id}/senders` | List the addresses this domain may send from. Read from the mail service, which is the source of truth |
| `POST /api/v1/email/domains/{domain_id}/senders` | Register an address as a permitted sender (`201`) |
| `DELETE /api/v1/email/domains/{domain_id}/senders/{username}` | Remove a sender (`204`) |
The domain must be **verified** before any of the three work; on a `pending`, `failed` or `paused` domain they return `400` with `"Verify the domain before managing its senders"`.
| Create field | Type | Required | Notes |
|--------------|------|----------|-------|
| `username` | string | Yes | A bare local part, 1–64 chars, for example `hello`. It must not contain `@` or `/`, else `422`. Lower-cased before registration |
`SenderOut` returns `username` and `address` (the full `username@domain`).
:::tabs
```bash [cURL]
# What can this domain send from today?
curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders \
-H "Authorization: Bearer cm_your_key"
# Register hello@acme.com as a sender
curl -X POST https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"username": "hello"}'
# Remove it again
curl -X DELETE https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders/hello \
-H "Authorization: Bearer cm_your_key"
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
domain_id = "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e"
httpx.get(f"{BASE}/domains/{domain_id}/senders", headers=h).json()
httpx.post(f"{BASE}/domains/{domain_id}/senders", headers=h, json={"username": "hello"}).json()
httpx.delete(f"{BASE}/domains/{domain_id}/senders/hello", headers=h)
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const domainId = "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
await fetch(`${BASE}/domains/${domainId}/senders`, { headers }).then((r) => r.json());
await fetch(`${BASE}/domains/${domainId}/senders`, {
method: "POST",
headers,
body: JSON.stringify({ username: "hello" }),
}).then((r) => r.json());
await fetch(`${BASE}/domains/${domainId}/senders/hello`, {
method: "DELETE",
headers: { Authorization: "Bearer cm_your_key" },
});
```
:::
A registered sender responds:
```json
{ "username": "hello", "address": "hello@acme.com" }
```
Registering is **idempotent**: it is a create-or-update, so re-registering an existing username succeeds rather than returning `409`. It is also eventually consistent, so a send from a just-registered address can still be refused for up to a couple of minutes.
| Status | Body | Meaning |
|--------|------|---------|
| 400 | string `detail` | The domain is not verified yet |
| 422 | string `detail` | `username` is not a bare local part (it contains `@` or `/`, or is empty) |
| 503 | string `detail` | The sender list could not be read, or the sender could not be registered or removed. Note this `503` is a plain string, not the `{error, reason}` shape the domain create/verify routes use |
### Reply-To
**To give people a real address to answer, set `reply_to`.** It is an ordinary header with no sender registration behind it, so it can be any address at all, including a mailbox on another provider:
```json
{
"from": "Acme Support ",
"reply_to": "support@acme.com"
}
```
If you want those replies to come back through the API, claim the same address as a [receiving address](/docs/email-inbound).
The sending **domain** is entirely yours (any verified domain on your account), and `to`, `cc`, `bcc` and `reply_to` are unrestricted.
## Next
- [Send Email](/docs/email-send) once a domain reports verified.
- [Receive Email](/docs/email-inbound) if you also published the `MX` record.
---
### Receive Email
URL: /docs/email-inbound
> Publish the MX record, claim receiving addresses on a verified domain, and read or forward inbound mail.
## Receive Email
Receiving is optional. Publish the domain's **MX** record (returned with its [DNS records](/docs/email-domains#add--verify-a-domain)), then claim receiving addresses. Mail to an unknown address is refused; there is no catch-all.
:::tabs
```bash [cURL]
# Claim support@acme.com on a verified domain
curl -X POST https://api.callmissed.com/api/v1/email/inbound/addresses \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"local_part": "support", "domain_id": "{domain_id}", "forward_url": "https://your-app.com/inbound"}'
# List received messages
curl https://api.callmissed.com/api/v1/email/inbound/messages \
-H "Authorization: Bearer cm_your_key"
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
httpx.post(f"{BASE}/inbound/addresses", headers=h, json={
"local_part": "support",
"domain_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"forward_url": "https://your-app.com/inbound",
})
messages = httpx.get(f"{BASE}/inbound/messages", headers=h).json()
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
await fetch(`${BASE}/inbound/addresses`, {
method: "POST",
headers,
body: JSON.stringify({
local_part: "support",
domain_id: "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
forward_url: "https://your-app.com/inbound",
}),
});
const messages = await fetch(`${BASE}/inbound/messages`, {
headers: { Authorization: "Bearer cm_your_key" },
}).then((r) => r.json());
```
:::
## Endpoints
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/email/inbound/addresses` | Claim an address on a verified domain |
| `GET /api/v1/email/inbound/addresses` | List your receiving addresses |
| `DELETE /api/v1/email/inbound/addresses/{id}` | Stop receiving at an address |
| `GET /api/v1/email/inbound/messages` | List received messages, newest first (`limit` / `offset`) |
| `GET /api/v1/email/inbound/messages/{id}` | Read one message (parsed text/HTML) |
Mail arriving for an address you have not claimed is refused at the door, so nothing is stored for it.
### Claim an address
**`POST /api/v1/email/inbound/addresses`**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `local_part` | string | Yes | The mailbox name to claim, 1–64 chars, for example `support` for `support@acme.com`. A bare name only: `@` or `/` in it is a `422`. Lower-cased before the address is built |
| `domain_id` | string (UUID) | Yes | The id of one of your **verified** domains. An unverified domain is a `400` |
| `forward_url` | string | No | Up to 2048 chars, and it must be a public `http`/`https` URL: an internal or private target is refused at write time with a `400`. Every message that arrives at this address is [POSTed to it](#forwarding-to-your-app) |
An inbound address is **globally unique**: one mailbox per address across the whole platform, so a `local_part` already claimed on that domain returns `409`.
`InboundAddressOut` returns `id`, `address` (the full `local_part@domain`), `domain_id`, `forward_url`, `is_active` and `created_at`.
### Forwarding to your app
Set `forward_url` on an address and every message that arrives there is POSTed to it as JSON, so you do not have to poll the messages endpoints.
```json
{
"type": "email.received",
"data": {
"id": "3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d",
"message_id": "",
"from": "customer@example.com",
"to": "support@acme.com",
"subject": "Where is my order?",
"text": "Hi, checking on order 1043.",
"html": "Hi, checking on order 1043.
",
"auth_results": "not_evaluated",
"size_bytes": 4821,
"received_at": "2026-08-13T09:41:02.118431+00:00"
}
}
```
The `data` object is the message as we stored it. Raw MIME is not included; it is not retained after parsing. `auth_results` carries the sender-authentication verdicts computed when the message was received, and is the string `"not_evaluated"` when none were recorded. The request carries an `X-CallMissed-Event: email.received` header.
The forward payload uses shorter key names than the messages API, so a handler written against one does not read the other unchanged:
| Forward payload | `GET /inbound/messages/{id}` |
|-----------------|------------------------------|
| `data.id` | `id` |
| `data.from` | `from_address` |
| `data.to` | `to_address` |
| `data.text` | `text_body` |
| `data.html` | `html_body` |
| `data.auth_results` | not returned |
`message_id`, `subject`, `size_bytes` and `received_at` are spelled the same on both.
How it behaves:
- **The message is stored first, then forwarded.** It is always readable through the API even if your endpoint is down, and the `id` in the payload is the one you can fetch.
- **The message's `status` records the outcome:** `forwarded` when your endpoint answered 2xx, `failed` when it did not. Poll `GET /inbound/messages` filtered by nothing and check `status` to find what your endpoint missed.
- **A failed forward never loses the message.** Delivery is best-effort on top of a message we have already persisted.
- **Redirects are not followed**, and the URL is re-validated at delivery time: one that resolves to a private or internal address is refused even though it passed validation when you claimed the address.
- **Forwards are not signed.** Unlike [email webhooks](/docs/email-webhooks), a `forward_url` carries no HMAC signature, because there is no per-subscription secret behind it. Treat the payload as unauthenticated: use an unguessable URL, and confirm anything you act on by re-reading the message with `GET /inbound/messages/{id}` using your API key.
If you want signed, retried, per-event delivery instead, subscribe to `email.received` on [Email Webhooks](/docs/email-webhooks) — note that event is accepted but not emitted yet, so `forward_url` is the live path for inbound mail today.
### Read what arrived
```bash
# The addresses you currently receive at
curl https://api.callmissed.com/api/v1/email/inbound/addresses \
-H "Authorization: Bearer cm_your_key"
# One message, parsed to text/HTML
curl https://api.callmissed.com/api/v1/email/inbound/messages/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \
-H "Authorization: Bearer cm_your_key"
# Stop receiving at an address
curl -X DELETE https://api.callmissed.com/api/v1/email/inbound/addresses/7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d \
-H "Authorization: Bearer cm_your_key"
```
`GET /inbound/messages` takes `limit` (1–200, default 50) and `offset` (≥0, default 0) and returns the newest first. Each `InboundMessageOut` carries `id`, `address_id`, `message_id`, `from_address`, `to_address`, `subject`, `text_body`, `html_body`, `size_bytes`, `status` (`received`, `forwarded` or `failed`) and `received_at`.
## Common inbound failures
| Status | Body | Meaning |
|--------|------|---------|
| 400 | string `detail` | The domain is not verified yet, or the `forward_url` is not a permitted public URL |
| 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 inbound message, or the receiving address, is not yours |
| 409 | string `detail` | The address is already claimed |
| 422 | string `detail` | `local_part` is not a bare mailbox name |
These use the plain-string `detail` shape with no `reason` key. See [Limits, Quotas & Errors](/docs/email-limits#response-shapes).
## Related
- [Domains & Senders](/docs/email-domains) for the `MX` record and verification.
- [Sender Addresses](/docs/email-domains#sender-addresses): claim the same address you put in `reply_to` if you want replies to come back through the API.
---
### Limits, Quotas & Errors
URL: /docs/email-limits
> The three sending ceilings, domain warm-up and reputation pauses, the four error body shapes, and every error reason.
## Overview
Everything that can stop a send lives here: the three ceilings that govern volume, and the four error shapes plus the full reason table a client has to branch on. Per-message limits (recipients, size, tags, attachments) are under [Send Email](/docs/email-send); batch limits under [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions).
## Limits & Quotas
Three separate ceilings govern sending, and they count three **different** things. A rejection always names which one you hit.
| Plan | Send rate | Recipients per calendar month | Daily-quota ceiling |
|------|-----------|-------------------------------|---------------------|
| Free | 10 requests / min | 2,000 | 200 |
| Starter | 60 requests / min | 50,000 | 5,000 |
| Pro | 300 requests / min | 1,000,000 | 50,000 |
| Enterprise, Scale, Business, Corporate | 1,000 requests / min | Unlimited | 500,000 |
- **Send rate** counts **requests** accepted in the trailing 60 seconds, across your whole account. One call is one request whether it carries 1 recipient or 50, and a `messageVersions` batch is still one request. Exceeding it is `429 rate_limited`.
- **Recipients per calendar month** counts **delivered recipients** in the current calendar month. It resets on the 1st, not on a rolling 30 days. A send is refused up front if it *would* push you past the cap. Exceeding it is `429 monthly_cap_exceeded`.
- **Daily quota** counts **sends**, meaning messages and not recipients, for **one domain** over a rolling 24 hours. Exceeding it is `429 quota_exceeded`.
**Warm-up.** The daily quota is a property of each domain, not of your plan. Every newly verified domain starts at **200 sends / day** and climbs the ladder **200 → 1,000 → 5,000 → 20,000 → 50,000**, at most one step per day, and only after a day that carried real volume with a healthy bounce and complaint rate. Past the top of the ladder the quota keeps **doubling** on each clean day rather than jumping straight to the plan ceiling, so a plan whose ceiling is higher than 50,000 is reached over several more clean days. The plan column above is the ceiling the plan permits; the figure actually enforced is the domain's current `daily_quota`, which `GET /api/v1/email/domains` returns.
**Reputation pause.** A domain whose bounce or complaint rate degrades is paused automatically, whatever the plan or remaining quota. Sends from it then return `403 domain_paused` carrying the reason, and `DomainOut` shows `paused_at` and `pause_reason`.
```bash
# Read the quota actually enforced for each domain
curl https://api.callmissed.com/api/v1/email/domains \
-H "Authorization: Bearer cm_your_key"
```
## Errors
### Response shapes
Error bodies come in **four** shapes. They are not interchangeable, and a client that always reads `reason` from the same place breaks, most obviously on `relay_failed`, where `reason` sits at the top level rather than under `detail`.
**1. Send rejection: `reason` nested under `detail`.** Everything the send pipeline refuses (every row in the table below except `relay_failed`), plus `403 email_not_enabled`, `422 scheduled_batch_unsupported`, `503 sending_unavailable`, and the `503` from the domain routes:
```json
{
"detail": {
"error": "acme.com has not completed DNS verification",
"reason": "domain_not_verified"
}
}
```
**2. `503 relay_failed`: flat, and it carries an `id`.** This one is **not** wrapped in `detail`. The send row already exists, so the `id` comes back for you to look up with `GET /api/v1/email/sends`:
```json
{
"error": "The message could not be accepted for delivery",
"reason": "relay_failed",
"id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e"
}
```
**3. Auth, not-found and conflict: `detail` is a plain string with no `reason` at all.** Covers `401`, the read-only-key `403`, every `404` (domain, template, inbound message, suppression, scheduled send), `409` (duplicate template name, address already claimed), the `400` on a duplicate domain, the `400`s and `422`s on the sender and inbound-address routes, the `503`s from the sender routes, and the `503`s raised when email is not configured:
```json
{ "detail": "A valid API key is required" }
```
**4. Schema-validation `422`: `detail` is an array, with no `reason` key.** Anything rejected before the send pipeline runs: a malformed or control-character-bearing address, a subject or header carrying control characters, an attachment that is not exactly one of `url`/`content` (or `content` without `name`), over-limit `params`, a `scheduledAt` in the past or beyond the 72-hour horizon, and the batch cross-version rules:
```json
{
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, invalid email address: 'not-an-email'",
"input": { "...": "..." }
}
]
}
```
When handling errors, branch on the HTTP status first, then check whether `detail` is an object, a string or an array before reaching for `reason`.
Handling all four shapes:
:::tabs
```python [Python]
import httpx
r = httpx.post(
"https://api.callmissed.com/api/v1/email/send",
headers={"Authorization": "Bearer cm_your_key"},
json={
"from": "Acme Ops ",
"to": ["Ada "],
"subject": "Your receipt",
"text": "Thanks for your order.",
},
)
if r.status_code != 202:
body = r.json()
if "reason" in body: # shape 2 - relay_failed, flat, has id
reason, send_id = body["reason"], body.get("id")
else:
detail = body.get("detail")
if isinstance(detail, dict): # shape 1 - send rejection
reason, send_id = detail.get("reason"), None
elif isinstance(detail, list): # shape 4 - schema validation
reason, send_id = "validation_error", None
else: # shape 3 - plain string detail
reason, send_id = None, None
print(r.status_code, reason, send_id)
```
```javascript [JavaScript]
const r = await fetch("https://api.callmissed.com/api/v1/email/send", {
method: "POST",
headers: {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme Ops ",
to: ["Ada "],
subject: "Your receipt",
text: "Thanks for your order.",
}),
});
if (r.status !== 202) {
const body = await r.json();
let reason = null;
let sendId = null;
if (body.reason) {
reason = body.reason; // shape 2 - relay_failed, flat, has id
sendId = body.id ?? null;
} else if (body.detail && typeof body.detail === "object" && !Array.isArray(body.detail)) {
reason = body.detail.reason; // shape 1 - send rejection
} else if (Array.isArray(body.detail)) {
reason = "validation_error"; // shape 4 - schema validation
} // else shape 3 - plain string detail
console.log(r.status, reason, sendId);
}
```
:::
### Reasons
| Status | Reason | Meaning |
|--------|--------|---------|
| 401 | *(none, string `detail`)* | Missing, malformed or unrecognised `Authorization` header |
| 402 | `payment_required` | Not enough credit balance to cover the send |
| 403 | *(none, string `detail`)* | The API key is read-only and this route writes |
| 403 | `email_not_enabled` | The API key lacks the email permission |
| 403 | `domain_not_found` | The From domain is not registered on your account at all |
| 403 | `domain_not_verified` | The From domain is registered but hasn't passed verification |
| 403 | `domain_paused` | Sending from the domain is paused (reputation) |
| 403 | `all_recipients_suppressed` | Every recipient is on your suppression list, nothing to send |
| 404 | `template_not_found` | The `templateId` doesn't exist or isn't yours |
| 409 | `idempotency_conflict` | The `Idempotency-Key` was already used by a different kind of send (a single send's key reused on a `messageVersions` batch, or the reverse). Use a fresh key |
| 422 | `no_sender` | Neither `from`/`sender` nor a template `default_sender` supplied one |
| 422 | `invalid_from` | The resolved sender is not a usable email address |
| 422 | `no_recipients` | The resolved recipient list came out empty |
| 422 | `empty_body` | Neither `text` nor `html` (nor a template body) was present |
| 422 | `invalid_headers` | A rendered header value contains invalid characters, usually a template `param` with a newline in it |
| 422 | `unresolvable_template_vars` | The subject or body references `{{ contact.something }}`, a namespace nothing can populate, so it would render empty. Pass the value in `params` instead |
| 422 | `message_too_large` | The message exceeds 10 MB, attachments included after base64 encoding |
| 422 | `template_inactive` | The template exists but is not active (`is_active: false`) |
| 422 | `too_many_recipients` | Over 50 recipients on a single send, or over the union limit on a batch |
| 422 | `scheduled_batch_unsupported` | A send set both `scheduledAt` and `messageVersions`, pick one |
| 422 | `invalid_attachment` | An attachment's `content` is not valid base64 |
| 422 | `attachment_fetch_failed` | A `url` attachment could not be fetched, or exceeded the size cap |
| 422 | `attachment_url_forbidden` | A `url` attachment points at a blocked (internal/private) address, or uses a scheme other than http/https |
| 429 | `rate_limited` | Per-minute request rate for your plan exceeded |
| 429 | `monthly_cap_exceeded` | Monthly recipient volume for your plan exceeded |
| 429 | `quota_exceeded` | The domain's daily send quota is exhausted |
| 503 | `relay_failed` | The message could not be accepted for delivery. Uses the flat shape above |
| 503 | `provider_unavailable` (older deployments: `acs_unavailable`) | Domain provisioning or verification is temporarily unavailable, retry the domain call. Both strings mean the same thing; match both |
| 503 | `sender_propagating` | The `from` address has now been registered as a sender for the domain, but the mail service has not finished propagating it. Retry the send shortly; no further setup is needed. See [Sender Addresses](/docs/email-domains#sender-addresses) |
| 503 | `sending_unavailable` | Sending is temporarily unavailable |
A **malformed recipient address is not `invalid_attachment`**. It is a schema `422` (shape 4), rejected before the send pipeline runs and before any charge.
---
### Delivery, Suppressions & Usage
URL: /docs/email-logs
> Read the send log, manage the suppression list, and check email spend and pricing.
## Overview
Four read-mostly surfaces cover what happened after a send: the send log (`/sends`), the suppression list (`/suppressions`), engagement metrics (`/emails/metrics`), and spend (`/usage`). Everything except the suppression writes is a read, so a read-only key works.
To be told about a bounce or a complaint as it happens instead of polling, subscribe to [Email Webhooks](/docs/email-webhooks).
## Suppressions
A suppression list per account prevents sending to addresses that hard-bounced or complained. Entries are added automatically from delivery feedback, and you can manage them:
| Endpoint | Purpose |
|----------|---------|
| `GET /api/v1/email/suppressions` | List suppressed addresses, newest first. `limit` (1–500, default 100) and `offset` (≥0, default 0) |
| `POST /api/v1/email/suppressions` | Suppress an address manually (`201`) |
| `POST /api/v1/email/suppressions/batch` | Suppress up to 100 addresses in one call (`201`) |
| `GET /api/v1/email/suppressions/{id}` | Retrieve one suppression by id |
| `DELETE /api/v1/email/suppressions/{id}` | Remove a suppression (`204`) |
| Create field | Type | Required | Notes |
|--------------|------|----------|-------|
| `address` | string | Yes | A valid email address. Stored lower-cased |
| `reason` | string | No | One of `hard_bounce`, `complaint`, `manual`, `unsubscribe`. Defaults to `manual`; anything else is a `422` |
| `detail` | string | No | Your own note about why |
Adding an address that is already suppressed is safe: the existing entry is returned unchanged rather than duplicated or rejected.
`SuppressionOut` returns `id`, `address`, `reason`, `detail`, and `created_at`. `GET /suppressions/{id}` returns the same object for a single entry; an id that is not yours is a `404`.
### Suppress in bulk
**`POST /api/v1/email/suppressions/batch`** applies one `reason` and `detail` to many addresses:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `addresses` | array | Yes | 1–100 valid email addresses. Stored lower-cased |
| `reason` | string | No | Same set as the single-add route. Defaults to `manual`; anything else is a `422` |
| `detail` | string | No | Your own note, applied to every address in the batch |
```bash
curl -X POST https://api.callmissed.com/api/v1/email/suppressions/batch \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{
"addresses": ["one@example.com", "two@example.com", "three@example.com"],
"reason": "unsubscribe",
"detail": "Imported from legacy list"
}'
```
The `201` response is an array of `SuppressionOut` in the order you sent, so it lines up with your input. It is idempotent per address: one already on the list is returned unchanged rather than erroring, and duplicates within a single payload are collapsed. That means a partially-applied batch can simply be retried.
:::tabs
```bash [cURL]
# List
curl https://api.callmissed.com/api/v1/email/suppressions \
-H "Authorization: Bearer cm_your_key"
# Suppress an address manually
curl -X POST https://api.callmissed.com/api/v1/email/suppressions \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-d '{"address": "blocked@example.com", "reason": "manual"}'
# Remove a suppression
curl -X DELETE https://api.callmissed.com/api/v1/email/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \
-H "Authorization: Bearer cm_your_key"
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
httpx.get(f"{BASE}/suppressions", headers=h).json()
httpx.post(f"{BASE}/suppressions", headers=h, json={
"address": "blocked@example.com",
"reason": "manual",
})
httpx.delete(f"{BASE}/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d", headers=h)
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
await fetch(`${BASE}/suppressions`, { headers }).then((r) => r.json());
await fetch(`${BASE}/suppressions`, {
method: "POST",
headers,
body: JSON.stringify({ address: "blocked@example.com", reason: "manual" }),
});
await fetch(`${BASE}/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d`, {
method: "DELETE",
headers: { Authorization: "Bearer cm_your_key" },
});
```
:::
Suppressed recipients are dropped from `to`, `cc`, and `bcc` before sending and returned in the send response's `suppressed` array. If every recipient is suppressed the send is refused with `403 all_recipients_suppressed`. A `404` on a suppression id uses the plain-string `detail` shape.
## Delivery Log & Usage
**`GET /api/v1/email/sends`** is your send log, newest first. Returns an array of `SendOut`:
| Field | Type | Notes |
|-------|------|-------|
| `id` | string (UUID) | The send id returned by `POST /send` |
| `message_id` | string \| null | RFC 5322 `Message-ID`; null if the message was never built |
| `from_address` | string | The sender the message went out with |
| `subject` | string | The rendered subject |
| `status` | string | `queued`, `sent` (accepted for delivery), `delivered`, `bounced`, `complained`, `rejected` (we refused it), or `failed` (delivery error) |
| `size_bytes` | integer | Assembled message size |
| `sent_at` | string \| null | When it was accepted for delivery |
| `smtp_response` | string \| null | `accepted for delivery` once the message was handed off. It shows the hand-off, not that the message reached the inbox: see `delivered_at` and [per-recipient delivery events](#per-recipient-delivery-events). `null` for sends made before this field existed and for messages that were never accepted |
| `delivered_at` | string \| null | When the first recipient's mail server accepted the message. The outcome for every recipient is in [per-recipient delivery events](#per-recipient-delivery-events) |
| `bounced_at` | string \| null | Set from bounce feedback |
| `complained_at` | string \| null | Set from a spam complaint |
| `created_at` | string \| null | When the row was written |
| `failure_reason` | string \| null | Why a `bounced`, `complained`, `rejected` or `failed` send didn't land, in plain language (e.g. `Address doesn't exist`, `Mailbox full`, `Recipient marked it as spam`, `Not sent: credit balance too low`). When only some recipients of a send failed, a summary such as `1 of 3 recipients bounced: Address doesn't exist` or `1 of 3 recipients not delivered: Blocked as spam by the recipient's server`; this can appear on `bounced`, `sent` or `delivered` sends. Otherwise `null` |
| `failure_code` | string \| null | Short code for the same outcome: the receiving server's SMTP status code when it gave one (e.g. `5.1.1`, `550`), otherwise one of `bounced`, `suppressed`, `complaint`, `insufficient_credit`, `billing_unavailable`, `rejected`, `recipient_refused`, `send_refused`, `temporary_failure`, `send_failed`, `filtered_spam`, `not_delivered`. For a partial failure, the first failed recipient's code. Otherwise `null` |
One row per **message**, so a `messageVersions` batch writes one row per version. This is how you find out which versions of a batch failed.
### Per-recipient delivery events
**`GET /api/v1/email/sends/{id}/events`** returns, for one send, each recipient's delivery outcome reported by the receiving side, ordered by attempt time. This is the audit trail behind `email.delivered` and `email.bounced`, and a read-only key can call it.
```bash
curl https://api.callmissed.com/api/v1/email/sends/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/events \
-H "Authorization: Bearer cm_your_api_key"
```
```json
[
{
"id": "5c2a9e41-7b3d-4f0a-9e8c-1d2b3c4d5e6f",
"send_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"recipient": "customer@example.com",
"status": "delivered",
"reason": null,
"code": null,
"detail": null,
"occurred_at": "2026-08-13T09:41:03.201000+00:00",
"received_at": "2026-08-13T09:41:05.770114+00:00"
}
]
```
| Field | Notes |
|-------|-------|
| `status` | `delivered`, `bounced`, `suppressed`, `failed`, `filtered_spam`, `quarantined` or `expanded` (a distribution list was expanded into its members) |
| `reason` | Why this recipient didn't get the message, in plain language (e.g. `Address doesn't exist`, `Blocked as spam by the recipient's server`). `null` when delivered |
| `code` | The receiving server's SMTP status code when it gave one (e.g. `5.1.1`), otherwise `bounced`, `suppressed`, `filtered_spam`, `not_delivered` or `quarantined`. `null` when delivered |
| `detail` | Deprecated. Same text as `reason` |
| `occurred_at` | When delivery to this recipient was attempted |
| `received_at` | When CallMissed recorded the outcome |
The list is empty until outcomes arrive, usually within seconds to a few minutes of sending. It is always empty for sends made before 2026-10-03. A send id that is not yours returns `404`. Outcomes are kept for the life of your account.
### Filtering the send log
| Query param | Type | Notes |
|-------------|------|-------|
| `limit` | integer | 1–200, default 50 |
| `offset` | integer | ≥0, default 0 |
| `status` | string | Exact send status, e.g. `delivered`, `bounced`, `queued`. An unknown value is a `422` listing the valid set |
| `message_id` | string | Exact RFC 5322 `Message-ID` match, up to 255 chars |
| `recipient` | string | Match a `To:` address on the send, up to 320 chars. Case-insensitive and exact per address, so `bob@ex.com` will not match `notbob@ex.com`. A display form like `Alice ` matches on the bare address. `cc` and `bcc` are deliberately not searched |
| `since` | string | ISO 8601. Only sends created at or after this timestamp |
| `until` | string | ISO 8601. Only sends created at or before this timestamp |
Filters combine. When you pass both `since` and `until`, the window may not exceed **90 days**, and `until` must not precede `since`; either violation is a `422`.
```bash
# Everything that bounced in a date window
curl -G https://api.callmissed.com/api/v1/email/sends \
-H "Authorization: Bearer cm_your_key" \
--data-urlencode "status=bounced" \
--data-urlencode "since=2026-07-01T00:00:00Z" \
--data-urlencode "until=2026-07-31T23:59:59Z"
# Every send to one recipient
curl -G https://api.callmissed.com/api/v1/email/sends \
-H "Authorization: Bearer cm_your_key" \
--data-urlencode "recipient=customer@example.com"
```
### Retrieve one send
**`GET /api/v1/email/sends/{send_id}`** returns a single `SendOut` for the `id` you got back from `POST /send`:
```bash
curl https://api.callmissed.com/api/v1/email/sends/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \
-H "Authorization: Bearer cm_your_key"
```
The fields are identical to a row from the list, so the two surfaces cannot drift. An id that is not yours is a `404`, not a `403`.
Message bodies are **not** returned: we do not retain the rendered `html`/`text` after the message is handed off for delivery. Keep your own copy if you need to display what was sent.
**`GET /api/v1/email/usage`** is spend for the account. Cost is summed from the price stamped on each send at send time, so a later price change never rewrites history. Returns `UsageOut`:
| Field | Type | Notes |
|-------|------|-------|
| `currency` | string | `INR` |
| `price_per_1000` | number | Current price per 1,000 emails |
| `billed_sends` | integer | Sends that were actually charged, all time |
| `total_cost` | number | All-time spend in whole currency units |
| `sends_30d` | integer | Billed sends in the trailing 30 days |
| `cost_30d` | number | Spend in the trailing 30 days |
Both endpoints are reads, so a read-only key works.
:::tabs
```bash [cURL]
# Newest 50 sends
curl "https://api.callmissed.com/api/v1/email/sends?limit=50&offset=0" \
-H "Authorization: Bearer cm_your_key"
# Spend
curl https://api.callmissed.com/api/v1/email/usage \
-H "Authorization: Bearer cm_your_key"
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
sends = httpx.get(f"{BASE}/sends", headers=h, params={"limit": 50, "offset": 0}).json()
usage = httpx.get(f"{BASE}/usage", headers=h).json()
for row in sends:
print(row["id"], row["status"], row["subject"])
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = { Authorization: "Bearer cm_your_key" };
const sends = await fetch(`${BASE}/sends?limit=50&offset=0`, { headers }).then((r) =>
r.json(),
);
const usage = await fetch(`${BASE}/usage`, { headers }).then((r) => r.json());
for (const row of sends) console.log(row.id, row.status, row.subject);
```
:::
## Engagement metrics
**`GET /api/v1/email/emails/metrics`** is one aggregate row for a time window: volume, engagement and the derived rates. A read, so a read-only key works.
The repeated `email/emails` in that path is correct, not a typo: the metrics route is named `/emails/metrics` and sits under the `/api/v1/email` prefix like every other endpoint here.
| Query param | Notes |
|-------------|-------|
| `start_date` | ISO 8601. Defaults to 6 days before `end_date`. A value with no offset is treated as UTC |
| `end_date` | ISO 8601. Defaults to now; a future value is clamped to now |
The window may not exceed **90 days**, and `start_date` must be on or before `end_date`; either violation is a `422`.
```bash
# Default window (the last 6 days)
curl https://api.callmissed.com/api/v1/email/emails/metrics \
-H "Authorization: Bearer cm_your_key"
# An explicit window
curl -G https://api.callmissed.com/api/v1/email/emails/metrics \
-H "Authorization: Bearer cm_your_key" \
--data-urlencode "start_date=2026-07-01T00:00:00Z" \
--data-urlencode "end_date=2026-07-31T23:59:59Z"
```
```json
{
"start_date": "2026-07-01T00:00:00Z",
"end_date": "2026-07-31T23:59:59Z",
"sent": 4820,
"delivered": 4731,
"bounced": 61,
"complained": 3,
"opened": 5904,
"unique_opened": 2140,
"clicked": 812,
"unique_clicked": 655,
"tracked_opens": 4820,
"tracked_clicks": 4820,
"delivery_rate": 0.9815,
"bounce_rate": 0.0127,
"complaint_rate": 0.0006,
"open_rate": 0.4439,
"click_rate": 0.1359
}
```
| Field | Type | Notes |
|-------|------|-------|
| `start_date` | string | The window actually used, after defaults and clamping |
| `end_date` | string | As above |
| `sent` | integer | Sends accepted for delivery in the window. This is the denominator for the delivery, bounce and complaint rates |
| `delivered` | integer | Confirmed delivered |
| `bounced` | integer | Bounced |
| `complained` | integer | Marked as spam |
| `opened` | integer | **Total** open hits. A mail client refetching the pixel increments this |
| `unique_opened` | integer | Distinct sends that were opened at least once |
| `clicked` | integer | Total click hits |
| `unique_clicked` | integer | Distinct sends that were clicked at least once |
| `tracked_opens` | integer | Sends in the window that actually carried an open pixel |
| `tracked_clicks` | integer | Sends in the window that actually carried rewritten links |
| `delivery_rate` | number | `delivered / sent` |
| `bounce_rate` | number | `bounced / sent` |
| `complaint_rate` | number | `complained / sent` |
| `open_rate` | number | `unique_opened / tracked_opens` |
| `click_rate` | number | `unique_clicked / tracked_clicks` |
Every rate is a fraction in `[0, 1]` rounded to 4 decimal places. An empty window returns zeros rather than nulls or an error, so a graph always has a number to plot.
**Open and click rates divide by the tracked subset, not by `sent`.** A message that carried no pixel cannot be opened, so counting it in the denominator would understate your real open rate. Whether a send carried tracking is recorded at send time, which is what keeps a rate meaningful across a window where you flipped a domain toggle. If `tracked_opens` is `0`, tracking is off for the domains you sent from: see [Open and click tracking](/docs/email-send#open-and-click-tracking).
Only sends accepted for delivery are counted. A rejected or still-queued send never reached a mailbox, so it is not in any denominator.
This is the aggregate total for one window. There is no per-day or per-dimension breakdown; call it once per window you want to chart.
## Pricing
**30 credits (₹30) per 1,000 emails**, charged per recipient to your credit balance, the same credits as every other API (your signup bonus counts). Only accepted sends are billed; rejected or failed sends cost nothing. See [Credits & Pricing](/docs/credits-rate-limits).
## 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 (suppression writes only) |
| 404 | string `detail` | The suppression id, or the send id, is not yours |
| 422 | string `detail` | An unknown `status` on `GET /sends`, a date window over 90 days, `until` before `since`, or an unknown `reason` on `POST /suppressions/batch` |
| 422 | schema array `detail` | An unknown `reason` on `POST /suppressions`, or a malformed address in a batch |
Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes).
---
### Scheduled & Batch Sending
URL: /docs/email-scheduled
> Send later with scheduledAt, cancel before it fires, or deliver many recipient sets in one call with messageVersions.
## Overview
Two variants of `POST /api/v1/email/send` live on this page: a send that fires later (`scheduledAt`) and a send that carries many recipient sets at once (`messageVersions`). A message cannot be both. Every other field behaves exactly as it does on [Send Email](/docs/email-send).
## Scheduled Sending
Send a message later by adding `scheduledAt` to `POST /api/v1/email/send`. The send is accepted and enqueued: **no charge and no delivery happen at enqueue time**; billing and delivery occur when it fires at `scheduledAt`.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `scheduledAt` | string | Yes (to schedule) | ISO-8601 UTC timestamp. Must be in the **future** and **within 72 hours**, else `422`. A timestamp with no offset is treated as UTC rather than rejected |
| `batchId` | string (UUID) | No | A UUID you supply to group related scheduled sends; auto-generated if omitted |
A message cannot be both scheduled **and** a `messageVersions` batch; sending both returns `422`.
A scheduled send returns `202`:
```json
{
"id": "3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d",
"message_id": "",
"messageId": "",
"messageIds": [],
"status": "scheduled",
"suppressed": [],
"batchId": "7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d",
"scheduledAt": "2026-07-28T09:00:00Z"
}
```
`message_id` is empty and `suppressed` is `[]` because nothing has been built or filtered yet: suppression, quota, billing and delivery all run when the send fires. `scheduledAt` is set only on a scheduled enqueue, and `batchId` only on a scheduled enqueue or a batch; both are `null` on an immediate single send.
### Managing scheduled sends
Manage scheduled sends by id **or** batchId:
| Endpoint | Purpose |
|----------|---------|
| `GET /api/v1/email/scheduled/{identifier}` | List the scheduled rows for a send `id` or a `batchId`, ordered by `scheduledAt`. A batch lists all its rows, including ones that already fired or were cancelled. A UUID you don't own returns an empty list |
| `DELETE /api/v1/email/scheduled/{identifier}` | Cancel the **pending** scheduled send(s) by id or batchId (`204`). `404` if there's nothing pending to cancel |
An `identifier` that is not a UUID at all is a `404` on both routes rather than a `422`.
Each `ScheduledSendOut` returns `id`, `batch_id` / `batchId`, `scheduled_at` / `scheduledAt`, `status` (`pending` / `sent` / `failed` / `cancelled`), `send_id` (the delivered send once it fires), and `created_at` / `createdAt`. The camelCase keys are Brevo-compatible aliases of the snake_case ones and carry the same values.
:::tabs
```bash [cURL]
# Schedule a send for later (within 72h)
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": "Reminder",
"text": "Your appointment is tomorrow.",
"scheduledAt": "2026-07-28T09:00:00Z"
}'
# Inspect the scheduled rows by send id or batchId
curl https://api.callmissed.com/api/v1/email/scheduled/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \
-H "Authorization: Bearer cm_your_key"
# Cancel it before it fires - never charged
curl -X DELETE https://api.callmissed.com/api/v1/email/scheduled/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \
-H "Authorization: Bearer cm_your_key"
```
```python [Python]
import httpx
BASE = "https://api.callmissed.com/api/v1/email"
h = {"Authorization": "Bearer cm_your_key"}
scheduled = httpx.post(f"{BASE}/send", headers=h, json={
"from": "Acme Ops ",
"to": ["Ada "],
"subject": "Reminder",
"text": "Your appointment is tomorrow.",
"scheduledAt": "2026-07-28T09:00:00Z",
}).json()
httpx.get(f"{BASE}/scheduled/{scheduled['id']}", headers=h).json()
httpx.delete(f"{BASE}/scheduled/{scheduled['id']}", headers=h)
```
```javascript [JavaScript]
const BASE = "https://api.callmissed.com/api/v1/email";
const headers = {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
};
const scheduled = await fetch(`${BASE}/send`, {
method: "POST",
headers,
body: JSON.stringify({
from: "Acme Ops ",
to: ["Ada "],
subject: "Reminder",
text: "Your appointment is tomorrow.",
scheduledAt: "2026-07-28T09:00:00Z",
}),
}).then((r) => r.json());
await fetch(`${BASE}/scheduled/${scheduled.id}`, {
headers: { Authorization: "Bearer cm_your_key" },
});
await fetch(`${BASE}/scheduled/${scheduled.id}`, {
method: "DELETE",
headers: { Authorization: "Bearer cm_your_key" },
});
```
:::
A cancelled send never fires and is never charged.
## Batch (messageVersions)
Send to many recipient sets in one call. Add `messageVersions`, an array where each version is its own recipient set that may override the global subject, body, params, or template.
**Per-version fields:**
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `to` | array | Yes | Recipient addresses (same forms as the top-level send) |
| `cc` | array | No | Carbon-copy recipients |
| `bcc` | array | No | Blind-copy recipients |
| `subject` | string | No | Overrides the global subject |
| `htmlContent` | string | No | Overrides the global HTML body |
| `textContent` | string | No | Overrides the global text body |
| `replyTo` | string \| object | No | Overrides the global reply-to |
| `params` | object | No | Substitution values for this version, shallow-merged over the global `params` |
| `templateId` | string (UUID) | No | Overrides the global template |
- **Base / override.** The top-level `subject` / `html` / `text` / `templateId` / `params` / `from` are the **base** each version overrides. A per-version body override requires a global body to be present; a per-version `templateId` requires a global `templateId`. Global attachments and tags apply to all versions; there are no per-version attachments.
- **Limits** (exceeding any → `422`): ≤99 recipients per version, ≤2000 recipients across the batch (deduped), ≤1000 versions, ≤100 KB per-version `params`, ≤1000 KB `params` across the batch. The 50-recipient single-send cap does **not** apply here; the batch union cap replaces it.
- **A recipient is delivered by exactly one version, the first.** Versions are processed in array order, and each version delivers only the recipients no earlier version already claimed. If `ada@example.com` appears in version 1 **and** version 2, she receives **version 1's** subject, body and params, and version 2 simply does not send to her at all. This is a delivery outcome, not only a billing rule: repeating an address across versions silently drops the later content. Keep each version's recipient set disjoint.
- **Billing.** The **deduped union** of recipients across all versions is billed **once** at 30 credits (₹30) per 1,000; a recipient in two versions is billed once, matching the delivery rule above. Suppression, quota, rate and monthly-cap checks are likewise evaluated once, over the union.
- **A partial failure still returns `202`.** The response `status` is `sent` when **any** version was accepted for delivery; only an all-versions-failed batch returns `503 relay_failed`. `messageIds` carries one id per version in array order **whether or not that version was accepted**, so the response alone cannot tell you which versions failed. To find out, list the sends and read each row's `status`.
A batch returns `202` with `messageIds` (one per version, in order) and a `batchId` grouping the batch's sends; `id` / `messageId` are the first version, and `suppressed` lists the union's suppressed addresses. Look up each send with `GET /api/v1/email/sends`. See [Delivery Log & Usage](/docs/email-logs).
:::tabs
```bash [cURL]
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 ",
"templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"subject": "Your receipt, {{ params.name }}",
"messageVersions": [
{ "to": ["Ada "], "params": { "name": "Ada", "order_id": "1043" } },
{ "to": ["Bo "], "params": { "name": "Bo", "order_id": "1044" } }
]
}'
```
```python [Python]
import httpx
httpx.post(
"https://api.callmissed.com/api/v1/email/send",
headers={"Authorization": "Bearer cm_your_key"},
json={
"from": "Acme Ops ",
"templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"subject": "Your receipt, {{ params.name }}",
"messageVersions": [
{"to": ["Ada "], "params": {"name": "Ada", "order_id": "1043"}},
{"to": ["Bo "], "params": {"name": "Bo", "order_id": "1044"}},
],
},
)
```
```javascript [JavaScript]
await fetch("https://api.callmissed.com/api/v1/email/send", {
method: "POST",
headers: {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme Ops ",
templateId: "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
subject: "Your receipt, {{ params.name }}",
messageVersions: [
{ to: ["Ada "], params: { name: "Ada", order_id: "1043" } },
{ to: ["Bo "], params: { name: "Bo", order_id: "1044" } },
],
}),
});
```
:::
```json
{
"id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"messageId": "<1a2b3c4d@acme.com>",
"messageIds": ["<1a2b3c4d@acme.com>", "<5e6f7g8h@acme.com>"],
"batchId": "7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d",
"status": "sent",
"suppressed": []
}
```
## Common failures
| Status | Reason | Meaning |
|--------|--------|---------|
| 422 | `scheduled_batch_unsupported` | A send set both `scheduledAt` and `messageVersions`, pick one |
| 422 | *(schema array `detail`)* | A `scheduledAt` in the past or beyond the 72-hour horizon, over-limit `params`, or a broken batch cross-version rule |
| 422 | `too_many_recipients` | Over the union limit on a batch |
| 404 | *(string `detail`)* | `DELETE /scheduled/{identifier}` found nothing pending to cancel |
| 503 | `relay_failed` | Every version of a batch failed. Flat shape, carries `id` |
Shapes and the full reason table: [Limits, Quotas & Errors](/docs/email-limits).
---
### Send Email
URL: /docs/email-send
> POST /api/v1/email/send: every field, attachment rule, header, semantic and response, plus the drop-in Brevo migration.
## Send Email
**Endpoint:** `POST /api/v1/email/send`
The send body is a **superset**: it accepts both the original CallMissed shapes (string `from`, string-list `to`, `text`/`html`) and Brevo-style shapes (`sender` object, recipient objects, `textContent`/`htmlContent`). A Brevo `sendTransacEmail` integration works here by changing only the base URL and the auth header. See [Switching from Brevo](#switching-from-brevo) below.
Note the `from` in every example below. `donotreply@your-verified-domain` is registered as a sender by verification, so it always works, and the address you want humans to answer goes in `reply_to`. Any other local part must be a registered sender on the domain; the send path registers it on first use and retries, which can surface as `503 sender_propagating` until the registration is live. See [Sender Addresses](/docs/email-domains#sender-addresses).
The example below uses `cc`, one URL attachment and one base64 attachment, `tags`, and the `Idempotency-Key` header:
:::tabs
```bash [cURL]
curl -X POST https://api.callmissed.com/api/v1/email/send \
-H "Authorization: Bearer cm_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043-receipt" \
-d '{
"from": "Acme Ops ",
"to": ["Ada "],
"cc": ["accounts@example.com"],
"subject": "Your receipt",
"text": "Thanks for your order. Your receipt is attached.",
"html": "Thanks for your order. Your receipt is attached.
",
"reply_to": "support@acme.com",
"tags": ["receipt", "order-1043"],
"attachment": [
{ "url": "https://acme.com/receipts/1043.pdf" },
{ "name": "terms.txt", "content": "VGhhbmsgeW91IGZvciB5b3VyIG9yZGVyLg==" }
]
}'
```
```python [Python]
import base64, httpx
pdf_b64 = base64.b64encode(b"...raw bytes...").decode()
httpx.post(
"https://api.callmissed.com/api/v1/email/send",
headers={
"Authorization": "Bearer cm_your_key",
"Idempotency-Key": "order-1043-receipt",
},
json={
"from": "Acme Ops ",
"to": ["Ada "],
"cc": ["accounts@example.com"],
"subject": "Your receipt",
"text": "Thanks for your order. Your receipt is attached.",
"html": "Thanks for your order. Your receipt is attached.
",
"reply_to": "support@acme.com",
"tags": ["receipt", "order-1043"],
"attachment": [
{"url": "https://acme.com/receipts/1043.pdf"},
{"name": "terms.txt", "content": pdf_b64},
],
},
)
```
```javascript [JavaScript]
const termsB64 = Buffer.from("Thank you for your order.").toString("base64");
await fetch("https://api.callmissed.com/api/v1/email/send", {
method: "POST",
headers: {
Authorization: "Bearer cm_your_key",
"Content-Type": "application/json",
"Idempotency-Key": "order-1043-receipt",
},
body: JSON.stringify({
from: "Acme Ops ",
to: ["Ada "],
cc: ["accounts@example.com"],
subject: "Your receipt",
text: "Thanks for your order. Your receipt is attached.",
html: "Thanks for your order. Your receipt is attached.
",
reply_to: "support@acme.com",
tags: ["receipt", "order-1043"],
attachment: [
{ url: "https://acme.com/receipts/1043.pdf" },
{ name: "terms.txt", content: termsB64 },
],
}),
});
```
:::
## Fields
An **address** may be written three ways, and you can mix them within one array: a bare `"a@b.com"`, a display form `"Name "`, or an object `{"email": "a@b.com", "name": "Name"}`.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `from` | string | one of `from` / `sender`, unless a template supplies `default_sender` | Sender as `"Name "` or bare address. The domain must be one of your verified domains and the local part must be a registered sender on it. See [Sender Addresses](/docs/email-domains#sender-addresses). The display name is stored on the sender address, not on each message, so recipients see the name most recently set for that address. After you change it, the first sends can show the old name for a couple of minutes. Use one display name per sender address; if you need different names, register a separate sender for each |
| `sender` | object | one of `from` / `sender`, unless a template supplies `default_sender` | Brevo-style sender `{"email", "name"}`, an alternative to `from`. Same registered-sender rule applies |
| `to` | array | Yes | One or more recipient addresses (min 1). Each delivered recipient is billed |
| `cc` | array | No | Carbon-copy recipients. Appear in the `Cc` header **and** are delivered |
| `bcc` | array | No | Blind-copy recipients. Delivered but **never** written to any header |
| `subject` | string | No | Up to 998 characters |
| `text` | string | body required | Plain-text body. Omit it on an HTML send and one is [generated for you](#automatic-plain-text); send `""` to opt out and ship HTML only |
| `html` | string | body required | HTML body |
| `textContent` | string | body required | Brevo alias for `text` |
| `htmlContent` | string | body required | Brevo alias for `html` |
| `reply_to` | string | No | Reply-To address as a string |
| `replyTo` | string \| object | No | Brevo alias for `reply_to`, string or `{"email", "name"}` |
| `headers` | object | No | Extra headers as string→string. Reserved headers (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `DKIM-Signature`, `Received`) are ignored |
| `attachment` | array | No | Attachments, and [inline images](#inline-images); also accepted as `attachments`. See below |
| `tags` | array | No | Up to 10 tags for your own categorisation. Each is either a plain string or a [`{name, value}` object](#tags); trimmed, empties dropped |
| `templateId` | string (UUID) | No | Send from a saved template: its subject/body are the base; explicit send fields override. See [Templates](/docs/email-templates) |
| `params` | object | No | Substitution values for `{{ params.KEY }}` placeholders. They are applied to the template's subject and bodies **and** to any inline `subject` / `text` / `html` you send, so `params` works with no `templateId` at all. Capped at 100 KB of JSON |
| `scheduledAt` | string | No | ISO-8601 UTC timestamp to send later (future, within 72h). See [Scheduled Sending](/docs/email-scheduled) |
| `batchId` | string (UUID) | No | Groups related scheduled sends; auto-generated if omitted |
| `messageVersions` | array | No | One call, many recipient sets, each overrides the base. See [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions) |
Provide **at least one** of `text` / `html` (or their Brevo aliases), a `templateId`, or `messageVersions`. Provide **exactly one** of `from` / `sender`. A top-level `to` is not required when `messageVersions` is present.
**Attachments.** Each item in `attachment` is **either** a URL reference **or** inline base64, exactly one of the two:
| Attachment field | Type | Required | Notes |
|------------------|------|----------|-------|
| `url` | string | one of `url` / `content` | `http`/`https` URL fetched at send time. Internal/private URLs are refused; the fetched file is size-capped |
| `content` | string | one of `url` / `content` | Base64-encoded file bytes |
| `name` | string | with `content` | Filename (≤255 chars). Required when `content` is set; optional with `url` |
| `content_id` | string | No | Makes the part an [inline image](#inline-images) your HTML references as `
`. Also accepted as `contentId`. Up to 255 chars, and only letters, numbers, `.`, `-`, `_` and `@`; anything else is a `422` |
| `disposition` | string | No | `inline` or `attachment`, to be explicit. Omitted, a part with a `content_id` is inline and everything else is an attachment |
### Inline images
Give an attachment a `content_id` and reference that id from the HTML body as `cid:`. The part is embedded where you placed it instead of arriving as a download:
```bash
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": ["customer@example.com"],
"subject": "Your receipt",
"html": "Thanks for your order.
",
"attachment": [
{
"name": "logo.png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
"content_id": "logo@acme"
}
]
}'
```
A `url` attachment can be inline too: set `content_id` on it and the fetched file is embedded the same way.
The one case `content_id` alone cannot express is a part that has a content id **and** should still appear as a normal downloadable attachment. Set `"disposition": "attachment"` for that; an explicit `disposition` always wins.
Inline parts count toward the 10 MB message ceiling like any other attachment, and `cid:` references in your HTML are never rewritten by [click tracking](#open-and-click-tracking).
### Tags
A tag is either a plain string, as before, or a `{name, value}` object when you need to carry a value:
```json
{
"tags": [
"receipt",
{ "name": "order_id", "value": "1043" },
{ "name": "campaign", "value": "spring-sale" }
]
}
```
Both shapes can be mixed in the same array. A structured tag's `name` and `value` are required, may contain only ASCII letters, numbers, underscores and dashes, and are capped at 256 characters each; anything else is a `422`. Up to 10 tags per send.
On the wire, tag **names** are joined into an `X-Tags` header exactly as before, and each structured tag additionally gets its own `X-Tag-: ` 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, 1–128 characters. A repeat with the same key returns the first send's result without sending or charging again. Reusing a single send's key on a batch (or the reverse) is `409 idempotency_conflict` |
## 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: 10 MB per message.** The whole message (subject, both bodies, and every attachment **after base64 encoding**) must stay within 10 MB, otherwise you get `422 message_too_large` before anything is charged or sent. Base64 makes attachments about 1.37 times larger, so in practice you have about **7 MB** of raw attachments, minus whatever the bodies take. While a `url` attachment is being fetched, it is capped at 25 MB, but the finished message still has to fit in 10 MB.
- **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 |
| `batchId` | string \| null | `null` on an immediate single send; set on a [scheduled](/docs/email-scheduled) or [batch](/docs/email-scheduled#batch-messageversions) send |
| `scheduledAt` | string \| null | `null` unless the send was scheduled |
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 |
| 409 | `idempotency_conflict` | The `Idempotency-Key` was already used by a different kind of send |
| 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 message exceeds 10 MB, attachments included after base64 encoding |
| 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 |
| 503 | `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`:
- A control character in `subject`, `default_sender` or `default_reply_to`. Those values are rendered into raw headers at send time. This is a schema rejection, so `detail` is the validation array.
- A body or subject that references `{{ contact.anything }}`, returned with a plain string `detail`. 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.sent` | The message was accepted for delivery. Fires once per message | **Live** |
| `email.opened` | A message with open tracking was opened for the first time. Fires once per message | **Live** |
| `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.delivered` | The recipient's mail server accepted the message. Fires once per recipient | **Live** |
| `email.received` | Inbound mail arrived at one of your receiving addresses | Subscribable; not emitted yet |
You can subscribe to all six now. `email.received` is accepted so you won't have to rewrite your subscription when it starts firing; until then it delivers nothing. For inbound mail right now, use the per-address `forward_url` on [Receive Email](/docs/email-inbound), which is live.
**Proof of delivery.** Two events track each message:
- `email.sent`: we accepted the message for delivery. It fires once per message.
- `email.delivered`: a recipient's mail server accepted the message. It fires once for each recipient, with the time of the delivery attempt.
If a recipient's server refuses the message, you get `email.bounced` for that recipient instead. [`GET /sends/{id}/events`](/docs/email-logs#per-recipient-delivery-events) returns the same per-recipient outcomes, so you can look up delivery evidence for any send at any time.
`email.delivered` means the receiving server took the message. It does not tell you whether the message went to the inbox or a spam folder, or whether anyone read it. Sends made before 2026-10-03 have no per-recipient delivery events.
**About opens.** Opens are counted with a tracking pixel, so they are approximate. Some mail apps and privacy proxies load images automatically, which can register an open nobody saw. Other apps block images, so a real open is never seen. Treat `email.opened` as a sign of engagement, not as proof that someone read the message.
### 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": "Bounced: Address doesn't exist",
"reason": "Address doesn't exist",
"code": "5.1.1",
"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.reason` | Why it didn't land, in plain language (e.g. `Address doesn't exist`, `Recipient marked it as spam`) |
| `data.code` | The receiving server's SMTP status code when it gave one (e.g. `5.1.1`), otherwise `bounced`, `suppressed`, `not_delivered`, `filtered_spam` or `complaint` |
| `data.detail` | Deprecated: `reason`, prefixed with the outcome for delivery-report failures |
| `data.domain` | Your sending domain the message went out on |
`email.bounced` and `email.complained` carry the shape above.
#### `email.sent`
```json
{
"type": "email.sent",
"created_at": "2026-08-13T09:40:58.402113+00:00",
"data": {
"email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"recipients": ["customer@example.com", "accounts@example.com"],
"domain": "acme.com",
"smtp_response": "accepted for delivery",
"accepted_at": "2026-08-13T09:40:58.398000+00:00"
}
}
```
#### `email.opened`
```json
{
"type": "email.opened",
"created_at": "2026-08-13T10:02:11.031552+00:00",
"data": {
"email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"recipients": ["customer@example.com", "accounts@example.com"],
"domain": "acme.com",
"opened_at": "2026-08-13T10:02:10.991000+00:00"
}
}
```
| Field | Notes |
|-------|-------|
| `data.recipients` | Every address the message was sent to (`to`, `cc` and `bcc`, minus suppressed addresses). Each message fires one event, not one per recipient. A message is accepted in one hand-off and has a single tracking pixel, so these events cannot be tied to an individual recipient |
| `data.smtp_response` | `accepted for delivery` once the message was handed off for delivery |
| `data.accepted_at` | When the message was accepted, the same value as `sent_at` on the send |
| `data.opened_at` | When the first open was recorded |
In a `messageVersions` batch, each version that was handed to the mail server fires its own `email.sent`.
#### `email.delivered`
```json
{
"type": "email.delivered",
"created_at": "2026-08-13T09:41:05.774201+00:00",
"data": {
"email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e",
"message_id": "<1a2b3c4d@acme.com>",
"recipient": "customer@example.com",
"delivered_at": "2026-08-13T09:41:03.201000+00:00",
"detail": null,
"reason": null,
"code": null
}
}
```
| Field | Notes |
|-------|-------|
| `data.recipient` | The one recipient this event is about. A message with three recipients can fire three `email.delivered` events |
| `data.delivered_at` | When the recipient's mail server accepted the message |
| `data.reason` | Always `null` on this event |
| `data.code` | Always `null` on this event |
| `data.detail` | Deprecated, always `null` on this event |
#### Which outcome fires which event
| Outcome reported for a recipient | Event | Address suppressed |
|---|---|---|
| Delivered | `email.delivered` | No |
| Bounced (the address or domain does not exist) | `email.bounced` | Yes |
| Suppressed (the address hard-bounced before) | `email.bounced` | Yes |
| Failed (the message could not be delivered) | `email.bounced` | No |
| Rejected as spam by the receiving side | `email.bounced` | No |
| Quarantined by the receiving side | none, recorded in [delivery events](/docs/email-logs#per-recipient-delivery-events) | No |
For these failures, `data.detail` on `email.bounced` starts with the outcome, for example `FilteredSpam: Blocked as spam by the recipient's server`, and `data.code` is `not_delivered`, `filtered_spam` or the receiving server's SMTP code.
### 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 342 tools that act on your CallMissed account: draft, build and configure voice agents, set up call flows, menus and queues, place and review calls, run outbound calling campaigns end to end, work the inbox and the support desk, search and update the CRM with its pipelines, custom fields and lead scoring, send WhatsApp and email, sell from a WhatsApp catalog, generate images, connect and disconnect WhatsApp, Facebook, Instagram, integrations and knowledge sources, manage webhooks and managed prompts, version and roll back agents, run evals and A/B experiments, 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 342 tools: draft, build and configure voice agents end to end, set up how inbound calls are answered with flows, menus and queues, place and inspect phone calls, read transcripts, build and run outbound calling campaigns, work the shared inbox and the support desk, search and update the CRM and set up its pipelines, custom fields and lead scoring, summarise and score calls, send WhatsApp messages, catalog products and Flow forms, send email, generate images, connect and disconnect WhatsApp, Facebook, Instagram, integrations and knowledge sources, wire up webhooks and managed prompts, version and roll back an agent, run evaluation suites and A/B experiments, 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 five permission groups.
| Group | What it allows | On by default | Can spend credits |
| --- | --- | --- | --- |
| **Read your data** | See your calls, contacts, companies, deals, leads, quotes, invoices, products, tasks, notes, conversations, agents and usage, plus how your CRM and support desk are set up: pipelines, custom fields, saved views, lead scores, tickets, SLA policies, macros, routing rules and satisfaction scores. Cannot change anything. Searching your knowledge base costs a small amount to read the question. | Yes | Yes |
| **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 — including leads, products, quotes and invoices — send WhatsApp messages and email — including catalog products, order updates and Flow forms — place and end calls, rent phone numbers, and generate images. It can also build outbound calling campaigns and start them, which dials everyone on the list, change how your CRM and support desk are set up — pipelines, custom fields, lead scoring, tickets, SLA policies, macros and routing — and open satisfaction surveys. Spends credits, and a rented number is a recurring charge. | No | Yes |
| **Set up and change your agents** | Create agents and change their instructions, voice, tools and knowledge, draft a new agent from a description, give them custom tools that call your own URLs, change how a team of agents hands callers between them, and run test suites and A/B experiments against them. Cannot send messages or place calls. Indexing a knowledge source, drafting an agent and running a test suite spend credits. | No | Yes |
| **Configure your workspace** | Set up where your events are delivered and when you are alerted, connect an external service or a WhatsApp, Facebook or Instagram account by handing you a link to approve, choose which agent answers each connected number or account, disconnect them, create, edit or delete WhatsApp message templates, and manage the prompts the API resolves by name. Cannot read or write the credentials of a connected service. Publishing a prompt or pinning a model to one changes what your own API calls do and what they cost. | No | Yes |
**Read your data** is the only group ticked when the screen opens. The other four 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.
Building an agent is its own tick rather than part of **Take actions**: letting an assistant send a WhatsApp message should not also let it rewrite the instructions, voice and tools of the agent answering your phone. Running an eval suite or an A/B experiment sits on that same tick, because it is work done on the agents it already governs.
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, including the campaign, CRM-setup, support-desk, commerce, prompt and eval reads; **Read call transcripts** grants `telephony:read` (so it can read every call, not only those the connection placed) and the `stt`, `tts` and `llm` permissions the three voice-session tools need; **Take actions** covers the record, CRM-setup, support-desk, messaging, commerce, telephony and campaign `:write` scopes plus `whatsapp:send` and the `image` and `email` permissions; **Set up and change your agents** covers `bots:write`, `knowledge:write`, `squads:write`, `evals:write` and `experiments:write`; and **Configure your workspace** covers `webhooks:write`, `integrations:write`, `prompts:write` and `whatsapp:write`.
#### 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.
Twenty-eight tools spend credits and several of them reach real people. `start_campaign` reaches the most: it dials every person on the campaign's list. 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
342 tools in sixteen 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 |
| `search_available_numbers` | Search the numbers you could rent in a country, with monthly price, whether each takes calls and whether it needs documents. Buys nothing | Read | `telephony:read` | No |
| `list_number_countries` | List the countries you can rent numbers in, India first, with dialling codes and whether each is open yet | Read | `telephony:read` | No |
| `get_number_requirements` | Get the documents and fields a country's numbers need before activation; every country differs | Read | `telephony:read` | No |
| `provision_phone_number` | Rent a number. It is bought immediately and renews monthly until released; Indian numbers and numbers that need documents require an approved compliance application for that country | Write | `telephony:write` | Yes |
| `attach_number_to_agent` | Point one of your numbers at an agent, so calls to it are answered by that agent. Pass `bot_id: null` to detach | Write | `telephony:write` | 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 |
#### Call handling
How an inbound call is answered: the flow it walks, the menu it hears, the queue it waits in, the message left on a machine, and whether the number has been flagged as spam. This is the whole console setup journey, available to an agent.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_call_flows` | List call flows with each one's shape and whether it is live. Returns no graphs | Read | `telephony:read` | No |
| `get_call_flow` | One flow with its full draft graph; the live snapshot is summarised, not repeated | Read | `telephony:read` | No |
| `create_call_flow` | Create a flow. It answers nothing until published and attached to a number | Write | `telephony:write` | No |
| `update_call_flow` | Change the draft. Sending `graph` replaces the whole graph | Write | `telephony:write` | No |
| `publish_call_flow` | Make the draft the version that answers **real** calls, from the next call on | Write, destructive | `telephony:write` | No |
| `attach_number_to_flow` | Answer one of your numbers with this flow. `detach: true` undoes it. A flow answers once it is published | Write | `telephony:write` | No |
| `simulate_call_flow` | Dry-run one step and see the instruction a real call would get. Webhook steps are not called | Read | `telephony:read` | No |
| `list_call_menus` | List press-1-for-sales menus with their prompt and key map | Read | `telephony:read` | No |
| `get_call_menu` | One menu with its prompt, key map and retry settings | Read | `telephony:read` | No |
| `create_call_menu` | Create a menu: what the caller hears and where each key sends them | Write | `telephony:write` | No |
| `update_call_menu` | Change a menu. Sending `options` replaces the whole key map | Write | `telephony:write` | No |
| `attach_number_to_menu` | Answer one of your numbers with this menu. `detach: true` undoes it. | Write | `telephony:write` | No |
| `list_call_queues` | List queues: how callers are held and in what order they are served | Read | `telephony:read` | No |
| `get_call_queue` | One queue with its serving order, hold settings and overflow behaviour | Read | `telephony:read` | No |
| `create_call_queue` | Create a queue that holds callers until a roster member is free | Write | `telephony:write` | No |
| `update_call_queue` | Change serving order, hold audio, waiting limit or overflow behaviour | Write | `telephony:write` | No |
| `list_queue_members` | Who answers for a queue, with priority, capacity and how busy each is | Read | `telephony:read` | No |
| `add_queue_member` | Put an agent (`bot_id`) or a person (`user_id`) on a queue's roster | Write | `telephony:write` | No |
| `remove_queue_member` | Take an answerer off the roster. They stop receiving calls immediately | Write, destructive | `telephony:write` | No |
| `attach_number_to_queue` | Send one of your numbers through this queue. `detach: true` undoes it | Write | `telephony:write` | No |
| `get_queue_live` | Who is holding right now, how long, and how much of the roster is free. Callers show as last four digits only | Read | `telephony:read` | No |
| `list_voicemail_templates` | The messages your calls leave on a machine, and whether each is ready to play | Read | `telephony:read` | No |
| `get_voicemail_template` | One voicemail message with its words, voice and state | Read | `telephony:read` | No |
| `create_voicemail_template` | Write the message a call should leave when a machine answers | Write | `telephony:write` | No |
| `update_voicemail_template` | Change a message. New words throw away the recording made from the old ones | Write | `telephony:write` | No |
| `get_number_reputation` | Whether one of your numbers is flagged as spam, and the complaints still open against it | Read | `telephony:read` | No |
Deleting a flow, menu, queue or voicemail message is not an MCP tool: an assistant should not be able to remove a routing object that live numbers still point at. Take one out of service reversibly instead — `enabled: false` on a menu or queue, or detach the number. Clearing a spam complaint needs a proof document and stays in the dashboard.
#### More voice and calls tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_voice_session` | Get one voice agent session's status, timing and post-call analysis (summary, outcome, sentiment) once it exists | Read | `stt` + `tts` + `llm` permissions | No |
| `update_number_call_settings` | Set a number's call settings | Write | `telephony:write` | No |
| `declare_number_for_ai_calls` | Record that a number has been declared to your telecom provider for automated/AI calls (India TRAI). Records a declaration already made; does not file one | Write | `telephony:write` | No |
| `list_telephony_compliance` | List this account's phone-number KYC applications and whether each is approved, pending or rejected (with the reason) | Read | `telephony:read` | No |
| `unpublish_call_flow` | Stop answering live calls with this flow; numbers on it answer normally again | Write, destructive | `telephony:write` | No |
| `update_queue_member` | Change a queue member's priority, concurrent-call limit, or enabled flag | Write | `telephony:write` | No |
### Calling campaigns
An outbound campaign is a list of people, a voice agent and the rules for when and how fast to dial. Build it as a draft, load the list, then start it. The account-wide do-not-call list lives here too.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_campaigns` | List campaigns, newest first, with status and contact counts | Read | `campaigns:read` | No |
| `get_campaign` | Fetch one campaign: status, schedule, calling hours, dial mode | Read | `campaigns:read` | No |
| `create_campaign` | Create a campaign. It starts as a draft and calls nobody | Write | `campaigns:write` | No |
| `update_campaign` | Change name, schedule, calling number, calling hours, dial mode or DLT registration fields | Write | `campaigns:write` | No |
| `add_campaign_contacts` | Add up to 1000 people to the calling list. Adding calls nobody | Write | `campaigns:write` | No |
| `list_campaign_contacts` | The per-person result: waiting, calling, done or failed, with attempts and last error | Read | `campaigns:read` | No |
| `get_campaign_live_state` | What the campaign is doing right now, and in preview mode who is awaiting a go-ahead | Read | `campaigns:read` | No |
| `get_campaign_trai_readiness` | What stops the campaign meeting India's TRAI rules for AI calls, and whether the account enforces them | Read | `campaigns:read` | No |
| `start_campaign` | **Starts calling.** Real calls to every person on the list | Write, destructive | `campaigns:write` | Yes |
| `pause_campaign` | Stop placing new calls. Can be started again later | Write | `campaigns:write` | No |
| `stop_campaign` | Cancel the campaign for good. Cannot be started again | Write, destructive | `campaigns:write` | No |
| `list_do_not_call` | List the numbers on this account's do-not-contact list, with the channel each one blocks | Read | `campaigns:read` | No |
| `add_do_not_call` | Suppress numbers from calls, WhatsApp, or both (`channel`, default `all`) | Write | `campaigns:write` | No |
Taking a number back **off** the do-not-call list is deliberately not a tool. Adding one is safe in the direction that matters — the worst case is a call that does not happen — but removing one un-does somebody's opt-out, and the next thing that happens is a call to a person who asked not to be called. That stays with a person in the [console](https://console.callmissed.com). API keys cannot remove an entry either.
Uploading a CSV to a campaign is not a tool either: it is a file upload, and MCP arguments are JSON. Use `add_campaign_contacts` for a list the agent already holds, or the [Campaigns API](/docs/voice-campaigns) for a file.
### 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 |
| `assign_conversation` | Give a conversation to a teammate by id or email, or unassign it | Write | `conversations:write` | No |
| `set_conversation_labels` | Replace a conversation's labels (short tags such as refund or vip) | Write | `conversations:write` | No |
| `add_conversation_note` | Write an internal note on the contact behind a conversation. Never sent to the customer | Write | `crm_notes: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 |
#### More conversations tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_conversation` | Get one conversation's details: channel, status, contact, agent and unread count | Read | `conversations:read` | No |
| `mark_conversation_read` | Clear a conversation's unread count, as opening it in the inbox does | Write | `conversations:write` | No |
| `assign_handoff` | Assign a waiting handoff to a teammate, or pass assignee_id null to unassign it | Write | `conversations:write` | No |
| `callback_handoff` | For a voice handoff, phone your operator number and then the customer and bridge them (two real, billed calls) | Write | `conversations:write` | Yes |
### 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 |
| `list_companies` | List companies, optionally matching a name or domain | Read | `companies:read` | No |
| `create_company` | Add a company. A domain has to be unique on the account | Write | `companies:write` | No |
| `update_company` | Change fields on an existing company | Write | `companies:write` | No |
#### Pipelines and stages
The board a deal moves across. Pipelines and stages share the deal scopes, because a stage is only meaningful as part of the pipeline it belongs to.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_pipelines` | List the sales pipelines, the default one first | Read | `crm_deals:read` | No |
| `create_pipeline` | Add a pipeline. It starts with no stages | Write | `crm_deals:write` | No |
| `update_pipeline` | Rename a pipeline, or make it the default | Write | `crm_deals:write` | No |
| `list_pipeline_stages` | List one pipeline's stages in board order | Read | `crm_deals:read` | No |
| `create_pipeline_stage` | Add a column, with its win probability and won/lost meaning | Write | `crm_deals:write` | No |
| `update_pipeline_stage` | Rename a stage, move it, or change what it means | Write | `crm_deals:write` | No |
| `reorder_pipeline_stages` | Rewrite the whole column order. Must name every stage exactly once | Write | `crm_deals:write` | No |
#### Custom fields
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_custom_fields` | List the custom fields defined on contacts, companies and deals, with each one's type | Read | `crm_custom_fields:read` | No |
| `create_custom_field` | Define a typed field: text, number, date, boolean or select | Write | `crm_custom_fields:write` | No |
| `update_custom_field` | Rename, reorder, re-option or (un)require a field. Its key and type never change | Write | `crm_custom_fields:write` | No |
| `list_custom_field_values` | Read every custom value on one record | Read | `crm_custom_fields:read` | No |
| `set_custom_field_value` | Set one field on one record. The value has to match the field's type | Write | `crm_custom_fields:write` | No |
| `clear_custom_field_value` | Unset one field on one record. The stored value is gone | Write, destructive | `crm_custom_fields:write` | No |
Deleting a custom field **definition** is not on this surface: the delete cascades to every value stored against it, on every record. Do that in the console.
#### Saved views
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_saved_views` | List the saved filtered lists you can see: shared ones plus your own | Read | `crm_views:read` | No |
| `create_saved_view` | Save a named filter, sort and column preset over a CRM list | Write | `crm_views:write` | No |
#### Lead scoring
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_lead_score_rules` | List the rules that award points, oldest first | Read | `crm_scores:read` | No |
| `create_lead_score_rule` | Add a rule over one of the allowed signals | Write | `crm_scores:write` | No |
| `update_lead_score_rule` | Change a rule's signal, comparison, points or enabled state | Write | `crm_scores:write` | No |
| `recompute_lead_scores` | Rescore up to 200 contacts or companies. All or nothing | Write | `crm_scores:write` | No |
| `list_lead_scores` | The leaderboard: scored records, highest first | Read | `crm_scores:read` | No |
| `get_lead_score` | One record's score with the breakdown of which rules fired | Read | `crm_scores:read` | No |
A new or changed rule does not move any score until `recompute_lead_scores` runs. The signals a rule may test are a closed list, documented on [Lead scoring](/docs/crm-lead-scores).
#### More cRM tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_contact` | Get one contact's full record by id | Read | `contacts:read` | No |
| `list_deals` | List deals, newest first, filtered by pipeline, stage, status, owner, contact or company | Read | `crm_deals:read` | No |
| `get_deal` | Get one deal by id | Read | `crm_deals:read` | No |
| `update_deal` | Change a deal's title, value, links, owner, close date, stage or open/won/lost status | Write | `crm_deals:write` | No |
| `list_tasks` | List CRM tasks, filtered by status, assignee, attached record, overdue or due window | Read | `crm_tasks:read` | No |
| `update_task` | Edit a task's title, description, due date, assignee, attached record or status | Write | `crm_tasks:write` | No |
| `list_crm_notes` | List the notes on one contact, company or deal, newest first | Read | `crm_notes:read` | No |
| `update_crm_note` | Replace the text of a note | Write | `crm_notes:write` | No |
| `list_crm_duplicates` | Find groups of contacts or companies that look like the same record | Read | `crm_search:read` | No |
| `merge_crm_duplicates` | Irreversibly fold duplicates into a primary record: their linked records move to it and the duplicates are deleted | Write, destructive | `crm_search:write` | No |
| `update_saved_view` | Rename a saved view or change its filters, sort, columns, layout or sharing; the list it belongs to cannot change | Write | `crm_views:write` | No |
| `get_company` | Get one company's record by id | Read | `companies:read` | No |
### Support desk
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_tickets` | List tickets by status, priority, assignee, contact, conversation or subject | Read | `support_tickets:read` | No |
| `get_ticket` | Fetch one ticket with its lifecycle timestamps | Read | `support_tickets:read` | No |
| `create_ticket` | Open a ticket, optionally linked to a conversation or contact | Write | `support_tickets:write` | No |
| `update_ticket` | Change subject, description, priority, links or tags | Write | `support_tickets:write` | No |
| `set_ticket_status` | Move a ticket through its lifecycle, keeping the SLA clocks honest | Write | `support_tickets:write` | No |
| `assign_ticket` | Hand a ticket to a teammate, or send it back to the queue | Write | `support_tickets:write` | No |
Deleting a ticket is not on this surface — it would destroy the record of a customer interaction and every SLA number computed from it. Close it instead.
#### SLA
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_sla_policies` | List the first-response and resolution targets the desk promises | Read | `sla:read` | No |
| `create_sla_policy` | Promise a first-response time, a resolution time, or both, with business hours | Write | `sla:write` | No |
| `update_sla_policy` | Change a policy's targets, priority, hours or enabled state | Write | `sla:write` | No |
| `get_ticket_sla_status` | Where one ticket stands: due times, what is missed, minutes left | Read | `sla:read` | No |
| `list_sla_breaches` | Tickets already past a deadline, oldest first | Read | `sla:read` | No |
#### Macros, tags and routing
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_support_macros` | List canned replies, most-used first | Read | `support_ops:read` | No |
| `create_support_macro` | Save a canned reply, with `{{placeholders}}` and optional actions | Write | `support_ops:write` | No |
| `update_support_macro` | Change a macro's text, actions, category or enabled state | Write | `support_ops:write` | No |
| `use_support_macro` | Fill a macro's placeholders and get the finished text. Sends nothing | Write | `support_ops:write` | No |
| `list_support_tags` | List the tag vocabulary with colours and descriptions | Read | `support_ops:read` | No |
| `create_support_tag` | Add a tag to the vocabulary | Write | `support_ops:write` | No |
| `list_ticket_routing_rules` | List the triage rules in evaluation order | Read | `support_ops:read` | No |
| `create_ticket_routing_rule` | Add a triage rule that assigns, prioritises and tags a match | Write | `support_ops:write` | No |
| `update_ticket_routing_rule` | Change a rule's conditions, actions, position or enabled state | Write | `support_ops:write` | No |
| `reorder_ticket_routing_rules` | Rewrite the evaluation order. Must name every rule exactly once | Write | `support_ops:write` | No |
| `preview_ticket_routing` | Dry run: which rule would match a ticket, and what it would do. Writes nothing | Read | `support_ops:read` | No |
Macros and rules are retired with `is_active: false` rather than deleted, so nothing that a past ticket refers to disappears.
#### Satisfaction
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `create_csat_survey` | Mint a survey and its public response link. **Does not send it** | Write | `csat:write` | No |
| `list_csat_surveys` | List surveys, optionally only answered or only still-open ones | Read | `csat:read` | No |
| `get_csat_stats` | Response rate, average rating and NPS over a window of up to a year | Read | `csat:read` | No |
`create_csat_survey` returns a `token`; the link you send is that token on the public response page. Delivering it over WhatsApp or email is a separate call, and that send is what costs credits.
#### More support desk tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `update_support_tag` | Rename a support tag or change its color or description | Write | `support_ops: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 |
| `send_whatsapp_buttons` | Send a message with up to three tap-to-reply buttons, inside the 24-hour window | Write | `whatsapp:send` | Yes |
| `send_whatsapp_media` | Send an image, video, audio clip, document or sticker from a public https link or an uploaded media id, inside the 24-hour window. Audio can go as a voice note | Write | `whatsapp:send` | Yes |
| `send_whatsapp_call_button` | Send a button that starts a WhatsApp call to your business, inside the 24-hour window | Write | `whatsapp:send` | Yes |
| `request_whatsapp_location` | Ask the customer to share their location with a "Send location" button, inside the 24-hour window | Write | `whatsapp:send` | Yes |
| `request_whatsapp_address` | Ask an Indian customer for a delivery address in a native WhatsApp form, inside the 24-hour window | Write | `whatsapp:send` | Yes |
| `list_whatsapp_templates` | Your templates with review status in plain words, rejection reason and quality | Read | `whatsapp:read` | No |
| `review_whatsapp_template` | WhatsApp's template rules, the practices that keep your account from being restricted (opt-in, opt-outs, the 24-hour window, quality rating), and a scored review of a draft: errors, warnings and tips. Call it before creating or editing a template | Read | `whatsapp:read` | No |
| `create_whatsapp_template` | Create a template (or copy a library template by name) and submit it for review. A draft that breaks WhatsApp's template rules is refused with the list of problems; remaining warnings come back as `policy_warnings` | Write | `whatsapp:write` | No |
| `edit_whatsapp_template` | Replace an approved, rejected or paused template's content and resubmit it, with the same rules check as create | Write | `whatsapp:write` | No |
| `delete_whatsapp_template` | **Permanently** delete a template in every language | Write, destructive | `whatsapp:write` | No |
| `get_whatsapp_business_verification` | Whether the business behind your WhatsApp account is verified | Read | `whatsapp:read` | No |
| `create_whatsapp_template_group` | Group templates from one business account so their metrics can be read together | Write | `whatsapp:write` | No |
| `get_whatsapp_template_group` | Read one template group and its member templates, live from WhatsApp | Read | `whatsapp:read` | No |
| `update_whatsapp_template_group` | Rename a template group or add and remove templates | Write | `whatsapp:write` | No |
| `delete_whatsapp_template_group` | **Permanently** delete a template group. Its templates and their metrics are kept | Write, destructive | `whatsapp:write` | No |
| `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 |
| `send_whatsapp_product` | Send one product from a WhatsApp catalog so the recipient can add it to a cart in the chat | Write | `wa_commerce:write` | Yes |
| `send_whatsapp_product_list` | Send up to 30 products grouped into titled sections, as one browsable message | Write | `wa_commerce:write` | Yes |
| `send_whatsapp_catalog` | Send the whole catalog attached to a business number, fronted by one product as the cover | Write | `wa_commerce:write` | Yes |
| `list_whatsapp_orders` | List the carts customers sent from your catalog, with status, currency and subtotal | Read | `wa_commerce:read` | No |
| `get_whatsapp_order` | One cart order with every line item: SKU, quantity and unit price | Read | `wa_commerce:read` | No |
| `update_whatsapp_order_status` | Advance a stored order and tell the customer in the same step. `completed` and `canceled` are final | Write, destructive | `wa_commerce:write` | Yes |
| `list_whatsapp_flows` | List your WhatsApp Flows with status and categories. The design document is left out | Read | `wa_flows:read` | No |
| `send_whatsapp_flow` | Send a Flow — a form filled in without leaving the chat — and get the token that identifies the reply | Write | `wa_flows:write` | Yes |
| `list_whatsapp_flow_responses` | The Flow forms customers submitted, with the answers they gave | Read | `wa_flows:read` | No |
| `publish_whatsapp_flow` | Publish a draft Flow. One-way: the design is then frozen and changing it means a new Flow | Write, destructive | `wa_flows:write` | No |
#### More messaging tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_whatsapp_numbers` | List your connected WhatsApp Business numbers with their status, quality rating and linked agent | Read | `whatsapp:read` | No |
| `refresh_whatsapp_number` | Re-read a number's status, name and quality rating from WhatsApp | Write | `whatsapp:write` | No |
| `link_whatsapp_number_to_agent` | Choose which agent auto-replies on a WhatsApp number; null unlinks it | Write | `whatsapp:write` | No |
| `set_whatsapp_autoreply` | Turn the linked agent's automatic replies on or off for one number | Write | `whatsapp:write` | No |
| `get_whatsapp_business_profile` | Read the public business profile customers see on a number | Read | `whatsapp:read` | No |
| `update_whatsapp_business_profile` | Change the text fields of a number's public business profile | Write | `whatsapp:write` | No |
| `search_whatsapp_template_library` | Search WhatsApp's pre-written utility templates, which can be created by name with create_whatsapp_template | Read | `whatsapp:read` | No |
| `sync_whatsapp_templates` | Pull the latest templates and review statuses from WhatsApp | Write | `whatsapp:write` | No |
| `draft_whatsapp_template` | Write a template draft from a plain-language intent, with notes on review-rejection risks | Write | `whatsapp:write` | Yes |
| `create_whatsapp_campaign` | Create a broadcast campaign draft for an approved template | Write | `whatsapp:write` | No |
| `get_whatsapp_campaign` | Read one campaign with its status and delivery counts | Read | `whatsapp:read` | No |
| `list_whatsapp_campaign_recipients` | Page through every recipient of a campaign with its delivery status and WhatsApp's timestamp for each status, for auditing who got it | Read | `whatsapp:read` | No |
| `add_whatsapp_campaign_recipients` | Add recipients, each with optional template variables, to a campaign that has not launched | Write | `whatsapp:write` | No |
| `launch_whatsapp_campaign` | Start sending a campaign to all its recipients | Write | `whatsapp:write` | Yes |
| `cancel_whatsapp_campaign` | Stop a campaign; messages already sent are not recalled | Write, destructive | `whatsapp:write` | No |
| `get_whatsapp_analytics` | Read WhatsApp messaging analytics: the delivery funnel, a daily time series, or credits spent | Read | `whatsapp:read` | No |
| `list_whatsapp_webhook_events` | List the most recent events WhatsApp delivered to your numbers, for debugging | Read | `whatsapp:read` | No |
| `get_whatsapp_message_status` | Look up one message by its `wamid` to prove whether it was delivered: its furthest status, WhatsApp's timestamp for each status, the failure error and every status recorded | Read | `whatsapp:read` | No |
| `list_whatsapp_calls` | List WhatsApp voice calls, newest first | Read | `whatsapp:read` | No |
| `get_whatsapp_call_settings` | Read whether calling is on for a number, its call hours and callback settings | Read | `whatsapp:read` | No |
| `update_whatsapp_call_settings` | Turn calling on or off for a number and set its call hours, call icon and callback permission | Write | `whatsapp:write` | No |
| `request_whatsapp_call_permission` | Send a customer WhatsApp's call-permission prompt; you can only call them after they accept | Write | `whatsapp:send` | No |
| `place_whatsapp_call` | Call a customer on WhatsApp; the number's linked agent does the talking | Write | `whatsapp:send` | Yes |
| `end_whatsapp_call` | Hang up a live WhatsApp call | Write, destructive | `whatsapp:send` | No |
### Images
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `generate_image` | Generate an image from a prompt. `model` picks which image model draws it — its enum lists each model's strength. Returns the image itself, so the model can see it, plus a link to the full-size version. No image model is processed in India, so with India-only processing on it fails with `region_unavailable` | 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 |
| `craft_agent_prompt` | Everything needed to write a strong voice-agent prompt — the four-section structure and limits, the rules that make a prompt work on a phone call, the agent's current prompt, enabled tools and declared `{{variables}}` — plus a scored review of your draft. Free, writes nothing | Read | `bots:read` | No |
| `get_agent_prompt` | An agent's instructions split into the four boxes its owner edits: objective, response guidelines, conversation script and first message | Read | `bots:read` | No |
| `list_voice_tiers` | The voice tiers a calling agent can run on: the `voice_tier` id, what it sounds like, what a minute costs in credits, and the voices you may pick from. Read before creating or reconfiguring a calling agent | Read | `bots:read` | No |
| `get_agent_config_schema` | Every configuration key an agent accepts, with allowed values, defaults, what happens when a key is omitted, and a minimal working example | Read | `bots:read` | No |
| `validate_agent_config` | Check a config you assembled and get one finding per problem. Writes nothing | Read | `bots:read` | No |
| `list_agent_tool_catalog` | The agent-tool names that go in the config's `tools` list, with what each does and the channels it cannot run on | Read | `bots:read` | No |
| `list_agent_skills` | The skill names that go in the config's `skills` list, with the tools each bundles and a preview of the instructions it injects. Chat channels only | Read | `bots:read` | No |
| `create_agent` | Create a voice or WhatsApp agent. Active immediately | Write | `bots:write` | No |
| `update_agent` | Rename an agent, or replace its instructions with one flat block | Write | `bots:write` | No |
| `update_agent_prompt` | Change the instructions section by section, so the objective / guidelines / script structure survives the write | Write | `bots:write` | No |
| `update_agent_config` | Change configuration keys in place — set some, remove others, leave the rest alone | Write | `bots:write` | No |
| `toggle_agent` | Flip an agent between active and paused | Write | `bots:write` | No |
| `add_agent_knowledge` | Add a document to one agent's knowledge base | Write | `knowledge:write` | 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 |
| `list_agent_custom_tools` | List the custom HTTP tools this account has defined. Stored credentials are never returned | Read | `bots:read` | No |
| `create_agent_custom_tool` | Define an HTTP endpoint an agent may call mid-conversation | Write | `bots:write` | No |
| `update_agent_custom_tool` | Change a custom HTTP tool. Omitted fields, including secrets, are kept | Write | `bots:write` | No |
| `test_agent_custom_tool` | Run a saved custom tool once. This **really calls** the endpoint with its stored credentials | Write | `bots:write` | No |
| `delete_agent_custom_tool` | **Permanently** delete a custom tool and its stored credentials, for every agent that uses it | Write, destructive | `bots:write` | No |
| `set_agent_voicemail_drop` | Make a calling agent leave a saved voicemail message when a machine answers | Write | `bots:write` | No |
| `draft_agent_from_description` | Describe the agent you want and get a complete, reviewable draft back. Creates nothing | Write | `squads:write` | Yes |
#### Building an agent with these tools
`create_agent` is not the first call. An agent's `config` is a free-form object whose keys are mostly not checked when written — a wrong value is stored and then fails on the call, usually without an error. So the order is:
1. `get_agent_config_schema` — the keys this agent type accepts, and a `minimal_example` that works. `draft_agent_from_description` is the shortcut past the blank page: describe the agent and review what comes back. It creates nothing, so whatever it proposes still goes through the steps below.
2. `validate_agent_config` — fix every `error` finding before writing anything.
3. `create_agent`, then `add_agent_knowledge`.
4. For a voice agent: `search_available_numbers` → `provision_phone_number` → `attach_number_to_agent`, then `place_call` and `get_voice_session_transcript` to hear the result.
5. `update_agent_config` to change one key later; it merges, so it cannot wipe the rest of the configuration.
6. `craft_agent_prompt` → `update_agent_prompt` to write or improve the instructions. `craft_agent_prompt` returns the structure, the phone-call rules and the agent's real tools and variables; write the four sections, pass them back as `draft` until the review has no `error` findings, then save. An agent's prompt is edited in four boxes — objective, response guidelines, conversation script and first message — and `update_agent`'s flat `system_prompt` collapses them into one, silently. The pair above is what keeps them.
The same flow over plain HTTP, with curl for every step and the five ways a config silently misbehaves, is on [Build an Agent](/docs/build-an-agent).
#### More agents and knowledge tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_knowledge_sources` | List the documents, URLs and facts in this account's knowledge base, optionally for one agent, with their ingest status | Read | `knowledge:read` | No |
| `add_agent_knowledge_url` | Fetch a public web page and add its text to one agent's knowledge base | Write | `knowledge:write` | Yes |
| `list_agent_memories` | List the durable facts one agent has been taught, newest first | Read | `bots:read` | No |
| `list_agent_squads` | List squads: groups of agents that hand a caller to one another by role | Read | `squads:read` | No |
| `get_agent_squad` | Fetch a squad with its handoff policy and full member roster | Read | `squads:read` | No |
| `create_agent_squad` | Create a squad whose entry agent answers the call; the entry agent is enrolled as its first member | Write | `squads:write` | No |
| `update_agent_squad` | Change a squad's name, description, entry agent (must already be a member), handoff policy, or active flag | Write | `squads:write` | No |
| `add_squad_member` | Put an agent on a squad's roster under a role; its description is what routing matches callers against | Write | `squads:write` | No |
| `update_squad_member` | Change a squad member's role, description or position | Write | `squads:write` | No |
| `remove_squad_member` | Take a member off a squad's roster; the entry agent cannot be removed | Write, destructive | `squads:write` | No |
| `simulate_squad_handoff` | Dry-run a squad's routing: given what a caller said, which member would take the call and why | Read | `squads:read` | 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 in US$, broken down by service, model and day, with prompt-cache reads and writes counted separately | Read | `usage:read` | No |
### Platform configuration
Webhooks, integrations, managed prompts and agent versions. Every webhook tool
— including the two that only read — needs `webhooks:write`, because a delivery
row carries customer conversation data and there is no `webhooks:read` scope to
grant.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_webhooks` | List webhook subscriptions with the events each receives. Signing secrets come back masked | Read | `webhooks:write` | No |
| `create_webhook` | Subscribe an HTTPS endpoint to account events. Returns the signing secret in full, once | Write | `webhooks:write` | No |
| `update_webhook` | Change a subscription's URL, events, agent scope or active flag. `is_active: false` pauses it | Write | `webhooks:write` | No |
| `test_webhook` | Send a real signed POST to the configured endpoint and report its status code and latency | Write | `webhooks:write` | No |
| `list_webhook_deliveries` | Delivery attempts for one webhook, with status code, attempts and error | Read | `webhooks:write` | No |
| `get_webhook_delivery` | One attempt in full, including the JSON payload that was sent | Read | `webhooks:write` | No |
| `replay_webhook_delivery` | Re-send a past payload to the current endpoint. Your server really receives it again | Write, destructive | `webhooks:write` | No |
| `list_integrations` | The external services connected to the account. Stored credentials are never returned | Read | `integrations:read` | No |
| `list_integration_catalog` | What can be connected, annotated with what already is | Read | `integrations:read` | No |
| `start_integration_oauth` | Return a console link that connects a sign-in (OAuth) service to the workspace of the person who opens it | Write | `integrations:write` | No |
| `list_prompts` | The managed prompts on the account, with each one's current version | Read | `prompts:read` | No |
| `create_prompt` | Create a managed prompt. Starts empty — add the text as a version | Write | `prompts:write` | No |
| `list_prompt_versions` | The revision log: version, label, pinned model, declared variables. Template text omitted | Read | `prompts:read` | No |
| `get_prompt_version` | One version in full, with its template, variables and pinned model and parameters | Read | `prompts:read` | No |
| `create_prompt_version` | Append a revision. Nothing live changes until a label points at it | Write | `prompts:write` | No |
| `set_prompt_label` | Point a label such as `production` at a version. This is how a prompt is published | Write, destructive | `prompts:write` | No |
| `render_prompt` | Substitute variables and get the text back, plus any you did not supply. No model call | Read | `prompts:read` | No |
| `list_prompt_presets` | Saved presets: a named model plus pinned parameters | Read | `prompts:read` | No |
| `create_prompt_preset` | Save a model and parameters under a name, optionally bound to a prompt version | Write | `prompts:write` | No |
| `list_agent_versions` | An agent's version history with label, status and commit message. Bodies omitted | Read | `bots:read` | No |
| `get_agent_version` | One version in full. Stored channel credentials are redacted | Read | `bots:read` | No |
| `create_agent_version` | Stage a draft from what is live now. Callers are unaffected until it is published | Write | `bots:write` | No |
| `update_agent_version` | Edit the staged draft. Published versions are immutable | Write | `bots:write` | No |
| `publish_agent_version` | Publish the draft onto the live agent. The next caller hears the new behaviour | Write, destructive | `bots:write` | No |
| `rollback_agent_version` | Put an older version back on the live agent. Appends rather than rewriting history | Write, destructive | `bots:write` | No |
`test_webhook` and `replay_webhook_delivery` both make a real outbound HTTP
request to **your own** endpoint, so whatever your server does on that event
happens for real — and a replay makes it happen a second time. Neither takes a
URL: they use the one already stored on the subscription, which is re-checked
against private and internal address ranges on every attempt.
`start_integration_oauth` hands back a console link for the account owner to
open. They sign in to CallMissed and approve access there, and the account is
connected to their own workspace; nothing is connected until they do. Confirm it with
`get_integration_status`, and disconnect with `disconnect_integration` (see
**Connected platforms**). There is no tool that writes a provider credential.
#### More platform configuration tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `update_prompt` | Change a prompt's name or description | Write | `prompts:write` | No |
| `update_prompt_preset` | Change a preset's name, model, default params or pinned prompt version | Write | `prompts:write` | No |
| `set_integration_spreadsheets` | Replace the list of spreadsheets agents may use through a Google Sheets connection | Write | `integrations:write` | No |
| `list_usage_logs` | Row-level log of this account's API calls, newest first: service, model, status, latency, token counts (prompt-cache reads and writes separate) and cost in US$ per call | Read | `usage:read` | No |
| `list_models` | The public model catalogue with pricing, context window, feature flags and `processing` (where each model is processed: `india`, `india_preferred` or `outside_india`) | Read | None | No |
| `get_model` | Pricing, context window, capabilities and `processing` of one model id from list_models | Read | None | No |
| `get_platform_status` | Current health of each CallMissed API service, as shown on the public status page | Read | None | No |
| `get_task` | Fetch one task in full | Read | `crm_tasks:read` | No |
### Testing and quality
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_eval_suites` | Evaluation suites on the account, with the agent each tests and its scorecard | Read | `evals:read` | No |
| `create_eval_suite` | Create a suite of synthetic conversations for one agent | Write | `evals:write` | No |
| `list_eval_cases` | The cases in a suite: persona, opening line, turn budget, success criteria | Read | `evals:read` | No |
| `create_eval_case` | Add one synthetic conversation to a suite | Write | `evals:write` | No |
| `create_eval_case_from_session` | Turn a real voice session into a test case: its opening, the caller's goal, and suggested outcome checks. The key also needs the stt, tts and llm permissions | Write | `evals:write` | No |
| `run_eval_suite` | Run every case against the agent and report what passed. Takes minutes on a big suite | Write | `evals:write` | Yes |
| `list_eval_runs` | Run history with pass counts, model and credits spent | Read | `evals:read` | No |
| `get_eval_run` | One run in full, including every synthetic transcript | Read | `evals:read` | No |
| `list_experiments` | A/B experiments with status, deciding metric and traffic split | Read | `experiments:read` | No |
| `create_experiment` | Create an experiment on one agent. Starts as a draft taking no traffic | Write | `experiments:write` | No |
| `create_experiment_arm` | Add a configuration to compare: an agent version plus optional overrides | Write | `experiments:write` | No |
| `start_experiment` | Put it live. Real callers are split across the arms from this moment | Write, destructive | `experiments:write` | No |
| `stop_experiment` | Stop splitting traffic. Everything already collected is kept | Write | `experiments:write` | No |
| `conclude_experiment` | Record the winner and close it. Final — it cannot be restarted or changed | Write, destructive | `experiments:write` | No |
| `get_experiment_results` | Per-arm sample sizes and the deciding metric, with a plain-language verdict | Read | `experiments:read` | No |
| `list_voice_alerts` | Metric alerts with their thresholds, windows and whether each is firing | Read | `webhooks:write` | No |
| `create_voice_alert` | Be notified when a call metric crosses a threshold over a rolling window | Write | `webhooks:write` | No |
| `update_voice_alert` | Change a threshold, window, channel or cooldown, or silence an alert | Write | `webhooks:write` | No |
`run_eval_suite` is the one tool here that spends: each case is a real model
conversation of up to its turn budget, and a suite with a scorecard adds a
grading call per case. No real caller is ever contacted — the caller is
synthetic. The balance is checked before anything runs, so a refusal costs
nothing.
Metric alerts use `webhooks:write` rather than a scope of their own. An alert is
a notification subscription that sends mail and fires webhooks, which is exactly
what that scope already governs.
#### More testing and quality tools
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `update_eval_suite` | Rename a suite, change its description or grading scorecard, or turn it on or off | Write | `evals:write` | No |
| `update_eval_case` | Change one case's name, persona, opening line, turn budget, success criteria or position | Write | `evals:write` | No |
| `get_experiment` | Fetch an experiment with its status, metric, traffic split and arms | Read | `experiments:read` | No |
| `update_experiment` | Change an experiment's name, hypothesis, metric (only while a draft) or traffic split | Write | `experiments:write` | No |
| `update_experiment_arm` | Change an arm's name, agent version, overrides or control flag | Write | `experiments:write` | No |
| `list_scorecards` | List the rubrics used to grade calls and eval runs | Read | `conversations:read` | No |
| `create_scorecard` | Create a grading rubric: up to 20 criteria, each a unique label, a relative weight (0-100] and optional guidance | Write | `conversations:write` | No |
| `update_scorecard` | Replace a rubric's name and full criteria list; scores already taken keep their old breakdown | Write | `conversations:write` | No |
### Connected platforms
Check, connect and disconnect the channels and services the account runs on, without opening the console. Listing and re-pointing WhatsApp numbers, Facebook Pages and Instagram accounts is in the Messaging and Facebook and Instagram groups; adding knowledge is in Agents and knowledge.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_integration_status` | Whether an integration is connected, and to which account. Call it after the person approves the `start_integration_oauth` link | Read | `integrations:read` | No |
| `disconnect_integration` | Disconnect an integration and discard its stored credentials. Reconnect with `start_integration_oauth` | Write, destructive | `integrations:write` | No |
| `start_channel_connection` | The link that connects a WhatsApp Business number, Facebook Page, Instagram account or personal WhatsApp number, with the steps to follow | Read | `whatsapp:write` | No |
| `list_whatsapp_accounts` | Connected WhatsApp Business accounts | Read | `whatsapp:read` | No |
| `get_whatsapp_number` | One WhatsApp number's live status | Read | `whatsapp:read` | No |
| `disconnect_whatsapp_number` | Deregister a WhatsApp number from this workspace. History is kept | Write, destructive | `whatsapp:write` | No |
| `get_knowledge_source` | One knowledge source's status, including the error if indexing failed | Read | `knowledge:read` | No |
| `remove_knowledge_source` | Remove a source from an agent's knowledge | Write, destructive | `knowledge:write` | No |
| `list_payment_requests` | Payment links your agents sent to customers during calls and chats, with amount, amount paid and status. The money goes to your own connected Razorpay account | Read | `integrations:read` | No |
Two steps cannot happen inside a chat: Meta's own sign-in window for WhatsApp Business, Facebook and Instagram, and the QR scan that links a personal WhatsApp number. `start_channel_connection` returns the one page that runs that step, and `list_whatsapp_numbers`, `list_facebook_pages` or `list_instagram_accounts` confirms it worked. No tool accepts an access token or provider key.
The disconnect and remove tools are marked destructive, so your client asks before running them. None of them deletes conversation history.
### Facebook and Instagram
Facebook Pages and Instagram accounts: publish and schedule posts, answer comments and DMs, read analytics. Posting, comments and DMs are not billed.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_facebook_pages` | List your connected Facebook Pages with their ids and linked agent | Read | `whatsapp:read` | No |
| `list_instagram_accounts` | List your connected Instagram professional accounts with their ids and linked agent | Read | `whatsapp:read` | No |
| `set_social_account_agent` | Choose which agent auto-replies to DMs on a Facebook Page or Instagram account; null unlinks it | Write | `whatsapp:write` | No |
| `publish_facebook_post` | Publish to a Facebook Page: text, a link, photos by URL, or a Reel from a video URL | Write | `whatsapp:send` | No |
| `publish_instagram_post` | Publish an image, video, Reel, story or carousel from public media URLs | Write | `whatsapp:send` | No |
| `get_instagram_container_status` | Check whether Instagram has finished processing a post's media | Read | `whatsapp:send` | No |
| `publish_instagram_container` | Publish a processed media container once its status is FINISHED | Write | `whatsapp:send` | No |
| `get_instagram_publishing_limit` | How many posts the account has published in the last 24 hours against Instagram's cap | Read | `whatsapp:send` | No |
| `generate_social_post` | Draft a ready-to-publish post: a caption, hashtags and, unless generate_image is false, an image link. Not available with India-only processing (`region_unavailable`) | Write | None | Yes |
| `list_social_comments` | List comments on a Facebook post or Instagram media | Read | `whatsapp:read` | No |
| `list_social_comment_replies` | List the replies under one comment | Read | `whatsapp:read` | No |
| `reply_to_social_comment` | Post a public reply to a Facebook or Instagram comment as the Page or account | Write | `whatsapp:send` | No |
| `hide_social_comment` | Hide a comment from everyone but its author, or unhide it | Write | `whatsapp:send` | No |
| `list_social_conversations` | List Facebook Messenger or Instagram DM threads, most recent first | Read | `whatsapp:read` | No |
| `get_social_conversation_messages` | Read the messages in one Facebook or Instagram DM thread | Read | `whatsapp:read` | No |
| `send_social_message` | Send a text reply into an existing Facebook Messenger or Instagram DM thread; Meta only allows it within its reply window | Write | `whatsapp:send` | No |
| `get_social_analytics` | Read Facebook or Instagram DM analytics: the conversation funnel or a daily time series | Read | `whatsapp:read` | No |
### Email
Domains, templates, suppressions, inbound addresses and send history for the Email API. Every tool needs a key with the `email` permission.
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `list_email_domains` | List your sending domains and whether each is verified | Read | `email` permission | No |
| `add_email_domain` | Add a sending domain | Write | `email` permission | No |
| `get_email_domain_records` | List the DNS records a domain needs and whether each is found | Read | `email` permission | No |
| `verify_email_domain` | Check a domain's DNS records now and mark it verified if they pass | Write | `email` permission | No |
| `list_email_sends` | List recent sends with their delivery status | Read | `email` permission | No |
| `get_email_usage` | Read email send volume and spend for the last 30 days | Read | `email` permission | No |
| `list_email_suppressions` | List addresses that will not be emailed (bounces, complaints, manual) | Read | `email` permission | No |
| `add_email_suppression` | Stop all future email to an address | Write | `email` permission | No |
| `list_inbound_email_addresses` | List the addresses that receive email on your verified domains | Read | `email` permission | No |
| `create_inbound_email_address` | Start receiving email at an address on a verified domain, optionally forwarding each message to a webhook URL | Write | `email` permission | No |
| `list_inbound_emails` | List recently received emails, newest first | Read | `email` permission | No |
| `list_email_templates` | List your saved email templates | Read | `email` permission | No |
| `create_email_template` | Save a reusable email template | Write | `email` permission | No |
| `update_email_template` | Change an email template; only the fields given change | Write | `email` permission | No |
| `get_scheduled_email` | Look up scheduled sends by the send id or batchId a send returned | Read | `email` permission | No |
| `cancel_scheduled_email` | Cancel every still-pending send for a send id or batchId | Write, destructive | `email` permission | No |
### Guides and playbooks
Step-by-step playbooks an assistant follows before it builds anything: it interviews you about the use case first. The same playbooks are served as MCP prompts (`build_voice_agent`, `build_whatsapp_agent`, `launch_campaign`, `setup_support_desk`, `setup_crm`).
| Tool | What it does | Access | Scope or permission | Credits |
| --- | --- | --- | --- | --- |
| `get_playbook` | Get a step-by-step playbook | Read | None | No |
### Calling tools are deployment-gated
35 of the 342 tools are gated on whether calling is switched on for your account, in two independent groups.
**22 telephony tools:** `place_call`, `list_calls`, `get_call`, `end_call`, `get_call_recording`, `click_to_call`, `list_phone_numbers`, `search_available_numbers`, `list_number_countries`, `get_number_requirements`, `provision_phone_number`, `attach_number_to_agent`, the four voicemail-message tools, `set_agent_voicemail_drop`, `get_number_reputation`, `update_number_call_settings`, `declare_number_for_ai_calls`, `list_telephony_compliance` and `callback_handoff`. With telephony off, `tools/list` returns the other 320.
**13 campaign tools:** the whole **Calling campaigns** table above. With campaigns off, `tools/list` returns the other 327.
The flow, menu and queue tools are **not** in either group. Call routing is authored before calling is switched on, so those routes are always mounted and those tools are always served.
Each group appears only where that feature is switched on. Where neither is, `tools/list` returns the other 307 and those 33 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": 342,
"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
}
}
```
When a tool calls the API and the API answers with an error code, the code follows the message in brackets, for example `Model 'flux-2-pro' is not available with India-only processing. … (code: region_unavailable)`, so the agent can tell a policy refusal from a temporary failure.
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-11-25` and also accepts `2025-06-18` and `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-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 = 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, as the gateway applies it. Each model's accepted values come from its provider's documentation and, where the documentation is silent, live probes:
| Model | `"none"` | `"low"` | `"medium"` | `"high"` | `"xhigh"` | `"minimal"` |
| --- | --- | --- | --- | --- | --- | --- |
| `gpt-6-sol`, `gpt-6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-6.1-sol` | ↓ `"low"` | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` | ↓ `"low"` | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5.5` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` |
| `gpt-5-mini` | ↓ `"minimal"` | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ |
| `gpt-4o`, `gpt-4.1`, `grok-4.3` | ⊘ | ⊘ | ⊘ | ⊘ | ⊘ | ⊘ |
| `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.1-pro-preview` | ↓ `"low"` | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"low"` |
| `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-flash-lite` | ↓ `"minimal"` | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ |
| `kimi-k2.5`, `kimi-k2.6` | ✅ off | ✅ | ✅ | ✅ | ↓ `"max"` | ↓ `"none"` |
| `kimi-k2.7-code` | ⊘ | ✅ | ✅ | ✅ | ↓ `"max"` | ⊘ |
| `kimi-k3.1-pro` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` |
| `gpt-oss-120b` | ↓ `"low"` | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"low"` |
| `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | ✅ off | ↓ on | ↓ on | ↓ on | ↓ on | ↓ on |
| `sarvam-105b`, `sarvam-105b-conversations` | ✅ off | ✅ | ⊘ default | ✅ | ↓ `"max"` | ✅ off |
| `glm-5.3` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` |
| `deepseek-v4.1-flash` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` |
| `glm-5.3-flash` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` |
| `deepseek-v4.1-flash-1017` | ✅ off | ✅ | ✅ | ✅ | ✅ | ✅ |
| `gemma-4-31b` | ✅ off | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"none"` |
| `deepgram-think-fast` | ✅ off | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ |
| `deepgram-phonellm-mini` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` |
Legend: ✅ = sent to the model as-is · ✅ off = thinking is switched off · ↓ = mapped to the listed value before forwarding · ⊘ = dropped from the request; the model runs at its default thinking behaviour and the request still succeeds.
Notes worth reading before you rely on a value:
- `kimi-k2.5`, `kimi-k2.6` and `kimi-k2.7-code` accept `"none"`, `"low"`, `"medium"`, `"high"` and `"max"`; `"xhigh"` runs as `"max"`. `"minimal"` turns thinking off on K2.5 and K2.6. `kimi-k2.7-code` always thinks, so `"none"` and `"minimal"` are dropped.
- `kimi-k3.1-pro` always thinks; thinking cannot be switched off. `"low"`, `"high"` and `"max"` are sent as-is. `"none"` and `"minimal"` run as `"low"`, `"medium"` as `"high"` and `"xhigh"` as `"max"`. Send `"low"` for the fastest replies.
- `DeepSeek-V4-Pro` and `DeepSeek-V4-Flash` have an on/off switch, not levels: `"none"` turns thinking off and any other value turns it on. Without `reasoning_effort` they think and return the trace in `reasoning_content`.
- `gpt-oss-120b` has three levels, `"low"`, `"medium"` and `"high"`. `"none"` and `"minimal"` run as `"low"` (it has no full off switch); `"xhigh"` and `"max"` run as `"high"`.
- The retired `glm-4.7-flash`, `glm-5.2`, `nemotron-3-super`, `gemma-4-26b-a4b-it` and `mistral-small-3.1` IDs follow the row of the model that answers them (`gpt-oss-120b`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2.5`).
- `sarvam-105b` and `sarvam-105b-conversations` switch thinking fully off for `"none"` and `"minimal"`. Their levels are `"low"`, `"high"` and `"max"`; `"medium"` is not one of them, so it is dropped and the model runs at its own default. When you omit `max_tokens` on a Sarvam model we send 4,096 (16,384 for `glm-5.3`), because thinking tokens count toward the limit and the upstream default of 2,048 cut answers short.
- `deepseek-v4.1-flash` always reasons and defaults to its highest level, `"max"`. Its levels are `"low"`, `"high"` and `"max"`, mapped as in the table. Reasoning tokens share the `max_tokens` budget, so raise `max_tokens` or lower the effort if answers come back short.
- `glm-5.3-flash` always reasons and cannot be switched off. Its levels are `"low"`, `"high"` and `"max"`, mapped as in the table, and it runs at `"high"` when you omit `reasoning_effort`. In our tests with `"low"`, the first token arrived in about 0.4 to 0.5s.
- `deepseek-v4.1-flash-1017` reasons at `"high"` by default, and `"none"` switches thinking off (first token in about 0.25 to 0.6s in our tests). Every value is sent as-is: upstream runs `"minimal"` as `"low"`, `"medium"` and `"xhigh"` as `"high"`, and `"max"` as `"max"`.
- `glm-5.3` always reasons and defaults to its highest level, `"max"`. Send `"low"` when latency matters: in our tests it cut time to first word from about 3.5s to about 0.9s. `gemma-4-31b` does not think unless asked. Any of `"low"`, `"medium"` or `"high"` switches thinking on (in our tests it did not track the level), and `"none"` or `"minimal"` keep it off at about 0.25s to first word.
- `deepgram-think-fast` accepts `"none"` (the default, thinking off), `"minimal"`, `"low"`, `"medium"` and `"high"`; `"xhigh"` and `"max"` run as `"high"`. The trace comes back as `reasoning_content`. In our tests every level above `"none"` took 2 to 5 seconds to answer, against about 0.3 seconds with `"none"`, so keep it off for voice. `deepgram-phonellm-mini` does not reason: every value is sent as `"none"`, because otherwise it would put its reasoning in the spoken answer.
- `gpt-4o`, `gpt-4.1` and `grok-4.3` take no reasoning control through this API. Every value is dropped and the request still returns 200.
- The `gemini-*` models map `reasoning_effort` onto their own thinking levels. They have no full off switch: `"none"` gives the lowest level the model accepts.
- The GPT-5.5 / GPT-5.6 / GPT-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`. `gpt-5-mini` is an original GPT-5 model: its ladder is `minimal`/`low`/`medium`/`high`, so `"none"` is sent as `"minimal"` and `"xhigh"` as `"high"`.
- On `gpt-6-sol` and `gpt-6-luna`, `temperature` and `top_p` are applied only when `reasoning_effort` is `"none"`; with any other effort they are dropped, because the model accepts only the default sampling while it reasons.
- On the GPT-5.6 and GPT-6 models, a request that includes `tools` always runs with `reasoning_effort: "none"`, whatever you send — those models cannot combine function tools with reasoning on Chat Completions.
- `gpt-6.1-sol` always reasons: it accepts `low`/`medium`/`high`/`xhigh` (default `medium`) and cannot switch thinking off, so `"none"` and `"minimal"` are sent as `"low"` and `"max"` is sent as `"xhigh"`.
- `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5` (Pro plan and up) accept `low`, `medium`, `high`, `xhigh` and `max`; `"none"` and `"minimal"` are accepted and run as `"low"`. Defaults when you omit it: `medium` on `claude-opus-5-5` and `claude-haiku-5-5`, `high` on `claude-sonnet-5-5`. `temperature`, `top_p` and `top_k` are ignored on these models. Tools can be combined with reasoning (see [function calling](/docs/chat-function-calling)).
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`, `gpt-6-luna`, or `DeepSeek-V4-Flash` (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 ≈ US$0.0104 (US$1 = ₹96).**
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 your first month, then 500 a month |
| Pro, monthly | 6,000 credits your first month, then 5,000 a month |
| Enterprise, monthly | 26,000 credits your first month, then 20,000 a month |
| Scale, monthly | 65,000 credits your first month, then 52,500 a month |
| Business, monthly | 1,30,000 credits your first month, then 1,10,000 a month |
| Corporate, monthly | 2,60,000 credits your first month, then 2,30,000 a month |
| Top-up | Any amount from 10 to 50,000 credits, at ₹1 per credit |
Usage is always metered. There is no unlimited tier — Pro and above remove 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.8438 in / $4.219 out: 500 in + 200 output tokens = $0.001266 = **0.1215 credits** |
| Speech to text | per audio hour | `saaras:v4` at $0.3125/hr: a 4-minute call = **2 credits** |
| Text to speech | per 10,000 characters | `bulbul:v3` at $0.3125/10K (`bulbul:v4-flash` is $0.2083/10K): a 400-character reply = **1.2 credits** |
| Image generation | per image | `flux-2-pro` at $0.1042: 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. Only Free and Starter have caps; Pro and above are uncapped and limited by request rate instead.
| Plan | LLM | STT | TTS | Image | Storage | Team |
|------|-----|-----|-----|-------|---------|------|
| Free | 100 | 50 | 50 | 50 | 100 MB | 2 |
| Starter | 5,000 | 2,500 | 2,500 | 500 | 1 GB | 5 |
| Pro | uncapped | uncapped | uncapped | uncapped | 10 GB | 20 |
| Enterprise, Scale, Business, Corporate | 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 |
| Scale | 20,000 |
| Business | 40,000 |
| Corporate | 80,000 |
Override a single key with `rate_limit_rpm` in the dashboard. An explicit
override can only lower the plan default; a higher value is capped at the
plan's limit.
## 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`, `gpt-4o-*transcribe*`, `deepgram-*`, `gnani-prisma-*`, `ink-*`, `assemblyai-*`, `inworld-stt-*`, `microsoft-speech-*` | Speech to text |
| `bulbul:*`, `deepgram-aura-*`, `gpt-4o-mini-tts`, `gnani-timbre-*`, `sonic-*`, `rime-*`, `inworld-tts-*`, `fish-audio-*`, `gradium-*`, `microsoft-neural-*` | Text to speech |
| `flux-*`, `gpt-image-*`, `nano-banana-*`, … | Image generation |
| everything else (e.g. `kimi-k2.5`, `gpt-5.6-terra`, `gpt-6-sol`) | 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 ≈ US$0.0104 (US$1 = ₹96).** 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 POST requests to the inference API safely retryable with an Idempotency-Key, so a network retry never runs or bills a call twice.
## 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 work twice and paying for it twice. An **idempotency key** lets you retry safely: the server runs the first request and returns the same stored result for any replay with the same key.
## Where it applies
Idempotency keys are honoured on every `POST` under `/v1/` and `/anthropic/v1/` (chat completions, messages, responses, embeddings, speech, transcription, image generation, search, batches, voice sessions), authenticated with a `cm_` API key in either `Authorization: Bearer` or `x-api-key`. On other paths, other methods, or without an API key the header is ignored and the request runs normally. The one exception is the Email API: `POST /api/v1/email/send` (including `messageVersions` batches) accepts its own `Idempotency-Key` of up to 128 characters — see [Send Email](/docs/email-send).
## Using the Header
Send an `Idempotency-Key` header with a unique value of up to 255 characters (a UUID works well):
```bash
curl -X POST https://api.callmissed.com/v1/chat/completions \
-H "Authorization: Bearer cm_your_key" \
-H "Idempotency-Key: 3f9c1d2e-0b9a-4c7d-8e1f-2a3b4c5d6e7f" \
-H "Content-Type: application/json" \
-d '{"model":"kimi-k2.5","messages":[{"role":"user","content":"Hello"}]}'
```
Reuse the **same** key when retrying the same logical request. Use a **new** key for a genuinely new action. A key longer than 255 characters is ignored.
## Behavior
- Keys are scoped to your account and kept for **24 hours**.
- A replay with the same key, endpoint **and** body returns the original status and body with an `Idempotent-Replay: true` header. The call is not run or billed again.
- Only a successful (`2xx`) response is stored. After an error the key is released, so retrying with the same key runs the request again.
- Streaming (`text/event-stream`) and binary responses (audio, images), and responses over 1 MiB, are not stored. The key is released after them, so a retry runs again.
| Status | `detail` | When |
| --- | --- | --- |
| `409` | `Idempotency-Key reused with a different request body` | Same key, different body |
| `409` | `Idempotency-Key reused for a different request` | Same key sent to a different endpoint |
| `409` | `A request with this Idempotency-Key is already in progress` | The first request has not finished yet. Wait and retry |
| `409` | `... Its response was not retained (zero data retention), so it cannot be replayed.` | The original ran under zero data retention, so there is nothing to replay |
---
### 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 | 139 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 **139 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 | 139 models — LLM, STT, TTS, realtime voice, image, embeddings
:::
## Get started
::::steps
## Create an API Key
In the [CallMissed console](https://console.callmissed.com/developer/keys), open **Developer → API keys** and create a new key. It is shown once, so copy it now. 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:v4",
file=f
)
print(response.text)
```
```javascript [JavaScript]
import fs from "fs";
const response = await client.audio.transcriptions.create({
model: "saaras:v4",
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:v4
```
:::
### 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, plan caps, budget caps, response headers, and how to handle 429s.
## Limit Layers
Requests pass through several limits:
| Layer | Limit | Scope | When exceeded |
| --- | --- | --- | --- |
| Per-key RPM | Plan defaults: Free 60 · Starter 500 · Pro 3,000 · Enterprise 10,000 · Scale 20,000 · Business 40,000 · Corporate 80,000 (override per key) | per API key | `429 rate_limit_exceeded` |
| In-flight requests | A cap on simultaneous chat-completion requests per key | per API key | `429 too_many_concurrent_requests` |
| Key budget | Optional credit cap on one key | per API key | `402 budget_exceeded` |
| Monthly budget cap | Optional credit cap for the whole account | per account | `429 quota_exceeded` until the 1st |
| Plan call caps | Monthly caps on LLM/STT/TTS/image calls (Free and Starter only), see [Credits & Rate Limits](/docs/credits-rate-limits#plan-call-caps) | per account | `429 quota_exceeded` until the 1st |
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 console.
## Response Headers
| Header | Sent on | Meaning |
| --- | --- | --- |
| `X-RateLimit-Limit` | Metered inference responses | The monthly call cap for that service |
| `X-RateLimit-Remaining` | Metered inference responses | Calls left this month |
| `X-RateLimit-Reset` | Metered inference responses | ISO-8601 time the monthly cap resets |
| `X-Usage-Warning` | At 80% and 95% of a monthly cap | `warning: …` or `critical: …` with the count used |
| `Retry-After` | `quota_exceeded`, `too_many_concurrent_requests`, and the per-key RPM 429 on `/api/v1/*` | Seconds to wait before retrying |
The `X-RateLimit-*` headers describe the **monthly plan cap**, not the per-minute limit. A per-key RPM 429 from an inference endpoint (`/v1/*`) carries no `Retry-After`: the window is a rolling minute, so back off and retry within it.
## Handling 429
Read the error `code` first (on `/api/v1/*` routes, the `detail` message):
1. `rate_limit_exceeded`: wait (`Retry-After` when present, otherwise exponential backoff with jitter, starting at about a second) and retry. Spread bursts over time, or raise the key's RPM.
2. `too_many_concurrent_requests`: wait the `Retry-After` seconds and retry. Lower your client's parallelism.
3. `quota_exceeded`: do not retry. The cap lasts until the 1st of next month. Upgrade the plan or raise the budget cap.
See [Error Codes](/docs/errors) for the full status/code reference.
---
### Libraries & SDKs
URL: /docs/sdks
> Use the OpenAI or Anthropic SDK to integrate CallMissed — point the client at our base URL and use your cm_ API key.
## Supported Libraries
CallMissed does not ship its own client library for the inference API. Our endpoints are compatible with **two existing SDK families**, so use whichever you prefer: change the base URL and use your `cm_` API key. The platform APIs under `/api/v1/` (agents, CRM, support, webhooks and the rest) are plain JSON over HTTPS; call them with any HTTP client.
### OpenAI SDK (recommended for most use cases)
| Language | Package | Manager |
|----------|---------|---------|
| Python | `openai` | PyPI |
| JavaScript / TypeScript | `openai` | npm |
| Go | `github.com/openai/openai-go/v3` | 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/v3
```
```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/v3
```
```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/v3"
"github.com/openai/openai-go/v3/option"
)
func main() {
client := openai.NewClient(
option.WithAPIKey("cm_your_api_key"),
option.WithBaseURL("https://api.callmissed.com/v1"),
)
resp, err := client.Chat.Completions.New(context.Background(),
openai.ChatCompletionNewParams{
Model: "sarvam-105b",
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("Hello"),
},
},
)
if err != nil {
panic(err)
}
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) |
| Docs in your coding agent | [`callmissed-docs-mcp`](/docs/mcp-server) on npm |
| 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`; if the extracted text is over **5 MB** of UTF-8 the upload returns `413`.
```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). The cost is checked before anything is embedded: if your balance (or monthly budget) cannot cover it, nothing is embedded or charged, and the source is saved with `status: "failed"` and an `error_message` saying so — the response's `status` is `"failed"` too. 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 |
| `resync_interval_hours` | int \| null | URL sources only: re-fetch the page every N hours (6–720). `null` = ingest once |
| `last_resync_at` | datetime \| null | When the last automatic re-fetch ran |
| `resync_error` | string \| null | Why the last automatic re-fetch failed; the previous content keeps serving |
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 |
| `resync_interval_hours` | int (6–720) | No | Re-fetch the page automatically every N hours. Omit to ingest once |
```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.
### Auto re-sync a URL
`PATCH /api/v1/knowledge/sources/{source_id}` · scope `knowledge:write`
Set `{"resync_interval_hours": 24}` (6–720) to have the page re-fetched on that schedule, or `{"resync_interval_hours": null}` to stop. Only URL sources can be re-synced (`400` otherwise). Each re-fetch uses the same public-URL checks as the first ingest. The page text is compared with the last indexed version; chunks are replaced only when it changed, and the replacement is all-or-nothing, so retrieval never sees a half-updated source. Re-embedding a changed page is billed as embedding tokens exactly like the first ingest; an unchanged page costs nothing. If a re-fetch fails (page down, no text, balance too low), the previous content stays in place, the reason appears in `resync_error`, and the next attempt is delayed progressively.
### 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**. The extracted text is also capped at 5 MB of UTF-8 (`413` above it) |
```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 typed; 5 MB of UTF-8 extracted from an upload | 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 |
| `503` | Indexing failed after the text was extracted — safe to retry |
A `503` means extraction succeeded but embedding did not, so nothing was stored. Retry the same request. See [Errors](/docs/errors) for the standard error shape.
---
### Agent Assist
URL: /docs/agent-assist
> Suggested replies for a human agent working a live conversation, grounded in the conversation's knowledge base.
## Overview
`POST /api/v1/assist/suggest` returns up to three short candidate replies for the
next message in one of your [conversations](/docs/conversations), plus the
knowledge-base snippets they were grounded in. It is built for a human agent who
has taken over a thread: show the suggestions, let the agent pick or edit one,
then send it yourself.
Nothing is sent to the customer by this endpoint — it only reads the thread and
returns text.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
The key needs the `conversations:write` scope. A dashboard session also works.
## POST `/api/v1/assist/suggest`
| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `conversation_id` | `UUID` | Yes | A conversation in your account |
| `turns` | `array` | No | Up to 12 recent `{role, content}` turns (`role` is `user`, `assistant` or `system`; `content` 1–4,000 characters). Omit it to use the conversation's stored messages |
| `instruction` | `string` | No | Extra steering for these suggestions only, at most 500 characters (e.g. "offer a callback") |
| `log_suggestions` | `boolean` | No | Default `false`. When `true`, the returned suggestions are saved on the conversation for audit |
Send `turns` when the agent can see a message that may not be stored yet; the
server otherwise reads the last 12 messages of the conversation.
```bash
curl -X POST https://api.callmissed.com/api/v1/assist/suggest \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"conversation_id": "0f5c2a8e-6b1d-4c3e-9a7f-2d4e6b8c0a12",
"instruction": "keep it under two sentences"
}'
```
```json
{
"suggestions": [
"Sorry about the delay! Could you share your order number so I can check it right away?",
"Your order left our warehouse yesterday and should arrive within 2 business days.",
"I'm escalating this to our delivery team now and will update you within the hour."
],
"kb_snippets": [
{
"source_id": "b3e1…",
"chunk_index": 4,
"score": 0.81,
"content": "Standard delivery takes 3–5 business days after dispatch…"
}
],
"model": "gpt-oss-120b",
"remaining_in_window": 29
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `suggestions` | `string[]` | Up to 3 replies, each at most 320 characters, in the customer's language |
| `kb_snippets` | `array` | Up to 4 knowledge chunks (score at least 0.55) from the conversation's bot, each `content` at most 600 characters. Empty when nothing relevant was found |
| `model` | `string` | The model that wrote the suggestions — the conversation's bot model, or `gpt-oss-120b` when the bot sets none |
| `remaining_in_window` | `integer` | Calls left for this conversation in the current 5-minute window |
## Limits
Each conversation allows **30 calls per rolling 5-minute window**. The 31st call
returns `429` with the number of seconds until the window resets in the message.
## Billing
Each call is billed as one small completion at the model's normal
[per-token rate](/docs/models), plus the knowledge-base search. The call fails
with `402` before any model runs when your balance is empty or your monthly
budget cap is reached.
## Errors
Errors use the `{"detail": "..."}` shape.
| Status | When |
| --- | --- |
| `400` | The conversation has no messages yet |
| `402` | `Insufficient credits` or `Monthly budget cap reached` |
| `403` | Key is missing the `conversations:write` scope |
| `404` | `Conversation not found` (unknown id, or another account's) |
| `422` | Body failed validation (bad `role`, `turns` over 12, `instruction` over 500 characters) |
| `429` | The conversation's 30-per-5-minutes limit is spent |
| `503` | The model call failed — safe to retry |
---
### 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); also at `/anthropic/v1/messages/count_tokens`
- `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-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-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-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-6-sol",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 25,
"output_tokens": 12,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}
```
## 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-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-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-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, `1`–`1,048,576` |
| `messages` | array | Yes | 1–2,000 `{role, content}` objects. `content` is a string or an array of `text`, `image` (`base64` or `url` source), `document` (`text` source), `tool_use` and `tool_result` blocks. A `tool_result` may hold text, images, text documents and search results, and `is_error`. OpenAI-style `image_url`, `input_audio` and `file` parts are also accepted. Any other block or source type returns `400` naming it |
| `system` | string or array | No | System prompt (top-level, not in messages); a string or a list of text blocks |
| `stream` | boolean | No | Enable streaming (default: false) |
| `temperature` | number | No | Sampling temperature, `0`–`1` |
| `top_p` | number | No | Nucleus sampling, `0`–`1` |
| `top_k` | integer | No | Top-K sampling, `1`–`500` |
| `stop_sequences` | array | No | Up to 16 stop sequences |
| `tools` | array | No | Up to 10,000 `{name, description, input_schema}` tools (128 on the GPT models listed in [Function Calling](/docs/chat-function-calling)). Server tools (for example `web_search_20250305`) return `400` |
| `thinking` | object | No | `{"type": "disabled"}` turns reasoning off (`reasoning_effort: "none"`). `enabled` and `adaptive` keep the model's default reasoning; `budget_tokens` is not applied |
| `output_config` | object | No | `effort` (`low`, `medium`, `high`, `xhigh`, `max`) sets the model's `reasoning_effort` and takes precedence over `thinking`. Each model keeps only the levels it supports. `format` (`{"type": "json_schema", "schema": {...}}`) requests structured JSON output |
| `tool_choice` | object | No | `{"type": "auto"}`, `{"type": "any"}`, `{"type": "none"}` or `{"type": "tool", "name": "..."}` |
| `metadata` | object | No | Up to 16 string, number or boolean values. `user_id` is your end user's id and enforces that user's [monthly budget](/docs/gateway-controls). `trace_id` and `session_id` are recorded on the usage row so you can filter [usage logs](/docs/usage-api) by them |
| `provider` | object | No | CallMissed extension: `{"zdr": true}` requires a [zero-data-retention route](/docs/gateway-controls); `{"data_residency": "in"}` limits the request to models processed in India (`region_unavailable` otherwise) |
> **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`, including legacy aliases of a catalog model.
```json
{ "model": "gpt-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-6-sol",
"messages": [{"role": "user", "content": "Hello, how are you?"}],
"system": "You are a helpful assistant."
}'
```
**Response:**
```json
{"input_tokens": 15}
```
Image blocks and PDF `document` blocks contribute a fixed estimate (~1,120
tokens each) rather than a fetch-and-resize pass, never their base64 size; text
`document` blocks count their text. 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": 4.167, "output": 20.83, "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-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 |
|------------|-------------|------|
| `invalid_request_error` | 400 / 422 | Bad request, image sent to a text-only model, a voice-only model id, a prompt longer than the model's context window, or `max_tokens` below the model's minimum |
| `authentication_error` | 401 | Bad, missing, revoked or expired API key |
| `billing_error` | 402 | Insufficient credits, key budget or end-user budget exhausted |
| `permission_error` | 403 | Account inactive, key lacks the `llm` permission, or a free-plan key calling a paid model |
| `not_found_error` | 404 | Model not found |
| `request_too_large` | 413 | Request body too large |
| `rate_limit_error` | 429 | Plan limit or API key rate limit exceeded |
| `api_error` | 500 / 503 | Upstream model failure, or the model is under maintenance (the message names an alternative) |
| `overloaded_error` | 503 / 529 | Model temporarily unavailable. Retry with backoff |
| `timeout_error` | 503 | Upstream model timed out |
**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
```
## Prompt caching
Models that support prompt caching reuse repeated prompt prefixes automatically. Usage uses
Anthropic's field names, with the same meaning:
- `input_tokens` — prompt tokens that were not read from or written to the cache
- `cache_read_input_tokens` — prompt tokens served from the cache (billed at the model's cached-input rate)
- `cache_creation_input_tokens` — prompt tokens written to the cache
Total prompt = `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`.
When streaming, the final counts arrive in the `message_delta` event.
`cache_control` blocks (on `system`, message content or `tools`) are accepted
so Anthropic SDK code runs unchanged, but explicit breakpoints and `ttl` are not
applied today — caching works from the prompt prefix automatically.
## 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. Send each `tool_use.id` back exactly as you received it: on some models it carries state the next turn needs.
- **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.
- **Claude models** (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, Pro plan and up) return their reasoning as standard `thinking` and `redacted_thinking` content blocks. Pass those blocks back unchanged in later turns, especially alongside `tool_result` blocks. `temperature`, `top_p` and `top_k` are ignored for these models, `tool_choice` forcing runs as `auto` on `claude-opus-5-5` and `claude-sonnet-5-5`, and the conversation must end with a `user` message (a trailing assistant prefill turn is dropped).
- **Billing** uses CallMissed credits, not Anthropic billing.
---
### Batch API
URL: /docs/batch
> Run thousands of chat completions asynchronously from one JSONL file — OpenAI Batch API compatible.
## Overview
The Batch API runs a file of chat-completion requests in the background and hands you the results as a file. Use it for work that does not need an answer right now: classifying a backlog, summarising transcripts overnight, generating evaluation sets.
It is compatible with the OpenAI Batch API. An OpenAI SDK pointed at `https://api.callmissed.com/v1` works unchanged.
| Endpoint | Purpose |
| --- | --- |
| `POST /v1/files` | Upload a JSONL input file (`purpose=batch`) |
| `POST /v1/batches` | Start a batch from an uploaded file |
| `GET /v1/batches/{batch_id}` | Poll status and progress |
| `GET /v1/files/{file_id}/content` | Download the input, output or error file |
| `POST /v1/batches/{batch_id}/cancel` | Stop a running batch |
| `GET /v1/batches` | List batches (newest first) |
| `GET /v1/files`, `GET /v1/files/{file_id}`, `DELETE /v1/files/{file_id}` | List, inspect and delete files |
Each line runs exactly like a live `POST /v1/chat/completions` call made with the same API key: the same models, free-plan model rules, `allowed_models`, key budget, plan limits, response cache and bring-your-own-key routing apply. Streaming is the only thing a batch line cannot do.
## Authentication
```
Authorization: Bearer cm_your_api_key
```
The key needs the **LLM** service permission (the same one chat completions needs). Creating a batch requires an API key, because every line later runs as that key. If the key is revoked or expires while the batch runs, the remaining lines fail with `api_key_revoked` or `api_key_expired`.
## 1. Write the input file
One JSON object per line. `custom_id` is yours and must be unique within the file; `body` is a normal chat-completions request.
```json
{"custom_id": "ticket-1041", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "sarvam-105b", "messages": [{"role": "user", "content": "Classify: my order never arrived"}], "max_tokens": 20}}
{"custom_id": "ticket-1042", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "sarvam-105b", "messages": [{"role": "user", "content": "Classify: how do I change my plan?"}], "max_tokens": 20}}
```
| Field | Rule |
| --- | --- |
| `custom_id` | String, 1–512 characters, unique in the file |
| `method` | `"POST"` |
| `url` | `"/v1/chat/completions"` |
| `body` | A valid chat-completions request with `model` set. `stream` must be absent or `false` |
The whole file is validated on upload. An invalid line rejects the upload with `400 invalid_batch_file` and a message naming the line, so nothing is half-accepted.
## 2. Upload it
```bash
curl https://api.callmissed.com/v1/files \
-H "Authorization: Bearer cm_your_api_key" \
-F purpose=batch \
-F file=@requests.jsonl
```
```json
{
"id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10",
"object": "file",
"bytes": 412,
"created_at": 1790000000,
"expires_at": 1792592000,
"filename": "requests.jsonl",
"purpose": "batch",
"status": "processed"
}
```
## 3. Create the batch
```bash
curl https://api.callmissed.com/v1/batches \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"metadata": {"job": "nightly-triage"}
}'
```
| Parameter | Type | Notes |
| --- | --- | --- |
| `input_file_id` | `string` | A file uploaded with `purpose=batch` |
| `endpoint` | `string` | `/v1/chat/completions` (the only supported endpoint) |
| `completion_window` | `string` | `24h` (the only supported window) |
| `metadata` | `object` | Optional. Up to 16 string pairs; keys ≤ 64 characters, values ≤ 512 |
Before the batch starts, every model in the file is checked against your plan and key. A model the key can never call is rejected here (`403 model_not_available` / `model_not_allowed`) rather than failing line by line. Creating a batch also needs a positive credit balance (`402 insufficient_credits`).
## 4. Poll for completion
```python
import time
from openai import OpenAI
client = OpenAI(base_url="https://api.callmissed.com/v1", api_key="cm_your_api_key")
batch_file = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
while batch.status not in ("completed", "failed", "expired", "cancelled"):
time.sleep(30)
batch = client.batches.retrieve(batch.id)
print(batch.request_counts)
if batch.output_file_id:
print(client.files.content(batch.output_file_id).text)
if batch.error_file_id:
print(client.files.content(batch.error_file_id).text)
```
### Batch object
```json
{
"id": "batch_0b9e1f3a5c7d4e2f8a6b4c2d0e8f6a4b",
"object": "batch",
"endpoint": "/v1/chat/completions",
"errors": null,
"input_file_id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10",
"completion_window": "24h",
"status": "completed",
"output_file_id": "file-1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6",
"error_file_id": null,
"created_at": 1790000000,
"in_progress_at": 1790000000,
"expires_at": 1790086400,
"finalizing_at": 1790000420,
"completed_at": 1790000421,
"failed_at": null,
"expired_at": null,
"cancelling_at": null,
"cancelled_at": null,
"request_counts": {"total": 2, "completed": 2, "failed": 0},
"metadata": {"job": "nightly-triage"}
}
```
| Status | Meaning |
| --- | --- |
| `in_progress` | Lines are running. `request_counts` updates live |
| `finalizing` | All lines are done; the result files are being written |
| `completed` | Results are ready in `output_file_id` / `error_file_id` |
| `cancelling` | Cancel requested; lines already running finish, the rest are skipped |
| `cancelled` | Stopped. Lines that finished before the cancel are in the output file |
| `expired` | The 24-hour window ran out. Finished lines are in the output file; the rest are in the error file with `batch_expired` |
`validating` and `failed` exist for OpenAI compatibility; CallMissed validates the file when you upload it, so a new batch starts directly in `in_progress`.
## 5. Read the results
The output file holds one line per successful request; the error file holds one line per failed request. Match lines to your input with `custom_id` — do not rely on line order.
```json
{"id": "batch_req_3c5e…", "custom_id": "ticket-1041", "response": {"status_code": 200, "request_id": "chatcmpl-…", "body": {"id": "chatcmpl-…", "object": "chat.completion", "model": "sarvam-105b", "choices": [ … ], "usage": { … }}}, "error": null}
```
An error-file line carries an `error` object. When the request reached the model and was refused (for example a context-length error), `response` holds that response too; when the line never ran, `response` is `null`.
```json
{"id": "batch_req_9a1b…", "custom_id": "ticket-1042", "response": null, "error": {"code": "batch_cancelled", "message": "This request was not executed because the batch was cancelled."}}
```
| `error.code` | Meaning |
| --- | --- |
| Any chat-completions error code | The line ran and got that error (e.g. `context_length_exceeded`, `model_not_allowed`) |
| `insufficient_credits` | Your balance ran out. Every line not yet run fails with this code |
| `budget_exceeded` | The key's budget cap was reached. Every line not yet run fails with this code |
| `api_key_revoked` / `api_key_expired` | The creating key stopped being usable |
| `account_inactive` / `account_terminated` | The account can no longer make requests |
| `batch_cancelled` | Skipped because you cancelled the batch |
| `batch_expired` | Not run before the 24-hour window ended |
| `server_error` | The line kept failing after retries |
Temporary failures (upstream errors, concurrency limits) are retried automatically, up to three attempts per line, before they are written to the error file.
## Cancel
```bash
curl -X POST https://api.callmissed.com/v1/batches/batch_0b9e…/cancel \
-H "Authorization: Bearer cm_your_api_key"
```
Returns the batch in `cancelling`; it moves to `cancelled` once lines already running finish. Cancelling a batch that has already finished returns `409 batch_not_cancellable`.
## List
`GET /v1/batches?limit=20&after=batch_…` returns `{object, data, first_id, last_id, has_more}`, newest first. `limit` is 1–100; pass the previous page's `last_id` as `after` for the next page.
## Files
| Call | Returns |
| --- | --- |
| `GET /v1/files?purpose=batch&limit=20` | Your files, newest first (`purpose` is `batch` for uploads, `batch_output` for results) |
| `GET /v1/files/{file_id}` | The file object |
| `GET /v1/files/{file_id}/content` | The raw JSONL |
| `DELETE /v1/files/{file_id}` | `{"id", "object": "file", "deleted": true}` — deletes it now instead of at expiry |
Every file — the ones you upload and the result files — is deleted automatically 30 days after it was created (`expires_at`). Download results you want to keep.
## Pricing
Batch lines are billed at the **same per-token rate as a live `/v1/chat/completions` call** for the model that served them, and each line appears in your usage logs with `batch_id` and `custom_id` in its metadata. Lines answered from the response cache or served on your own provider key cost what they would live. Lines that fail are not charged.
## Limits
| Limit | Value |
| --- | --- |
| Requests per file | 10,000 |
| File size | 20 MB |
| Running batches per account | 5 (`429 too_many_active_batches` above that) |
| Completion window | 24 hours |
| File retention | 30 days |
Lines of one account run a few at a time so a large batch does not crowd out your live traffic. Most batches finish well inside the window; the 24 hours is an upper bound, not a target.
## Errors
| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_batch_file` | The upload is not valid batch JSONL (the message names the line) |
| `400` | `invalid_purpose` | `purpose` is not `batch` |
| `400` | `invalid_input_file` | `input_file_id` is not a file uploaded with `purpose=batch` |
| `400` | `unsupported_endpoint` / `invalid_completion_window` / `invalid_metadata` | Bad create parameters |
| `400` | `zdr_unavailable` | [Zero data retention](/docs/gateway-controls) is on for the account or key, or a line sends `provider.zdr: true`. A batch stores its input and output files, so it cannot run under ZDR |
| `400` | `region_unavailable` | [India-only processing](/docs/gateway-controls) is on for the account or key, or a line sends `provider.data_residency: "in"`, and the line's model is not processed in India |
| `402` | `insufficient_credits` | No credit balance at create time |
| `403` | `permission_denied` | The key lacks the LLM permission |
| `403` | `api_key_required` | Batch create called without an API key |
| `403` | `model_not_available` / `model_not_allowed` | A model in the file is not available to this key |
| `404` | `file_not_found` / `batch_not_found` | Unknown id, or it belongs to another account |
| `409` | `batch_not_cancellable` | The batch already finished |
| `413` | `file_too_large` | Upload over 20 MB |
| `429` | `too_many_active_batches` | Five batches already running |
| `503` | `storage_unavailable` | File storage is temporarily unavailable — retry |
---
### 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`). Defaults to `sarvam-105b` when omitted |
| `messages` | array | **Required.** 1–2,000 `{role, content}` objects. System prompt goes here as `{"role": "system", "content": "..."}`. `content` may be a string or an array of text, image, audio or document parts (see [Vision](#vision-image-input) and [Audio and documents](#audio-and-document-input)) |
| `stream` | boolean | Enable streaming SSE responses (default `false`) |
| `stream_options` | object | `{"include_usage": true}` to get token counts in the stream |
| `temperature` | number | Sampling temperature, `0`–`2` |
| `max_tokens` | integer | Maximum tokens to generate, `1`–`1,048,576`. A model with a documented output limit (the `max_output_tokens` field in `GET /api/v1/models`) rejects a larger value with `400 max_tokens_too_large` |
| `max_completion_tokens` | integer | Same as `max_tokens` (the newer OpenAI name); used when `max_tokens` is absent |
| `n` | integer | Number of completions, `1`–`5` (default `1`) |
| `top_p` | number | Nucleus sampling, `0`–`1` |
| `top_k` | integer | Top-K sampling, `1`–`200` |
| `frequency_penalty` | number | Penalize repeated tokens, `-2`–`2` |
| `presence_penalty` | number | Penalize new topics, `-2`–`2` |
| `repetition_penalty` | number | Reduce repetition, `0`–`2` |
| `seed` | integer | Deterministic sampling, `0`–`2^63-1` |
| `stop` | string or array | Up to 16 stop sequences |
| `logit_bias` | object | Token id → bias between `-100` and `100`; at most 1,024 entries |
| `logprobs` | boolean | Return log probabilities |
| `top_logprobs` | integer | Top N log probs per token, `0`–`20` |
| `tools` | array | Up to 10,000 function definitions (128 on the GPT-4o, GPT-4.1, GPT-5 and GPT-6 models other than `gpt-6.1-sol`) — see [Function Calling](/docs/chat-function-calling) |
| `tool_choice` | string or object | `"auto"`, `"none"`, `"required"`, or `{"type": "function", "function": {"name": "..."}}` |
| `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 |
| `reasoning_effort` | string | `"none"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` — each model accepts a different subset and the gateway maps the rest; see the [per-model matrix](/docs/api-speed#3-reasoning-effort-by-model) |
| `models` | array | Up to 32 fallback model ids, tried in order if `model` fails with a retryable error — see [Model substitution](#model-substitution) |
| `user` | string | Your end user's id. Enforces that user's [monthly budget](/docs/gateway-controls) when one is set (caps apply to ids of at most 256 characters) |
| `provider` | object | `{"zdr": true}` serves the request only on a [zero-data-retention route](/docs/gateway-controls), or fails with `zdr_unavailable`. `{"data_residency": "in"}` serves it only on models whose `processing` is `"india"`, or fails with `region_unavailable` ([details](/docs/gateway-controls)) |
| `prompt_cache_key` | string | Up to 1,024 characters. Reuse the same key for requests that share a long prompt prefix to raise the cache hit rate — see [Prompt caching](#prompt-caching) |
| `bot_id` | string | ID of one of your [bots](/docs/bots). Its knowledge base is searched with the latest user message and the top matches are added to the prompt — see [Knowledge](/docs/knowledge) |
| `knowledge_top_k` | integer | With `bot_id`: how many knowledge chunks to add, `1`–`50` (default `6`) |
| `knowledge_min_score` | number | With `bot_id`: minimum similarity score, `0`–`1` (default `0`) |
On GPT-5 and GPT-6 family models, `temperature`, `top_p` and `logit_bias` are not supported by the model and are left out of the request; the model runs at its default sampling.
> **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 on its own. Send a `model` and you get
that model, or a clean error (`429`/`503` with `Retry-After`).
To opt in to failover, list fallbacks yourself in `models`:
```json
{
"model": "kimi-k2.6",
"models": ["kimi-k2.5", "gpt-oss-120b"],
"messages": [{"role": "user", "content": "Hello"}]
}
```
If `model` fails with a retryable error (an upstream outage or rate limit), the
next id in `models` is tried. Each fallback must pass the same checks as
`model` — your plan, the key's `allowed_models`, maintenance status, vision
support and context window — and ids that fail them are skipped. The response's
`model` field names the model that actually answered, and you are billed at
that model's rate. Requests that send `tools`, `tool_choice`,
`response_format` or `structured_outputs` never fall back, because a different
model could change the result shape.
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-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: `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `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`, `gemini-3.8-flash`,
`gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`,
`gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemma-4-31b`, `glm-5.3-flash`, `deepseek-v4.1-flash-1017`,
`kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3.1-pro`.
`GET /v1/models` is authoritative. Read `supports_vision` there rather than
hard-coding this list.
An `image_url` may be an `https://` URL or a base64 data URL. The Gemini models,
`gemma-4-31b`, `glm-5.3-flash` and `deepseek-v4.1-flash-1017` only take image bytes, so CallMissed downloads a URL image
for them: it must be publicly reachable without redirects, return an image
content type, and be at most 20 MB (10 MB for `gemma-4-31b`, `glm-5.3-flash` and `deepseek-v4.1-flash-1017`). Gemini accepts
PNG, JPEG, WEBP, HEIC and HEIF. If a download fails the request returns `400`;
send base64 to avoid the extra fetch.
## Audio and Document Input
The Gemini models (`gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`,
`gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`,
`gemini-3.1-flash-lite`) also take audio and PDF parts:
```json
{"type": "input_audio", "input_audio": {"data": "", "format": "wav"}}
{"type": "file", "file": {"file_data": "data:application/pdf;base64,", "filename": "report.pdf"}}
```
Audio `format` may be `wav`, `mp3`, `aac`, `flac`, `ogg`, `opus`, `m4a`, `aiff`
or `webm`. A `file` part must carry the PDF inline in `file_data`; `file_id`
references are not supported. Any other model rejects these parts with
`400 unsupported_audio_input` or `400 unsupported_file_input` before the
upstream call, so you're not charged. Content that can't be passed on as sent
is rejected with a `400` that names the part, rather than being dropped.
## 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.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-6-sol`, `gpt-6-luna`, `gpt-6.1-sol` | 1,050,000 |
| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` | 1,000,000 |
| `glm-5.3`, `glm-5.3-flash`, `deepseek-v4.1-flash`, `deepseek-v4.1-flash-1017`, `kimi-k3.1-pro` | 1,048,576 |
| `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | 1,000,000 |
| `gpt-4.1` | 300,000 |
| `gpt-5-mini` | 400,000 |
| `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code` | 262,144 |
| `grok-4.3` | 200,000 |
| `sarvam-105b`, `gpt-oss-120b`, `gemma-4-31b` | 131,072 |
| `gpt-4o` | 128,000 |
| `deepgram-think-fast`, `deepgram-phonellm-mini` | 32,768 |
| `sarvam-105b-conversations` | 32,000 |
Images, audio and files count toward the window by what the model reads, not
by their encoded size, so a large base64 image does not by itself cause
`context_length_exceeded`. A prompt that is clearly longer than the window is
rejected with `400 context_length_exceeded` before the model is called (on
`/v1/chat/completions`, `/v1/responses` and `/v1/messages`); a prompt close to
the limit is sent to the model, and if the model finds it too long you get the
same `400 context_length_exceeded` (as an error event mid-stream when
`stream: true`). Either way nothing is billed.
## Prompt caching
Models that support prompt caching reuse repeated prompt prefixes
automatically — there is nothing to turn on. Cached prompt tokens are billed at
the model's cached-input rate where one is published (see [Models](/docs/models));
a model with no cached rate bills them at its normal input rate. Tokens written
to the cache are billed at the input rate, except on models that publish a
separate cache-write rate (such as `gpt-6.1-sol`, `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`).
Every response reports the cached share of the prompt:
```json
"usage": {
"prompt_tokens": 3120,
"completion_tokens": 42,
"total_tokens": 3162,
"prompt_tokens_details": { "cached_tokens": 2944 }
}
```
- `prompt_tokens` is the whole prompt, cached part included.
- `prompt_tokens_details.cached_tokens` is always present (`0` on a miss or the first request).
- `prompt_tokens_details.cache_write_tokens` appears when the model reported tokens written to the cache (GPT-5.6 and later bill these at their own rate).
- Streaming: the same block is in the final usage chunk when you send `stream_options: {"include_usage": true}`.
- `/v1/responses` reports the same numbers as `usage.input_tokens_details.cached_tokens` and `usage.input_tokens_details.cache_write_tokens`.
To improve hit rates:
- Put stable content first (system prompt, tool definitions, reference documents) and the changing part last.
- Send a `prompt_cache_key` (up to 1,024 characters, on `/v1/chat/completions` and `/v1/responses`) and reuse it for requests that share a prefix. It is accepted on every model and not forwarded today; prompt-prefix caching is automatic, and cached tokens are reported in `usage.prompt_tokens_details.cached_tokens` where the model returns them.
- Explicit per-block cache breakpoints (`cache_control` / `prompt_cache_breakpoint` on a content part) are not applied today — caching works from the prompt prefix automatically.
```json
{
"model": "kimi-k2.6",
"prompt_cache_key": "support-bot:policy-v3",
"messages": [
{"role": "system", "content": ""},
{"role": "user", "content": "Where is my order?"}
]
}
```
A prefix usually needs to be at least ~1,024 tokens before it is cached, and an
idle cache expires after a few minutes. Hits are not guaranteed.
## 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-6-sol",
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-6-sol",
"input": "Write a haiku about databases."
}'
```
:::
| Field | Type | Notes |
|-------|------|-------|
| `model` | string | Any chat model id. Defaults to `kimi-k2.5` when omitted |
| `input` | string or array | A plain string, or the Responses item array (messages, `function_call` and `function_call_output` items). Message content parts: `input_text`, `input_image` (`image_url`), `input_file` (inline `file_data`). A `function_call_output` `output` may be a string or an array of those parts; its images are passed to the model with the tool result |
| `instructions` | string | System prompt |
| `max_output_tokens` | integer | `1`–`1,048,576` |
| `temperature` / `top_p` | number | `0`–`2` / `0`–`1` |
| `tools` | array | Flat function tools (`{"type": "function", "name", "description", "parameters"}`). Any other tool type returns `400 unsupported_tool_type` |
| `tool_choice`, `parallel_tool_calls` | | As in the Responses API. A `tool_choice` naming a hosted tool returns `400 unsupported_tool_choice` |
| `reasoning` | object | `{"effort": "..."}` — mapped like `reasoning_effort` on chat completions |
| `text` | object | `{"format": {"type": "json_object" \| "json_schema", ...}}` for structured output |
| `stream` | boolean | Emits Responses-style SSE events (`response.output_text.delta`, …) |
| `user`, `provider`, `prompt_cache_key` | | Same meaning as on `/v1/chat/completions` |
- The same models, pricing, plan rules, vision and tool-calling support as `/v1/chat/completions` apply — this is a request/response-shape adapter, not a different model set.
- Responses are not stored: `store` is accepted for compatibility and ignored, and `previous_response_id` returns `400 unsupported_parameter`. Send the full conversation in `input` each turn.
- An `input_image` given only as `file_id`, or an `input_file` given as `file_id` or `file_url`, returns `400 unsupported_content_part` naming the part. Send the bytes inline instead.
If you're starting fresh, `/v1/chat/completions` is the most widely-supported surface; use `/v1/responses` when porting an existing Responses-API integration.
## Errors
All errors return the OpenAI-compatible envelope:
```json
{
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}
```
| Status | `code` | When |
|--------|--------|------|
| `400` | `unsupported_image_input` | Image content sent to a model without vision support |
| `400` | `unsupported_audio_input` / `unsupported_file_input` | `input_audio` or `file` parts sent to a model that doesn't take them |
| `400` | `unsupported_content_part` | A content part that can't be passed on as sent (for example a `file_id` reference on `/v1/responses`) |
| `400` | `voice_agent_only_model` | A realtime or managed-voice model id — use `POST /v1/voice/sessions` |
| `400` | `zdr_unavailable` | Zero data retention is on and the model has no zero-retention route |
| `400` | `region_unavailable` | India-only processing is on and the model is not processed in India |
| `400` | `context_length_exceeded` | The prompt is longer than the model's context window |
| `400` | `too_many_tools` | The request sends more tools than the model accepts (128 on the GPT models listed in [Function Calling](/docs/chat-function-calling)) |
| `400` | `max_tokens_too_small` | `max_tokens` below 3 on `gpt-5.6-luna`, `gpt-6-sol`, `gpt-6-luna` or `gpt-6.1-sol`, or below 16 on `gpt-6.1-sol` when `tools` are sent |
| `400` | `max_tokens_too_large` | `max_tokens` above the model's `max_output_tokens` (for example 65,536 on the `gemini-*` models, 16,384 on `gpt-4o`, 32,768 on `gpt-4.1`, 128,000 on the GPT-5 and GPT-6 models) |
| `401` | `invalid_api_key` / `api_key_expired` | Missing, malformed or revoked key / expired key |
| `402` | `insufficient_credits` | Balance exhausted, or already held by your other in-flight requests: each request holds its estimated cost (input plus `max_tokens`, or up to 4,096 output tokens when unset) until it finishes, then is charged its real cost. `X-Credits-Balance` carries the balance |
| `402` | `budget_exceeded` | The key's own budget cap was reached |
| `402` | `end_user_budget_exceeded` | The `user` id has used its monthly budget |
| `403` | `permission_denied` | The key lacks the `llm` permission |
| `403` | `account_inactive` | The account is suspended or closed |
| `403` | `model_not_available` | A free-plan key calling a paid model, or a free or Starter key calling a Claude model (Pro plan and up) |
| `403` | `model_not_allowed` | The key's `allowed_models` list excludes the model |
| `404` | `model_not_found` | Unknown model id |
| `422` | — | Request body failed validation (a field out of range, a missing `messages`). Body is `{"detail": [{loc, msg, type}]}` |
| `429` | `quota_exceeded` | Monthly plan call cap reached |
| `429` | `rate_limit_exceeded` | Per-key requests-per-minute exceeded |
| `429` | `too_many_concurrent_requests` | Too many requests in flight on this key. Honour `Retry-After` |
| `503` | `model_under_maintenance` | The model is temporarily unavailable; the message names an alternative |
| `503` | `provider_error` | The model is temporarily unavailable upstream. Safe to retry |
Every response carries an `X-Request-ID` header — quote it when you contact support.
---
### 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.
Up to 10,000 tools per request. Every model with `supports_tools: true` in [`GET /v1/models`](/docs/models#models-api) accepts them, except `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-6-sol` and `gpt-6-luna`, which accept at most 128 and return `400 too_many_tools` above that. On the GPT-5.6 and GPT-6 models, a request that sends `tools` runs with reasoning switched off (`reasoning_effort` is sent as `"none"`), because those models cannot combine function tools with reasoning on this endpoint. A request with `tools` never uses the `models` fallback list.
### Claude models
`claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5` (Pro plan and up) accept `tools` with reasoning on. Two things differ from the other models:
- `tool_choice: "required"` or a named function is honoured on `claude-haiku-5-5`. On `claude-opus-5-5` and `claude-sonnet-5-5` it runs as `"auto"`, because those models do not support forced tool use.
- When a Claude response contains tool calls on `/v1/chat/completions`, the first tool call carries an extra `anthropic_thinking` field holding the model's full turn, including its signed reasoning. Send the assistant message back **unchanged**, including that field, in your next request so the model keeps its reasoning across tool calls. On [`/v1/messages`](/docs/anthropic-api) the same reasoning comes back as standard `thinking` / `redacted_thinking` content blocks; pass them back unchanged.
## 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
import json
from openai import OpenAI
client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1")
# 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]`
- **Reasoning models** stream their thinking as `delta.reasoning_content`, separate from `delta.content`. Ignore it if you only want the answer.
The response carries `X-Request-ID` and `Cache-Control: no-cache` headers.
## Errors mid-stream
Errors found before the first byte (bad key, no credits, unknown model) return a normal JSON error with an HTTP status — see [Chat Completion → Errors](/docs/chat-completion#errors). If the model fails after streaming has started, the stream sends one error event and then closes **without** `data: [DONE]`:
```
data: {"error":{"message":"...","type":"server_error","code":"upstream_error","request_id":"chatcmpl-..."}}
```
`code` is `upstream_timeout`, `upstream_unavailable` or `provider_error`.
Treat a stream that ends without `[DONE]` as failed, even if some content arrived.
## 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,"prompt_tokens_details":{"cached_tokens":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.02083 |
| `text-embedding-3-large` | 3072 | 8,192 tokens | $0.1354 |
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. Your end user's id; enforces that user's [monthly budget](/docs/gateway-controls) when one is set |
| `provider` | `object` | No | — | `{"zdr": true}` requires a [zero-data-retention route](/docs/gateway-controls); otherwise `400 zdr_unavailable`. `{"data_residency": "in"}` requires a model processed in India; otherwise `400 region_unavailable` ([details](/docs/gateway-controls)) |
### 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 in credits`, where 1 credit = ₹1 ≈ US$0.0104 (US$1 = ₹96).
- `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` |
| `503` | `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, Scale, Business, Corporate) 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, Embedding). 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", "gpt-oss-120b", "saaras:v4", "bulbul:v3", "bulbul:v4-flash", "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", "gpt-oss-120b"],
"stt": ["saaras:v4"],
"tts": ["bulbul:v3", "bulbul:v4-flash"],
"image": [],
"embedding": ["text-embedding-3-small", "text-embedding-3-large"]
},
"restriction": "Only the models listed here, plus the retired LLM and speech IDs that still answer on the free plan (see /docs/models#retired-and-deprecated-models), are callable on the free plan. Other models require a paid plan; Claude models require Pro or higher."
},
"starter": {
"models": ["...every model in the catalog"],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable except Claude models, which need Pro or higher. Per-service monthly call limits apply…"
},
"pro": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable. No monthly call caps; per-minute rate limits apply."
},
"enterprise": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable. No monthly call caps; per-minute rate limits apply."
},
"scale": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable. No monthly call caps; per-minute rate limits apply."
},
"business": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable. No monthly call caps; per-minute rate limits apply."
},
"corporate": {
"models": ["..."],
"by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] },
"restriction": "All models callable. No monthly call caps; per-minute rate limits apply."
}
}
}
```
## 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-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-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 **11 models**:
| Category | Models |
|----------|--------|
| LLM (6) | `sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `gpt-oss-120b` |
| STT (1) | `saaras:v4` |
| TTS (2) | `bulbul:v3`, `bulbul:v4-flash` |
| Image (0) | None. Image generation needs a paid plan. |
| Embedding (2) | `text-embedding-3-small`, `text-embedding-3-large` |
The retired LLM, speech-to-text and text-to-speech IDs (`glm-4.7-flash`, `glm-5.2`, `nemotron-3-super`, `gemma-4-26b-a4b-it`, `mistral-small-3.1`, `nova-3`, `whisper-large-v3-turbo`, `aura-2-en`, `aura-2-es`, `melotts`) still answer on the free plan. They are not listed. See [Retired and deprecated models](/docs/models#retired-and-deprecated-models).
`restriction` is a human-readable note — branch on `models` / `by_category`, not on this text.
Every other model — `kimi-k3.1-pro`, `glm-5.3`, `glm-5.3-flash`, `deepseek-v4.1-flash`, `deepseek-v4.1-flash-1017`, `gemma-4-31b`, `deepgram-think-fast`, `deepgram-phonellm-mini`, the `gemini-*` chat models, the first-party OpenAI / xAI / DeepSeek IDs, the realtime voice models, the Deepgram direct line, the other paid speech models, and the paid image models — requires a paid plan (Starter or higher), except Claude models, which need Pro or higher.
## 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/plans",
"type": "invalid_request_error",
"code": "model_not_available"
}
}
```
HTTP status: `403`. The request is refused before any model runs, so it is not billed.
A key whose `allowed_models` list excludes the model gets `403` with `code: "model_not_allowed"` instead, on any plan.
---
### 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:** 32K 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 and same price as `sarvam-105b`, with a shorter 32K window — 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 |
|-------|-------------|
| `"none"` / `"minimal"` | Thinking off — fastest |
| `"low"` | Light reasoning |
| `"high"` | Deep reasoning — better quality, slower |
| `"max"` | Maximum reasoning (`"xhigh"` maps here) |
`"medium"` is not one of the models' levels, so it is dropped and the model
runs at its default. Thinking tokens count toward `max_tokens`; when you omit
it we send 4,096 so the answer is not cut off by the upstream default of
2,048. See the [reasoning_effort matrix](/docs/api-speed#3-reasoning-effort-by-model).
## Speech to Text
### saaras:v4
- **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.
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.
### bulbul:v4-flash
- **Voices:** 222 persona voices
- **Languages:** 9 (`hi-IN`, `en-IN`, `bn-IN`, `gu-IN`, `kn-IN`, `mr-IN`, `pa-IN`, `ta-IN`, `te-IN`) plus Hinglish; no Malayalam or Odia
- **Max input:** 2,500 characters per request
- **Audio codecs:** WAV, MP3, FLAC, AAC, PCM
- **Pace:** 0.5–2.0 (maps to `speed` parameter)
- **Sample rates:** 8000, 16000, 22050, 24000 Hz
- **Endpoint:** `POST /v1/audio/speech`
Default voice: `shubh_enhi_ads`. Voice IDs follow `__