# CallMissed Docs (full corpus) > CallMissed is an AI communication platform with one API for LLM chat, speech-to-text, > text-to-speech, voice agents, WhatsApp, email, and phone numbers, built for English and > 22 Indic languages. The APIs are OpenAI-compatible: keep your existing SDK, point it at > `https://api.callmissed.com/v1`, and authenticate with a `cm_` API key. - Base URL: `https://api.callmissed.com` (OpenAI-compatible surface at `/v1`) - Auth header: `Authorization: Bearer cm_your_api_key` - Every page below is also available as one markdown file: https://docs.callmissed.com/llms-full.txt - One page as markdown: append `.md` to its URL, e.g. https://docs.callmissed.com/docs/introduction.md This file contains all 85 documentation pages as markdown, in sidebar order. The short index lives at https://docs.callmissed.com/llms.txt. --- # Getting Started ## Welcome to CallMissed API Source: https://docs.callmissed.com/docs/introduction CallMissed provides AI-powered communication APIs to deploy WhatsApp chatbots and voice call agents for your business. - [Quickstart](https://docs.callmissed.com/docs/quickstart): Make your first API call in under a minute - [Models](https://docs.callmissed.com/docs/models): 123 models — Indic STT/TTS, direct-routed LLMs, realtime voice, image gen, embeddings - [Voice Agent](https://docs.callmissed.com/docs/voice-agent): LiveKit 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 **123 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 ## Developer Quickstart Source: https://docs.callmissed.com/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_`). ```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"}]}' ``` - [SDKs & Libraries](https://docs.callmissed.com/docs/sdks): Install the OpenAI SDK in your language - [Model Catalog](https://docs.callmissed.com/docs/models): 123 models — LLM, STT, TTS, realtime voice, image, embeddings ### Get started ### Create an API Key Visit the [CallMissed Dashboard](https://app.callmissed.com) and create a new API key. Keep this key secure — you'll need it to authenticate your requests. ### Set up your environment Export your API key as an environment variable: ```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: ```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](https://docs.callmissed.com/docs/model-access). > **Tip:** Store your API key in environment variables. Never commit keys to version control. **Ready to explore?** See [Chat Completion](https://docs.callmissed.com/docs/chat-completion) for streaming and tool calling, or browse the [Model Catalog](https://docs.callmissed.com/docs/models). ### CallMissed APIs #### Chat Completion ```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 ```python [Python] with open("audio.wav", "rb") as f: response = client.audio.transcriptions.create( model="saaras:v3", file=f ) print(response.text) ``` ```javascript [JavaScript] import fs from "fs"; const response = await client.audio.transcriptions.create({ model: "saaras:v3", file: fs.createReadStream("audio.wav"), }); console.log(response.text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/transcriptions \ -H "Authorization: Bearer cm_your_key" \ -F file=@audio.wav \ -F model=saaras:v3 ``` #### Text to Speech ```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 - [Models](https://docs.callmissed.com/docs/models): LLM, STT, TTS, and image models across every major provider - [Chat Completion](https://docs.callmissed.com/docs/chat-completion): Streaming, tool calling, vision, and the Responses API - [Voice Agent](https://docs.callmissed.com/docs/voice-agent): LiveKit WebRTC voice agents with Indic STT/TTS - [Cookbooks](https://docs.callmissed.com/docs/call-analytics): Step-by-step tutorials for production workflows ## Libraries & SDKs Source: https://docs.callmissed.com/docs/sdks Use the OpenAI SDK to integrate CallMissed APIs — our endpoints are fully OpenAI-compatible. ### Official Libraries CallMissed supports **two SDK families** — use whichever you prefer. Just change the base URL and use your `cm_` API key. #### OpenAI SDK (recommended for most use cases) | Language | Package | Manager | |----------|---------|---------| | Python | `openai` | PyPI | | JavaScript / TypeScript | `openai` | npm | | Go | `openai-go` | go modules | | PHP | `openai-php/client` | Composer | | Ruby | `ruby-openai` | RubyGems | | Java / Kotlin | HTTP client | Maven / Gradle | #### Anthropic SDK (for /v1/messages endpoint) | Language | Package | Manager | |----------|---------|---------| | Python | `anthropic` | PyPI | | JavaScript / TypeScript | `@anthropic-ai/sdk` | npm | > **Tip:** The Anthropic SDK talks to `/v1/messages`. See the [Anthropic API docs](https://docs.callmissed.com/docs/anthropic-api) for full details. ### Installation ```bash [Python] pip install openai ``` ```bash [JavaScript / TypeScript] npm install openai ``` ```bash [Go] go get github.com/openai/openai-go ``` ```bash [PHP] composer require openai-php/client ``` ```bash [Ruby] gem install ruby-openai ``` ```bash [Python (Anthropic)] pip install anthropic ``` ```bash [JS/TS (Anthropic)] npm install @anthropic-ai/sdk ``` Upgrade to the latest version: ```bash [Python] pip install --upgrade openai ``` ```bash [JavaScript / TypeScript] npm install openai@latest ``` ```bash [Go] go get -u github.com/openai/openai-go ``` ```bash [PHP] composer update openai-php/client ``` ```bash [Ruby] gem update ruby-openai ``` ### Configuration ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1" ) response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Hello"}] ) print(response.choices[0].message.content) ``` ```typescript [TypeScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }], }); console.log(response.choices[0].message.content); ``` ```javascript [JavaScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }], }); console.log(response.choices[0].message.content); ``` ```go [Go] package main import ( "context" "fmt" "github.com/openai/openai-go" "github.com/openai/openai-go/option" ) func main() { client := openai.NewClient( option.WithAPIKey("cm_your_api_key"), option.WithBaseURL("https://api.callmissed.com/v1"), ) resp, _ := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{ Model: openai.F("sarvam-105b"), Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ openai.UserMessage("Hello"), }), }, ) fmt.Println(resp.Choices[0].Message.Content) } ``` ```php [PHP] withApiKey('cm_your_api_key') ->withBaseUri('https://api.callmissed.com/v1') ->make(); $response = $client->chat()->create([ 'model' => 'sarvam-105b', 'messages' => [['role' => 'user', 'content' => 'Hello']], ]); echo $response->choices[0]->message->content; ``` ```ruby [Ruby] require "openai" client = OpenAI::Client.new( access_token: "cm_your_api_key", uri_base: "https://api.callmissed.com/v1" ) response = client.chat( parameters: { model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }] } ) puts response.dig("choices", 0, "message", "content") ``` ```bash [cURL] curl https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"model": "sarvam-105b", "messages": [{"role": "user", "content": "Hello"}]}' ``` ### Resources | Resource | Link | |----------|------| | API Reference | [docs.callmissed.com](https://docs.callmissed.com) | | Dashboard | [app.callmissed.com](https://app.callmissed.com) | | LinkedIn | [linkedin.com/company/callmissed](https://www.linkedin.com/company/callmissed) | ## Docs MCP Server Source: https://docs.callmissed.com/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. - [Quickstart](https://docs.callmissed.com/docs/quickstart): Make your first API call in under a minute - [SDKs & Libraries](https://docs.callmissed.com/docs/sdks): 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. ```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. ## Account MCP Server Source: https://docs.callmissed.com/docs/agent-tools-mcp Give an AI agent tools that act on your CallMissed account — send WhatsApp messages, review campaigns, conversations and contacts, and place calls — over the Model Context Protocol with your API key. - [Docs MCP Server](https://docs.callmissed.com/docs/mcp-server): Searchable CallMissed docs inside your coding agent - [Quickstart](https://docs.callmissed.com/docs/quickstart): 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, authenticate with an API key, and the agent can send a WhatsApp message, look through conversations and contacts, or place a call with one of your voice agents. It is hosted, so there is nothing to install and nothing to run locally. This is a **different server** from the [Docs MCP Server](https://docs.callmissed.com/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 an API key, and acts on your real account 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 — this server does not offer a server-initiated event stream. ### Authentication Authenticate with an API key from your dashboard, exactly as you would for any other CallMissed endpoint: ``` Authorization: Bearer cm_your_api_key ``` **API keys only.** 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 only apply to API keys — so this endpoint accepts 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`. ### Scopes Give each key only the scopes the agent 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. | Tool | Scope required | | --- | --- | | `send_whatsapp_message` | `whatsapp:send` | | `list_whatsapp_campaigns` | `whatsapp:read` | | `list_conversations` | `conversations:read` | | `list_contacts` | `contacts:read` | | `place_call` | `telephony:write` | `send_whatsapp_message` and `place_call` spend credits and reach real people. Consider a separate key with only the read scopes for agents that should look but not act. ### Tools #### `send_whatsapp_message` Send a free-form WhatsApp text from one of your connected business numbers. | Argument | Type | Notes | | --- | --- | --- | | `to` | string | **Required.** Recipient in E.164 form, e.g. `+919000000000`. | | `text` | string | **Required.** Message body, up to 4096 characters. | | `phone_id` | string | Which connected number to send from. Optional when you have one. | | `phone_number_id` | string | Alternative selector for the sending number. | Free-form text only reaches someone inside the 24-hour customer service window. Outside it, send an approved template from the [messages API](https://docs.callmissed.com/docs/whatsapp-messages). #### `list_whatsapp_campaigns` List your broadcast campaigns, newest first, with status and recipient counts. | Argument | Type | Notes | | --- | --- | --- | | `limit` | integer | 1–100, default 50. | #### `list_conversations` List conversations across channels, newest first, each with a preview of the latest message and an unread count. | Argument | Type | Notes | | --- | --- | --- | | `channel` | string | Restrict to one channel, e.g. `whatsapp`, `voice`, `web`. | | `status` | string | Restrict to one status, e.g. `active`, `escalated`, `closed`. | | `bot_id` | string | Restrict to conversations handled by one agent. | | `limit` | integer | 1–500, default 50. | | `offset` | integer | For paging. | #### `list_contacts` List contacts in your address book, newest first, with their per-channel opt-in state. | Argument | Type | Notes | | --- | --- | --- | | `q` | string | Substring match on phone, email, or name. | | `limit` | integer | 1–200, default 50. | | `offset` | integer | For paging. | #### `place_call` Place an outbound call from one of your numbers, answered by one of your voice agents. | Argument | Type | Notes | | --- | --- | --- | | `from_number_id` | string | **Required.** Which of your numbers to call from. | | `to_e164` | string | **Required.** Number to call, in E.164 form. | | `bot_id` | string | Which voice agent handles the call. | | `reason` | string | Plain-language purpose; the agent uses it in its opening line. | | `variables` | object | Per-call `{{token}}` values merged into the greeting and prompt. | `place_call` only appears in `tools/list` on accounts where calling is switched on. If you do not see it, calling is not enabled for your account yet — talk to us and we will turn it on. ### Connect a client 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** — an unknown tool, a missing argument, 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** — a missing scope, no credits, a closed messaging window — is a successful result carrying `isError`, so the agent can read the reason and adapt instead of treating it as a crash: ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "API key missing required scope: whatsapp:send." }], "isError": true } } ``` Authentication failures happen before any tool runs and use normal HTTP status codes: `401` for a missing, malformed, or non-API-key credential, `402` when the key is out of budget, `429` when it is over its rate limit. ### Protocol version The server implements MCP revision `2025-06-18` and also accepts `2025-03-26`. Send the version you speak on every call after initializing: ``` MCP-Protocol-Version: 2025-06-18 ``` Omit the header and the server assumes `2025-03-26`, per the specification. Send a version it does not support and the call returns `400`. Batched requests are not accepted — the `2025-06-18` revision removed them, so send one request object per POST. ## How CallMissed Works Source: https://docs.callmissed.com/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. - **Your app**: One SDK, one base URL, one `cm_` key for every capability - **CallMissed gateway**: Authenticate, enforce tenant isolation + rate limits, route by model id - **Best provider**: the best-fit backend for each model — chosen automatically - **Credits & logs**: Deduct from one credit balance and record usage for every request ### Model Routing The model id picks the backend. You never manage multiple SDKs or keys. | Model id | Routed to | | --- | --- | | `sarvam-*` (e.g. `sarvam-105b`) | Indic LLM | | `saaras:*`, `whisper*`, `nova-3`, `deepgram-*`, `gnani-prisma-*` | Speech to text | | `bulbul:*`, `aura-2-*`, `melotts`, `gnani-timbre-*` | Text to speech | | `flux-*`, `gpt-image-*`, `lucid-origin`, `phoenix-1.0`, … | Image generation | | everything else (e.g. `kimi-k2.5`, `gpt-5.6-terra`, `glm-5.2`) | Chat completions | Every id is a plain CallMissed id. One API surface, one key, one bill. Need a model that isn't in the catalog? We deploy 300+ more on demand — see [Models on demand](https://docs.callmissed.com/docs/models#models-on-demand). ### One Credit Currency Every call — LLM tokens, STT minutes, TTS characters, an image, a web search — deducts **credits**. **1 credit = ₹1.** This removes per-provider pricing math: top up once, spend across every capability. See [Credits & Rate Limits](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/whatsapp) or [voice calls](https://docs.callmissed.com/docs/voice), handling the inbound webhook, the AI turn, and the reply. ## Idempotency Source: https://docs.callmissed.com/docs/idempotency Make mutating requests safely retryable with an Idempotency-Key so network retries never duplicate an action. ### Why Idempotency If a request times out or the connection drops, you often can't tell whether the server processed it. Retrying blindly risks doing the action twice — charging a card twice, creating two bots. An **idempotency key** lets you retry safely: the server processes the first request and returns the same stored result for any replay with the same key. ### Using the Header Send an `Idempotency-Key` header with a unique value (a UUID works well) on any mutating request (`POST`, `PUT`, `PATCH`): ```bash curl -X POST https://api.callmissed.com/api/v1/bots \ -H "Authorization: Bearer cm_your_key" \ -H "Idempotency-Key: 3f9c1d2e-0b9a-4c7d-8e1f-2a3b4c5d6e7f" \ -H "Content-Type: application/json" \ -d '{"name":"Support Bot","type":"whatsapp"}' ``` Reuse the **same** key when retrying the same logical request. Use a **new** key for a genuinely new action. ### Behavior - A replay with the same key **and** the same body returns the original response — the action runs only once. - A replay with the same key but a **different** body returns `409 Conflict`. - Keys are scoped to your tenant and retained for a limited window, then expire. - Idempotency is most important for resource-creation endpoints, where a blind retry would otherwise create a duplicate. ## Rate Limits & Quotas Source: https://docs.callmissed.com/docs/rate-limits How CallMissed limits request rate — per-key RPM, budget caps, response headers, and how to handle 429s. ### Limit Layers Requests pass through several limits, in order: | Layer | Limit | Scope | | --- | --- | --- | | Per-key RPM | plan defaults: Free 60 · Starter 500 · Pro 3,000 · Enterprise 10,000 (override per key) | per API key | | Monthly budget | configurable credit cap | per tenant / per key | | Plan limits | tier-based caps on LLM/STT/TTS calls, conversations, storage, team size | per tenant | Abuse protection also runs in front of the API and may throttle traffic that looks automated or hostile, independently of your plan's per-key RPM. Set a per-key RPM and a [budget cap](https://docs.callmissed.com/docs/keys) when issuing keys, then track live consumption for each key from the dashboard. ### Response Headers Rate-limited responses include standard headers so you can pace requests: | Header | Meaning | | --- | --- | | `Retry-After` | Seconds to wait before retrying (on 429) | | `X-RateLimit-Limit` | The ceiling for the current window | | `X-RateLimit-Remaining` | Requests left in the window | ### Handling 429 When you receive `429 Too Many Requests`: 1. Read `Retry-After` and wait at least that long. 2. Use exponential backoff with jitter for repeated 429s. 3. Spread bursty workloads across time, or request a higher per-key RPM. See [Error Codes](https://docs.callmissed.com/docs/errors) for the full status/code reference. ## Credits & Rate Limits Source: https://docs.callmissed.com/docs/credits-rate-limits How CallMissed credits are priced and spent, the per-plan call caps and request rates, and how to handle 402 and 429. ### Credits One currency for every service. **1 credit = ₹1 = $0.01.** Every call deducts credits: LLM tokens, STT audio, TTS characters, an image, a web search. Credits do not expire. | Grant | Amount | |-------|--------| | Signup bonus (once per account) | 1,000 credits | | Free plan, monthly | 100 credits | | Starter, monthly | 550 credits | | Pro, monthly | 6,000 credits | | Enterprise, monthly | 26,000 credits | Usage is always metered. There is no unlimited tier — Enterprise removes the monthly call caps, not the per-call credit cost. ### How each service is metered | Service | Unit | Worked example | |---------|------|----------------| | LLM | per 1M input + 1M output tokens | `kimi-k2.5` at $0.81 in / $4.05 out: 500 in + 200 output tokens = $0.001215 = **0.1215 credits** | | Speech to text | per audio hour | `saaras:v3` at $0.30/hr: a 4-minute call = **2 credits** | | Text to speech | per 10,000 characters | `bulbul:v3` at $0.30/10K: a 400-character reply = **1.2 credits** | | Image generation | per image | `flux-2-klein-9b` at $0.10: one image = **10 credits** | | Web search | flat | **1 credit** per search, whichever provider serves it | Per-model rates are in the [model catalog](https://docs.callmissed.com/docs/models#pricing) and live at `GET /api/v1/models`. ### Plan call caps Monthly caps counted per service, reset on the 1st. `-1` means uncapped. | Plan | LLM | STT | TTS | Image | Conversations | Storage | Team | |------|-----|-----|-----|-------|---------------|---------|------| | Free | 100 | 50 | 50 | 50 | 50 | 100 MB | 2 | | Starter | 5,000 | 2,500 | 2,500 | 500 | 1,000 | 1 GB | 5 | | Pro | 50,000 | 25,000 | 25,000 | 5,000 | 10,000 | 10 GB | 20 | | Enterprise | uncapped | uncapped | uncapped | uncapped | uncapped | uncapped | uncapped | Caps are separate from credits. Exceeding a cap returns `429` even with credits in the balance; running out of credits returns `402` even under the cap. ### Request rate Per API key, requests per minute: | Plan | Default RPM | |------|-------------| | Free | 60 | | Starter | 500 | | Pro | 3,000 | | Enterprise | 10,000 | Override a single key with `rate_limit_rpm` in the dashboard. An explicit override wins over the plan default. ### Response headers Every response to a metered endpoint carries the current cap state. | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | Monthly call cap for that service | | `X-RateLimit-Remaining` | Calls left this month | | `X-RateLimit-Reset` | ISO-8601 timestamp when the cap resets (the 1st) | | `X-Usage-Warning` | Present at 80% (`warning:`) and 95% (`critical:`) of the cap | | `X-Credits-Balance` | Credits remaining (sent on 402 responses and on search) | ### 402 — out of credits ```json { "error": { "message": "Insufficient credits (balance: 0.0). Purchase more at https://app.callmissed.com/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 app.callmissed.com/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](https://docs.callmissed.com/docs/errors) for the full status and code tables. ## API Speed: Best Practices Source: https://docs.callmissed.com/docs/api-speed How to make CallMissed API calls feel as fast as the playground — streaming, model choice, connection reuse, and Kimi instant mode. > **About the numbers on this page.** [Inference] Latency and throughput figures here (token rates, time-to-first-byte, per-call timings) are representative measurements taken under specific conditions — small prompts, warm connections, a given region and provider. They illustrate *relative* differences between settings; they are not SLAs or guarantees and will vary with prompt size, model, upstream load, and your network. AI behavior is not guaranteed and may vary. ### Why the playground feels faster A single playground call and a typical API call hit the **exact same endpoint** at `https://api.callmissed.com/v1/chat/completions`. When the API feels slower, three compounding factors are usually at work: | Setting | Playground default | Common API default | Effect | | --- | --- | --- | --- | | Streaming | `stream: true` | `stream: false` | Non-streaming waits for the *whole* generation before any byte returns | | Model | `gpt-oss-120b` (fast free model) | `gpt-5.6-sol` | Bigger model → 2-3× wall-clock for the same prompt | | Connection | One persistent HTTP/2 connection | New TCP+TLS per call | Adds ~150-300ms handshake to every request | Same endpoint, very different perceived speed. Below: how to close the gap. ### 1. Use stream: true With `stream: false` your client waits for the full generation. With `stream: true` the first byte typically arrives in well under a second — often around 100ms on fast models — and tokens flow as the model produces them instead of all at the end. ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1", ) # stream=True is the default in the Anthropic SDK; explicit here. stream = client.chat.completions.create( model="gpt-oss-120b", messages=[{"role": "user", "content": "Explain quicksort in one paragraph."}], stream=True, ) for chunk in stream: delta = chunk.choices[0].delta.content or "" print(delta, end="", flush=True) ``` ```ts [TypeScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Explain quicksort in one paragraph." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` ### 2. Pick the right model Same prompt, three different routes — measured first-token and total times: | Model | Route | TTFB | Total | | --- | --- | --- | --- | | `gpt-oss-120b` | Direct-routed | ~50ms | ~1.5s | | `kimi-k2.6` | Direct-routed | ~90ms | ~2.0s | | `gpt-5.6-sol` | First-party flagship | ~70ms | ~4.8s | | `gpt-5.6-luna` | First-party fast | ~80ms | ~2.0s | For latency-sensitive integrations (autocomplete, agent tool loops), prefer **`gpt-oss-120b`** or **`kimi-k2.6`** — both direct-routed, both OpenAI-compatible, both sub-2s on small prompts. ### 3. Reasoning effort by model Reasoning models can spend 100+ tokens "thinking" before producing visible content. On short answers, that's both a wall-clock and a credit cost the user never sees. Use `reasoning_effort` to dial it down — or off, where supported. ```json { "model": "kimi-k2.6", "messages": [{ "role": "user", "content": "What is 2+2?" }], "reasoning_effort": "none" } ``` Behaviour per model — verified live against each upstream on 2026-05-01: | Model | `"none"` | `"low"` | `"medium"` | `"high"` | `"xhigh"` | `"minimal"` | | --- | --- | --- | --- | --- | --- | --- | | `gpt-5.6-sol` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5.6-terra` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5.6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5.5` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `kimi-k2.5` | ✅ off | ✅ | ✅ | ✅ | — | ↓ `"none"` | | `kimi-k2.6` | ✅ off | ✅ | ✅ | ✅ | — | ↓ `"none"` | | `gpt-oss-120b` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` | | `nemotron-3-super` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` | | `glm-4.7-flash` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off | | `glm-5.2` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off | | `gemma-4-26b-a4b-it` | ✅ off | ⊘ | ⊘ | ⊘ | — | ✅ off | | `sarvam-105b`, `sarvam-105b-conversations` | ↓ `"low"` | ✅ | ✅ | ✅ | — | ↓ `"low"` | | `kimi-k2.7-code` | ⊘ | ⊘ | ⊘ | ⊘ | — | ⊘ | | `mistral-small-3.1` | ⊘ | ⊘ | ⊘ | ⊘ | — | ⊘ | Legend: ✅ = sent to upstream verbatim · ✅ off = thinking is switched off · ↓ = mapped to the listed value before forwarding · ⊘ = dropped from the request; the model runs at its default thinking behaviour · — = not accepted by that model. Three notes worth reading before you rely on a value: - `glm-4.7-flash`, `glm-5.2` and `gemma-4-26b-a4b-it` honour only the off switch. `"none"` and `"minimal"` turn thinking off. `"low"`, `"medium"` and `"high"` are dropped, so the model thinks at its own default — they are not an intensity dial. - `kimi-k2.7-code` and `mistral-small-3.1` expose no reasoning control. Every value is dropped. Both still return 200. - The GPT-5.5 / GPT-5.6 family accepts the full `none`/`low`/`medium`/`high`/`xhigh` ladder. `"minimal"` is rejected upstream, so we map it to `"low"`. Send `xhigh`, not `max` or `ultra`. Concrete numbers on `kimi-k2.6` answering "What is 2+2?": | Mode | Answer | Completion tokens | | --- | --- | --- | | Default (thinking on) | `2 + 2 = 4` | 101 | | `reasoning_effort: "none"` | `2 + 2 = 4` | **9** | Same answer, **11× fewer tokens** and a proportionally shorter wall-clock. Pass per-request based on whether you need the trace. ### 4. Reuse the HTTP connection CallMissed serves HTTP/2. A persistent connection multiplexes many requests with no extra TLS handshake per call. Most modern HTTP clients do this *if you reuse the same client instance*: ```python [Python] # Bad — new connection per call (~150-300ms TLS overhead each time) def ask(prompt): return OpenAI(api_key=K, base_url=URL).chat.completions.create(...) # Good — reuse one client (and its underlying connection pool) client = OpenAI(api_key=K, base_url=URL) def ask(prompt): return client.chat.completions.create(...) ``` ```ts [TypeScript] // Bad async function ask(prompt: string) { const c = new OpenAI({ apiKey: K, baseURL: URL }); return c.chat.completions.create(...); } // Good — module-scoped client const client = new OpenAI({ apiKey: K, baseURL: URL }); async function ask(prompt: string) { return client.chat.completions.create(...); } ``` Three back-to-back streaming calls on a reused HTTP/2 connection clock in around **400-460ms each** for a small prompt; the first call without reuse pays an extra TLS handshake on top. ### Checklist Before reporting "the API feels slow," verify: - [ ] `stream: true` is set on every chat completion request - [ ] Model is one of `gpt-oss-120b`, `kimi-k2.6`, `mistral-small-3.1`, or `gpt-5.6-luna` (avoid the 1M-context flagships for latency-bound paths) - [ ] For Kimi: `reasoning_effort: "none"` is passed when you don't need the reasoning trace - [ ] One `OpenAI()` / `Anthropic()` client instance is shared across requests - [ ] HTTP client supports HTTP/2 (the official OpenAI / Anthropic SDKs do) If all four are checked and you still see latency that doesn't match the playground, share the request and the upstream model — there are very few request shapes that beat playground default for the same model. --- # LLM & AI ## Models Source: https://docs.callmissed.com/docs/models Every model CallMissed serves — Indic STT/TTS/LLM, fast direct-routed LLMs, first-party flagships, realtime voice and image — through one OpenAI-compatible API, plus 300+ more we deploy on demand. - [Model Access by Plan](https://docs.callmissed.com/docs/model-access): Free, Starter, Pro, and Enterprise model tiers - [Indic Models](https://docs.callmissed.com/docs/models-indic): Indic STT, TTS, and LLM models - [Fast LLMs](https://docs.callmissed.com/docs/models-kimi-fast): High-throughput Kimi tier for voice-agent latency - [API Speed](https://docs.callmissed.com/docs/api-speed): Latency benchmarks and reasoning-effort matrix ### Overview 123 models, one OpenAI-compatible API. Same auth, same request shape — change the `model` field and nothing else. | Group | What it is | |-------|------------| | **Fast LLMs** | Kimi K2.5 at up to ~414 tok/s. The default for voice agents. | | **Indic models** | STT, TTS and LLM built for 22 Indian languages. | | **Direct-routed LLMs** | Sub-2s open-weights models: Kimi K2.5/K2.6/K2.7 Code, GPT-OSS, Gemma 4, GLM, Nemotron, Mistral Small. | | **First-party** | `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-*`, `grok-4.3`, `DeepSeek-V4-*`, realtime voice, plus first-party STT/TTS. | | **On demand** | [300+ more we deploy on request](#models-on-demand). | ### Models API List all available models programmatically. **No authentication required.** ```bash # List all models curl https://api.callmissed.com/api/v1/models # Filter by category: llm, stt, tts curl https://api.callmissed.com/api/v1/models?category=llm # Filter free-plan models only curl https://api.callmissed.com/api/v1/models?free=true # Get a specific model curl https://api.callmissed.com/api/v1/models/sarvam-105b # Which models each plan tier can call curl https://api.callmissed.com/api/v1/models/access ``` Response includes: `id`, `name`, `description`, `category`, `owned_by`, `context_window`, `context_length` (alias of `context_window` for OpenAI-style clients), `pricing`, `free`, `supports_streaming`, `supports_tools`, `supports_reasoning`, and `supports_vision`. The OpenAI-compatible listing at `GET /v1/models` (requires `Authorization: Bearer cm_*`) returns the same fields but a **shorter list**: it hides models that are only valid for voice sessions (`nova-sonic*`, `gpt-realtime*`, `deepgram-voice-*`). Use `GET /api/v1/models` for the full catalog. The Anthropic-shape listing at `GET /anthropic/v1/models` returns the same set inside Anthropic's `{data, has_more, first_id, last_id}` envelope. ### Free Plan Models The free tier includes **27 models** across five categories. Use `GET /api/v1/models?free=true` to list them, or see the [Model Access by Plan](https://docs.callmissed.com/docs/model-access) page for the full breakdown. #### LLM (11 models) | Model ID | Description | |----------|-------------| | `sarvam-105b` | 105B MoE — complex reasoning, Indic languages | | `sarvam-105b-conversations` | 105B MoE tuned for conversation and voice — 128K context, tool calling | | `kimi-k2.5` | Moonshot K2.5 — 256K context, reasoning | | `kimi-k2.6` | Moonshot K2.6 — improved reasoning + coding, 262K context | | `kimi-k2.7-code` | Moonshot K2.7 Code — frontier 1T-param agentic coding, 262K context, vision + tools | | `glm-4.7-flash` | GLM 4.7 Flash — fast inference | | `glm-5.2` | GLM 5.2 — Z.ai flagship agentic coding, 262K context, tools + reasoning | | `gpt-oss-120b` | GPT-OSS 120B — open-weights large model | | `nemotron-3-super` | Nvidia Nemotron 3 Super | | `gemma-4-26b-a4b-it` | Google Gemma 4 26B | | `mistral-small-3.1` | Mistral Small 3.1 — 24B instruct, tool use | #### STT (4 models) | Model ID | Description | |----------|-------------| | `saaras:v3` | 23 langs (22 Indic + English), best for code-mixed | | `saaras:v4` | 24 langs — five output modes: transcribe, translate, verbatim, transliterate, code-mix | | `whisper-large-v3-turbo` | Whisper — 99 langs with auto-detect, transcribe + translate | | `nova-3` | Nova 3 — 11 langs, diarization, smart-format, streaming-capable | #### TTS (4 models) | Model ID | Description | |----------|-------------| | `bulbul:v3` | 37 voices, 11 Indian languages | | `aura-2-en` | Aura 2 — 40 English voices, low-latency streaming | | `aura-2-es` | Aura 2 — 10 Spanish voices, low-latency streaming | | `melotts` | MeloTTS — en + fr, cheapest TTS available | #### Image (6 free + 6 paid) | Model ID | Description | |----------|-------------| | `flux-2-klein-9b` | Flux 2 Klein — highest quality | | `flux-2-dev` | Flux 2 Dev — flagship fidelity | | `lucid-origin` | Lucid Origin — cinematic | | `phoenix-1.0` | Phoenix — photorealistic | | `sdxl-lightning` | SDXL Lightning — fast | | `dreamshaper-8-lcm` | DreamShaper 8 LCM — fast | #### Embedding (2 models) | Model ID | Description | |----------|-------------| | `text-embedding-3-small` | 1536 dimensions, 8,192-token inputs — best price/performance | | `text-embedding-3-large` | 3072 dimensions, 8,192-token inputs — highest accuracy | | `flux-2-pro` | Flux 2 Pro — flagship BFL quality *(paid)* | | `flux-1.1-pro` | Flux 1.1 Pro — fast high-quality *(paid)* | | `gpt-image-2` | OpenAI GPT Image 2 — accurate on-image text *(paid)* | | `gpt-image-1.5` | OpenAI GPT Image 1.5 — precise image editing, strong logo/face preservation *(paid)* | | `nano-banana-2` | Google Gemini 3.1 Flash Image — multimodal, highest LM-Arena Elo *(paid · maintenance)* | | `nano-banana-pro` | Google Gemini 3 Pro Image — flagship typography + fidelity *(paid · maintenance)* | A free-plan key calling a paid model gets `403 model_not_available` — it is not billed, it is refused. Upgrade to Starter or above first. All other models — including `kimi-k2.5-fast`, first-party IDs (`gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-*`, `grok-4.3`, `DeepSeek-V4-*`, `gpt-realtime*`, `nova-sonic*`, first-party STT/TTS), the Deepgram direct line (`deepgram-nova-3`, `deepgram-flux-general-en/multi`, `deepgram-nova-2*`, `deepgram-enhanced*`, `deepgram-base*`, `deepgram-whisper-*`, `deepgram-aura-2`, `deepgram-aura-1`, `deepgram-flux-tts`, Deepgram Voice Agent `deepgram-voice-*` ids, the `deepgram-summarize/topics/sentiment/intents` Audio Intelligence features, and the `deepgram-text-summarize/topics/sentiment/intents` Text Intelligence features), and paid image models (`flux-2-pro`, `gpt-image-2`, `gpt-image-1.5`, `nano-banana-*`) — require Starter, Pro, or Enterprise. #### Pricing All models are pay-per-use. Pricing is in USD. | Model | Input / 1M tokens | Output / 1M tokens | |-------|-------------------|-------------------| | `kimi-k2.5-fast` | $0.81 | $4.05 | | `sarvam-105b` | $0.35 (₹30) | $0.35 (₹30) | | `sarvam-105b-conversations` | $0.35 (₹30) | $0.35 (₹30) | | `gpt-5.6-sol` | $5.00 | $30.00 | | `gpt-5.6-terra` | $2.50 | $15.00 | | `gpt-5.6-luna` | $1.00 | $6.00 | | `nova-sonic-2` | $4.00 | $15.00 | | `nova-sonic` | $4.50 | $17.00 | | `gpt-realtime` | $4.00 | $16.00 | | `gpt-realtime-mini` | $0.60 | $2.40 | | `gpt-realtime-2` | $4.00 | $24.00 | | `gpt-realtime-1.5` | $4.00 | $16.00 | | `gpt-realtime-2.1` | $4.00 | $24.00 | | `gpt-realtime-2.1-mini` | $0.60 | $2.40 | | `deepgram-voice-*` | per-minute Voice Agent tier | Standard $0.075/min, Advanced $0.163/min *(voice-agent only)* | | STT Model | Price | |-----------|-------| | `saaras:v3` | $0.30 / hour (₹30/hr) | | `saaras:v4` | $0.30 / hour (₹30/hr) | | `gnani-prisma-v2.5` | $0.27 / hour | | `whisper-large-v3-turbo` | $0.06 / hour | | `nova-3` | $0.50 / hour | | `deepgram-nova-3` | $0.29 / hour | | `deepgram-nova-3-medical` | $0.29 / hour | | `deepgram-flux-general-en` | $0.39 / hour | | `deepgram-flux-general-multi` | $0.47 / hour | | `deepgram-nova-2` (+ domain variants) | $0.35 / hour | | `deepgram-nova` / `deepgram-whisper-*` | $0.35 / hour | | `deepgram-enhanced` (+ variants) | $0.99 / hour | | `deepgram-base` (+ variants) | $0.87 / hour | | TTS Model | Price | |-----------|-------| | `bulbul:v3` | $0.30 / 10K chars (₹30/10K) | | `gnani-timbre-v2.0` | $0.27 / 10K chars | | `aura-2-en` | $0.40 / 10K chars | | `aura-2-es` | $0.40 / 10K chars | | `deepgram-aura-2` | $0.30 / 10K chars | | `deepgram-aura-1` | $0.15 / 10K chars | | `deepgram-flux-tts` | $0.45 / 10K chars | | `melotts` | $0.05 / 10K chars | | Intelligence feature (not a model ID) | Price | |-------------------------------|-------| | `deepgram-summarize` / `-topics` / `-sentiment` / `-intents` (audio) | $0.0003 / 1K input + $0.0006 / 1K output tokens | | `deepgram-text-summarize` / `-text-topics` / `-text-sentiment` / `-text-intents` | $0.0003 / 1K input + $0.0006 / 1K output tokens | These eight values go in the `features` field, not in `model`. They are not catalog models — `GET /api/v1/models/deepgram-summarize` returns 404. Full pricing for all models is available via the API: `GET /api/v1/models` ```python import requests # List all LLM models models = requests.get("https://api.callmissed.com/api/v1/models?category=llm").json() for m in models["data"]: print(f"{m['id']} — {m['name']} ({m['context_window']} tokens) {'FREE' if m['free'] else 'PAID'}") ``` ### Fast LLMs High-throughput Kimi K2.5 inference tier optimized for voice-agent latency. | Model ID | Status | Context | Best For | |----------|--------|---------|----------| | `kimi-k2.5-fast` | **Under maintenance** — fall back to `kimi-k2.5` | 256K | Voice agents, fast inference, reasoning tasks | While `kimi-k2.5-fast` is in maintenance (returns HTTP 503), use `kimi-k2.5`: ```python response = client.chat.completions.create( model="kimi-k2.5", messages=[{"role": "user", "content": "Hello"}] ) ``` ### Indic Models #### Speech to Text | Model | Description | Languages | |-------|-------------|-----------| | `saaras:v3` | Latest STT — best accuracy on Indian + code-mixed | 23 languages (22 Indic + English) | | `saaras:v4` | Five output modes on one model — transcribe, translate, verbatim, transliterate, code-mix | 24 languages | | `gnani-prisma-v2.5` | India-first telephony STT — code-switching, sub-4% WER on Indian English | 10 Indian languages | For 99-language general-purpose transcription, see `whisper-large-v3-turbo`. For diarization + smart-format on calls, see `nova-3`. Both are free-tier and live under the [audio model routes](#audio-models). #### Text to Speech | Model | Description | Voices | |-------|-------------|--------| | `bulbul:v3` | Natural TTS — 37 voices, 11 Indian languages | shubh (default) + 36 more | | `gnani-timbre-v2.0` | India-first neural TTS — context-aware tone, low-latency | 24 voices (English + Hindi) | For low-latency English / Spanish voice agents, see `aura-2-en` / `aura-2-es`. For ultra-cheap en/fr notification audio, see `melotts`. All three are free-tier. #### Chat Completion (LLM) | Model | Params | Context | Best For | |-------|--------|---------|----------| | `sarvam-105b` | 105B MoE | 128K tokens | Complex reasoning, agentic tasks, long documents | | `sarvam-105b-conversations` | 105B MoE | 128K tokens | Conversation and voice agents, tool calling | Both Sarvam models support hybrid thinking via `reasoning_effort: "low" | "medium" | "high"`. `"none"` and `"minimal"` map down to `"low"`, so an OpenAI-style client sending `"none"` gets a 200 rather than an error — but thinking stays on. To turn thinking fully off, use `kimi-k2.5`, `kimi-k2.6`, `glm-4.7-flash`, `glm-5.2`, or `gemma-4-26b-a4b-it` with `reasoning_effort: "none"`. See the [per-model matrix](https://docs.callmissed.com/docs/api-speed#3-reasoning-effort-by-model). ### Audio Models Free-tier on every plan. See the [Pricing](https://docs.callmissed.com/docs/pricing) page for current rates. #### Speech to Text | Model | Languages | Best for | Price | |-------|-----------|----------|-------| | `whisper-large-v3-turbo` | 99 with auto-detect | Multilingual general-purpose; transcribe + translate | $0.06 / hour | | `nova-3` | 11 BCP-47 incl. `multi` auto-detect | Diarization, smart-format, streaming voice agents | $0.50 / hour | | `whisper` | 99 with auto-detect | Whisper batch + translate | $0.40 / hour | | `gpt-4o-transcribe` | Streaming | Higher-accuracy OpenAI transcription | $0.40 / hour | | `gpt-4o-mini-transcribe` | Streaming | Low-cost OpenAI transcription | $0.24 / hour | | `gpt-4o-transcribe-diarize` | Streaming + diarization | Multi-speaker meetings / calls | $0.40 / hour | **Deepgram (direct)** — the full Deepgram speech-to-text line, billed per audio hour at the rates below: | Model | Languages | Best for | Price | |-------|-----------|----------|-------| | `deepgram-flux-general-en` | English | Conversational voice agents — model-native turn detection, ultra-low latency | $0.39 / hour | | `deepgram-flux-general-multi` | 10 (multilingual) | Multilingual voice agents with code-switching | $0.47 / hour | | `deepgram-nova-3` | 45+ incl. `multi` | Flagship general-purpose ASR, keyterm prompting, PII redaction | $0.29 / hour | | `deepgram-nova-3-medical` | English | Clinical / medical terminology | $0.29 / hour | | `deepgram-nova-2` | 36 incl. `multi` | High-accuracy ASR + filler-word detection | $0.35 / hour | | `deepgram-nova-2-{meeting,phonecall,finance,conversationalai,voicemail,video,medical,drivethru,automotive,atc}` | English | Domain-tuned Nova-2 variants | $0.35 / hour | | `deepgram-nova` / `-phonecall` / `-medical` | en/es/hi | Legacy Nova-1 | $0.35 / hour | | `deepgram-enhanced` (+ meeting/phonecall/finance) | 13 | Legacy, keyword boosting | $0.99 / hour | | `deepgram-base` (+ 6 variants) | 17 | Legacy, high-volume batch | $0.87 / hour | | `deepgram-whisper-{tiny,base,small,medium,large}` | 99 | Deepgram-managed Whisper Cloud | $0.35 / hour | #### Text to Speech | Model | Languages | Voices | Price | |-------|-----------|--------|-------| | `aura-2-en` | English | 40 (luna default) | $0.40 / 10K chars | | `aura-2-es` | Spanish | 10 (aquila default) | $0.40 / 10K chars | | `deepgram-aura-2` | en/es/de/fr/nl/it/ja | 90+ (thalia default) | $0.30 / 10K chars | | `deepgram-aura-1` | English | 12 (asteria default) | $0.15 / 10K chars | | `deepgram-flux-tts` | English | 11 (priya default) | $0.45 / 10K chars | | `melotts` | English + French | 1 per language | $0.05 / 10K chars | | `gpt-4o-mini-tts` | Multilingual steerable | 6 OpenAI voices | $0.20 / 10K chars | Aura 2 returns linear16 PCM streamed at 24 kHz for low-latency playback. MeloTTS returns base64 MP3. Output formats may vary as models are updated. `deepgram-flux-tts` is streaming-first and built for voice agents: synthesis is turn-based and prosody carries across turns. It serves 11 English voices, including `priya` (Indian-accented English, the default). It is English-only — a multilingual voice set is planned for a later release — and exposes no expressive/emotion/style controls and no SSML. #### Audio Intelligence (Deepgram) Deepgram Audio Intelligence runs analysis over an uploaded audio file via `POST /v1/audio/intelligence` (English only, 150K input-token limit). Token-billed at $0.0003/1K input + $0.0006/1K output. | Feature | Model ID | Returns | |---------|----------|---------| | Summarization | `deepgram-summarize` | A concise `summary` of the audio | | Topic Detection | `deepgram-topics` | Per-segment `topics` with confidence | | Sentiment Analysis | `deepgram-sentiment` | Per-segment + average `sentiments` | | Intent Recognition | `deepgram-intents` | Per-segment `intents` with confidence | Request multiple features in one call with a comma-separated `features` form field (e.g. `features=deepgram-summarize,deepgram-sentiment`). #### Text Intelligence (Deepgram) Deepgram Text Intelligence runs the same four analyses over **text** input (a string or a hosted text URL) via `POST /v1/text/intelligence` (English only, 150K input-token limit). Token-billed at $0.0003/1K input + $0.0006/1K output. Requires the `llm` key permission. | Feature | Model ID | Returns | |---------|----------|---------| | Summarization | `deepgram-text-summarize` | A concise `summary` of the text | | Topic Detection | `deepgram-text-topics` | Per-segment `topics` with confidence | | Sentiment Analysis | `deepgram-text-sentiment` | Per-segment + average `sentiments` | | Intent Recognition | `deepgram-text-intents` | Per-segment `intents` with confidence | Send a JSON body with `features` (array or comma-separated string) and exactly one of `text` or `url`: ```json { "features": ["deepgram-text-summarize", "deepgram-text-sentiment"], "text": "Your text to analyze here." } ``` ### Direct-Routed LLMs Low-latency models routed directly through CallMissed — sub-2s end-to-end on small prompts and free-tier eligible per the [reasoning_effort matrix](https://docs.callmissed.com/docs/api-speed#3-reasoning-effort-by-model). | Model ID | Creator | Context | |----------|---------|---------| | `kimi-k2.5` | Moonshot AI | 256K | | `kimi-k2.6` | Moonshot AI | 262K | | `kimi-k2.7-code` | Moonshot AI | 262K | | `gpt-oss-120b` | OpenAI (open-weights) | 128K | | `gemma-4-26b-a4b-it` | Google | 128K | | `glm-4.7-flash` | Zhipu | 128K | | `glm-5.2` | Z.ai | 262K | | `nemotron-3-super` | NVIDIA | 256K | | `mistral-small-3.1` | Mistral | 128K | ### Models on Demand `GET /api/v1/models` lists everything that is live today: **123** model IDs callable right now with a `cm_` key. Beyond that we deploy **300+ further models on demand** on CallMissed infrastructure. Send the model you need and your expected throughput to `sales@callmissed.com`. Once deployed it appears in your `GET /api/v1/models` response with a plain CallMissed ID and published per-token pricing, on the same `/v1/chat/completions` endpoint as every other model. Same key, same credit balance, no new SDK. Enterprise accounts get dedicated capacity. Starter and Pro get shared capacity where the model allows it. ### First-Party Models Credit-covered first-party models. Use the bare model ID in API requests — e.g. `gpt-4o`. | Model ID | Type | Notes | |----------|------|-------| | `gpt-4o` | LLM | Multimodal text + vision, 128K context | | `gpt-4.1` | LLM | Long-context (1M) multimodal | | `gpt-5-mini` | LLM | Fast reasoning, 400K context | | `gpt-5.5` | LLM | GPT-5.5 reasoning flagship, 1M context, vision + tools | | `gpt-5.6-sol` | LLM | GPT-5.6 flagship, 1.05M context, vision + tools | | `gpt-5.6-terra` | LLM | GPT-5.6 balanced intelligence/cost, 1.05M context | | `gpt-5.6-luna` | LLM | GPT-5.6 fast + affordable, 1.05M context | | `grok-4.3` | LLM | xAI Grok, 200K context | | `DeepSeek-V4-Pro` | LLM | Flagship DeepSeek reasoning, 1M context | | `DeepSeek-V4-Flash` | LLM | Fast DeepSeek reasoning | | `nova-sonic-2` | Realtime voice | Default speech-to-speech voice model — 16 voices, Hindi + en-IN, live | | `nova-sonic` | Realtime voice | First-generation Amazon speech-to-speech voice model | | `gpt-realtime` | Realtime voice | OpenAI flagship speech-to-speech (10 concurrent), live | | `gpt-realtime-mini` | Realtime voice | Lowest-cost realtime, ~3× cheaper than gpt-realtime, live | | `gpt-realtime-2` | Realtime voice | Newest realtime with stronger tool calling, live | | `gpt-realtime-1.5` | Realtime voice | Pinned 1.5 snapshot of gpt-realtime, live | | `gpt-realtime-2.1` | Realtime voice | Latest realtime — better recognition, silence/interrupt handling, configurable reasoning, live | | `gpt-realtime-2.1-mini` | Realtime voice | Distilled low-cost 2.1 realtime, live | | `whisper` | STT | OpenAI Whisper — 99 langs | | `gpt-4o-transcribe` | STT | Streaming transcription | | `gpt-4o-mini-transcribe` | STT | Low-cost streaming STT | | `gpt-4o-transcribe-diarize` | STT | Speaker diarization | | `gpt-4o-mini-tts` | TTS | Steerable OpenAI TTS, 6 voices | See [Credits & Rate Limits](https://docs.callmissed.com/docs/credits-rate-limits) for per-model USD pricing. ### Full Model Catalog A curated, representative slice of the **123** models (57 LLM · 43 STT · 9 TTS · 12 image · 2 embedding) served by `GET /api/v1/models` as of the latest deploy — the per-domain variants of the direct Deepgram STT line and the `deepgram-voice-*` managed LLM ids are covered in their own sections above rather than repeated below. For live pricing and capability flags (`supports_vision`, `supports_tools`, `free`), query the API — it always reflects the current catalog. #### LLM (30 models) | Model ID | Description | Context | Free | Pricing | |----------|-------------|---------|------|---------| | `sarvam-105b` | 105B MoE. Complex reasoning, agentic tasks, long documents. | 131K | Yes | $0.35 in / $0.35 out per 1M | | `sarvam-105b-conversations` | 105B MoE tuned for conversation and voice. Tool calling. | 131K | Yes | $0.35 in / $0.35 out per 1M | | `gpt-4o` | Multimodal text + vision. | 128K | No | $2.50 in / $10.00 out per 1M | | `gpt-4.1` | Long-context multimodal. Strong instruction following. | 1M | No | $2.00 in / $8.00 out per 1M | | `gpt-5-mini` | Fast, affordable reasoning. | 400K | No | $0.25 in / $2.00 out per 1M | | `gpt-5.5` | Reasoning flagship. Vision, tools, prompt caching. | 1M | No | $5.00 in / $30.00 out per 1M | | `gpt-5.6-sol` | Frontier model for complex professional work. Vision, reasoning, tools. | 1.05M | No | $5.00 in / $30.00 out per 1M | | `gpt-5.6-terra` | Balances intelligence and cost. Vision, reasoning, tools. | 1.05M | No | $2.50 in / $15.00 out per 1M | | `gpt-5.6-luna` | Cost-sensitive, high-volume workloads. Vision, reasoning, tools. | 1.05M | No | $1.00 in / $6.00 out per 1M | | `grok-4.3` | xAI Grok 4.3. Reasoning + vision. | 200K | No | $3.50 in / $15.00 out per 1M | | `DeepSeek-V4-Pro` | Flagship DeepSeek reasoning. | 1M | No | $1.00 in / $3.00 out per 1M | | `DeepSeek-V4-Flash` | Fast, affordable DeepSeek reasoning. | 131K | No | $0.30 in / $1.20 out per 1M | | `kimi-k2.5` | Strong on coding and math. Vision. | 256K | Yes | $0.81 in / $4.05 out per 1M | | `kimi-k2.5-fast` *(maintenance)* | Kimi K2.5 at ~414 tok/s for voice-agent latency. | 256K | No | $0.81 in / $4.05 out per 1M | | `kimi-k2.6` | Improved reasoning and coding over K2.5. Vision. | 262K | Yes | $1.28 in / $5.40 out per 1M | | `kimi-k2.7-code` | 1T-param agentic coding. Vision + tools. | 262K | Yes | $1.28 in / $5.40 out per 1M | | `glm-4.7-flash` | Fast, cost-efficient bilingual model. Strong tool use. | 131K | Yes | $0.50 in / $2.00 out per 1M | | `glm-5.2` | Flagship agentic coding. Tools + reasoning. | 262K | Yes | $1.89 in / $5.94 out per 1M | | `gpt-oss-120b` | Open-weight 120B MoE. Reasoning-grade at lower cost. | 128K | Yes | $1.00 in / $4.00 out per 1M | | `nemotron-3-super` | 120B MoE tuned for long-context reasoning. | 256K | Yes | $1.50 in / $6.00 out per 1M | | `gemma-4-26b-a4b-it` | 26B MoE (4B active). Efficient instruct model. Vision. | 131K | Yes | $0.40 in / $1.60 out per 1M | | `mistral-small-3.1` | 24B instruct. Strong tool use, fast. Vision. | 128K | Yes | $0.47 in / $0.76 out per 1M | | `nova-sonic-2` | Amazon Nova 2 Sonic. Native speech-to-speech voice model — STT, reasoning, and TTS in one; 16 voices across 8 languages including Hindi + en-IN. | 32K | No | $4.00 in / $15.00 out per 1M • $0.064/min | | `nova-sonic` | Amazon Nova Sonic 1.0. Native speech-to-speech voice model with 11 voices across English, Spanish, French, Italian, and German. | 32K | No | $4.50 in / $17.00 out per 1M • $0.071/min | | `gpt-realtime` | OpenAI flagship realtime speech-to-speech model — STT + reasoning + function calling + TTS in one. 10 concurrent. | 32K | No | $4.00 in / $16.00 out per 1M • $0.375/min | | `gpt-realtime-mini` | Lowest-cost realtime — same single-model shape as gpt-realtime, ~3× cheaper. 20 concurrent. | 32K | No | $0.60 in / $2.40 out per 1M • $0.118/min | | `gpt-realtime-2` | Newest realtime with stronger tool calling. 128K text context. | 128K | No | $4.00 in / $24.00 out per 1M • $0.375/min | | `gpt-realtime-1.5` | Pinned 1.5 snapshot of gpt-realtime. Use when you want version stability. | 32K | No | $4.00 in / $16.00 out per 1M • $0.375/min | | `gpt-realtime-2.1` | Latest realtime speech-to-speech — better alphanumeric recognition, silence/noise + interruption handling, configurable reasoning effort. Voice-agent only. | 128K | No | $4.00 in / $24.00 out per 1M • $0.375/min | | `gpt-realtime-2.1-mini` | Distilled, lower-cost realtime for faster voice interactions. Voice-agent only. | 128K | No | $0.60 in / $2.40 out per 1M • $0.117/min | #### Speech to Text (9 models) | Model ID | Description | Context | Free | Pricing | |----------|-------------|---------|------|---------| | `saaras:v3` | 23 languages (22 Indic + English). Best on code-mixed speech. | — | Yes | $0.30 / hr | | `saaras:v4` | 24 languages. Five output modes: transcribe, translate, verbatim, transliterate, code-mix. | — | Yes | $0.30 / hr | | `gnani-prisma-v2.5` | India-first telephony STT. 10 Indian languages, code-switching. | — | No | $0.27 / hr | | `whisper-large-v3-turbo` | 99 languages with auto-detect. Transcribe + translate. | — | Yes | $0.06 / hr | | `nova-3` | Diarization, punctuation, smart-format. Streaming-capable. | — | Yes | $0.50 / hr | | `whisper` | 99 languages. Transcription + translation to English. | — | No | $0.40 / hr | | `gpt-4o-transcribe` | Higher accuracy than Whisper. Streaming. | — | No | $0.40 / hr | | `gpt-4o-mini-transcribe` | Cheaper, faster streaming transcription. | — | No | $0.24 / hr | | `gpt-4o-transcribe-diarize` | Streaming transcription with speaker labels. | — | No | $0.40 / hr | #### Text to Speech (6 models) | Model ID | Description | Voices | Free | Pricing | |----------|-------------|--------|------|---------| | `bulbul:v3` | Indic TTS across 11 Indian languages. | 37 | Yes | $0.30 / 10K chars | | `gnani-timbre-v2.0` | India-first neural TTS, English + Hindi. Context-aware tone. | 24 | No | $0.27 / 10K chars | | `aura-2-en` | Conversational English TTS, low-latency streaming. | 40 | Yes | $0.40 / 10K chars | | `aura-2-es` | Spanish TTS, low-latency streaming. | 10 | Yes | $0.40 / 10K chars | | `melotts` | Lightweight English + French TTS. Cheapest available. | 1 per language | Yes | $0.05 / 10K chars | | `gpt-4o-mini-tts` | Steerable — takes an `instructions` field to direct tone. | 6 | No | $0.20 / 10K chars | #### Image Generation (12 models) | Model ID | Description | Free | Pricing | |----------|-------------|------|---------| | `flux-2-klein-9b` | Flux 2 Klein. 1024×1024 default. | Yes | $0.10 / image | | `flux-2-dev` | Flux 2 Dev. Higher fidelity, 50-step inference. | Yes | $0.12 / image | | `flux-2-pro` | Flux 2 Pro. Flagship BFL fidelity. | No | $0.10 / image | | `flux-1.1-pro` | Flux 1.1 Pro. Fast, production-grade. | No | $0.05 / image | | `gpt-image-2` | Accurate on-image text rendering. | No | $0.25 / image | | `gpt-image-1.5` | Precise image editing. Strong logo/face preservation. | No | $0.25 / image | | `lucid-origin` | Vibrant, cinematic compositions. | Yes | $0.08 / image | | `phoenix-1.0` | Strong prompt adherence, photorealistic portraits. | Yes | $0.10 / image | | `sdxl-lightning` | 4-step inference. Fastest for iterative prompting. | Yes | $0.04 / image | | `dreamshaper-8-lcm` | Stylised illustrations, fast generation. | Yes | $0.04 / image | | `nano-banana-2` *(maintenance)* | Fast multimodal image generation. | No | $0.067 / image | | `nano-banana-pro` *(maintenance)* | Flagship typography and fidelity. | No | $0.134 / image | #### Embeddings (2 models) | Model ID | Description | Dimensions | Free | Pricing | |----------|-------------|------------|------|---------| | `text-embedding-3-small` | Fast, low-cost embeddings. Best price/performance for large corpora. | 1536 | Yes | $0.02 / 1M input tokens | | `text-embedding-3-large` | Highest-accuracy embeddings. | 3072 | Yes | $0.13 / 1M input tokens | Both accept 8,192-token inputs and support shortening the vector with `dimensions`. See [Embeddings](https://docs.callmissed.com/docs/embeddings). > **Tip:** Filter programmatically — `GET /api/v1/models?category=llm`, `?category=stt`, `?category=tts`, `?category=image`, `?category=embedding`, or `?free=true` for free-plan models only. ### Model Selection Pass the model ID in your request: ```python # Indic LLM response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Hello in Hindi"}] ) # Indic LLM with thinking mode response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Solve this step by step"}], extra_body={"reasoning_effort": "high"} ) # First-party flagship model response = client.chat.completions.create( model="gpt-5.6-luna", messages=[{"role": "user", "content": "Hello"}] ) ``` The API automatically routes to the correct backend based on the model ID: - Bare names (`kimi-k2.5`, `gpt-4o`, `DeepSeek-V4-Pro`, `mistral-small-3.1`, …) → direct-routed or first-party - `sarvam-*` prefix → Indic LLMs - `saaras:*` / `bulbul:*` / `deepgram-*` / image IDs → the matching speech or image backend Every ID is a plain CallMissed ID with no vendor prefix — including models we [deploy on demand](#models-on-demand). ## Model Access by Plan Source: https://docs.callmissed.com/docs/model-access See which models each plan tier (Free, Starter, Pro, Enterprise) can access, with cURL, Python, and JavaScript examples. ### Overview The Model Access endpoint returns which model IDs each plan tier can call, bucketed by category (LLM, STT, TTS, Image). This is useful for: - Showing users which models they can access on their current plan - Building model selectors that grey out unavailable models - Checking whether a specific model requires an upgrade **No authentication required.** ### Endpoint ``` GET /api/v1/models/access ``` ### Response Shape ```json { "plans": { "free": { "models": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "glm-4.7-flash", "glm-5.2", "gpt-oss-120b", "nemotron-3-super", "gemma-4-26b-a4b-it", "mistral-small-3.1", "saaras:v3", "saaras:v4", "whisper-large-v3-turbo", "nova-3", "bulbul:v3", "aura-2-en", "aura-2-es", "melotts", "flux-2-klein-9b", "flux-2-dev", "lucid-origin", "phoenix-1.0", "sdxl-lightning", "dreamshaper-8-lcm", "text-embedding-3-small", "text-embedding-3-large"], "by_category": { "llm": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "glm-4.7-flash", "glm-5.2", "gpt-oss-120b", "nemotron-3-super", "gemma-4-26b-a4b-it", "mistral-small-3.1"], "stt": ["saaras:v3", "saaras:v4", "whisper-large-v3-turbo", "nova-3"], "tts": ["bulbul:v3", "aura-2-en", "aura-2-es", "melotts"], "image": ["flux-2-klein-9b", "flux-2-dev", "lucid-origin", "phoenix-1.0", "sdxl-lightning", "dreamshaper-8-lcm"], "embedding": ["text-embedding-3-small", "text-embedding-3-large"] }, "restriction": "27 models across 5 categories" }, "starter": { "models": ["...every model in the catalog"], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models" }, "pro": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models" }, "enterprise": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models + models deployed on demand" } } } ``` ### Code Examples ```bash [cURL] curl https://api.callmissed.com/api/v1/models/access ``` ```python [Python] import requests resp = requests.get("https://api.callmissed.com/api/v1/models/access") data = resp.json() # List free LLM models free_llms = data["plans"]["free"]["by_category"]["llm"] print("Free LLM models:", free_llms) # Check if a model is available on free plan model = "gpt-5.6-luna" is_free = model in data["plans"]["free"]["models"] print(f"{model} on free plan: {is_free}") # False ``` ```typescript [JavaScript / TypeScript] const resp = await fetch("https://api.callmissed.com/api/v1/models/access"); const data = await resp.json(); // List free LLM models const freeLLMs = data.plans.free.by_category.llm; console.log("Free LLM models:", freeLLMs); // Check if a model is available on free plan const model = "gpt-5.6-luna"; const isFree = data.plans.free.models.includes(model); console.log(model, "on free plan:", isFree); // false ``` ### Free Plan Models The free tier includes **27 models**: | Category | Models | |----------|--------| | LLM (11) | `sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `glm-4.7-flash`, `glm-5.2`, `gpt-oss-120b`, `nemotron-3-super`, `gemma-4-26b-a4b-it`, `mistral-small-3.1` | | STT (4) | `saaras:v3`, `saaras:v4`, `whisper-large-v3-turbo`, `nova-3` | | TTS (4) | `bulbul:v3`, `aura-2-en`, `aura-2-es`, `melotts` | | Image (6) | `flux-2-klein-9b`, `flux-2-dev`, `lucid-origin`, `phoenix-1.0`, `sdxl-lightning`, `dreamshaper-8-lcm` | | Embedding (2) | `text-embedding-3-small`, `text-embedding-3-large` | Every other model — `kimi-k2.5-fast`, the first-party OpenAI / xAI / DeepSeek IDs, the realtime voice models, the Deepgram direct line, and the paid image models — requires Starter, Pro, or Enterprise. ### Error Handling When a free-plan user calls a paid model, the API returns: ```json { "error": { "message": "Model 'gpt-5.6-luna' requires a paid plan. See GET /api/v1/models/access for the full list of free-plan models. Upgrade at https://app.callmissed.com/pricing", "type": "invalid_request_error", "code": "model_not_available" } } ``` HTTP status: `403` ## Kimi K2.5 Fast (Maintenance) Source: https://docs.callmissed.com/docs/models-kimi-fast High-throughput Kimi K2.5 inference tier — currently under maintenance. Use kimi-k2.5 in the meantime. > **Under maintenance.** `kimi-k2.5-fast` is temporarily unavailable. Requests return HTTP 503 with `code: "model_under_maintenance"`. Use [`kimi-k2.5`](https://docs.callmissed.com/docs/models) for production traffic; both ride on the same Kimi K2.5 model from Moonshot AI. ### Overview The `kimi-k2.5-fast` tier targets ultra-low-latency voice-agent workloads via a high-throughput inference partner. While it's under maintenance, route the same workload through `kimi-k2.5` — the model and tokeniser are identical, only the inference latency differs. ### Kimi K2.5 Fast | Field | Value | |-------|-------| | Model ID | `kimi-k2.5-fast` | | Status | **Under maintenance** — returns 503 | | Recommended fallback | `kimi-k2.5` | | Architecture | MoE (Mixture of Experts) | | Context window | 256,000 tokens | | Supports streaming | Yes | | Supports tools | Yes | Kimi K2.5 (by Moonshot AI) is a 1T-parameter MoE model with 32B active parameters. It excels at reasoning, coding, and multilingual tasks. ### Usage While `kimi-k2.5-fast` is in maintenance, point your code at `kimi-k2.5`: ```python from openai import OpenAI client = OpenAI( base_url="https://api.callmissed.com/v1", api_key="cm_your_api_key", ) response = client.chat.completions.create( model="kimi-k2.5", # kimi-k2.5-fast is under maintenance messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Explain quantum computing briefly."}, ], stream=True, ) for chunk in response: print(chunk.choices[0].delta.content or "", end="", flush=True) ``` ### Pricing | Direction | Cost per 1M tokens | |-----------|-------------------| | Input | $0.81 | | Output | $4.05 | **1 credit = ₹1 = $0.01.** A typical voice-agent turn — 500 input + 200 output tokens — costs $0.001215, or **0.1215 credits**. These rates apply once `kimi-k2.5-fast` leaves maintenance. See [Credits & Rate Limits](https://docs.callmissed.com/docs/credits-rate-limits). ## Indic Models Source: https://docs.callmissed.com/docs/models-indic Indic STT, TTS, and LLM models — optimized for Indian languages. ### LLM #### sarvam-105b - **Architecture:** 105B MoE, MLA architecture - **Context:** 128K tokens - **Training:** Pre-trained on 12T tokens - **Best for:** Complex reasoning, agentic tasks, long documents - **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"` #### sarvam-105b-conversations - **Architecture:** 105B MoE, tuned for conversation and voice - **Context:** 128K tokens - **Tool calling:** yes - **Streaming:** yes - **Best for:** Multi-turn dialogue, voice agents, assistants that talk - **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"` Same family, same price and same 128K window as `sarvam-105b` — tuned for spoken dialogue rather than long-form work. It does not accept image input. #### Thinking Mode The `sarvam-105b` models support hybrid thinking mode: ```python response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Solve this complex problem step by step"}], extra_body={"reasoning_effort": "high"} ) ``` | Value | Description | |-------|-------------| | `"low"` | Minimal reasoning — fastest, cheapest | | `"medium"` | Balanced reasoning | | `"high"` | Deep reasoning — best quality, slower | The `sarvam-*` models reject `"none"` and `"minimal"`; the API maps both of those values down to `"low"` so an OpenAI-style client that sends `reasoning_effort: "none"` for thinking-off still works. Full thinking-disable is available on the direct-routed `kimi-k2.5` / `kimi-k2.6` / `kimi-k2.7-code` / `gemma-4-26b-a4b-it` models — see the [reasoning_effort matrix](https://docs.callmissed.com/docs/api-speed#3-reasoning-effort-by-model). ### Speech to Text #### saaras:v3 - **Languages:** 23 (22 Indic + English) - **Output modes:** transcribe, translate, verbatim, translit, codemix - **Auto language detection:** yes - **Telephony support:** 8kHz audio - **Endpoint:** `POST /v1/audio/transcriptions` Supported languages include: Hindi, Bengali, Gujarati, Kannada, Malayalam, Marathi, Odia, Punjabi, Tamil, Telugu, Urdu, Assamese, Bodo, Dogri, Kashmiri, Konkani, Maithili, Manipuri, Nepali, Sanskrit, Santali, Sindhi, and English. #### saaras:v4 - **Languages:** 24 - **Output modes:** transcribe, translate, verbatim, translit, codemix - **Auto language detection:** yes - **Endpoint:** `POST /v1/audio/transcriptions` Five output modes on one model — standard transcription, English translation, verbatim (fillers kept), Latin-script transliteration, and code-mixed output. Select one with the `mode` form field; `transcribe` is the default. ### Text to Speech #### bulbul:v3 - **Voices:** 37 speakers - **Languages:** 11 - **Audio codecs:** WAV, MP3, OPUS, FLAC, AAC, Mulaw, Alaw, PCM - **Pace:** 0.5–2.0 (maps to `speed` parameter) - **Sample rates:** 8000, 16000, 22050, 24000, 48000 Hz - **Endpoint:** `POST /v1/audio/speech` Default voice: `shubh`. See the [Voices](https://docs.callmissed.com/docs/tts-voices) page for the full list. ## Chat Completion Source: https://docs.callmissed.com/docs/chat-completion Generate text responses using our OpenAI-compatible chat completion API. - [Streaming](https://docs.callmissed.com/docs/chat-streaming): Server-sent events for real-time responses - [Function Calling](https://docs.callmissed.com/docs/chat-function-calling): Tool use with structured outputs - [Model Catalog](https://docs.callmissed.com/docs/models): Pick the right model for your workload - [Anthropic API](https://docs.callmissed.com/docs/anthropic-api): 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: - **Your app**: Send `POST /v1/chat/completions` with `model` + `messages` - **CallMissed gateway**: Authenticate the `cm_` key, check credits, route by model id - **Provider**: Run inference on the best-fit backend — picked from the model id - **CallMissed gateway**: Stream tokens back and deduct credits when the response completes - **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](https://docs.callmissed.com/docs/how-it-works). ### Make your first request ### Get an API key Create a key in the [dashboard](https://app.callmissed.com) (**Profile → 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`. #### 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](https://docs.callmissed.com/docs/chat-streaming) for full examples. #### Function calling Pass a `tools` array to let the model call your functions. See [Function Calling](https://docs.callmissed.com/docs/chat-function-calling). ### Basic Usage ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) response = client.chat.completions.create( model="sarvam-105b", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is the capital of India?"} ] ) print(response.choices[0].message.content) ``` ```javascript [JavaScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "What is the capital of India?" }, ], }); console.log(response.choices[0].message.content); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "sarvam-105b", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is the capital of India?"} ] }' ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `model` | string | Model ID (e.g. `sarvam-105b`, `gpt-5.6-luna`) | | `messages` | array | List of `{role, content}` objects. System prompt goes here as `{"role": "system", "content": "..."}` | | `stream` | boolean | Enable streaming SSE responses | | `temperature` | number | Sampling temperature (0–2) | | `max_tokens` | integer | Maximum tokens to generate | | `n` | integer | Number of completions to generate (default 1) | | `top_p` | float | Nucleus sampling (0–1) | | `top_k` | integer | Top-K sampling | | `frequency_penalty` | float | Penalize repeated tokens (−2 to 2) | | `presence_penalty` | float | Penalize new topics (−2 to 2) | | `repetition_penalty` | float | Reduce repetition (0–2) | | `seed` | integer | Deterministic sampling | | `stop` | array | Stop sequences | | `logit_bias` | object | Token probability adjustments | | `logprobs` | boolean | Return log probabilities | | `top_logprobs` | integer | Top N log probs per token | | `tools` | array | Tool/function definitions for function calling | | `parallel_tool_calls` | boolean | Allow parallel function calls | | `response_format` | object | `{"type": "json_object"}` or `{"type": "json_schema", "json_schema": {...}}` | | `structured_outputs` | boolean | Enforce strict JSON schema | | `stream_options` | object | `{"include_usage": true}` to get token counts in stream | | `reasoning_effort` | string | `"none"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` — see the per-model matrix below. `"xhigh"` (maximum reasoning) is accepted by the GPT-5.5 / GPT-5.6 family; other models map it down to their highest supported value. | > **OpenAI Python SDK note** — The OpenAI client validates kwargs against its > known parameters, so a CallMissed-specific field such as `reasoning_effort` > raises `TypeError: Completions.create() got an unexpected keyword argument`. > Pass it via `extra_body` instead: > > ```python > client.chat.completions.create( > model="kimi-k2.6", > messages=[...], > extra_body={"reasoning_effort": "none"}, > ) > ``` > > Raw HTTP / curl users can keep it at the top level — only the OpenAI SDK gates kwargs. ### Model Substitution CallMissed never substitutes your model. Send a `model` and you get that model, or a clean error (`429`/`503` with `Retry-After`). You are never billed for a model you did not name. Need a model that is not in the catalog? See [Models on demand](https://docs.callmissed.com/docs/models#models-on-demand). ### Vision (Image Input) Multimodal content (text + image parts) is accepted on any model whose `supports_vision` flag is `true` in `GET /v1/models`. Models without vision support reject image content with `400 unsupported_image_input` **before** the upstream call, so you're not charged. ```python from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") resp = client.chat.completions.create( model="gpt-5.6-sol", # supports_vision: true messages=[{ "role": "user", "content": [ {"type": "text", "text": "What is in this image?"}, {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, ], }], ) ``` Vision-capable models: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `grok-4.3`, `kimi-k2.5`, `kimi-k2.5-fast`, `kimi-k2.6`, `kimi-k2.7-code`, `gemma-4-26b-a4b-it`, `mistral-small-3.1`. `GET /v1/models` is authoritative. Read `supports_vision` there rather than hard-coding this list. ### Context Window Every model in the catalog advertises a `context_window` (token count for the combined prompt + completion). The `GET /v1/models` response exposes it under two keys for cross-client compatibility: - `context_window` (OpenAI/CallMissed canonical name) - `context_length` (OpenAI SDK convention — same value) ```python from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") for m in client.models.list(): extra = m.model_extra or {} print(m.id, extra.get("context_window"), extra.get("supports_vision")) ``` Snapshot — `GET /v1/models` is authoritative: | Model | context_window | |-------|----------------| | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | | `gpt-4.1` | 1,047,576 | | `gpt-5.5`, `DeepSeek-V4-Pro` | 1,000,000 | | `gpt-5-mini` | 400,000 | | `kimi-k2.6`, `kimi-k2.7-code`, `glm-5.2` | 262,144 | | `kimi-k2.5`, `kimi-k2.5-fast`, `nemotron-3-super` | 256,000 | | `grok-4.3` | 200,000 | | `sarvam-105b`, `sarvam-105b-conversations`, `glm-4.7-flash`, `gemma-4-26b-a4b-it`, `DeepSeek-V4-Flash` | 131,072 | | `gpt-4o`, `gpt-oss-120b`, `mistral-small-3.1` | 128,000 | ### Responses API For clients built on OpenAI's newer **Responses API**, CallMissed exposes a compatible `POST /v1/responses` endpoint. It accepts a Responses-shaped body and translates to the same chat engine under the hood — so you can point an OpenAI Responses client at `https://api.callmissed.com/v1` without changes. **Endpoint:** `POST /v1/responses` ```python [Python] from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") resp = client.responses.create( model="gpt-4.1", input="Write a haiku about databases.", ) print(resp.output_text) ``` ```bash [cURL] curl https://api.callmissed.com/v1/responses \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4.1", "input": "Write a haiku about databases." }' ``` - `input` accepts a plain string or the Responses message-array form. - Streaming is supported (`stream: true`) and emits Responses-style SSE events. - The same models, pricing, vision, and tool-calling support as `/v1/chat/completions` apply — this is a request/response-shape adapter, not a different model set. If you're starting fresh, `/v1/chat/completions` is the most widely-supported surface; use `/v1/responses` when porting an existing Responses-API integration. ### Error Format All errors return OpenAI-compatible format: ```json { "error": { "message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key" } } ``` ## Streaming Source: https://docs.callmissed.com/docs/chat-streaming Stream chat completion responses in real-time using Server-Sent Events. ### Overview Enable streaming by setting `"stream": true`. The response is a Server-Sent Events (SSE) stream with `Content-Type: text/event-stream`. ### SSE Format Each event is a line starting with `data: ` followed by a JSON chunk: ``` data: {"id":"...","choices":[{"delta":{"role":"assistant","content":""},"finish_reason":null}]} data: {"id":"...","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]} data: {"id":"...","choices":[{"delta":{},"finish_reason":"stop"}]} data: [DONE] ``` - **First chunk** always includes `{"delta": {"role": "assistant", "content": ""}}` - **Content chunks** carry `{"delta": {"content": "token"}}` - **Final chunk** has `{"delta": {}, "finish_reason": "stop"}` - **End marker** is `data: [DONE]` ### Usage in Stream To get token usage in the stream, set `stream_options: {"include_usage": true}`. A final chunk with a `usage` field is sent before `[DONE]`: ```json data: {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46,"tool_call_count":0}} data: [DONE] ``` The `usage.tool_call_count` field is the number of tool calls the model made in this response (`0` when none). It is always present in the usage chunk. While the response streams, any chunk that carries a `delta.tool_calls` fragment also includes a running `tool_call_count` at the top level, so you can show a live counter as tools are invoked. ### Code Example ```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}' ``` ## Function Calling Source: https://docs.callmissed.com/docs/chat-function-calling Use tool calls and function calling with the chat completion API. ### Overview Function calling lets the model invoke your functions. Pass a `tools` array and the model returns structured `tool_calls` when it wants to call a function. ### Defining Tools ```json { "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a city", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "City name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["city"] } } } ] } ``` ### Tool Choice | Value | Description | |-------|-------------| | `"auto"` | Model decides whether to call a tool (default) | | `"none"` | Never call tools | | `"required"` | Always call at least one tool | | `{"type": "function", "function": {"name": "get_weather"}}` | Force a specific function | Set `parallel_tool_calls: true` to allow the model to call multiple tools in one response. ### Handling Response When the model calls a tool, `finish_reason` is `"tool_calls"` and `message.content` is `null`: ```json { "choices": [{ "finish_reason": "tool_calls", "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{"city": "Mumbai"}" } }] } }] } ``` Send the tool result back as a `tool` role message: ```json { "role": "tool", "tool_call_id": "call_abc123", "content": "{"temperature": 32, "condition": "sunny"}" } ``` ### Counting Tool Calls Every response includes a `usage.tool_call_count` field — the number of tool calls the model made in that response (`0` when none). It is present on both streaming and non-streaming responses, so you can track tool usage per request: ```json { "choices": [{ "finish_reason": "tool_calls", "message": { "tool_calls": [/* ... */] } }], "usage": { "prompt_tokens": 18, "completion_tokens": 25, "total_tokens": 43, "tool_call_count": 2 } } ``` When streaming, each chunk that carries a `delta.tool_calls` fragment also includes a top-level `tool_call_count` that increments as new tool calls begin — useful for showing a live "tools called" counter in your UI. The definitive total is always in the final `usage` chunk (requires `stream_options: {"include_usage": true}`). ### Full Example ```python # Step 1: Send initial request with tools response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "What's the weather in Mumbai?"}], tools=[{ "type": "function", "function": { "name": "get_weather", "description": "Get weather for a city", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } }], tool_choice="auto" ) # Step 2: Check if model wants to call a tool msg = response.choices[0].message if msg.tool_calls: tool_call = msg.tool_calls[0] # Execute your function here... result = get_weather(json.loads(tool_call.function.arguments)["city"]) # Step 3: Send result back final = client.chat.completions.create( model="sarvam-105b", messages=[ {"role": "user", "content": "What's the weather in Mumbai?"}, msg, # assistant message with tool_calls {"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result)} ] ) print(final.choices[0].message.content) ``` ## Anthropic-Compatible API Source: https://docs.callmissed.com/docs/anthropic-api Use the Anthropic SDK with CallMissed — just change the base URL. Full Messages API compatibility. ### Overview CallMissed provides an **Anthropic Messages API-compatible endpoint** alongside the OpenAI-compatible API. If you're already using the Anthropic SDK, you can switch to CallMissed by changing only the `base_url`. **Endpoints:** - `POST /v1/messages` — chat completions (streaming + non-streaming) - `POST /v1/messages/count_tokens` — token count estimation (real BPE, not char-based) - `GET /anthropic/v1/models` — list models in Anthropic shape with capability metadata - `GET /anthropic/v1/models/{model_id}` — single model detail - `POST /anthropic/v1/messages` — alternate path for the chat endpoint **Authentication:** Use either header style: - `x-api-key: cm_your_key` (Anthropic SDK default) - `Authorization: Bearer cm_your_key` (OpenAI style) ### Basic Usage ```python [Python] import anthropic client = anthropic.Anthropic( api_key="cm_your_key", base_url="https://api.callmissed.com" ) message = client.messages.create( model="gpt-5.6-sol", max_tokens=1024, system="You are a helpful assistant.", messages=[ {"role": "user", "content": "What is the capital of India?"} ] ) print(message.content[0].text) ``` ```javascript [JavaScript] import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com", }); const message = await client.messages.create({ model: "gpt-5.6-sol", max_tokens: 1024, system: "You are a helpful assistant.", messages: [ { role: "user", content: "What is the capital of India?" }, ], }); console.log(message.content[0].text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-5.6-sol", "max_tokens": 1024, "system": "You are a helpful assistant.", "messages": [ {"role": "user", "content": "What is the capital of India?"} ] }' ``` **Response:** ```json { "id": "msg-abc123def456", "type": "message", "role": "assistant", "content": [ {"type": "text", "text": "The capital of India is New Delhi."} ], "model": "gpt-5.6-sol", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 25, "output_tokens": 12 } } ``` ### Streaming Set `stream: true` to receive Server-Sent Events with the full Anthropic streaming lifecycle: ```python [Python] import anthropic client = anthropic.Anthropic( api_key="cm_your_key", base_url="https://api.callmissed.com" ) with client.messages.stream( model="gpt-5.6-sol", max_tokens=1024, messages=[{"role": "user", "content": "Tell me a short story."}] ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ``` ```javascript [JavaScript] import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com", }); const stream = client.messages.stream({ model: "gpt-5.6-sol", max_tokens: 1024, messages: [{ role: "user", content: "Tell me a short story." }], }); for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { process.stdout.write(event.delta.text); } } ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-5.6-sol", "max_tokens": 1024, "stream": true, "messages": [ {"role": "user", "content": "Tell me a short story."} ] }' ``` **SSE event lifecycle:** ``` event: message_start → message metadata + input token count event: content_block_start → new content block begins event: content_block_delta → text chunks (repeats) event: content_block_stop → content block complete event: message_delta → stop_reason + output token count event: message_stop → stream complete ``` ### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `model` | string | Yes | Model ID (e.g. `gpt-5.6-sol`, `sarvam-105b`, `kimi-k2.6`) | | `max_tokens` | integer | Yes | Maximum tokens to generate | | `messages` | array | Yes | List of `{role, content}` objects | | `system` | string | No | System prompt (top-level, not in messages) | | `stream` | boolean | No | Enable streaming (default: false) | | `temperature` | number | No | Sampling temperature (0–1) | | `top_p` | float | No | Nucleus sampling (0–1) | | `top_k` | integer | No | Top-K sampling | | `stop_sequences` | array | No | Stop sequences | | `metadata` | object | No | Request metadata (e.g. `{"user_id": "u123"}`) | > **Note:** Unlike the OpenAI API, `max_tokens` is **required** and `system` is a **top-level parameter** (not a message with `role: "system"`). ### Model Selection Send any model ID from the [Models](https://docs.callmissed.com/docs/models) catalog — not just Anthropic-shaped names. The `model` field takes the same values as `/v1/chat/completions`. ```json { "model": "gpt-5.6-sol", "max_tokens": 1024, "messages": [...] } ``` ### Token Counting Estimate input token count before sending a request. The endpoint uses a BPE tokenizer (tiktoken `cl100k_base`) — close to Claude's real tokenizer on typical English prompts, and noticeably more accurate than char-length heuristics. ```bash curl -X POST https://api.callmissed.com/v1/messages/count_tokens \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-5.6-sol", "messages": [{"role": "user", "content": "Hello, how are you?"}], "system": "You are a helpful assistant." }' ``` **Response:** ```json {"input_tokens": 15} ``` Image content blocks contribute a fixed estimate (~258 tokens per image) rather than a fetch-and-resize pass. Tool definitions are counted against the total by serializing each to JSON and tokenizing the schema. ### Listing Models List all available models via the Anthropic-shape endpoint: ```bash curl https://api.callmissed.com/anthropic/v1/models \ -H "x-api-key: cm_your_key" ``` **Response:** ```json { "data": [ { "type": "model", "id": "gpt-5.6-sol", "display_name": "GPT-5.6 Sol", "created_at": "2023-11-14T22:13:20+00:00", "description": "Frontier model for complex professional work. Multimodal, reasoning + tools.", "category": "llm", "context_window": 1050000, "context_length": 1050000, "pricing": {"input": 5.00, "output": 30.00, "unit": "per_million_tokens", "currency": "USD"}, "supports_streaming": true, "supports_tools": true, "supports_reasoning": true, "supports_vision": true } ], "has_more": false, "first_id": "...", "last_id": "..." } ``` Fetch a single model at `GET /anthropic/v1/models/{model_id}`. ### Vision (Image Input) Send images on any model whose `supports_vision` flag is `true` in the model listing. That is the authoritative source; see the [vision list](https://docs.callmissed.com/docs/chat-completion#vision-image-input) for the current set. Models without vision reject image content with `400 invalid_request_error` before the upstream call — you are not charged. ```bash curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-5.6-sol", "max_tokens": 1024, "messages": [{ "role": "user", "content": [ {"type": "text", "text": "What is in this image?"}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}} ] }] }' ``` ### Error Format Errors return the Anthropic format (different from the OpenAI endpoints): ```json { "type": "error", "error": { "type": "authentication_error", "message": "Invalid API key" } } ``` | Error type | HTTP Status | When | |------------|-------------|------| | `authentication_error` | 401 | Bad or missing API key | | `permission_error` | 403 | Account inactive, domain blocked, or free tier model restriction | | `invalid_request_error` | 400/402 | Bad request or insufficient credits | | `rate_limit_error` | 429 | Plan limit or API key rate limit exceeded | | `not_found_error` | 404 | Model not found | | `api_error` | 502 | Provider failure | **Rate limit headers** are returned in Anthropic format: ``` anthropic-ratelimit-requests-limit: 60 anthropic-ratelimit-requests-remaining: 45 anthropic-ratelimit-requests-reset: 2026-05-01T00:00:00+00:00 ``` ### Differences from Anthropic This endpoint is designed to work with the Anthropic SDK out of the box. Key differences from the official Anthropic API: - **`anthropic-version` header** is accepted but not required - **Model routing** — requests can target any model in the CallMissed catalogue, not just Anthropic-shaped names. - **Token counting** uses a BPE tokenizer approximation (tiktoken `cl100k_base`). Expect ~5-10% variance from Anthropic's native counts on English prompts; larger on CJK and heavy-punctuation text. - **Tools** are supported — `tools` and `tool_choice` work as documented, and `tool_use`/`tool_result` content blocks are preserved. - **Vision** is supported on models whose `supports_vision` flag is `true`. Image content sent to text-only models is rejected with a `400 invalid_request_error` before the upstream call, so your credits are safe. - **Message Batches API** (`/v1/messages/batches`) is not implemented — use the regular `/v1/messages` endpoint. - **Billing** uses CallMissed credits, not Anthropic billing. ## Embeddings Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/knowledge) instead — it ingests, chunks, embeds and searches for you. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` This endpoint is gated by the key's **service permission**, not by a resource scope. The key needs `llm` (or `*`). There is no separate `embedding` permission — a key that can call `/v1/chat/completions` can call `/v1/embeddings`. A key without it returns `403`: ```json { "error": { "message": "This API key does not have permission for embeddings (requires LLM permission). Update key permissions in your dashboard.", "type": "invalid_request_error", "code": "permission_denied" } } ``` ### Models Both embedding models are **free-plan callable** — they are metered per input token, so your credit balance is the only governor. | Model | Dimensions | Max input | Price (per 1M input tokens) | | --- | --- | --- | --- | | `text-embedding-3-small` | 1536 | 8,192 tokens | $0.02 | | `text-embedding-3-large` | 3072 | 8,192 tokens | $0.13 | Start with `text-embedding-3-small`: it is the better price/performance choice for large corpora. Move to `-large` only when you have measured that retrieval quality is the bottleneck. Both appear in `GET /v1/models` with `"owned_by": "openai"`. ### Quickstart ```bash curl https://api.callmissed.com/v1/embeddings \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "model": "text-embedding-3-small", "input": "Where is my order?" }' ``` ```json { "object": "list", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023064255, -0.009327292, 0.015797347] } ], "model": "text-embedding-3-small", "usage": { "prompt_tokens": 5, "total_tokens": 5 } } ``` ```python from openai import OpenAI client = OpenAI( api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1", ) resp = client.embeddings.create( model="text-embedding-3-small", input=["Where is my order?", "How do I return this?"], ) vectors = [row.embedding for row in resp.data] ``` ### Request | Field | Type | Required | Default | Constraints | | --- | --- | --- | --- | --- | | `model` | `string` | Yes | — | `text-embedding-3-small` or `text-embedding-3-large` | | `input` | `string` or `string[]` | Yes | — | Up to **128 items** per request; each item non-empty, at most **100,000 characters** and within the model's 8,192-token limit. Pre-tokenised integer arrays are **not** accepted | | `encoding_format` | `string` | No | `float` | `float` or `base64` | | `dimensions` | `integer` | No | model native | `1 <= dimensions <= 3072` (large) or `1536` (small) | | `user` | `string` | No | — | At most 256 characters. An opaque end-user identifier for your own abuse tracing | #### Batching Send an array to embed up to 128 strings in one round trip. The `index` on each row matches the position in your `input` array, so you can zip the results back onto your records without re-ordering. ```json { "model": "text-embedding-3-small", "input": ["first chunk", "second chunk", "third chunk"] } ``` A batch of 129 or more returns `422`: ```json { "error": { "message": "`input` array too long: 200 items (maximum 128). Split the batch across multiple requests.", "type": "invalid_request_error", "code": "invalid_request_error" } } ``` #### Shortening vectors with `dimensions` Both models support Matryoshka-style truncation. Passing `dimensions` returns a shorter, renormalised vector — smaller index, faster search, slightly lower recall. ```json { "model": "text-embedding-3-large", "input": "hello", "dimensions": 256 } ``` `dimensions` must be between `1` and the model's native size. Anything else returns `422`. > Vectors of different lengths are not comparable. Pick one model **and** one `dimensions` value per index and keep it fixed — re-embed the whole corpus if you change either. #### `encoding_format: "base64"` `base64` returns each vector as a base64 string of little-endian `float32` values instead of a JSON array. It is roughly a third of the payload size, which matters when you are embedding thousands of chunks. ```python import base64, struct raw = base64.b64decode(resp.data[0].embedding) vector = list(struct.unpack(f"<{len(raw) // 4}f", raw)) ``` ### Response | Field | Type | Notes | | --- | --- | --- | | `object` | `string` | Always `list` | | `data[].object` | `string` | Always `embedding` | | `data[].index` | `integer` | Position in your `input` array | | `data[].embedding` | `number[]` or `string` | Float array, or a base64 string when `encoding_format` is `base64` | | `model` | `string` | The model that served the request | | `usage.prompt_tokens` | `integer` | Input tokens billed | | `usage.total_tokens` | `integer` | Same as `prompt_tokens` — embeddings have no output tokens | ### Billing Embeddings are metered on **input tokens only**. Credits are deducted as `tokens / 1,000,000 x rate`, where 1 credit = $0.01. - `text-embedding-3-small` — 2 credits per 1M input tokens - `text-embedding-3-large` — 13 credits per 1M input tokens A request that fails with a `4xx` or `5xx` is recorded in your usage log but **not charged**. Track spend with [`GET /v1/usage/summary`](https://docs.callmissed.com/docs/usage-api). ### Errors | Status | `code` | When | | --- | --- | --- | | `400` | `invalid_request_error` | Body is not a JSON object | | `400` | `context_length_exceeded` | An input item exceeds the model's 8,192-token limit | | `401` | `invalid_api_key` / `api_key_expired` | Missing, malformed, or expired key | | `402` | `insufficient_credits` | Balance is exhausted. The `X-Credits-Balance` header carries the current balance | | `402` | `budget_exceeded` | The key's own budget cap was hit | | `403` | `permission_denied` | Key lacks the `llm` permission | | `403` | `model_not_allowed` | The key's `allowed_models` list excludes this model | | `404` | `model_not_found` | Unknown embedding model id | | `413` | `invalid_request_error` | An input item is over 100,000 characters | | `422` | `invalid_request_error` | Batch too long, empty input, bad `dimensions`, unsupported `encoding_format`, or a token array instead of a string | | `429` | `rate_limit_exceeded` | Per-key request rate exceeded. Retry with backoff | | `429` | `quota_exceeded` | Plan or monthly budget cap reached. Honour `Retry-After` | | `502` | `upstream_error` | Embedding generation failed. Safe to retry | | `503` | `service_unavailable` | Temporary capacity problem. Retry with backoff | Every error uses the standard envelope: ```json { "error": { "message": "…", "type": "invalid_request_error", "code": "model_not_found", "request_id": "req_…" } } ``` ### Building a search index 1. Chunk your documents to roughly 200–500 tokens with a little overlap. 2. Embed chunks in batches of 128 with `text-embedding-3-small`. 3. Store `{id, text, vector, metadata}` in your vector database. 4. At query time, embed the query with the **same model and `dimensions`**, then retrieve by cosine similarity. 5. Pass the top chunks to [`POST /v1/chat/completions`](https://docs.callmissed.com/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) ``` --- # Speech ## Speech to Text Source: https://docs.callmissed.com/docs/speech-to-text Transcribe audio to text with our Indic saaras model and 22 Indic language support. - [Real-time STT](https://docs.callmissed.com/docs/stt-realtime): Stream audio for live transcription - [Translation](https://docs.callmissed.com/docs/stt-translation): Transcribe and translate in one call - [Batch](https://docs.callmissed.com/docs/stt-batch): Process large audio files asynchronously - [Indic Models](https://docs.callmissed.com/docs/models-indic): saaras:v3 and other Indic STT models ### Overview The Speech to Text API transcribes audio files into text. Uses our saaras:v3 model with support for 22 Indian languages + English. Supports auto language detection. **Endpoint:** `POST /v1/audio/transcriptions` #### How transcription works - **Your app**: Upload an audio file (WAV/MP3) to `POST /v1/audio/transcriptions` - **CallMissed gateway**: Validate the key, detect language (or use `language`), apply `mode` - **saaras:v3**: Run speech recognition across 22 Indic languages + English - **Your app**: Receive `text` (plus word timestamps in `verbose_json`) > **Tip:** Leave `language` unset and saaras:v3 auto-detects it. Set `mode=translate` to get English text out of any supported language in a single call. ### Basic Usage ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) with open("audio.wav", "rb") as f: response = client.audio.transcriptions.create( model="saaras:v3", file=f ) print(response.text) ``` ```javascript [JavaScript] import OpenAI from "openai"; import fs from "fs"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.audio.transcriptions.create({ model: "saaras:v3", file: fs.createReadStream("audio.wav"), }); console.log(response.text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/transcriptions \ -H "Authorization: Bearer cm_your_key" \ -F file=@audio.wav \ -F model=saaras:v3 ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `model` | string | `saaras:v3`, `saaras:v4`, or any other STT model ID | | `file` | file | Audio file (WAV, MP3, etc.) | | `language` | string | Language code (auto-detected if omitted) | | `mode` | string | Output mode — see below | | `response_format` | string | `json`, `text`, or `verbose_json` | | `timestamp_granularities[]` | array | `["word"]` for word-level timestamps (OpenAI-compatible) | ### Output Modes | Mode | Description | |------|-------------| | `transcribe` | Standard transcription (default) | | `translate` | Transcribe and translate to English | | `verbatim` | Exact transcription including filler words | | `translit` | Transliteration to Latin script | | `codemix` | Code-mixed output (Indic + English) | `saaras:v4` serves all five modes on one model across 24 languages, and is free-tier like `saaras:v3`: ```bash curl -X POST https://api.callmissed.com/v1/audio/transcriptions \ -H "Authorization: Bearer cm_your_key" \ -F file=@audio.wav \ -F model=saaras:v4 \ -F mode=codemix ``` ### Deepgram feature parameters When you select a Deepgram model (`deepgram-nova-3`, `deepgram-nova-2`, `deepgram-flux-general-en`, etc.), these extra form fields are accepted. They are ignored for non-Deepgram models. Model-restricted features are dropped automatically when the chosen model doesn't support them. | Parameter | Type | Description | |-----------|------|-------------| | `diarize` | boolean | Label each speaker (`[Speaker 0]`, `[Speaker 1]`, …) | | `utterances` | boolean | Segment the transcript into utterances | | `utt_split` | number | Silence gap (seconds) used to split utterances | | `paragraphs` | boolean | Split the transcript into paragraphs | | `numerals` | boolean | Write numbers as digits (e.g. "five" → "5") | | `measurements` | boolean | Abbreviate measurement units (English) | | `dictation` | boolean | Convert spoken "comma"/"period" to punctuation (English) | | `profanity_filter` | boolean | Mask recognized profanity with `****` | | `filler_words` | boolean | Keep "uh"/"um" (Nova / Nova-2 / Nova-3) | | `multichannel` | boolean | Transcribe each audio channel independently | | `detect_entities` | boolean | Tag entities like names and locations (English) | | `detect_language` | string | `true` to auto-detect, or repeat with codes to restrict | | `redact` | string | `pci`, `pii`, `phi`, `numbers`, or a specific entity type (repeatable) | | `keyterm` | string | Boost recognition of a term/phrase (Nova-3 + Flux; repeatable) | | `keywords` | string | `keyword:intensifier` boost/suppress (Nova-2 / legacy; repeatable) | | `search` | string | Phonetically search the audio for a term (repeatable) | | `replace` | string | `find:replacement` substitution (repeatable) | #### Dialects & locales Deepgram models accept locale-specific language codes so you can pin a dialect for best accuracy. Pass the code in the `language` field. Each model's exact dialect list is published in the `dialects` array on `GET /v1/models`. Examples: - **English:** `en-US`, `en-GB`, `en-IN`, `en-AU`, `en-NZ`, `en-CA`, `en-IE` - **Spanish:** `es`, `es-419` (Latin America) - **Portuguese:** `pt-BR`, `pt-PT` - **Chinese:** `zh-CN`, `zh-TW`, `zh-HK` (Cantonese) - **Multilingual code-switching:** `multi` (Nova-3, Nova-2, Flux multilingual) ## Audio Translation Source: https://docs.callmissed.com/docs/stt-translation Translate audio in any supported language to English text. OpenAI-compatible endpoint. ### Overview Translates speech in any of 23 supported languages to **English text**. OpenAI-compatible `/v1/audio/translations`. Unlike [Speech to Text](https://docs.callmissed.com/docs/speech-to-text) (which transcribes in the original language), this endpoint always outputs English. **Endpoint:** `POST /v1/audio/translations` **Supported input languages (23):** Hindi, Bengali, Tamil, Telugu, Kannada, Malayalam, Marathi, Gujarati, Punjabi, Odia, Assamese, Urdu, Nepali, Konkani, Kashmiri, Sindhi, Sanskrit, Santali, Manipuri, Bodo, Maithili, Dogri, English. Omit `language` to auto-detect. ### Basic Usage ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) # Translate Hindi audio to English text with open("hindi_audio.wav", "rb") as f: translation = client.audio.translations.create( model="saaras:v3", file=f, ) print(translation.text) ``` ```javascript [JavaScript] import OpenAI from "openai"; import fs from "fs"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const translation = await client.audio.translations.create({ model: "saaras:v3", file: fs.createReadStream("hindi_audio.wav"), }); console.log(translation.text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/translations \ -H "Authorization: Bearer cm_your_key" \ -F model=saaras:v3 \ -F file=@hindi_audio.wav ``` **Response:** ```json {"text": "Hello, how are you? I wanted to discuss the project."} ``` ### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `file` | file | Yes | Audio file (WAV, MP3, AAC, OGG, FLAC, WebM, M4A) | | `model` | string | No | Model ID (default: `saaras:v3`; `saaras:v4` also translates to English) | | `response_format` | string | No | `json` (default), `text`, or `verbose_json` | | `temperature` | float | No | Sampling temperature | | `prompt` | string | No | Prompt to guide transcription style | ### Response Formats #### json (default) ```json {"text": "Hello, how are you?"} ``` #### text Returns plain text with no JSON wrapping. #### verbose_json ```json { "task": "translate", "language": "en", "duration": 4.52, "text": "Hello, how are you?", "segments": [], "words": [] } ``` > **Tip:** For transcription in the original language (not translated), use [Speech to Text](https://docs.callmissed.com/docs/speech-to-text) instead. For output modes like transliteration or code-mixing, use the `mode` parameter on the transcription endpoint. ## Real-time STT Source: https://docs.callmissed.com/docs/stt-realtime Real-time speech-to-text transcription via WebSocket. ### Overview Real-time STT is available through the **Voice Agent WebSocket** pipeline. Audio is streamed as PCM s16le 16kHz mono, and transcripts are returned in real-time as the user speaks. There is no standalone real-time STT WebSocket endpoint — real-time transcription is part of the full Voice Agent pipeline (STT → LLM → TTS). For file-based transcription, use the [Speech to Text](https://docs.callmissed.com/docs/speech-to-text) REST API. ### Via Voice Agent Connect to `WS /ws/voice-agent`, send audio chunks, and receive `transcript` messages: ```json {"type": "transcript", "text": "Hello, how are you?", "is_final": true} ``` ### Example ```javascript [JavaScript] const ws = new WebSocket( "wss://api.callmissed.com/ws/voice-agent?key=cm_your_key" ); ws.onopen = () => { // Send configuration ws.send(JSON.stringify({ type: "config", bot_id: "your-bot-id", stt_language: "hi-IN", tts_voice: "shubh", })); // Stream audio from microphone navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" }); recorder.ondataavailable = (e) => ws.send(e.data); recorder.start(250); // send chunks every 250ms }); }; ws.onmessage = (event) => { if (typeof event.data === "string") { const msg = JSON.parse(event.data); if (msg.type === "transcript") { console.log("User said:", msg.text); } else if (msg.type === "llm_token") { process.stdout.write(msg.token); } } else { // Binary data = TTS audio chunk (MP3) playAudio(event.data); } }; ``` ```python [Python] import asyncio import websockets import json async def realtime_stt(): uri = "wss://api.callmissed.com/ws/voice-agent?key=cm_your_key" async with websockets.connect(uri) as ws: # Send config await ws.send(json.dumps({ "type": "config", "bot_id": "your-bot-id", "stt_language": "hi-IN", })) # Send audio file as chunks with open("recording.wav", "rb") as f: while chunk := f.read(16000): # 0.5s chunks at 16kHz await ws.send(chunk) await asyncio.sleep(0.25) # Listen for transcripts async for message in ws: if isinstance(message, str): data = json.loads(message) if data["type"] == "transcript": print(f"Transcript: {data['text']}") asyncio.run(realtime_stt()) ``` See the [Voice Agent](https://docs.callmissed.com/docs/voice-agent) page for the full WebSocket protocol and all message types. ## Batch STT Source: https://docs.callmissed.com/docs/stt-batch Batch speech-to-text transcription with speaker diarization for call analytics. ### Overview Batch STT with **speaker diarization** is available through the Call Analytics API. Upload audio files and receive: - Diarized transcript (SPEAKER_1, SPEAKER_2, etc.) - Per-speaker timing breakdown - LLM-powered analysis across 9 dimensions - Structured summaries **Supported formats:** WAV, MP3, MP4, M4A, OGG, FLAC, WebM, AAC, AMR (max 100 MB) ### Analyze a Recording ``` POST /api/v1/analytics/calls/analyze Authorization: Bearer Content-Type: multipart/form-data ``` ```python [Python] import requests response = requests.post( "https://api.callmissed.com/api/v1/analytics/calls/analyze", headers={"Authorization": "Bearer YOUR_ACCESS_TOKEN"}, files={"file": open("call_recording.wav", "rb")}, data={"language": "hi-IN"}, ) result = response.json() print(result["transcript"]) print("Speaker timing:", result["speaker_timing"]) print("Analysis:", result["analysis"]) ``` ```javascript [JavaScript] const formData = new FormData(); formData.append("file", fs.createReadStream("call_recording.wav")); formData.append("language", "hi-IN"); const response = await fetch( "https://api.callmissed.com/api/v1/analytics/calls/analyze", { method: "POST", headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" }, body: formData, } ); const result = await response.json(); console.log(result.transcript); console.log("Speaker timing:", result.speaker_timing); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/analytics/calls/analyze \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -F file=@call_recording.wav \ -F language=hi-IN ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | Yes | Audio file (max 100 MB) | | `language` | string | No | BCP-47 language code (default: `unknown` for auto-detect) | ### Ask Questions After analyzing a call, ask follow-up questions about the transcript: ```python [Python] response = requests.post( "https://api.callmissed.com/api/v1/analytics/calls/question", headers={ "Authorization": "Bearer YOUR_ACCESS_TOKEN", "Content-Type": "application/json", }, json={ "transcript": result["transcript"], "question": "Did the customer agree to the terms?" }, ) print(response.json()["answer"]) ``` ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/analytics/calls/question \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "transcript": "SPEAKER_1: Hello...\nSPEAKER_2: Hi...", "question": "Did the customer agree to the terms?" }' ``` ### Response Format ```json { "transcript": "SPEAKER_1: Namaste, main aapki kaise madad kar sakta hoon?\nSPEAKER_2: Mujhe apne order ke baare mein jaanna hai.", "speaker_timing": { "SPEAKER_1": 45.2, "SPEAKER_2": 38.7 }, "analysis": "Customer Sentiment: Neutral\nResolution: Resolved\nAgent Performance: Good\n...", "summary": "Order inquiry, resolved successfully", "generated_at": "2026-04-12T10:00:00Z" } ``` The analysis covers 9 dimensions: sentiment, intent, resolution, agent performance, escalation risk, key topics, action items, compliance, and satisfaction score. See the [Call Analytics Cookbook](https://docs.callmissed.com/docs/call-analytics) for a full walkthrough with real examples. ## Text to Speech Source: https://docs.callmissed.com/docs/text-to-speech Convert text to natural-sounding speech with our Indic TTS. ### Overview The Text to Speech API converts text into audio — Indic languages, 37 voices across 11 languages. **Endpoint:** `POST /v1/audio/speech` - **Your app**: Send text + a `voice` and `language` to `POST /v1/audio/speech` - **bulbul:v3**: Synthesize speech in the chosen voice and `response_format` - **Your app**: Receive the audio stream and play or save it ### Basic Usage ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) response = client.audio.speech.create( model="bulbul:v3", voice="shubh", input="Namaste, kaise hain aap?" ) response.stream_to_file("speech.mp3") ``` ```javascript [JavaScript] import OpenAI from "openai"; import fs from "fs"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.audio.speech.create({ model: "bulbul:v3", voice: "shubh", input: "Namaste, kaise hain aap?", }); const buffer = Buffer.from(await response.arrayBuffer()); fs.writeFileSync("speech.mp3", buffer); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/speech \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model": "bulbul:v3", "input": "Namaste, kaise hain aap?", "voice": "shubh"}' \ --output speech.mp3 ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `model` | string | `bulbul:v3` | | `input` | string | Text to synthesize | | `voice` | string | Voice ID — default `shubh` (37 voices available) | | `language` | string | Language code (e.g. `hi-IN`, `ta-IN`) | | `speed` | number | Speech speed (default 1.0). `bulbul:v3` 0.5–2.0, `gpt-4o-mini-tts` 0.25–4.0, `deepgram-aura-2`/`-1` 0.7–1.5. Not supported by `aura-2-en`, `aura-2-es`, `melotts`, `gnani-timbre-v2.0` | | `speech_sample_rate` | integer | 8000, 16000, 22050, 24000, or 48000 Hz | | `response_format` | string | Output format — see below | | `temperature` | number | Expressiveness, 0.01–2.0. `bulbul:v3` only — higher is more expressive, lower is more consistent. Defaults to 0.9 (warmer than the model's flat default) | | `instructions` | string | Natural-language delivery direction — tone, emotion, accent, pacing. `gpt-4o-mini-tts` only. Max 2000 chars. Example: `"Speak slowly and warmly, like you're reassuring someone."` | | `humanize` | boolean | Default `true`. Shapes your text for natural speech before synthesis — strips markdown and emoji, speaks URLs and emails as words, groups long digit runs into readable chunks. Set `false` to synthesize your text byte-for-byte | | `stream` | boolean | Deepgram Aura-2 only — stream audio as it's generated (lower time-to-first-byte) | ### Audio Formats Supported values for `response_format`: `mp3`, `opus`, `aac`, `flac`, `wav`, `pcm` ### Making speech sound human Naturalness comes from three places, in order of impact: **1. The text you send.** Every engine sounds more human when the input reads like speech rather than like a screen. `humanize` (on by default) handles the mechanical part — markdown, emoji, `https://callmissed.com` → "callmissed dot com", `2039123456` → `203.912.3456`. Beyond that, write short sentences and use contractions; if an LLM generates your text, tell it that its output will be spoken aloud. **2. Expressiveness parameters**, where the model supports them: ```bash # bulbul:v3 — temperature is its expressiveness control curl -X POST https://api.callmissed.com/v1/audio/speech \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model": "bulbul:v3", "input": "Bilkul, main abhi check karta hoon.", "voice": "shubh", "temperature": 1.1}' \ --output speech.mp3 # gpt-4o-mini-tts — direct the performance in plain language curl -X POST https://api.callmissed.com/v1/audio/speech \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model": "gpt-4o-mini-tts", "input": "Your order shipped this morning.", "voice": "nova", "instructions": "Cheerful and upbeat, like sharing good news with a friend."}' \ --output speech.mp3 ``` **3. Pauses in the text.** `deepgram-aura-2`, `deepgram-aura-1`, `aura-2-en` and `aura-2-es` read pause cues written into your text: `...` gives a longer natural pause, a comma or period gives a short one, and `um`/`uh` render as natural hesitation. ```json {"model": "deepgram-aura-2", "input": "Let me pull that up... okay, found it."} ``` `bulbul:v3`, `gnani-timbre-v2.0` and `deepgram-flux-tts` do not support pause markup or SSML — they would speak the dots aloud. Use sentence length and real punctuation for rhythm on those models. ### Choosing an expressive voice | Want | Use | |------|-----| | Direct the emotion in words | `gpt-4o-mini-tts` with `instructions` | | Indian languages, warm delivery | `bulbul:v3` with `temperature` 0.9–1.2 | | Pauses and hesitation in text | `deepgram-aura-2` (91 voices, many tagged expressive/cheerful) | | Lowest cost | `melotts` — no expressive controls; rely on `humanize` | | English voice agents, turn-aware prosody | `deepgram-flux-tts` (11 English voices, default `priya`) — no expressive controls or SSML | ### Streaming (Deepgram Aura-2) For `deepgram-aura-2` and `deepgram-aura-1`, set `"stream": true` to receive audio frames as they're synthesized over Deepgram's low-latency WebSocket, relayed to you as a chunked HTTP response. Ideal for real-time playback where you want the first audio bytes as fast as possible. Streaming supports **raw encodings only** — `response_format` must be `linear16` (or `pcm`/`wav`), `mulaw`, or `alaw`. Compressed formats (`mp3`, `opus`, `aac`, `flac`) are not WebSocket-streamable; if you request one with `stream:true`, the full audio is returned in one buffered response instead. ```bash [cURL] curl -N -X POST https://api.callmissed.com/v1/audio/speech \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model": "deepgram-aura-2", "voice": "thalia", "input": "Streaming hello.", "response_format": "linear16", "stream": true}' \ --output speech.raw ``` Billing is identical to the non-streaming path (per character). Other providers ignore `stream` and return the full audio in one response. ## Voices Source: https://docs.callmissed.com/docs/tts-voices Available voices for text-to-speech synthesis. ### Indic Voices **bulbul:v3** provides **37 voices** spanning 11 Indian languages (`bn-IN`, `en-IN`, `gu-IN`, `hi-IN`, `kn-IN`, `ml-IN`, `mr-IN`, `od-IN`, `pa-IN`, `ta-IN`, `te-IN`). Pass the voice ID as the `voice` parameter and the target `language`. The default voice is `shubh`; an unrecognized voice falls back to `shubh`. ```text shubh · aditya · ritu · priya · neha · rahul · pooja · rohan · simran · kavya amit · dev · ishita · shreya · ratan · varun · manan · sumit · roopa · kabir aayan · ashutosh · advait · anand · tanya · tarun · sunny · mani · gokul · vijay shruti · suhani · mohit · kavitha · rehan · soham · rupali ``` Preview every voice in the [Playground](https://platform.callmissed.com/playground/tts). **gnani-timbre-v2.0** provides **24 voices** across English and Hindi with context-aware tone for telephony-grade delivery. The default voice is `Karan`; an unrecognized voice falls back to the default. ```text Karan · Pranav · Deepak · Raju · Kaveri · Simran · Shubhra · Nara · Riya · Trupti Vikrant · Viraj · Shlok · Omkar · Tanmay · Girish · Roopesh · Devika · Poorvi · Nalini Bhavna · Yashvi · Urmila · Chitra ``` > **Other TTS providers** also expose voices via the same `POST /v1/audio/speech` endpoint — **aura-2-en** (40 English voices, default `luna`), **aura-2-es** (10 Spanish voices), **deepgram-aura-2** (91 voices across English, Spanish, German, French, Dutch, Italian, and Japanese via the direct Deepgram API, default `thalia`), **deepgram-aura-1** (12 legacy English voices via the direct Deepgram API at half the Aura-2 rate, default `asteria`), and **gpt-4o-mini-tts** (`alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`). See [Credits & Rate Limits](https://docs.callmissed.com/docs/credits-rate-limits) for per-model pricing. ### Flux TTS Voices **deepgram-flux-tts** provides **11 English voices** for streaming voice agents — synthesis is turn-based and prosody carries across turns. The default voice is `priya`, an Indian-accented English voice; an unrecognized voice falls back to the default. ```text alexis · bruce · cole · drew · haley · heather jack · marcus · priya · rufus · sharon ``` English only — a multilingual voice set is planned for a later release. The model exposes no expressive/emotion/style controls and does not interpret SSML. --- # Voice Agents ## Voice Agent Source: https://docs.callmissed.com/docs/voice-agent Real-time voice AI agent powered by LiveKit (WebRTC) — native speech-to-speech with Nova 2 Sonic by default, plus STT→LLM→TTS fallback. - [Voice Sessions API](https://docs.callmissed.com/docs/voice-sessions-api): Create sessions and generate LiveKit tokens - [Voice SDK](https://docs.callmissed.com/docs/voice-sdk): Client SDK for browser and mobile WebRTC - [Real-time STT](https://docs.callmissed.com/docs/stt-realtime): Streaming speech-to-text over WebSocket - [Text to Speech](https://docs.callmissed.com/docs/text-to-speech): Indic TTS for agent responses ### Overview The Voice Agent is a real-time conversational AI powered by **LiveKit** (open-source WebRTC). By default it uses a native speech-to-speech model: ``` Mic (WebRTC) → Nova 2 Sonic (speech-to-speech) → Speaker (WebRTC) ``` Nova 2 Sonic handles speech understanding, reasoning, turn-taking, function calling, and speech output in one model. If the speech-to-speech model is unavailable, the agent falls back to a cascaded STT→LLM→TTS stack automatically so sessions still connect. **Default stack:** - **LLM / voice:** `nova-sonic-2` (Amazon Nova 2 Sonic, speech-to-speech, 16 voices) - **Fallback STT:** `saaras:v3` (streaming, 23 languages) - **Fallback LLM:** `gpt-oss-120b` (fast no-think pipeline model) - **Fallback TTS:** `bulbul:v3` (streaming, 37 voices) - **Transport:** LiveKit (WebRTC) ### Architecture You create a session over REST and receive a LiveKit room URL + token. Your client connects to that room with the `livekit-client` SDK; the CallMissed voice agent joins automatically and handles the speech pipeline. Audio flows over WebRTC — there is no direct WebSocket between your client and the CallMissed API. - **Browser (livekit-client SDK)**: Captures mic audio and streams it over WebRTC - **LiveKit room**: WebRTC transport that connects your client to the voice agent - **CallMissed voice agent**: Runs Nova Sonic speech-to-speech, or falls back to the STT → LLM → TTS loop - **Browser**: Receives synthesized speech back over WebRTC and plays it #### One conversational turn With Nova Sonic selected, every turn stays in one speech-to-speech model. With a cascaded model selected (or when the speech-to-speech model is unavailable), every turn streams through STT, LLM, and TTS concurrently to minimize time-to-first-audio: - **STT**: Streams partial transcripts as the user speaks, finalizes on end-of-speech - **LLM**: Generates the reply at high throughput and pushes sentence chunks downstream - **TTS**: Synthesizes each sentence chunk as it arrives — playback starts before generation finishes ### Quickstart **1. Create a session:** ```bash curl -X POST https://api.callmissed.com/v1/voice/sessions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "system_prompt": "You are a helpful assistant.", "voice": "shubh", "language": "en-IN", "llm_model": "kimi-k2.5" }' ``` **Response:** ```json { "id": "uuid", "ws_url": "wss://livekit.callmissed.com", "token": "eyJhbGciOi...", "status": "created" } ``` **2. Connect via LiveKit client:** ```javascript import { Room, RoomEvent, Track } from "livekit-client"; const room = new Room(); room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => { if (track.kind === Track.Kind.Audio) { const el = track.attach(); document.body.appendChild(el); } }); room.on(RoomEvent.TranscriptionReceived, (segments, participant) => { for (const seg of segments) { if (seg.final) { const who = participant?.isLocal ? "You" : "Agent"; console.log(who + ": " + seg.text); } } }); await room.connect(session.ws_url, session.token); await room.localParticipant.setMicrophoneEnabled(true); ``` The agent joins automatically, greets the user, and responds to speech. ### Configuration | Field | Type | Default | Description | |-------|------|---------|-------------| | `system_prompt` | string | "You are a helpful voice assistant..." | System prompt for LLM | | `voice` | string | `shubh` | TTS voice ID (37 voices available) | | `language` | string | `en-IN` | Language code for STT and TTS | | `llm_model` | string | `kimi-k2.5` | LLM model (`kimi-k2.5`, `sarvam-105b`, or any catalog model). `kimi-k2.5-fast` is currently under maintenance. | | `tts_provider` | string | `sarvam` | TTS provider (currently only `sarvam`) | | `max_duration_seconds` | int | 300 | Max session duration (30-3600) | ### Features - **Interruption handling** — speak while the agent is talking and it stops immediately, listens to you - **STT-based turn detection** — server-side VAD detects speech start/end with low-latency (~50ms) endpointing - **Preemptive generation** — LLM starts generating before STT fully confirms the transcript - **Streaming pipeline** — each stage streams to the next, no buffering between stages - **Session management** — REST API for creating, listing, deleting sessions and retrieving transcripts - **Per-model pricing** — usage tracked and billed per model ($0.81/$4.05 per 1M tokens) ### Legacy WebSocket The direct WebSocket endpoint is still available for backward compatibility: ``` WS /ws/voice-agent?key=cm_your_api_key ``` Send a config message after connecting, then stream PCM audio. This uses the custom backend pipeline (not LiveKit). See the [Session API](https://docs.callmissed.com/docs/voice-sessions-api) for the recommended LiveKit-based approach. ## Voice Session API Source: https://docs.callmissed.com/docs/voice-sessions-api REST API for creating and managing LiveKit-based voice agent sessions. ### Overview The Voice Session API provides a two-step flow for voice agent interactions: 1. **Create a session** via REST — returns a LiveKit room URL + JWT 2. **Connect via LiveKit WebRTC** — stream audio with the `livekit-client` SDK; the agent joins automatically and handles STT → LLM → TTS Audio flows over WebRTC to the LiveKit room. There is **no direct WebSocket between the browser and the CallMissed API** — the REST endpoints handle session metadata, token issuance, usage tracking, and transcript storage. **Authentication:** All REST endpoints accept both **JWT** (`Authorization: Bearer `) and **API key** (`Authorization: Bearer cm_`). API keys must have `stt`, `tts`, and `llm` permissions to create a session. ### Create Session ```bash curl -X POST https://api.callmissed.com/v1/voice/sessions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "system_prompt": "You are a helpful assistant.", "voice": "shubh", "language": "en-IN", "llm_model": "kimi-k2.5", "tts_provider": "sarvam", "max_duration_seconds": 300, "webhook_url": "https://your-app.com/webhooks/voice" }' ``` **Response (201 Created):** ```json { "id": "7c2b9e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "tenant_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "bot_id": null, "status": "created", "config": { "system_prompt": "You are a helpful assistant.", "voice": "shubh", "language": "en-IN", "llm_model": "kimi-k2.5", "tts_provider": "sarvam", "max_duration_seconds": 300, "livekit_room": "voice-" }, "ws_url": "wss://livekit.callmissed.com", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "started_at": null, "ended_at": null, "duration_seconds": null, "turn_count": 0, "total_audio_seconds": 0, "end_reason": null, "metadata": null, "created_at": "2026-04-19T12:00:00Z" } ``` - `ws_url` is the **LiveKit server** URL — not the CallMissed API. - `token` is a **LiveKit JWT** (not an opaque `vs_*` string). TTL is **1 hour**. - The token is returned **once** on creation and is not fetchable again. ### Request Body | Field | Type | Default | Notes | |-------|------|---------|-------| | `bot_id` | uuid | — | Optional bot to load prompt/knowledge from | | `system_prompt` | string | "You are a helpful voice assistant..." | Max 4096 chars. Overrides bot's prompt if both set | | `voice` | string | `shubh` | TTS voice ID (37 voices) | | `language` | string | `en-IN` | BCP-47 language for STT + TTS | | `llm_model` | string | `kimi-k2.5` | Any catalog LLM (`sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.6`, `gpt-5.6-luna`, …). `kimi-k2.5-fast` is under maintenance. | | `tts_provider` | string | `sarvam` | Currently `sarvam` only | | `max_duration_seconds` | int | `300` | 30–3600 | | `webhook_url` | string | — | Receives session events (see below) | | `metadata` | object | — | Arbitrary JSON stored with the session | ### Connect via LiveKit Use `ws_url` + `token` returned by create. **Do not** try to open a WebSocket to the CallMissed API — use the LiveKit client: ```bash npm install livekit-client ``` ```javascript import { Room, RoomEvent, Track } from "livekit-client"; const session = await fetch("https://api.callmissed.com/v1/voice/sessions", { method: "POST", headers: { "Authorization": "Bearer cm_your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ system_prompt: "You are a helpful assistant.", voice: "shubh", language: "en-IN", llm_model: "kimi-k2.5", }), }).then(r => r.json()); const room = new Room(); room.on(RoomEvent.TrackSubscribed, (track) => { if (track.kind === Track.Kind.Audio) { document.body.appendChild(track.attach()); } }); room.on(RoomEvent.TranscriptionReceived, (segments, participant) => { for (const seg of segments) { if (!seg.final) continue; const who = participant?.isLocal ? "You" : "Agent"; console.log(who + ": " + seg.text); } }); await room.connect(session.ws_url, session.token); await room.localParticipant.setMicrophoneEnabled(true); ``` The agent joins the room, greets the user, listens for speech, and responds. Server-side VAD detects speech boundaries; interruptions are handled automatically (speak while the agent is talking and it stops and listens). ### List Sessions ```bash curl "https://api.callmissed.com/v1/voice/sessions?status=completed&limit=50&offset=0" \ -H "Authorization: Bearer cm_your_api_key" ``` **Query parameters:** | Param | Values | Default | |-------|--------|---------| | `status` | `created` / `active` / `completed` / `failed` / `timeout` | — | | `limit` | 1–200 | 50 | | `offset` | ≥ 0 | 0 | Returns an array of `VoiceSessionOut` objects (same shape as Get Session). ### Get Session ```bash curl https://api.callmissed.com/v1/voice/sessions/{id} \ -H "Authorization: Bearer cm_your_api_key" ``` Response contains everything from the create response **except** `ws_url` and `token` — those are issued once at creation. ### Get Transcript ```bash curl "https://api.callmissed.com/v1/voice/sessions/{id}/transcript?format=json" \ -H "Authorization: Bearer cm_your_api_key" ``` **`format` query param** — defaults to `json`: | Format | Content-Type | Shape | |--------|--------------|-------| | `json` | application/json | Array of turns: `turn_index`, `user_transcript`, `agent_response`, `interrupted`, `stt_ms`, `first_token_ms`, `first_audio_ms`, `total_ms`, `llm_model`, `created_at` | | `txt` | text/plain | Human-readable alternating `User:` / `Agent:` lines | | `srt` | application/x-subrip | SubRip subtitles with timing derived from per-turn durations | ### Delete Session ```bash curl -X DELETE https://api.callmissed.com/v1/voice/sessions/{id} \ -H "Authorization: Bearer cm_your_api_key" ``` Returns `204 No Content`. Sessions in `created` or `active` state are marked `completed` with `end_reason = "api_delete"`; already-finished sessions are left unchanged. ### Limits | Limit | Value | Behavior on exceed | |-------|-------|--------------------| | Session create rate | 10 / minute / tenant | HTTP 429 | | Concurrent active sessions (free) | 1 | HTTP 429 | | Concurrent active sessions (starter) | 5 | HTTP 429 | | Concurrent active sessions (pro) | 20 | HTTP 429 | | Concurrent active sessions (enterprise) | unlimited | — | | Minimum credit balance to create | server-configured | HTTP 402 | | Max session duration | 3600s (capped by `max_duration_seconds`) | session auto-ends | | LiveKit token TTL | 3600s (1 hour) | reconnect requires a new session | ### Webhook Events If `webhook_url` is set on session creation, the following events are delivered as `POST` requests with JSON body and HMAC-SHA256 signature: | Event | When | |-------|------| | `voice_session.started` | Session created (token issued) | | `voice_session.ended` | Session marked completed (normal finish or `DELETE`) | | `voice_session.failed` | Session entered failed state | **Delivery headers:** | Header | Value | |--------|-------| | `X-CallMissed-Event` | Event name (e.g. `voice_session.started`) | | `X-CallMissed-Delivery` | Delivery UUID (unique per attempt batch) | | `X-CallMissed-Signature` | `sha256=` HMAC of the raw body using your webhook secret | **Verify the signature:** ```python import hmac, hashlib raw_body = await request.body() # bytes — do not re-serialize received = request.headers["X-CallMissed-Signature"] # e.g. "sha256=abc123..." expected = "sha256=" + hmac.new( webhook_secret.encode(), raw_body, hashlib.sha256 ).hexdigest() assert hmac.compare_digest(expected, received) ``` ```javascript import crypto from "node:crypto"; const received = req.headers["x-callmissed-signature"]; // "sha256=..." const expected = "sha256=" + crypto .createHmac("sha256", webhookSecret) .update(rawBody) // raw Buffer / string .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) { return res.status(401).send("invalid signature"); } ``` ## Voice SDKs Source: https://docs.callmissed.com/docs/voice-sdk Python and JavaScript SDKs for building voice agents. ### Python SDK Install: ```bash pip install callmissed ``` #### Quick Start ```python import asyncio from callmissed import CallMissed, SessionConfig async def main(): async with CallMissed(jwt_token="your_token") as client: # Create session session = await client.voice.create_session( SessionConfig( system_prompt="You are a helpful assistant.", voice="shubh", llm_model="sarvam-105b", ) ) # Connect and stream async with client.voice.connect(session) as ws: ws.on("transcript", lambda t: print(f"User: {t}")) ws.on("agent_text", lambda t: print(t, end="")) ws.on("audio", lambda chunk: play_audio(chunk)) ws.on("error", lambda msg: print(f"Error: {msg}")) await ws.send_audio(pcm_bytes) await asyncio.sleep(60) # Get transcript turns = await client.voice.get_transcript(session.id) for turn in turns: print(f"User: {turn.user_transcript}") print(f"Agent: {turn.agent_response}") asyncio.run(main()) ``` #### Events | Event | Data | Description | |-------|------|-------------| | `ready` | None | Pipeline initialized | | `transcript` | str | User speech transcribed | | `agent_text` | str | Streaming LLM token | | `llm_response` | str | Complete response | | `audio` | bytes | MP3 audio chunk | | `audio_start` | None | Audio starting | | `audio_end` | None | Audio complete | | `interrupted` | None | Agent interrupted | | `error` | str | Error message | ### Browser Client (JS) Install: ```bash npm install @callmissed/voice ``` #### Quick Start ```typescript import { VoiceSession } from "@callmissed/voice"; // Get wsUrl and token from your backend (POST /v1/voice/sessions) const session = new VoiceSession({ wsUrl, token }); session.on("transcript", (text) => console.log("User:", text)); session.on("agentText", (text) => console.log("Agent:", text)); session.on("stateChange", (state) => updateUI(state)); session.on("error", (msg) => console.error(msg)); await session.connect(); // Starts mic + WebSocket // Later... session.disconnect(); ``` #### API - `new VoiceSession({ wsUrl, token, sampleRate?, echoCancellation? })` - `session.connect()` — Start mic and WebSocket - `session.disconnect()` — Stop everything - `session.on(event, handler)` — Register event handler - `session.getState()` — Current state: idle | connecting | listening | speaking - `session.getMicVolume()` — Current mic volume (0-1) ## Agent Evals Source: https://docs.callmissed.com/docs/voice-evals Regression-test a voice agent against scripted personas with pass/fail assertions, and read the transcript of every case. ### Overview An **eval suite** is a regression test for one voice agent. Each **case** in the suite gives a simulated caller a persona and an opening line, lets the conversation run for up to a fixed number of turns, and then checks the transcript against **success criteria**. Running a suite produces a **run** — a pass count plus the full transcript and per-assertion result for every case. Use it before promoting a prompt change, exactly as you would a test suite. > **Running a suite calls models and costs credits.** Everything else on this page is free. See [Billing](#billing). ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List/get suites, list cases, list/get runs | `evals:read` | | Create/update/delete suites and cases, **run a suite** | `evals:write` | ### Limits | Thing | Limit | | --- | --- | | Cases executed per run | **50** | | Turns per case | `1..20`, default `6` | | Success criteria per case | 20 | | Suite name | 255 characters | | Persona | 4,000 characters | | Opening line | 2,000 characters | A suite may **store** more than 50 cases; the cap is on what one run executes. --- ### Suites ```json { "id": "aa10…", "bot_id": "b1f2…", "name": "Booking flow — regression", "description": "Covers the happy path plus three refusals.", "scorecard_id": "sc33…", "is_active": true, "created_at": "2026-08-12T09:00:00Z", "updated_at": "2026-08-12T09:00:00Z" } ``` Attaching a `scorecard_id` adds a graded score on top of the pass/fail assertions. #### GET `/api/v1/voice/evals` Newest first. Filters: `bot_id`, `is_active`. `limit` `1..200` (default `50`), `offset` `0..100000`. #### POST `/api/v1/voice/evals` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `bot_id` | `UUID` | Yes | Must be your agent | | `name` | `string` | Yes | 1–255 characters, unique per agent | | `description` | `string` | No | At most 500 characters | | `scorecard_id` | `UUID` | No | Must be your scorecard | | `is_active` | `boolean` | No | Default `true` | Returns `201`. `409 A suite named '…' already exists for this bot` on a duplicate. #### GET / PATCH / DELETE `/api/v1/voice/evals/{suite_id}` `DELETE` returns `204` and cascades the suite's cases **and its run history**. --- ### Cases ```json { "id": "bb20…", "suite_id": "aa10…", "name": "Caller wants a Saturday slot", "persona": "An impatient customer in Pune who only has Saturdays free and dislikes being put on hold.", "opening": "Hi, can I move my appointment to Saturday?", "max_turns": 6, "success_criteria": [ { "type": "contains", "value": "Saturday", "role": "agent" }, { "type": "tool_called", "value": "reschedule_appointment" }, { "type": "max_turns_under", "value": 5 } ], "position": 0, "created_at": "2026-08-12T09:05:00Z", "updated_at": "2026-08-12T09:05:00Z" } ``` #### Success criteria | `type` | `value` | Passes when | | --- | --- | --- | | `contains` | text, at most 500 characters | The transcript contains the text | | `not_contains` | text | The transcript does not contain it | | `regex` | pattern, at most 200 characters | The pattern matches | | `tool_called` | tool name | The agent invoked that tool | | `max_turns_under` | integer `1..20` | The conversation finished in fewer turns | | `ends_with_handoff` | omitted | The call ended in a handoff to a human | Optional per criterion: `role` (`agent` — the default, `caller`, or `any`) and `case_sensitive` for the text types. Design the criteria as assertions about **outcomes**, not exact wording: `tool_called` and `ends_with_handoff` survive a prompt rewrite, `contains` on a whole sentence will not. #### GET `/api/v1/voice/evals/{suite_id}/cases` Ordered by `position`, then oldest first. `limit` `1..200` (default `100`), `offset` `0..100000`. #### POST `/api/v1/voice/evals/{suite_id}/cases` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters | | `persona` | `string` | Yes | 1–4,000 characters | | `opening` | `string` | Yes | 1–2,000 characters | | `max_turns` | `integer` | No | `1 <= n <= 20`, default `6` | | `success_criteria` | `object[]` | No | At most 20 | | `position` | `integer` | No | `0 <= position <= 10000`, default `0` | #### PATCH / DELETE `/api/v1/voice/evals/cases/{case_id}` Note the path: cases are addressed directly, **not** under their suite. --- ### Running a suite #### POST `/api/v1/voice/evals/{suite_id}/run` No body. Returns `201` with the run and every case result. ```bash curl -X POST https://api.callmissed.com/api/v1/voice/evals/aa10…/run \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "id": "run77…", "suite_id": "aa10…", "status": "completed", "started_at": "2026-08-17T08:00:00Z", "finished_at": "2026-08-17T08:01:44Z", "total_cases": 12, "passed_cases": 11, "model": "kimi-k2.6", "cost_credits": 3.812, "created_at": "2026-08-17T08:00:00Z", "results": [ { "id": "res01…", "run_id": "run77…", "case_id": "bb20…", "passed": true, "transcript": [ { "role": "caller", "content": "Hi, can I move my appointment to Saturday?" }, { "role": "agent", "content": "Of course — I can move it to Saturday." } ], "assertions": [ { "type": "contains", "value": "Saturday", "passed": true } ], "score": 0.92, "error": null, "created_at": "2026-08-17T08:00:12Z" } ] } ``` The call is **synchronous** — it returns when every case has finished, so allow a generous client timeout for a large suite. #### Nothing is charged before the work starts Checks run in this order, and a failure at any step costs nothing and writes nothing: 1. Suite and agent loaded and confirmed yours. 2. Cases fetched and the 50-case cap checked. 3. Scorecard loaded, if attached. 4. Credit balance checked. 5. Only then does any model run. | Status | Detail | | --- | --- | | `402` | `Insufficient credits to run an eval suite. Add credits to use this feature.` | | `409` | `This suite has no cases to run.` | | `422` | `A run executes at most 50 cases. Split this suite.` | | `404` | `Eval suite not found` / `Bot not found` / `Scorecard not found` | An over-cap suite is **rejected, not truncated** — a silently-shortened run would report a green result it did not earn. ### Runs #### GET `/api/v1/voice/evals/runs` Newest first. Filter by `suite_id`. `limit` `1..100` (default `25`), `offset` `0..100000`. #### GET `/api/v1/voice/evals/runs/{run_id}` The run plus its case results, oldest first, **capped at 50 results** — the same bound as a run. ### Billing Only `POST /{suite_id}/run` charges. The cost is the agent model's usage across every case, plus the scoring model when a scorecard is attached, deducted after the run completes and visible in [usage logs](https://docs.callmissed.com/docs/usage-api) as `service: "llm"`. Cost scales with `cases × (max_turns × 2 + 1)` model calls, so trimming `max_turns` is the cheapest lever. A run that completes but whose deduction fails is still returned to you in full. ### Errors | Status | When | | --- | --- | | `402` | Credit balance exhausted at the pre-run gate | | `403` | Key is missing `evals:read` / `evals:write` | | `404` | Suite, case, run, agent or scorecard not in your tenant | | `409` | Duplicate suite name, or an empty suite | | `422` | Blank name/persona/opening, over 20 criteria, an unknown criterion type, or over 50 cases in a run | ## A/B Experiments Source: https://docs.callmissed.com/docs/voice-experiments Split voice traffic across agent variants, assign callers deterministically, and read per-arm results against a chosen metric. ### Overview An **experiment** compares variants of one voice agent on a single metric. Each variant is an **arm**: a bot version, or a set of overrides (system prompt, voice, model, timing). Exactly one arm is the **control**. `traffic_split` decides what share of callers each arm gets. `POST /assign` buckets a caller into an arm deterministically, and `GET /results` reports the metric per arm. Nothing on this page consumes credits — the calls the experiment configures are billed as normal voice usage. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List/get experiments, read results | `experiments:read` | | Create, edit arms, start/stop/conclude, **assign** | `experiments:write` | `assign` needs the **write** scope — it records a durable assignment. ### Lifecycle ``` draft ──start──▶ running ──stop──▶ stopped ──conclude──▶ concluded │ ▲ └──────────────conclude────────────────┘ ``` | Status | What it means | | --- | --- | | `draft` | Being configured. The metric can still be changed | | `running` | Assigning traffic. Arms are frozen except for renaming | | `stopped` | Not assigning. Arms can be edited again, and it can restart | | `concluded` | Terminal and **immutable** — a winner is recorded and nothing can change | Stop before editing an arm; conclude only when you are done for good. ### Metrics | `metric` | Label | Better | | --- | --- | --- | | `goal_completed` | Goal completion rate | Higher | | `avg_score` | Average scorecard total | Higher | | `handoff_rate` | Human-handoff rate | Lower | | `completion_rate` | Call completion rate | Higher | | `avg_duration_seconds` | Average call duration | Lower | The deciding metric can only be changed while the experiment is a `draft` — picking the winner after seeing the data is exactly what that rule prevents. ### The experiment object ```json { "id": "ex10…", "tenant_id": "a0b1…", "bot_id": "b1f2…", "name": "Shorter opening line", "hypothesis": "A one-sentence greeting raises goal completion.", "status": "running", "traffic_split": { "arm-a-id": 50, "arm-b-id": 50 }, "metric": "goal_completed", "winner_arm_id": null, "started_at": "2026-08-14T09:00:00Z", "stopped_at": null, "created_at": "2026-08-13T09:00:00Z", "updated_at": "2026-08-14T09:00:00Z", "arms": [] } ``` `GET` and every mutating call return the detail shape, with `arms` populated oldest first. ### GET `/api/v1/voice/experiments` Newest first. Filters: `bot_id`, `status`. `limit` `1..200` (default `50`), `offset` `0..100000`. ### POST `/api/v1/voice/experiments` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `bot_id` | `UUID` | Yes | Must be your agent | | `name` | `string` | Yes | At most 255 characters, not blank, unique per agent | | `metric` | `string` | Yes | One of the five metrics | | `hypothesis` | `string` | No | At most 500 characters | Created as `draft` with an empty `traffic_split`. Returns `201`. ### Arms ```json { "id": "arm-b-id", "tenant_id": "a0b1…", "experiment_id": "ex10…", "name": "short-greeting", "bot_version_number": 12, "overrides": { "system_prompt": "Greet in one sentence, then ask how you can help.", "voice": "anushka", "timing": { "interrupt_sensitivity": 0.6, "silence_timeout_ms": 2000 } }, "is_control": false, "created_at": "2026-08-13T09:05:00Z" } ``` #### POST `/api/v1/voice/experiments/{experiment_id}/arms` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | At most 64 characters, unique within the experiment | | `bot_version_number` | `integer` | No | `1 <= n <= 1000000` | | `overrides` | `object` | No | See below | | `is_control` | `boolean` | No | Default `false`. Only one arm may be the control | **At most 6 arms per experiment.** #### `overrides` Unknown keys are rejected with `422` rather than ignored. | Field | Type | Constraints | | --- | --- | --- | | `system_prompt` | `string` | At most 8,000 characters | | `voice` | `string` | At most 64 characters | | `model` | `string` | At most 100 characters | | `timing` | `object` | See the timing table | | `node_timing` | `object` | `{ node_id: timing }`, at most 100 entries, node ids at most 64 characters | ##### Timing fields | Field | Type | Range | | --- | --- | --- | | `allow_interruptions` | `boolean` | | | `interrupt_sensitivity` | `number` | `0.0`–`1.0` | | `resume_delay_ms` | `integer` | `0`–`5000` | | `silence_timeout_ms` | `integer` | `500`–`30000` | | `max_node_duration_ms` | `integer` | `1000`–`600000` | #### PATCH / DELETE `/api/v1/voice/experiments/arms/{arm_id}` Arms are addressed directly, not under their experiment. While the experiment is `running` you may change only `name` — anything else returns `409 Stop the experiment before changing an arm's configuration`, because a mid-flight change would silently mix two configurations into one arm's numbers. Deleting an arm also removes it from `traffic_split` in the same transaction. ### Traffic split `traffic_split` maps every arm id to a whole-number percentage. | Rule | Error when broken | | --- | --- | | Must name every arm, and only arms of this experiment | `traffic_split is missing arm(s): …` / `…names arm(s) that do not belong…` | | Percentages are whole numbers in `0..100` | `traffic_split percentages must be whole numbers` | | Must sum to exactly 100 | `traffic_split must sum to 100 (got 90)` | | Exactly one arm is the control | `exactly one arm must be the control (found 0)` | Set it with `PATCH /{experiment_id}`, or pass it on start. ### Start, stop, conclude #### POST `/api/v1/voice/experiments/{experiment_id}/start` Optional body `{ "traffic_split": { … } }`; falls back to the stored split. Needs **at least two arms** — `422 An experiment needs at least two arms to compare`. #### POST `/api/v1/voice/experiments/{experiment_id}/stop` No body. `409 This experiment is not running` if it was not. #### POST `/api/v1/voice/experiments/{experiment_id}/conclude` | Field | Type | Required | | --- | --- | --- | | `winner_arm_id` | `UUID` | Yes — must be an arm of this experiment | After this the experiment is immutable. A `draft` cannot be concluded — `409 Start the experiment before concluding it`. ### Assignment #### POST `/api/v1/voice/experiments/{experiment_id}/assign` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `voice_session_id` | `UUID` | Conditional | Must be your session | | `key` | `string` | Conditional | At most 128 characters | Send at least one. When both are present the session id is the bucketing key. ```bash curl -X POST https://api.callmissed.com/api/v1/voice/experiments/ex10…/assign \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "voice_session_id": "vs99…" }' ``` ```json { "experiment_id": "ex10…", "arm_id": "arm-b-id", "arm_name": "short-greeting", "voice_session_id": "vs99…", "assignment_id": "as55…", "created": true, "assigned_at": "2026-08-17T08:20:00Z" } ``` Bucketing is **deterministic** — the same key always lands in the same arm for the same split, so a returning caller keeps their variant. Assignment by session is **idempotent**: a repeat call returns the existing row with `created: false`, and a concurrent double-call re-reads the winner rather than creating two. > A **key-only** call writes nothing. `assignment_id` comes back `null` and the result is a preview of which arm that key maps to. Use it to plan; use `voice_session_id` to record. `409 This experiment is not running; no traffic is assigned` outside the running state. ### Results #### GET `/api/v1/voice/experiments/{experiment_id}/results` No parameters. ```json { "experiment_id": "ex10…", "status": "running", "metric": "goal_completed", "metric_label": "Goal completion rate", "higher_is_better": true, "min_sample_per_arm": 30, "total_assignments": 412, "sufficient_data": true, "leader_arm_id": "arm-b-id", "winner_arm_id": null, "verdict": "short-greeting is ahead on goal completion rate", "arms": [ { "arm_id": "arm-a-id", "name": "control", "is_control": true, "sample_size": 205, "metric_value": 0.61 }, { "arm_id": "arm-b-id", "name": "short-greeting", "is_control": false, "sample_size": 207, "metric_value": 0.68 } ] } ``` | Field | Notes | | --- | --- | | `sufficient_data` | `false` while any arm has fewer than **30** assignments, or fewer than two arms have a value | | `leader_arm_id` | Currently ahead on the metric. Not a verdict | | `winner_arm_id` | Only set once you conclude | > There is deliberately **no p-value or significance field**. `sufficient_data` is a floor, not a test — treat `leader_arm_id` as a signal to keep running, and decide the winner yourself. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `experiments:read` / `experiments:write` | | `404` | Experiment, arm, agent or voice session not in your tenant | | `409` | Concluded and immutable, already running / not running, or an arm edit while running | | `422` | Over 6 arms, a second control, fewer than two arms on start, a `traffic_split` that does not add up, or a winner that is not an arm of the experiment | ## Agent Squads Source: https://docs.callmissed.com/docs/voice-squads Group specialist voice agents behind one entry point, control handoffs with a policy, dry-run the routing decision, and draft a new agent from a description. ### Overview A **squad** is several specialist voice agents behind one entry point. The entry agent answers, and hands off to a member when the caller's need matches that member's role. A **handoff policy** bounds how far that can go, so a call cannot bounce between agents forever. Two extras sit alongside the roster: - `POST /{squad_id}/simulate-handoff` — a pure dry run that shows which member *would* be picked and why. - `POST /author/draft` — describe an agent in prose and get a complete configuration proposal back. **This one costs credits.** ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List/get squads, list members, **simulate a handoff** | `squads:read` | | Create/update/delete squads and members, **draft an agent** | `squads:write` | ### Limits | Thing | Limit | | --- | --- | | Members per squad | **12**. Past that, use a call flow | | Handoffs per call | `0..5`, default `3` | | Role name | 64 characters | | Member description | 2,000 characters | --- ### Squads ```json { "id": "sq10…", "tenant_id": "a0b1…", "name": "Support desk", "description": "Front line plus billing and technical specialists.", "entry_bot_id": "b1f2…", "handoff_policy": { "max_handoffs": 3, "allow_return_to_previous": false, "min_score": 1, "fallback_role": "generalist" }, "is_active": true, "created_at": "2026-08-11T09:00:00Z", "updated_at": "2026-08-11T09:00:00Z", "members": [] } ``` #### Handoff policy | Field | Type | Default | Constraints | | --- | --- | --- | --- | | `max_handoffs` | `integer` | `3` | `0 <= n <= 5`. `0` disables handoffs entirely | | `allow_return_to_previous` | `boolean` | `false` | Leaving this `false` is what stops two agents ping-ponging a caller | | `min_score` | `integer` | `1` | `1 <= n <= 20`. The match strength a member must reach to be handed to | | `fallback_role` | `string \| null` | `null` | Role to use when nothing scores high enough | Unknown keys inside the policy are rejected with `422`. #### GET `/api/v1/voice/squads` Newest first. Filter by `is_active`. `limit` `1..200` (default `50`), `offset` `0..100000`. #### POST `/api/v1/voice/squads` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, unique per tenant | | `description` | `string` | No | At most 500 characters | | `entry_bot_id` | `UUID` | Yes | The agent that answers the call | | `entry_role` | `string` | No | 1–64 characters, default `entry` | | `entry_description` | `string` | No | At most 2,000 characters | | `handoff_policy` | `object` | No | Omit for the defaults above | | `is_active` | `boolean` | No | Default `true` | Creating a squad **automatically enrols the entry agent as the first member** at `position: 0` — you do not add it yourself. Returns `201` with the squad and its roster. #### GET / PATCH / DELETE `/api/v1/voice/squads/{squad_id}` `PATCH` takes `name`, `description`, `entry_bot_id`, `handoff_policy` (an explicit `null` clears it back to defaults) and `is_active`. `entry_bot_id` must point at an agent **already in the squad** — `422 entry_bot_id must be a bot that is already a member of this squad`. Add the member first, then promote it. `DELETE` returns `204` and cascades the roster. --- ### Members ```json { "id": "mb20…", "tenant_id": "a0b1…", "squad_id": "sq10…", "bot_id": "b7c8…", "role": "billing", "description": "Handles invoices, refunds and payment failures.", "position": 1, "created_at": "2026-08-11T09:02:00Z" } ``` `role` and `description` are what the routing engine matches a caller's utterance against — write the description as the things this agent handles, in the caller's words. #### GET `/api/v1/voice/squads/{squad_id}/members` Ordered by `position`, then oldest first. **No pagination** — the 12-member cap bounds it. #### POST `/api/v1/voice/squads/{squad_id}/members` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `bot_id` | `UUID` | Yes | Must be your agent, not already in this squad | | `role` | `string` | Yes | 1–64 characters, not blank | | `description` | `string` | No | At most 2,000 characters | | `position` | `integer` | No | `0 <= position <= 1000`, default `0` | `422 A squad holds at most 12 agents. Past that, use a call flow.` · `409 That agent is already in this squad`. #### PATCH / DELETE `/api/v1/voice/squads/members/{member_id}` `PATCH` takes `role`, `description` (explicit `null` clears it) and `position`. `bot_id` is not editable — remove the member and add the other agent. Removing the entry agent returns `409 This agent answers the call for the squad. Point entry_bot_id at another member before removing it.` --- ### Simulating a handoff #### POST `/api/v1/voice/squads/{squad_id}/simulate-handoff` Requires `squads:read`. Pure and read-only: no model call, no credits, nothing written. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `utterance` | `string` | Yes | 1–2,000 characters | | `handoffs_used` | `integer` | No | `0 <= n <= 100`, default `0` | | `current_member_id` | `UUID` | No | Who is handling the call now | | `previous_member_id` | `UUID` | No | Who handled it before — used for the ping-pong check | ```bash curl -X POST https://api.callmissed.com/api/v1/voice/squads/sq10…/simulate-handoff \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "utterance": "my card was charged twice", "handoffs_used": 1, "current_member_id": "mb01…" }' ``` ```json { "squad_id": "sq10…", "handoff": true, "target_member_id": "mb20…", "target_bot_id": "b7c8…", "target_role": "billing", "reason": "matched billing on 'charged'", "blocked_by": null, "score": 3, "handoffs_used": 1, "max_handoffs": 3, "scores": [ { "member_id": "mb20…", "role": "billing", "score": 3, "eligible": true }, { "member_id": "mb30…", "role": "technical", "score": 0, "eligible": true } ] } ``` `scores` shows every member's match strength, so a wrong route is debuggable: if the right agent scored `0`, its description is missing the words callers actually use. #### Why a handoff was blocked | `blocked_by` | Meaning | | --- | --- | | `no_members` | The squad has no one to hand to | | `max_handoffs` | The policy's handoff budget is spent | | `ping_pong` | The target is the previous member and returns are disallowed | | `already_current` | The best match is already handling the call | | `no_match` | Nothing reached `min_score` | | `unknown_role` | The configured fallback role matches no member | --- ### Drafting an agent #### POST `/api/v1/voice/squads/author/draft` Requires `squads:write`. **Charges credits.** It creates nothing — you get a proposal to review and then submit yourself via the bots API. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `description` | `string` | Yes | 20–4,000 characters, not blank | | `bot_type` | `string` | No | `inbound_call` (default), `outbound_call`, `ivr`, `whatsapp` or `whatsapp_voice` | ```bash curl -X POST https://api.callmissed.com/api/v1/voice/squads/author/draft \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "description": "An agent for a Pune dental clinic that books, moves and cancels appointments in Hindi and English, and escalates anything about pain to a human.", "bot_type": "inbound_call" }' ``` ```json { "draft": { "name": "Clinic Reception", "bot_type": "inbound_call", "system_prompt": "You are the receptionist for a dental clinic in Pune…", "objective": "Book, reschedule or cancel appointments; escalate pain reports.", "response_guidelines": "Keep replies under two sentences…", "conversation_script": "", "first_message": "Namaste, thanks for calling. How can I help?", "tools": ["book_appointment", "cancel_appointment", "handoff_to_human"], "voice_model": "…", "tts_model": "…", "stt_model": "…", "voice": "anushka", "language": "hi-IN" }, "dropped_tools": ["send_invoice"], "model": "…" } ``` `dropped_tools` lists tools the draft asked for that are not in your tool registry — they were removed so the configuration is valid on submission. Check this list: a dropped tool usually means the capability you described is not wired up yet. At most 12 tools are proposed, de-duplicated and validated against the registry. | Status | Detail | Note | | --- | --- | --- | | `402` | `Not enough credits to draft an agent. Top up to continue.` | Checked **before** any model runs — costs nothing | | `422` | `Could not draft an agent: …` | The model returned an unusable draft. **This attempt is still billed** — the work was done | | `502` | `The agent drafting service is unavailable.` | Retry | Cost appears in [usage logs](https://docs.callmissed.com/docs/usage-api) as `service: "llm"`. ### Errors | Status | When | | --- | --- | | `402` | Credit balance exhausted before drafting | | `403` | Key is missing `squads:read` / `squads:write` | | `404` | Squad, member or agent not in your tenant | | `409` | Duplicate squad or role name, agent already a member, or removing the entry agent | | `422` | Over 12 members, a blank name/role, an unknown key in `handoff_policy`, or an `entry_bot_id` that is not a member | --- # Numbers (PSTN) ## Voice Calling Source: https://docs.callmissed.com/docs/voice AI-powered inbound voice call agents via Twilio. ### Setup 1. Create a bot with `type: "inbound_call"` 2. Configure Twilio credentials in Settings 3. Set your Twilio phone number webhook to: ``` https://api.callmissed.com/api/v1/webhooks/twilio/voice ``` ### Call Flow ``` Incoming call → Twilio → POST /api/v1/webhooks/twilio/voice → Returns TwiML to open WebSocket stream → WebSocket /ws/call/{call_id} receives audio → Audio chunks → STT → text → Text → LLM → response → Response → TTS → audio → Audio streamed back to caller ``` ### WebSocket Streaming Connect to the voice WebSocket for real-time audio. Requires an API key: ``` wss://api.callmissed.com/ws/call/{call_id}?api_key=cm_your_key ``` The WebSocket implements a full STT → LLM → TTS pipeline: 1. **Audio in** — Twilio sends 8kHz mulaw audio chunks 2. **STT** — saaras:v3 transcribes in real-time 3. **LLM** — Generates response using bot's system prompt + conversation history 4. **TTS** — bulbul:v3 synthesizes speech (MP3 at 24kHz) 5. **Audio out** — Streamed back to the caller LLM and TTS run concurrently for minimum latency — audio playback begins while the LLM is still generating. ### Outbound Calling The `outbound_call` bot type exists, but a public API to **initiate** outbound calls is not yet available — today the voice pipeline is driven by inbound Twilio calls (and LiveKit voice sessions). Programmatic outbound dialing is on the roadmap. [Talk to us](https://docs.callmissed.com/docs/talk-to-us) if you need it. ## Twilio Voice Setup Source: https://docs.callmissed.com/docs/twilio-setup Connect a Twilio voice number to CallMissed so an AI agent answers inbound calls in real time. Connect a [Twilio](https://www.twilio.com/) voice number so an AI agent answers inbound calls — transcribing the caller, generating a reply, and speaking it back over a real-time audio stream. CallMissed stores your Twilio credentials per tenant and serves the TwiML that bridges the call to its streaming pipeline. ### Prerequisites - A **Twilio account** (a trial account works for testing). - A **voice-capable phone number** purchased in the [Twilio Console](https://console.twilio.com/) (**Phone Numbers → Manage → Buy a number**, with the *Voice* capability). - A CallMissed account with the **owner** or **admin** role. ### Get your Twilio credentials From the [Twilio Console](https://console.twilio.com/) home page, copy: - **Account SID** — starts with `AC…`. - **Auth Token** — click to reveal it under *Account Info*. - **Phone Number** — your purchased number in **E.164** format (for example `+14155550123`). ### Save credentials in CallMissed ### Open Integration Settings In the [Dashboard](https://app.callmissed.com), go to **Settings → Integrations → Twilio**. Credentials are saved from the dashboard, signed in as an owner or admin. ### Enter your credentials Paste the **Account SID**, **Auth Token** and **Phone Number** you copied above, then save. The auth token is stored write-only and shown masked afterwards. ### Verify the connection Click **Verify**. CallMissed runs a live check against the Twilio API and reports whether the credentials work before you route any calls. ### Create a voice bot Create a bot with `type: "inbound_call"` and a system prompt for the agent's persona: ```bash curl -X POST https://api.callmissed.com/api/v1/bots \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Reception Agent", "type": "inbound_call", "system_prompt": "You are the front-desk agent for Acme Clinic. Be concise and friendly." }' ``` ### Point your number at CallMissed In the Twilio Console, open your number (**Phone Numbers → Manage → Active numbers → your number**). Under **Voice Configuration → A call comes in**, set: - **Webhook**, HTTP **POST**, URL: ``` https://api.callmissed.com/api/v1/webhooks/twilio/voice ``` Save. Twilio will now POST to CallMissed on every inbound call, and CallMissed handles the call with its real-time voice AI pipeline. ### Test the call Call your Twilio number. The real-time path is: - **Caller**: Dials your Twilio number - **Twilio**: POSTs to `/webhooks/twilio/voice`; CallMissed returns TwiML opening a media stream - **STT (saaras:v3)**: Transcribes the caller's audio in real time - **LLM**: Generates the reply from the bot's system prompt + conversation history - **TTS (bulbul:v3)**: Synthesizes speech — playback starts before generation finishes - **Caller**: Hears the AI agent respond > **Tip:** For browser/mobile WebRTC agents (no phone number required) use the LiveKit-based [Voice Agent](https://docs.callmissed.com/docs/voice-agent) and [Voice Sessions API](https://docs.callmissed.com/docs/voice-sessions-api) instead. See the [Voice Calling](https://docs.callmissed.com/docs/voice) guide for the full telephony protocol. ## Telephony API Source: https://docs.callmissed.com/docs/telephony-api Complete India KYC, rent Indian phone numbers, link them to a voice-agent bot, place outbound PSTN calls, and fetch recordings — all with your cm_ key. ### Overview The Telephony API is a full lifecycle for **CallMissed Numbers**: submit an India KYC (compliance) application, wait for it to be accepted, search available Indian numbers, **buy** one (a paid action that draws your real credit balance), manage it, and place outbound **PSTN** calls answered by your AI voice agent. **Base path:** `https://api.callmissed.com/api/v1/telephony` > **The journey is ordered.** You cannot buy a number until you hold an **accepted** KYC application, and you cannot place a call until you own an **active** number. Follow the flow below top to bottom. - **Submit KYC**: Upload your business documents and details once - **CallMissed**: Reviews the application; poll or sync until it is `accepted` - **Buy & call**: Search a number, buy it (paid), link a bot, place calls **Authentication.** Every endpoint accepts both a **JWT** (`Authorization: Bearer `) and an **API key** (`Authorization: Bearer cm_`). API-key callers need the `telephony:read` scope for search/list/get and `telephony:write` for buy, release, patch, KYC submit/sync, and originating calls. Money-affecting and destructive actions (buy, release, patch, KYC submit, originate call) additionally require an **owner/admin** role when called with a JWT. > **Availability.** Telephony is **India-only** and enabled per tenant. When the feature is not enabled for your tenant, the routes are unmounted and every call returns `404`. ### 1. Submit KYC (Compliance) Every rented Indian number must be backed by an **accepted** KYC application. Submission is a **multipart form** carrying your business details plus the two **mandatory** documents: - **Registration certificate** — Certificate of Incorporation (CIN) or Udyam certificate - **GST certificate** Files must be **PDF, JPEG, or PNG**, up to **5 MB each**. The legal business name must match **exactly** on both documents or the application is rejected upstream. `POST /compliance` · scope `telephony:write` (owner/admin for JWT) **Form fields:** | Field | Type | Required | Notes | |-------|------|----------|-------| | `alias` | string (1–128) | Yes | A label for this application | | `business_name` | string (1–100) | Yes | Legal name, exactly as printed on **both** documents | | `registration_number` | string (1–64) | Yes | CIN or Udyam number | | `email` | string (3–254) | Yes | Business contact email | | `address_line1` | string (1–255) | Yes | | | `address_line2` | string (0–255) | No | | | `city` | string (1–100) | Yes | | | `state` | string (1–100) | Yes | | | `postal_code` | string (1–16) | Yes | | | `registration_cert` | file | Yes | COI or Udyam certificate (PDF/JPEG/PNG, ≤ 5 MB) | | `gst_cert` | file | Yes | GST certificate (PDF/JPEG/PNG, ≤ 5 MB) | ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/telephony/compliance \ -H "Authorization: Bearer cm_your_api_key" \ -F 'alias=Acme India KYC' \ -F 'business_name=ACME TECHNOLOGIES PRIVATE LIMITED' \ -F 'registration_number=U72900KA2020PTC000000' \ -F 'email=compliance@acme.in' \ -F 'address_line1=123 MG Road' \ -F 'address_line2=Suite 400' \ -F 'city=Bengaluru' \ -F 'state=Karnataka' \ -F 'postal_code=560001' \ -F 'registration_cert=@certificate-of-incorporation.pdf' \ -F 'gst_cert=@gst-certificate.pdf' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/telephony" headers = {"Authorization": "Bearer cm_your_api_key"} data = { "alias": "Acme India KYC", "business_name": "ACME TECHNOLOGIES PRIVATE LIMITED", "registration_number": "U72900KA2020PTC000000", "email": "compliance@acme.in", "address_line1": "123 MG Road", "address_line2": "Suite 400", "city": "Bengaluru", "state": "Karnataka", "postal_code": "560001", } files = { "registration_cert": ("coi.pdf", open("coi.pdf", "rb"), "application/pdf"), "gst_cert": ("gst.pdf", open("gst.pdf", "rb"), "application/pdf"), } resp = httpx.post(f"{BASE}/compliance", headers=headers, data=data, files=files) application = resp.json() print(application["id"], application["status"]) # e.g. "...", "submitted" ``` **Response (200 OK)** — a compliance application: ```json { "id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e", "tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "alias": "Acme India KYC", "country_iso": "IN", "number_type": "local", "user_type": "business", "status": "submitted", "rejection_reason": null, "business_name": "ACME TECHNOLOGIES PRIVATE LIMITED", "registration_number": "U72900KA2020PTC000000", "created_at": "2026-04-19T12:00:00Z" } ``` **Status codes** | Code | Meaning | |------|---------| | `200` | Application created and submitted | | `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin | | `413` | A document exceeds the 5 MB limit | | `422` | A document is missing, empty, or not a PDF/JPEG/PNG | | `404` | Telephony not enabled for your tenant | ### 2. Check KYC Status Applications move through `draft` → `submitted` → `accepted` / `rejected`. Only an **`accepted`** application can back a number purchase. | Endpoint | Scope | Purpose | |----------|-------|---------| | `GET /compliance` | `telephony:read` | List your applications | | `GET /compliance/{application_id}` | `telephony:read` | Get one application + status | | `POST /compliance/{application_id}/sync` | `telephony:write` | Refresh status from the carrier | `GET /compliance` accepts `limit` (1–200, default 50) and `offset` (≥ 0). It returns an array of applications, newest first. `POST /compliance/{application_id}/sync` pulls the latest status and, if rejected, populates `rejection_reason`. ```bash # List applications curl https://api.callmissed.com/api/v1/telephony/compliance \ -H "Authorization: Bearer cm_your_api_key" # Refresh one application's status curl -X POST https://api.callmissed.com/api/v1/telephony/compliance/{application_id}/sync \ -H "Authorization: Bearer cm_your_api_key" ``` Once `status` is `accepted`, the application carries a **compliance reference** you pass as `compliance_application_id` when buying a number (step 4). A `GET`/`sync` on an application you do not own returns `404`. ### 3. Search Available Numbers Search for Indian numbers before buying. Rates are returned **after** your tenant markup — `rental_credits` is what you will actually be charged per month. `GET /numbers/search` · scope `telephony:read` **Query parameters** | Param | Values | Default | |-------|--------|---------| | `country_iso` | 2-letter ISO (`IN`) | `IN` | | `type` | `local` / `mobile` / `tollfree` | — | | `pattern` | digit substring to match (max 32 chars) | — | | `limit` | 1–20 | 20 | ```bash curl "https://api.callmissed.com/api/v1/telephony/numbers/search?country_iso=IN&type=local&pattern=80802&limit=10" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** — an array of search hits: ```json [ { "number": "+918080247309", "number_type": "local", "country": "IN", "region": "Mumbai", "monthly_rental_rate_usd": 2.5, "rental_credits": 250, "voice_enabled": true, "sms_enabled": false } ] ``` ### 4. Buy a Number Rent one of the searched numbers. This is a **paid action** — it draws your **real (paid) credit balance**. The signup bonus does **not** cover a number rental; if your paid balance is short, you get a `402` telling you to top up. `POST /numbers` · scope `telephony:write` (owner/admin for JWT) **Request body** | Field | Type | Required | Notes | |-------|------|----------|-------| | `e164` | string | Yes | The number to buy, e.g. `+918080247309` | | `compliance_application_id` | string | Yes | The compliance reference from your **accepted** KYC application | The purchase runs in a strict, money-safe order: it verifies your KYC application is accepted (else `409`), confirms the number is still available and prices it live (else `422`), deducts the rental credits, and only then rents the number. If the rent fails, the credits are refunded automatically. ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/telephony/numbers \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "e164": "+918080247309", "compliance_application_id": "your-accepted-compliance-reference" }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/telephony" headers = {"Authorization": "Bearer cm_your_api_key"} resp = httpx.post( f"{BASE}/numbers", headers=headers, json={ "e164": "+918080247309", "compliance_application_id": "your-accepted-compliance-reference", }, ) if resp.status_code == 402: print("Top up your paid balance before buying a number") elif resp.status_code == 409: print("Your KYC application is not accepted yet") else: number = resp.json() print(number["id"], number["status"]) # e.g. "...", "active" ``` **Response (200 OK)** — the rented number: ```json { "id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "e164": "+918080247309", "country_iso": "IN", "number_type": "local", "status": "active", "bot_id": null, "alias": null, "monthly_rental_rate_usd": 2.5, "rental_credits": 250, "added_on": "2026-04-19", "renewal_date": "2026-05-19", "config": null, "metadata": null, "created_at": "2026-04-19T12:00:00Z" } ``` **Status codes** | Code | Meaning | |------|---------| | `200` | Number rented and active | | `402` | Insufficient **real** balance — top up to buy | | `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin | | `409` | KYC not accepted, or you already hold this number | | `422` | Number no longer available, or `e164` is malformed | | `404` | Telephony not enabled for your tenant | ### 5. Manage Numbers | Endpoint | Scope | Purpose | |----------|-------|---------| | `GET /numbers` | `telephony:read` | List your rented numbers | | `GET /numbers/{number_id}` | `telephony:read` | Get one number | | `PATCH /numbers/{number_id}` | `telephony:write` | Update alias / linked bot / per-number call config | | `DELETE /numbers/{number_id}?confirm=true` | `telephony:write` | Release a number (permanent) | `GET /numbers` accepts `status` (e.g. `active`), `limit` (1–200, default 50), and `offset` (≥ 0). A number moves through `pending` → `active` → `suspended` (unpaid) → `released`. ```bash # List your rented numbers curl https://api.callmissed.com/api/v1/telephony/numbers \ -H "Authorization: Bearer cm_your_api_key" # Get one curl https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \ -H "Authorization: Bearer cm_your_api_key" # Update the alias and link a voice-agent bot curl -X PATCH https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"alias": "Support line", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"}' ``` #### Per-number call overrides The optional `config` object holds per-number call-handling overrides that **win over the linked bot's config on this number's calls** — so two numbers can share one bot yet greet and speak differently. The object **replaces** the stored overrides on every write (send the full set each time; `{}` clears every override). Unknown keys return `422`. | Key | Type | Notes | |-----|------|-------| | `voice_model` | string | Voice LLM model id | | `voice` | string | Voice / speaker id | | `language` | string | e.g. `hi-IN` | | `stt_model` | string | Speech-to-text model id | | `tts_model` | string | Text-to-speech model id | | `tts_provider` | string | TTS provider id | | `tts_engine` | string | TTS engine id | | `greeting` | string | Opening line spoken on the call | | `system_prompt` | string | Overrides the bot's persona for this number | | `max_call_duration_seconds` | integer | 30–14400 | | `voice_fallbacks` | array of strings | Up to 2 fallback model ids | | `tools` | array of strings | Up to 20 agent tool names | ```bash curl -X PATCH https://api.callmissed.com/api/v1/telephony/numbers/{number_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"config": {"greeting": "Namaste! Aap Support line par pahunche hain.", "language": "hi-IN", "max_call_duration_seconds": 600}}' ``` #### Release a number ```bash curl -X DELETE "https://api.callmissed.com/api/v1/telephony/numbers/{number_id}?confirm=true" \ -H "Authorization: Bearer cm_your_api_key" ``` `confirm=true` is **required** — releasing a number is permanent, stops its monthly rental charge, and is **not refunded**. Returns `204 No Content` on success, `400` if `confirm` is omitted, and `404` if the number is not found or not owned by your tenant. ### 6. Place a Call Originate an outbound PSTN call from one of your **active** numbers. Link a `bot_id` to have your AI voice agent handle the call; if you omit it, the number's persistently bound bot is used. `POST /calls` · scope `telephony:write` (owner/admin for JWT) **Request body** | Field | Type | Required | Notes | |-------|------|----------|-------| | `from_number_id` | UUID | Yes | An active number you own | | `to_e164` | string | Yes | The destination, e.g. `+919000000000` | | `bot_id` | UUID | No | Voice-agent bot to answer the call | | `reason` | string (≤ 500) | No | Plain-language purpose; spoken on the outbound greeting | ```bash curl -X POST https://api.callmissed.com/api/v1/telephony/calls \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "to_e164": "+919000000000", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "reason": "Confirming your appointment for tomorrow" }' ``` **Response (200 OK)** — the created call (status advances via webhooks): ```json { "id": "5e6a7b8c-9d0e-1f2a-3b4c-5d6e7f8a9b0c", "tenant_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "voice_session_id": "7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c", "direction": "outbound", "status": "initiated", "remote_e164": "+919000000000", "bill_duration_seconds": null, "billed_duration_seconds": null, "cost_credits": null, "hangup_cause_code": null, "hangup_source": null, "recording_id": null, "metadata": null, "created_at": "2026-04-19T12:00:00Z" } ``` **Status codes** | Code | Meaning | |------|---------| | `200` | Call created and dialing | | `402` | Insufficient credits to reserve the call | | `403` | Missing `telephony:write` scope, or JWT caller is not an owner/admin | | `404` | `from_number_id` not found/active, or an explicit `bot_id` not found | | `422` | `to_e164` is malformed | | `429` | Concurrent-call limit reached (up to 10 live calls per tenant) | ### 7. List & Fetch Calls | Endpoint | Scope | Purpose | |----------|-------|---------| | `GET /calls` | `telephony:read` | List your calls | | `GET /calls/{call_id}` | `telephony:read` | Get one call | | `GET /calls/{call_id}/recording` | `telephony:read` | Signed recording URL | **`GET /calls` query parameters** | Param | Values | Default | |-------|--------|---------| | `direction` | `inbound` / `outbound` | — | | `status` | `initiated` / `ringing` / `in_progress` / `completed` / `failed` / `no_answer` / `busy` | — | | `limit` | 1–200 | 50 | | `offset` | ≥ 0 | 0 | ```bash curl "https://api.callmissed.com/api/v1/telephony/calls?direction=outbound&status=completed&limit=50&offset=0" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns an array of calls, newest first. `GET /calls/{call_id}` fetches a single call (`404` if not owned). #### Recordings If a call was recorded, fetch a short-lived signed URL for its audio: ```bash curl https://api.callmissed.com/api/v1/telephony/calls/{call_id}/recording \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK):** ```json { "url": "https://media.callmissed.com/recordings/....?token=..." } ``` The URL is time-limited — fetch it on demand rather than storing it. Returns `404` if the call has no recording, and `503` if recording storage is temporarily unavailable. ### Scopes | Scope | Grants | |-------|--------| | `telephony:read` | Search numbers, list/get numbers, list/get compliance applications, list/get calls, fetch recording URLs | | `telephony:write` | Buy/release/update numbers, submit & sync compliance applications, originate calls | Buy, release, patch, KYC submit, and originate-call also require an **owner/admin** role when called with a JWT (an API key carrying `telephony:write` is sufficient on its own). ### Billing | Charge | When | |--------|------| | **Number rental** | Monthly, in credits, per active number (`rental_credits`). Paid from your **real** balance — not the signup bonus. Renews on `renewal_date`. | | **Call usage** | Reserved when a call is placed, then settled to the real cost from the call record after the call completes. | Releasing a number stops its monthly rental charge (no refund for the current period). Ensure sufficient credits before buying numbers or placing calls, or those calls return `402`. ### Webhooks Telephony call-lifecycle and recording-ready events are delivered to the endpoints you configure via the [Webhooks API](https://docs.callmissed.com/docs/webhooks). Payloads are HMAC-SHA256 signed — verify the `X-CallMissed-Signature` header exactly as shown on the [Webhooks](https://docs.callmissed.com/docs/webhooks) page before trusting a payload. ## Bring Your Own Telephony Source: https://docs.callmissed.com/docs/bring-your-own-telephony Connect a telephony account you already own, import your existing numbers, and let a CallMissed AI voice agent answer the calls. ### Overview **Bring your own telephony (BYO)** lets you keep the phone numbers and the carrier contract you already have, and use them with a CallMissed AI voice agent. Nothing is ported, and you do not rent a number from us. A connection has two halves, and calls only work once **both** are done: 1. **You give CallMissed your provider credentials.** We verify them against your provider, store them encrypted, and provision the trunk that carries audio between your provider and our voice agents. 2. **You point the number's call routing at CallMissed.** Inbound calls have to arrive at us, so the routing on the number (or on the trunk it belongs to) is switched over to the SIP endpoint we show you after connecting. - **Connect the provider**: Paste your provider credentials into CallMissed; we verify them before storing - **Provision the trunk**: CallMissed creates the SIP trunk on both sides and hands you the inbound SIP endpoint - **Import numbers**: Your voice-capable numbers are read from your account and imported - **Route and answer**: Point the number's call routing at CallMissed and assign a voice-agent bot **Prerequisites** - An **active account** with a supported telephony provider, holding at least one **voice-capable** number. - **Admin access to that provider's dashboard**, enough to create or edit a trunk and change a number's call routing. - A CallMissed account with the **owner** or **admin** role. > **Note:** Numbers you bring keep their existing contract and billing with your provider. CallMissed does not charge you a monthly number rental for them, unlike numbers you rent from us through the [Telephony API](https://docs.callmissed.com/docs/telephony-api). ### Rent from us, or bring your own | | Rent from CallMissed | Bring your own | |---|---|---| | **Time to first call** | Fastest. Search, buy, assign a bot. | Depends on your provider's trunk setup. | | **KYC and compliance** | We handle it. Submit one [KYC application](https://docs.callmissed.com/docs/telephony-api) and we do the rest. | You already hold the number, so its compliance stays with your provider. | | **Numbers** | New numbers, issued by us. | Your existing numbers, unchanged. | | **Carrier billing** | One bill: rental plus usage, in credits. | Your provider keeps billing carriage; CallMissed bills the AI voice agent. | | **Best for** | Starting from zero, or adding a line quickly. | Keeping published numbers, existing rates, or an existing carrier relationship. | Both paths converge: once a number is in CallMissed, whether rented or imported, you assign a bot and configure the call the same way. ### Supported providers | Provider | How it connects | Numbers auto-imported | Status | |---|---|---|---| | **Twilio** | SIP trunk (Elastic SIP Trunking) | Yes | Self-serve | | **Plivo** (your own account) | SIP trunk (Zentrunk) | Yes | Self-serve | | **Custom SIP** | SIP trunk (any provider) | Manual entry | Self-serve | | **Exotel** | SIP trunk (vSIP) | Yes | Set up by our team | | **Smartflo** (Tata) | Media streaming (WebSocket) | Yes | Set up by our team | | **Pulse** | Contact us | n/a | Contact us | | **InTalk** | Contact us | n/a | Contact us | | **Vobiz** | Contact us | n/a | Contact us | What the **Status** column means: - **Self-serve**: you can connect it yourself from the dashboard, start to finish. The three self-serve providers are documented below. - **Set up by our team**: the integration exists, but part of the trunk mapping has to be arranged with the provider on your behalf. Write to `support@callmissed.com` with your account details and we complete the connection with you. - **Contact us**: not wired yet. Tell us which provider you are on at `support@callmissed.com` and we will scope it. > **Do not follow the self-serve steps for a "Set up by our team" provider.** Their trunk mapping is done by the provider's own support team, not from your console, and a half-configured trunk silently drops inbound calls. ### Twilio Connects as an **Elastic SIP Trunk** in your own Twilio account. ### Get your Twilio credentials From the [Twilio Console](https://console.twilio.com/) home page, under **Account Info**, copy: - **Account SID**, starts with `AC…`. - **Auth Token**, click to reveal. Prefer a scoped credential? Instead of the Auth Token you can supply a Twilio **API Key SID** (starts with `SK…`) and its **API Key Secret**. Give both or neither: an API Key SID without its secret is rejected. The credentials must belong to an account (or subaccount) allowed to manage **Elastic SIP Trunking** and to list incoming phone numbers. ### Enter the details in CallMissed Open **Phone numbers → Bring your own telephony → Connect**, choose **Twilio**, and paste the Account SID and Auth Token. CallMissed makes a live call to Twilio to verify the pair before anything is stored. Bad credentials fail here, not later on a live call. ### We provision the trunk CallMissed creates the Elastic SIP Trunk in your Twilio account and wires both directions: an **origination URI** pointing at our SIP endpoint for inbound calls, and a **termination URI** for outbound. The termination domain Twilio issues always ends in `pstn.twilio.com`. If you are supplying an existing trunk instead of letting us create one, its termination domain must end in `pstn.twilio.com` or the connection is rejected. ### Import your numbers Your voice-capable Twilio numbers are read from the account and listed for import. Numbers must be in **`+E.164`** form, with the leading `+` and the country code, for example `+14155550123`. A number that Twilio reports in any other format is skipped. Pick the numbers you want CallMissed to answer. Each imported number is pointed at the trunk we created, which is what takes it off its old voice webhook and routes it to your agent. > **Inbound calls on a Twilio trunk are matched on the called number**, not on a SIP password. A number that is not imported, or that is still routed by its own voice webhook in the Twilio Console, will not reach your agent even though the credentials are valid. ### Plivo (your own account) Connects as a **Zentrunk** SIP trunk in your own Plivo account. This is the BYO path. It is separate from numbers you rent from CallMissed, which are billed as rentals. ### Get your Plivo credentials In the [Plivo Console](https://console.plivo.com/), open **Account → Keys & Credentials** and copy your **Auth ID** and **Auth Token**. The account must be allowed to manage Zentrunk trunks and to list phone numbers. ### Enter the details in CallMissed Open **Phone numbers → Bring your own telephony → Connect**, choose **Plivo**, and paste the Auth ID and Auth Token. They are verified against Plivo before they are stored. ### We provision the trunks Zentrunk splits the two directions, so CallMissed creates **both** an inbound trunk and an outbound trunk on your Plivo account, and points the inbound trunk's destination at our SIP endpoint. ### Import your numbers Your Plivo voice numbers are listed for import. Note the format difference: **Plivo returns numbers in E.164 without a leading `+`** (for example `918080247309`, not `+918080247309`). CallMissed normalises them to `+E.164` on import, so they appear the same way as every other number in the dashboard. Finally, attach each imported number to the inbound trunk in the Plivo Console. Plivo binds a number to a trunk on their side, so this last step is done in their console, not ours. ### Custom SIP Use this for any provider not listed above that can terminate a SIP trunk. You supply the trunk details yourself, and you enter the numbers manually because there is no account API for us to read them from. **Fields you supply** | Field | Example | Notes | |---|---|---| | **Termination host** | `sip.example.com` | The outbound SIP host, as a bare hostname. No `sip:` or `sips:` prefix, no path, no `;transport=` parameter. An explicit `:port` is accepted if your provider needs one. | | **Transport** | `tcp` | One of `auto`, `udp`, `tcp`, `tls`. Defaults to `tcp`. Match what the provider's trunk actually accepts. | | **SIP username** | `acme-outbound` | The digest username we authenticate outbound calls with. | | **SIP password** | your trunk password | Stored encrypted, never shown again. | | **Phone numbers** | `+911140848000` | The `+E.164` numbers to accept inbound calls on. Every entry must carry the leading `+`. | > **Outbound calls authenticate with the SIP username and password, not with an IP allowlist.** We cannot guarantee a static egress IP for allowlisting, so an IP-only trunk cannot be authorised. Ask your provider to enable digest (username and password) authentication on the trunk. If they cannot, write to `support@callmissed.com` before you start. **Inbound.** After the connection is created, CallMissed shows you the SIP endpoint to route to. Set that as the inbound destination on your provider's trunk, then confirm each number is attached to that trunk on their side. Only the numbers you entered are accepted. ### Connecting in the dashboard The wizard is the same for every self-serve provider. ### Open the Phone numbers page In the [Dashboard](https://app.callmissed.com), go to **Phone numbers**. The **Bring your own telephony** card sits next to the rent-a-number card. Choose **Connect**. ### Pick a provider Select your provider from the list. Providers marked *Set up by our team* show a contact panel instead of a credential form. ### Step 1. Get credentials The panel tells you exactly where the credentials live in that provider's console, with the field names they use. Fetch them in another tab. ### Step 2. Enter details Paste the credentials, and for Custom SIP the termination host, transport, and number list. Optionally give the connection a **label** (for example "Twilio, prod account") so two accounts on the same provider are easy to tell apart. Submitting verifies the credentials with your provider. If verification fails, the connection stays unconnected and shows the reason. Nothing partial is left behind. ### Step 3. Configure and import numbers CallMissed provisions the trunk, then lists the numbers it found on your account. Select the ones to import. For Custom SIP, this step confirms the numbers you typed in. A connection moves through **pending → provisioning → active**. If provisioning fails it lands in **error** with a short, readable reason on the connection card. Fix the cause at your provider and reconnect. A connection you no longer want can be **disabled**. ### After connecting - **Imported numbers appear alongside rented ones.** They show up on the Phone numbers page and in the number list of the [Telephony API](https://docs.callmissed.com/docs/telephony-api), tagged with the provider they came from. - **Assign an agent the same way.** Link a voice-agent bot to the number exactly as you would for a rented number. See [Voice Calling](https://docs.callmissed.com/docs/voice) for building the bot. - **Per-number call settings still apply.** Greeting, language, voice, STT and TTS models, system prompt, tools, and maximum call duration are all set per number and override the linked bot on that number's calls. The full list is in [Per-number call overrides](https://docs.callmissed.com/docs/telephony-api). - **Disconnecting only affects CallMissed.** Removing a provider connection removes its numbers from CallMissed. It does **not** release or cancel anything at your provider, and it does not change your contract with them. Point the number's routing back at your own application before you disconnect, or inbound calls will go nowhere. ### Security - **Credentials are encrypted at rest** before they reach the database. They are decrypted only in memory, only when a call to your provider needs them. - **They are never returned by the API.** No response body, log line, or webhook payload contains a provider secret. A connection exposes only whether credentials are present, a masked account identifier, and the non-secret connection facts (trunk ids, SIP address, transport). - **Scoped per tenant.** A connection belongs to one tenant and is only ever readable inside it. - **To rotate a credential**, change it at your provider, then connect the provider again in CallMissed with the new pair. Verification runs against the new credential before it replaces the old one. If you believe a credential has leaked, revoke it at your provider first, then reconnect. Revoking at the provider takes effect immediately, whatever is stored on our side. ### Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | Inbound calls ring, then drop or hit voicemail. The agent never picks up. | The number's call routing still points at your provider's old app, IVR, or trunk. | Point the number (or its trunk) at the SIP endpoint shown on the connection, and confirm the number is attached to that trunk on the provider's side. | | One number fails while others on the same account work. | That number was never imported, or it is attached to a different trunk. | Import it, then attach it to the trunk CallMissed provisioned. | | Outbound calls fail immediately with an authentication error. | Wrong SIP username or password, or the provider's trunk expects IP-based authentication. | Re-enter the SIP credentials. If the trunk is IP-authenticated, ask your provider to enable digest authentication, since outbound uses username and password. | | Outbound calls time out instead of failing fast. | Wrong termination host, or the wrong transport (for example `tls` on a trunk that only accepts `udp`). | Check the host is a bare hostname with no `sip:` prefix and no parameters, and set the transport to what your provider documents for the trunk. | | A number is missing from the import list. | The API credentials cannot list numbers, the number is not voice-capable, or it lives in a subaccount the credentials do not cover. | Use credentials for the account that actually holds the number, and confirm the number has the voice capability. | | A number imported but shows a different format than expected. | Plivo returns numbers without a leading `+`. | Nothing to do. CallMissed normalises to `+E.164` on import. If you enter numbers manually, always include the `+` and the country code. | | The connection sits in **error**. | Credential verification or trunk provisioning failed upstream. | Read the reason on the connection card, fix it at the provider, and reconnect. Credentials are re-verified on every connect. | Still stuck, or on a provider marked *Set up by our team*? Write to `support@callmissed.com` with your provider, the connection label, and the number you are testing. ## Migrate from Twilio Source: https://docs.callmissed.com/docs/migrate-from-twilio Point an existing Twilio Programmable Voice integration at CallMissed by changing only the base URL and the credentials. Same path, same HTTP Basic scheme, same form parameters, same Call object and error envelope. ### Overview If you already place calls through Twilio Programmable Voice, you can move to CallMissed by changing **two things**: the **base URL** and the **credentials**. The path shape, the HTTP Basic scheme, the `application/x-www-form-urlencoded` body with TitleCase parameters, the JSON Call object, the status enum, the list envelope and the error envelope are all Twilio's — your existing SDK or HTTP client keeps working. ```diff - https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls.json + https://api.callmissed.com/2010-04-01/Accounts/{AccountSid}/Calls.json - -u "ACxxxxxxxx:your_auth_token" # Twilio SID : auth token + -u "any:cm_your_api_key" # CallMissed key as the password ``` > **`{AccountSid}` is accepted, ignored, and never trusted.** Keep your old `AC…` sid in the path — every response echoes your *authenticated* CallMissed account sid, and every query is scoped to your tenant, so an arbitrary path sid can never reach another tenant's data. The only credential is your CallMissed API key. **Authentication.** Both forms work: - `Authorization: Basic base64(:cm_your_api_key)` — what a Twilio client sends (`AccountSid:AuthToken`). The **password** carries the key; a key in the username slot (`-u "cm_...:"`) is accepted too. - `Authorization: Bearer cm_your_api_key` — the native form. API-key callers need the `telephony:read` scope for fetch/list and `telephony:write` to place a call; placing a call additionally requires an owner/admin role when called with a JWT. > **Availability.** Telephony is India-only and enabled per tenant. When it is not enabled, these routes are unmounted and return `404`. See [Telephony API](https://docs.callmissed.com/docs/telephony-api) for the native lifecycle (KYC, buying a number, linking an agent). ### Place a call ``` POST /2010-04-01/Accounts/{AccountSid}/Calls.json Content-Type: application/x-www-form-urlencoded ``` `201 Created` on success, matching Twilio. | Parameter | Maps to | Notes | |-----------|---------|-------| | `To` | destination | E.164, e.g. `+919876543210` | | `From` | one of your active numbers | Must be an **active** number on your account (matched with or without a leading `+`) | | `ApplicationSid` | the agent that handles the call | A CallMissed **agent** is the analogue of a Twilio Application. Accepts an `AP` sid or a bare agent UUID. Omitted → the from-number's bound agent | | `CallMissedReason` | spoken context | *(added — not a Twilio param)* a reason the agent can reference | | `CallMissedVariables` | template values | *(added)* a JSON object of `{{token}}` values rendered into the agent's greeting/prompt | ```bash [cURL] curl -X POST \ https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json \ -u "any:cm_your_api_key" \ --data-urlencode "To=+919876543210" \ --data-urlencode "From=+911140000000" \ --data-urlencode "ApplicationSid=AP0123456789abcdef0123456789abcdef" ``` ```python [Python] import httpx httpx.post( "https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json", auth=("any", "cm_your_api_key"), data={ "To": "+919876543210", "From": "+911140000000", "ApplicationSid": "AP0123456789abcdef0123456789abcdef", }, ) ``` ```javascript [Node.js] const body = new URLSearchParams({ To: "+919876543210", From: "+911140000000", ApplicationSid: "AP0123456789abcdef0123456789abcdef", }); await fetch( "https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json", { method: "POST", headers: { Authorization: "Basic " + btoa("any:cm_your_api_key"), "Content-Type": "application/x-www-form-urlencoded", }, body, }, ); ``` #### The Call object Twilio's snake_case Call object, with a few honest CallMissed additions: ```json { "sid": "CA0f1e2d...", "account_sid": "AC9a8b7c...", "to": "+919876543210", "from": "+911140000000", "status": "queued", "start_time": "Thu, 24 Aug 2023 05:01:45 +0000", "end_time": null, "duration": null, "price": null, "price_unit": "credits", "direction": "outbound-api", "date_created": "Thu, 24 Aug 2023 05:01:45 +0000", "date_updated": "Thu, 24 Aug 2023 05:01:45 +0000", "uri": "/2010-04-01/Accounts/AC9a8b7c.../Calls/CA0f1e2d....json", "api_version": "2010-04-01", "callmissed_call_id": "…", "callmissed_agent_sid": "AP…" } ``` Fidelity details that match Twilio exactly: - **`duration` and `price` are strings**, not numbers, and stay `null` until the call is billed. `price` is negative (an amount debited). - **Dates are RFC 2822** (`"Thu, 24 Aug 2023 05:01:45 +0000"`), not ISO 8601. - **`price_unit` is `"credits"`** — CallMissed bills in credits, not a currency, so the field says so rather than pretending to be `"USD"`. Your upstream carrier cost is never exposed. - **`status`** is exactly `queued`, `ringing`, `in-progress`, `canceled`, `completed`, `busy`, `failed`, `no-answer`. ### Fetch and list ```bash # One call curl https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls/CA0f1e2d....json \ -u "any:cm_your_api_key" # List (filters: To, From, Status; paging: Page, PageSize) curl "https://api.callmissed.com/2010-04-01/Accounts/ACxxxxxxxx/Calls.json?Status=completed&PageSize=50" \ -u "any:cm_your_api_key" ``` The list envelope is Twilio's, and the array key is the lower-cased resource name — **`calls`** — with `page`, `page_size`, `uri`, `first_page_uri`, `next_page_uri` and `previous_page_uri`. ### Parameters that are rejected, not ignored Silently ignoring a parameter would connect the call and then behave differently from what you asked — worse than refusing it. These return `400` with a Twilio-shaped error that names the parameter: - **`Url` / `Twiml` / `Method` / `Fallback*`** — there is no TwiML interpreter. CallMissed calls are agent-driven; the behaviour comes from `ApplicationSid` (the agent), not a markup document. - **`Record` / `RecordingStatusCallback*`** — per-call recording is not controllable through this API. - **`MachineDetection*` / `AsyncAmd*`** — no answering-machine detection. - **`SendDigits`** — no post-answer DTMF injection. - **`Timeout` / `TimeLimit`** — no per-call ring/duration override (the agent's configured max duration applies). - **`StatusCallback` / `StatusCallbackEvent` / `StatusCallbackMethod`** — per-call status callbacks are not delivered. Subscribe instead to the `call.started` / `call.completed` / `call.failed` [webhook events](https://docs.callmissed.com/docs/webhooks) at `/api/v1/webhooks`. ### Error envelope Twilio's shape, unchanged: ```json { "status": 400, "message": "The 'From' number +911140000000 is not an active phone number on this account.", "code": 21210, "more_info": "https://www.twilio.com/docs/errors/21210" } ``` Where a real Twilio error code fits, it is used (`21201`, `21211`, `21212`, `21213`, `21210`, `21217`, `20003`, `20404`, `20429`), and `more_info` points at `twilio.com`. Where no Twilio code fits, a CallMissed code in the `61000–61999` range is used, and its `more_info` points at `docs.callmissed.com` — never at a `twilio.com` page that would describe something unrelated. ### When to use the native API instead For new builds, the [Telephony API](https://docs.callmissed.com/docs/telephony-api) exposes CallMissed's full lifecycle — India KYC, buying numbers, linking agents, richer call records — with your `cm_` key directly. This Twilio-compat surface exists to make an *existing* Programmable Voice integration a two-line migration. --- # WhatsApp ## WhatsApp Bot Source: https://docs.callmissed.com/docs/whatsapp Run an AI agent on your WhatsApp Business number: connect a WABA, auto-reply to inbound messages, and send, template, campaign and call through one API. CallMissed connects your **WhatsApp Business Account (WABA)** to an AI agent. Inbound messages land on a webhook, get stored as a conversation, and are answered by your bot's system prompt plus knowledge base. Everything the agent can do by itself you can also do programmatically over the REST API: send any WhatsApp message type, manage approved templates, run bulk template campaigns, place voice calls on WhatsApp, and read delivery analytics. ### What you get | Capability | Where | |---|---| | AI auto-reply to inbound WhatsApp messages | Automatic once a bot is linked to a number | | Send text, template, media, interactive, flow, location, reaction, contact cards | [Sending Messages](https://docs.callmissed.com/docs/whatsapp-messages) | | Create, list, delete and sync message templates, including carousel, limited-time offer and coupon formats | [Message Templates](https://docs.callmissed.com/docs/whatsapp-templates) | | Bulk template sends with per-recipient variables | [Campaigns](https://docs.callmissed.com/docs/whatsapp-campaigns) | | Take UPI payments in the chat with order details and order status messages | [Payments](https://docs.callmissed.com/docs/whatsapp-payments) | | Voice calls over WhatsApp, answered by the same agent | [Calling](https://docs.callmissed.com/docs/whatsapp-calling) | | Connected accounts, numbers, ice breakers and commands, delivery funnel, cost | [WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api) | ### Two ways in **Dashboard.** Connect a number under **Settings → Integrations → WhatsApp**, create a bot, link the two, and the agent starts replying. Nothing to build. **API.** Everything the dashboard does is an endpoint under `https://api.callmissed.com/api/v1/whatsapp`, authenticated with a `cm_` API key. Use it to embed WhatsApp into your own product, run campaigns from your backend, or ship a custom inbox. The two share one data model. A number connected in the dashboard is immediately sendable from the API, and a message sent over the API appears in the dashboard conversation thread. ### Message flow Meta posts every inbound event to a single CallMissed endpoint. You never configure that endpoint yourself: connecting a number subscribes the CallMissed app to your WABA's webhooks. - **Customer**: Sends a WhatsApp message to your business number - **Meta**: POSTs the event to `/api/v1/webhooks/whatsapp` with an `X-Hub-Signature-256` header - **CallMissed**: Verifies the signature, archives the raw event, and acknowledges with `200` immediately - **Agent**: Routes the number to its linked bot, stores the message, marks it read, and runs the LLM with the conversation history plus knowledge base - **Customer**: Receives the reply through the WhatsApp Cloud API The acknowledgement is sent before the LLM runs, so a slow model never causes Meta to retry the event. #### When the bot replies An inbound message is **always stored**. The agent only answers when all of these hold: 1. The number is **explicitly linked** to a bot (`POST /phone_numbers/{phone_id}/link-bot`). An unlinked number stores messages and stays silent. 2. The bot has a non-empty `system_prompt`. 3. AI auto-reply is on for the number (`ai_autoreply_enabled`, togglable per number). 4. AI auto-reply is on for that conversation (an agent can take over a single thread from the inbox without pausing the whole number). 5. The message is **text**, or an **image** when the bot's model supports vision. Audio, stickers, reactions, interactive replies and other types are stored but not auto-answered. ### Quickstart Send your first message and get an AI reply in five calls. You need an API key with the `whatsapp:read`, `whatsapp:write` and `whatsapp:send` scopes, plus a connected number ([Business Setup](https://docs.callmissed.com/docs/whatsapp-setup)). ### Create the bot ```bash curl -X POST https://api.callmissed.com/api/v1/bots \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Support", "type": "whatsapp", "system_prompt": "You are the support agent for Acme, an Indian D2C coffee brand. Answer in under 60 words. If asked about an order, ask for the order id first. Never invent a delivery date." }' ``` The response carries the bot `id`. Keep it. ```json { "id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "tenant_id": "7f2e1d0c-9b8a-4756-b3c2-1a0f9e8d7c6b", "name": "Acme Support", "type": "whatsapp", "system_prompt": "You are the support agent for Acme...", "is_active": true } ``` ### Find your connected number ```bash curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers \ -H "Authorization: Bearer cm_your_api_key" ``` Take `id` (CallMissed's `phone_id`) and `phone_number_id` (Meta's id) from the number you want to use. ### Link the bot to the number Without this the bot never auto-replies. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }' ``` ### Subscribe to inbound messages Register your own HTTPS endpoint so every customer message is pushed to you. ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/hooks/callmissed", "events": ["message.received"] }' ``` See [Inbound events](https://docs.callmissed.com/docs/whatsapp-api#inbound-events-you-receive) for the exact payload and the signature header. ### Send a message Free-form sends need an open 24-hour window (the customer messaged you within the last 24 hours). Outside it, send a template instead. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "text": "Thanks for reaching out. How can we help?" }' ``` ```json { "wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA=", "contacts": [{ "input": "+919000000000", "wa_id": "919000000000" }] } ``` Now message your business number from a personal WhatsApp account. The message appears in the dashboard conversation, hits your webhook, and the agent answers. ### The 24-hour window WhatsApp only allows free-form messages (text, media, interactive, location) inside the 24-hour customer service window that opens each time the user messages you. Outside it you must send an **approved template**. A closed-window send returns `422` with an actionable message: ```json { "detail": "The 24-hour customer service window is closed. Send a template message instead, or wait for the user to message you." } ``` Templates are never window-limited, which is why order updates, reminders and one-time codes are all template sends. See [Message Templates](https://docs.callmissed.com/docs/whatsapp-templates). ### Bot configuration A bot is channel-agnostic. What makes it a WhatsApp agent is the `link-bot` binding to a connected number, not its `config`. Credentials live on the connected number (encrypted at rest), so a linked bot needs no WhatsApp keys of its own. ```json { "name": "Acme Support", "type": "whatsapp", "system_prompt": "You are the support agent for Acme Coffee.", "config": { "model": "kimi-k2.5", "language": "en" } } ``` Add product facts, FAQs and policies as [knowledge base](https://docs.callmissed.com/docs/knowledge) entries. The agent calls a `search_knowledge_base` tool on demand and grounds its answer in what it retrieves. ### Where to next - [Business Setup](https://docs.callmissed.com/docs/whatsapp-setup): Connect a WABA and register a number, with or without Embedded Signup. - [WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api): Auth, scopes, error shapes, accounts, numbers, analytics and inbound events. - [Sending Messages](https://docs.callmissed.com/docs/whatsapp-messages): Every send endpoint plus media upload and download. - [Message Templates](https://docs.callmissed.com/docs/whatsapp-templates): Create, list, delete and sync approved templates. - [Campaigns](https://docs.callmissed.com/docs/whatsapp-campaigns): Bulk template sends with per-recipient variables. - [Payments](https://docs.callmissed.com/docs/whatsapp-payments): UPI payment configurations, order details and order status messages. - [Calling](https://docs.callmissed.com/docs/whatsapp-calling): Voice calls over WhatsApp, answered by the same agent. ## Business Setup Source: https://docs.callmissed.com/docs/whatsapp-setup Connect a Meta WhatsApp Business Account to CallMissed, register the number, link an agent, and let AI write the first system prompt and templates. Connecting a number binds your **WhatsApp Business Account (WABA)** to CallMissed, subscribes CallMissed to the WABA's webhooks, and registers the number on the WhatsApp Cloud API. After that, inbound messages flow to your agent and you can send from the API. There are two ways to connect, and one thing you never have to do: **you do not configure a webhook in Meta**. Connecting subscribes the CallMissed app to your WABA automatically. See [the Meta-facing webhook](https://docs.callmissed.com/docs/whatsapp-api#the-meta-facing-webhook) if you want to know what that endpoint is. ### Prerequisites - A **Meta Business account** with a verified business. - A **WhatsApp Business Account** and a phone number in [WhatsApp Manager](https://business.facebook.com/wa/manage/). The number must not be tied to a personal WhatsApp app. - A payment method on the WABA in WhatsApp Manager. Until Meta has one, sends fail. - A CallMissed workspace. The manual path additionally needs the **owner** or **admin** role. ### Option 1: connect from the dashboard Go to **Settings → Integrations → WhatsApp** in the [dashboard](https://app.callmissed.com) and follow Embedded Signup. Meta's popup handles the account selection and consent, and CallMissed does the rest: exchanging the authorisation code, subscribing to webhooks, and registering the number with a two-step verification PIN. This is the recommended path. It is also the only path that registers a brand-new number for you. ### Option 2: connect an existing WABA over the API Use this when the number was set up outside Embedded Signup, for example registered directly in the Meta dashboard or migrated from another provider. `POST /api/v1/whatsapp/onboarding/manual` **Owner or admin dashboard login only.** This endpoint accepts a long-lived business token in the body, so an API key cannot call it: even a key with `whatsapp:write` gets `403`. Authenticate with a dashboard session JWT. | Field | Type | Required | Notes | |---|---|---|---| | `waba_id` | string, 1 to 64 chars | Yes | Meta's WABA id | | `phone_number_id` | string, 1 to 64 chars | Yes | Meta's phone number id, not the phone number itself | | `access_token` | string, 20 to 4096 chars | Yes | A long-lived business token. A System User token is strongly recommended, since 24-hour tokens break delivery when they expire | | `business_id` | string, max 64 | No | Meta business id | | `bot_id` | UUID | No | Link an agent to the number in the same call | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/onboarding/manual \ -H "Authorization: Bearer eyJhbGciOi...your-dashboard-session-jwt" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "phone_number_id": "1234567890", "business_id": "441329482726", "access_token": "EAAG...long-lived-system-user-token", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }' ``` The token is encrypted at rest. Meta's `register` step is skipped, because a number provisioned outside Embedded Signup is already registered and calling it again would fail and burn your registration quota. Webhook subscription is still attempted, so events flow. **Response (200 OK)** ```json { "account": { "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "business_id": "441329482726", "name": "Acme Coffee", "currency": "INR", "review_status": "APPROVED", "account_status": "ACTIVE", "account_restriction_reason": null, "payment_setup_complete": true, "is_active": true, "created_at": "2026-04-19T12:00:00Z" }, "phone_number": { "id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "phone_number_id": "1234567890", "display_phone_number": "+91 80802 47309", "verified_name": "Acme Coffee", "code_verification_status": "VERIFIED", "quality_rating": "GREEN", "messaging_limit_tier": "TIER_1K", "throughput_level": "STANDARD", "registration_status": "REGISTERED", "registration_error": null, "name_status": "APPROVED", "ai_autoreply_enabled": true, "is_active": true, "created_at": "2026-04-19T12:00:00Z" }, "fully_provisioned": true, "onboarding_error": null } ``` | Field | Type | Notes | |---|---|---| | `account` | object | The connected WABA | | `phone_number` | object | The connected number. Its `id` is the `phone_id` you use everywhere else | | `fully_provisioned` | boolean | `false` means the rows exist but a setup step did not complete. The connection is recoverable, so retry rather than starting over | | `onboarding_error` | string, nullable | A short reason when `fully_provisioned` is `false` | **Failures** | Code | Meaning | |---|---| | `403` | Not an owner or admin, or called with an API key instead of a dashboard session | | `404` | The `bot_id` does not belong to your workspace | | `409` | The WABA or number is already connected to a different workspace | | `422` | Meta rejected the token or the ids | ### Completing Embedded Signup yourself If you are building your own Embedded Signup flow rather than using the dashboard, post Meta's callback data to: `POST /api/v1/whatsapp/onboarding/exchange` · scope `whatsapp:write` | Field | Type | Required | Notes | |---|---|---|---| | `code` | string, 1 to 2048 chars | Yes | The exchangeable code from Meta's login callback. It expires in about 30 seconds, so post it immediately | | `waba_id` | string, 1 to 64 chars | Yes | From the signup event data | | `phone_number_id` | string, 1 to 64 chars | Yes | From the signup event data | | `business_id` | string, max 64 | No | From the signup event data | | `bot_id` | UUID | No | Link an agent in the same call | | `data_localization_region` | string, exactly 2 chars | No | ISO 3166-1 alpha-2 for data-at-rest residency, from Meta's supported list | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/onboarding/exchange \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "code": "AQBx-hBsH...code-from-meta", "waba_id": "102290129340398", "phone_number_id": "1234567890", "business_id": "441329482726", "data_localization_region": "IN" }' ``` CallMissed exchanges the code for a business token, saves the account and number, subscribes to the WABA's webhooks, and registers the number with a two-step verification PIN it generates and stores. Returns the same object as the manual path. The rows are saved before registration is attempted, so a failure at the last step leaves a recoverable connection rather than losing the WABA association. Check `fully_provisioned` and retry if it is `false`. > **Reconnecting a number keeps its original PIN.** WhatsApp has no way to disable two-step verification, so a reconnect reuses the stored PIN. If it cannot be read, you get `409` asking you to reset the PIN in WhatsApp Manager first. Guessing would burn Meta's limit of registration attempts. ### Link an agent A connected number stores inbound messages but stays silent until an agent is linked. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }' ``` See [Phone numbers](https://docs.callmissed.com/docs/whatsapp-api#phone-numbers) for linking, pausing auto-reply, refreshing metadata from Meta, disconnecting and deleting. ### Let AI write the first setup Two endpoints turn a description of your business into a working agent. Both are billed to your workspace. #### Bootstrap the whole number `POST /api/v1/whatsapp/ai/bootstrap` · scope `whatsapp:write` Generates a system prompt, a persona, a welcome message and a set of starter templates from a plain-language description, and optionally applies them to the number's agent. | Field | Type | Required | Notes | |---|---|---|---| | `phone_number_id` | UUID | Yes | The **CallMissed** phone id, from `GET /phone_numbers` | | `company_description` | string, 10 to 2000 chars | Yes | What the business does and how it wants to sound | | `mode` | enum | No | `suggest`, `auto_apply` or `autonomous`. Default `auto_apply` | | `language` | string, 2 to 8 chars | No | Default `en` | `suggest` changes nothing and returns the plan for review. `auto_apply` and `autonomous` write the system prompt and create the templates. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/bootstrap \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "company_description": "Acme Coffee is an Indian D2C roastery. We sell single-origin beans by subscription, ship in 2 to 3 days, and get asked about order status, roast dates and returns.", "mode": "suggest", "language": "en" }' ``` **Response (200 OK)** ```json { "bootstrap_id": null, "mode": "suggest", "applied": false, "system_prompt": "You are the support agent for Acme Coffee, an Indian D2C roastery...", "persona": { "name": "Acme Coffee Support", "tone": "warm and concise", "signature": "Acme Coffee" }, "welcome_message": "Hi, this is Acme Coffee. Ask me about an order, a roast date, or a return.", "templates": [ { "name": "order_shipped", "category": "UTILITY", "body": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days.", "applied": false, "template_id": null } ] } ``` | Field | Type | Notes | |---|---|---| | `bootstrap_id` | UUID, nullable | Present when the plan was applied. Pass it to undo | | `mode` | string | Echoes the requested mode | | `applied` | boolean | Whether the plan was written to the agent | | `system_prompt` | string | The generated prompt | | `persona` | object | `name`, `tone` and `signature` | | `welcome_message` | string | Suggested opening line | | `templates[]` | array | Each with `name`, `category`, `body`, `applied` and `template_id` when created | `400` when the plan cannot be applied, for example the number does not exist. `422` when the model cannot produce a usable plan. #### Undo a bootstrap `POST /api/v1/whatsapp/ai/bootstrap/{bootstrap_id}/undo` · scope `whatsapp:write` No body. Restores the agent's previous system prompt and removes the still-pending templates the bootstrap created, within a 7-day window. Templates Meta has already approved are kept, because deleting them would break sends already relying on them. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/bootstrap/7b3c9d10-2e4f-4a5b-8c6d-9e0f1a2b3c4d/undo \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "undone": true, "reverted_at": "2026-04-19T12:44:03+00:00", "reason": "ok" } ``` | Field | Type | Notes | |---|---|---| | `undone` | boolean | Whether anything was reverted | | `reverted_at` | string, nullable | ISO 8601 timestamp of the revert, `null` when nothing was reverted | | `reason` | string | `ok` on success. Otherwise `bootstrap_id not found` or `undo window expired` | A refusal is still a `200`: read `undone`, not the status code. #### Write or improve a system prompt `POST /api/v1/whatsapp/ai/build_system_prompt` · scope `whatsapp:read` Read-only. Returns markdown you review and save on the agent yourself. | Field | Type | Required | Notes | |---|---|---|---| | `intent` | string, 10 to 8000 chars | Yes | The business and what the agent should do | | `language` | string, 2 to 8 chars | No | Default `en` | | `tone` | string, max 40 | No | For example `friendly`, `formal` | | `existing_prompt` | string, max 8000 | No | Set this to improve an existing prompt instead of starting fresh | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/build_system_prompt \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "intent": "Support agent for Acme Coffee. Handle order status, roast dates and returns. Escalate anything about a refund over 5000 rupees to a human.", "tone": "warm and concise" }' ``` **Response (200 OK)** ```json { "system_prompt": "## Role\nYou are the support agent for Acme Coffee...\n\n## Rules\n- Answer in under 60 words..." } ``` `422` when the model cannot produce a usable prompt. Shorten or clarify the intent and retry. To draft message templates the same way, see [Draft a template with AI](https://docs.callmissed.com/docs/whatsapp-templates#draft-a-template-with-ai). ### Verify it works Message your business number from a personal WhatsApp account. Then: 1. `GET /api/v1/whatsapp/webhook_events` shows the raw event arriving with `signature_valid: true`. 2. Your own webhook subscription receives a `message.received` event. See [Inbound events](https://docs.callmissed.com/docs/whatsapp-api#inbound-events-you-receive). 3. The agent replies, and the thread appears in the dashboard inbox. If the message arrives but nothing replies, walk the [auto-reply checklist](https://docs.callmissed.com/docs/whatsapp#when-the-bot-replies). The usual cause is a number that was never linked to a bot. > **Going to production.** While your number is in Meta's test mode you can only message numbers you have added as recipients. Submit your business for verification and request production access in the Meta dashboard to message any customer who opts in. Sends also fail until Meta approves a display name for the number, which shows as `name_status` on the phone-number object. ## WhatsApp API Source: https://docs.callmissed.com/docs/whatsapp-api Base path, authentication scopes, error shapes, connected accounts and numbers, inbound webhook events, and delivery analytics. The WhatsApp API is the programmatic surface for a connected **WhatsApp Business Account (WABA)**. This page covers the parts every other WhatsApp page depends on: authentication, how you pick a sending number, what an error looks like, how to read your connected accounts and numbers, what CallMissed pushes to your own webhook, and the delivery analytics. **Base URL:** `https://api.callmissed.com` **Base path:** `/api/v1/whatsapp` | Area | Page | |---|---| | Connect a WABA and register a number | [Business Setup](https://docs.callmissed.com/docs/whatsapp-setup) | | Send text, template, media, interactive, location, reaction, contacts | [Sending Messages](https://docs.callmissed.com/docs/whatsapp-messages) | | Create, list, delete and sync templates | [Message Templates](https://docs.callmissed.com/docs/whatsapp-templates) | | Bulk template sends | [Campaigns](https://docs.callmissed.com/docs/whatsapp-campaigns) | | Voice calls over WhatsApp | [Calling](https://docs.callmissed.com/docs/whatsapp-calling) | ### Authentication Every endpoint accepts either a `cm_` API key or a dashboard JWT: ``` Authorization: Bearer cm_your_api_key ``` API keys are checked against three WhatsApp scopes. A JWT session is authorized by role instead, so scopes do not apply to it. | Scope | Grants | |---|---| | `whatsapp:read` | List accounts, numbers, templates, campaigns, calls, webhook events, analytics; resolve and download media | | `whatsapp:write` | Onboard and manage numbers, link bots, template create/delete/sync, campaign create/launch/cancel, upload media, calling settings | | `whatsapp:send` | Send any message type, mark as read, request call permission, place and terminate calls | A key without the scope gets `403`: ```json { "detail": "API key missing required scope: whatsapp:send. Add it under the key's 'Permissions' section in your dashboard." } ``` Add scopes when you create the key. See [API Keys](https://docs.callmissed.com/docs/keys). ### Choosing the sending number Every endpoint that acts on a specific number needs to know which one. Supply **exactly one** of these. They are interchangeable, and both are always tenant-scoped, so you can only ever act on a number your workspace owns. | Field | Type | Where it comes from | |---|---|---| | `phone_id` | UUID | The `id` field from `GET /phone_numbers` (CallMissed's id) | | `phone_number_id` | string, max 64 | Meta's `phone_number_id` for the same number | On send endpoints they go in the JSON body. On `GET` endpoints they are query parameters. On `POST /media` they are multipart form fields. Omitting both returns `400`: ```json { "detail": "Either phone_id (UUID) or phone_number_id (Meta) is required" } ``` ### Error shape Every error is a single JSON object with a `detail` string: ```json { "detail": "The 24-hour customer service window is closed. Send a template message instead, or wait for the user to message you." } ``` The one exception is request-body validation, where `detail` is an array of field-level errors instead of a string: ```json { "detail": [ { "type": "string_too_short", "loc": ["body", "to"], "msg": "String should have at least 5 characters", "input": "+91" } ] } ``` Upstream WhatsApp errors are never echoed verbatim. They are mapped to a short, actionable message and a status code that tells you whether to retry. #### Platform errors These come from CallMissed before any WhatsApp call is made. | Code | Meaning | Fix | |---|---|---| | `400` | Neither `phone_id` nor `phone_number_id` supplied, or a variant-specific field is missing | Add the missing field | | `401` | Missing, malformed or expired credentials | Check the `Authorization` header | | `402` | Not enough credits to pay for the send or campaign, or a workspace budget cap would be exceeded. Nothing was sent and nothing was charged | Top up, or raise the cap | | `403` | API key is missing the required WhatsApp scope, or the action needs an owner or admin login | Add the scope, or sign in as an owner or admin | | `404` | The number, template, campaign or call does not exist on your workspace | Verify the id | | `409` | The number is disconnected, or has no stored access token | Reconnect the number | | `422` | Request body failed validation | Read the `loc` path in `detail` | | `429` | Rate limit exceeded. Media upload is limited more tightly than other calls | Back off and retry | | `503` | Stored credentials could not be decrypted on this server | Contact support | A `404` is deliberately identical whether the resource does not exist or belongs to another workspace, so the API cannot be used to probe for ids. #### Running out of credits Sends, campaign launches and outbound calls are checked against your balance **before** WhatsApp is called, because the actual charge lands after delivery and there is nothing to un-send. The refusal is a `402` that tells you the shortfall: ```json { "detail": "Not enough credits to send this message, so nothing was sent and nothing was charged. It needs at least 7.51 credits and you have 2.00 spendable (balance 12.00, 10.00 held for running campaigns) -- short by 5.51. Top up your balance and try again." } ``` A separate `402` covers a self-imposed monthly budget cap, where the fix is raising the cap rather than topping up. Credits held for a running campaign or an in-flight call are reserved, not spent, and are released when the work settles. #### WhatsApp errors Errors returned by Meta are translated. These are the mappings you will actually hit: | Code | When | Retryable | |---|---|---| | `400` | Media MIME type does not match the file, or the request was rejected outright | No, fix the payload | | `401` | The number's Meta access token is invalid or expired | No, reconnect the number | | `403` | The app lacks advanced access for this WABA | No, contact support | | `409` | Number is not registered on the WhatsApp Business Platform, was recently deleted, or calling is not enabled on it | No, finish the setup step named in `detail` | | `413` | Media file exceeds 100 MB | No, shrink the file | | `422` | 24-hour window closed, display name not yet approved by Meta, no call permission from the user, or a generic WhatsApp rejection | No, follow the instruction in `detail` | | `429` | Per-user-pair send rate limit, or too many registration attempts in a short window | Yes, with backoff | | `502` | WhatsApp returned a server error | Yes | | `503` | The WhatsApp integration is not configured on this server | No, contact support | ### Accounts A WABA is the Meta-side container for your numbers and templates. #### List accounts `GET /api/v1/whatsapp/accounts` · scope `whatsapp:read` No parameters. Returns every WABA on your workspace, newest first. ```bash curl https://api.callmissed.com/api/v1/whatsapp/accounts \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "business_id": "441329482726", "name": "Acme Coffee", "currency": "INR", "review_status": "APPROVED", "account_status": "ACTIVE", "account_restriction_reason": null, "payment_setup_complete": true, "is_active": true, "created_at": "2026-04-19T12:00:00Z" } ] ``` | Field | Type | Notes | |---|---|---| | `id` | UUID | CallMissed's account id, used as `account_id` on template endpoints | | `waba_id` | string | Meta's WABA id | | `business_id` | string, nullable | Meta business id | | `name` | string, nullable | WABA display name, filled in from Meta | | `currency` | string, nullable | ISO 4217, the currency Meta bills the WABA in | | `review_status` | string | Meta's business verification state | | `account_status` | string | `ACTIVE` while healthy | | `account_restriction_reason` | string, nullable | Set when Meta restricts the account, null otherwise | | `payment_setup_complete` | boolean | `false` means Meta has no payment method on the WABA and sends will fail | | `is_active` | boolean | Whether the account is live on your workspace | | `created_at` | datetime | ISO 8601 UTC | #### Delete an account `DELETE /api/v1/whatsapp/accounts/{account_id}` · scope `whatsapp:write` Permanently removes the WABA and everything under it: phone numbers, templates, campaigns and call records all cascade. Conversation history is preserved. CallMissed first tries to deregister each number and unsubscribe from the WABA's webhooks upstream, then deletes locally whether or not that succeeded. ```bash curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/accounts/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "ok": true, "error": null } ``` `error` is a short string when the upstream cleanup partly failed. The local delete still happened. ### Phone numbers #### List numbers `GET /api/v1/whatsapp/phone_numbers` · scope `whatsapp:read` No parameters. Newest first. ```bash curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "phone_number_id": "1234567890", "display_phone_number": "+91 80802 47309", "verified_name": "Acme Coffee", "code_verification_status": "VERIFIED", "quality_rating": "GREEN", "messaging_limit_tier": "TIER_1K", "throughput_level": "STANDARD", "registration_status": "REGISTERED", "registration_error": null, "name_status": "APPROVED", "ai_autoreply_enabled": true, "is_active": true, "created_at": "2026-04-19T12:00:00Z" } ] ``` | Field | Type | Notes | |---|---|---| | `id` | UUID | Use as `phone_id` anywhere a sending number is needed | | `account_id` | UUID | The owning WABA | | `bot_id` | UUID, nullable | The linked agent. `null` means no auto-reply | | `phone_number_id` | string | Meta's id for the number | | `display_phone_number` | string | Human-readable number | | `verified_name` | string, nullable | The business name shown to customers | | `code_verification_status` | string | Meta's number verification state | | `quality_rating` | string | `GREEN`, `YELLOW`, `RED` or `UNKNOWN` | | `messaging_limit_tier` | string | Meta's 24-hour send cap tier, for example `TIER_1K` | | `throughput_level` | string | Meta's throughput class, `STANDARD` by default | | `registration_status` | string | `PENDING`, `REGISTERED`, `FAILED` or `DEREGISTERED` | | `registration_error` | string, nullable | Why registration failed | | `name_status` | string | Display-name approval. `APPROVED` or `AVAILABLE_WITHOUT_REVIEW` means sends work. Anything else blocks free-form sends | | `ai_autoreply_enabled` | boolean | Master AI auto-reply switch for the number | | `is_active` | boolean | `false` after a disconnect | | `created_at` | datetime | ISO 8601 UTC | #### Get one number `GET /api/v1/whatsapp/phone_numbers/{phone_id}` · scope `whatsapp:read` Same object as a list element. `404` if the number is not on your workspace. ```bash curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d \ -H "Authorization: Bearer cm_your_api_key" ``` #### Refresh metadata from Meta `POST /api/v1/whatsapp/phone_numbers/{phone_id}/refresh` · scope `whatsapp:write` No body. Re-pulls live values from Meta and updates `display_phone_number`, `verified_name`, `code_verification_status`, `quality_rating`, `messaging_limit_tier`, `throughput_level` and `name_status`. Use it when a freshly onboarded number is still missing its display number, or after Meta approves a display name or raises your tier. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/refresh \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the updated phone-number object. `400` if the number has no stored token (reconnect it), `422` if Meta rejected the read, `502` if Meta failed. #### Link or unlink a bot `POST /api/v1/whatsapp/phone_numbers/{phone_id}/link-bot` · scope `whatsapp:write` This is what turns a number into an AI agent. Without a link the number stores inbound messages and never replies. | Field | Type | Required | Notes | |---|---|---|---| | `bot_id` | UUID or null | Yes | The bot to link. Pass `null` to unlink | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/link-bot \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" }' ``` Returns the updated phone-number object. `404` if the bot does not belong to your workspace. #### Pause or resume auto-reply `POST /api/v1/whatsapp/phone_numbers/{phone_id}/autoreply` · scope `whatsapp:write` Master switch for the whole number. Set `false` and the agent stays silent on every conversation on that number until you set it back. Messages are still received and stored. | Field | Type | Required | Notes | |---|---|---|---| | `enabled` | boolean | Yes | `false` pauses the agent for this number | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/autoreply \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` Returns the updated phone-number object. #### Conversational automation In-chat guidance on a number: **ice breakers**, the tappable openers a customer sees before they have said anything, and **commands**, the slash-command hints that appear while they type. Both are per number. `GET /api/v1/whatsapp/phone_numbers/{phone_id}/conversational_automation` · scope `whatsapp:read` ```bash curl https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/conversational_automation \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "enable_welcome_message": true, "commands": [ { "command_name": "track", "command_description": "Track your latest order" }, { "command_name": "invoice", "command_description": "Get an invoice by order id" } ], "prompts": ["Track my order", "Talk to a human", "Store timings"] } ``` A number that has never been configured returns empty lists rather than an error. `POST /api/v1/whatsapp/phone_numbers/{phone_id}/conversational_automation` · scope `whatsapp:write` | Field | Type | Required | Notes | |---|---|---|---| | `commands` | array of objects, max 30 | No | Each is `{ "command_name": string (1-32), "command_description": string (1-256) }` | | `prompts` | array of strings, max 4 | No | The ice breakers. Each max 80 characters | | `enable_welcome_message` | boolean | No | Show a welcome message on a brand-new chat. Forwarded to WhatsApp only when you set it explicitly, and never defaulted | Every field is optional and updates are partial, so posting only `commands` leaves your ice breakers untouched, and vice versa. A field you do send replaces the whole list, so send the full set you want, not just the additions. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/conversational_automation \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "prompts": ["Track my order", "Talk to a human", "Store timings"], "commands": [ { "command_name": "track", "command_description": "Track your latest order" }, { "command_name": "invoice", "command_description": "Get an invoice by order id" } ], "enable_welcome_message": true }' ``` Returns the configuration as WhatsApp reports it after the update, in the same shape as the read. `422` when a cap is exceeded: more than 30 commands, more than 4 ice breakers, an ice breaker over 80 characters, a `command_name` over 32, or a `command_description` over 256. A tapped ice breaker or command arrives as an ordinary inbound text message, so your agent answers it with no extra wiring. Point the copy at things the agent can actually do. #### Disconnect a number `POST /api/v1/whatsapp/phone_numbers/{phone_id}/disconnect` · scope `whatsapp:write` No body. Reversible teardown: CallMissed tries to deregister the number and unsubscribe from the WABA's webhooks, then always marks the local row `is_active: false` with `registration_status: "DEREGISTERED"`, even if the upstream calls failed. Reconnect later by onboarding the number again. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d/disconnect \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "ok": true, "error": null } ``` #### Delete a number `DELETE /api/v1/whatsapp/phone_numbers/{phone_id}` · scope `whatsapp:write` Permanent, unlike disconnect. Best-effort deregister upstream first, then the row is removed regardless. Campaigns and call records that reference the number cascade. Conversation history is preserved. ```bash curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/phone_numbers/9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "ok": true, "error": null } ``` ### Raw webhook events `GET /api/v1/whatsapp/webhook_events` · scope `whatsapp:read` An audit peek at the raw events Meta delivered for your WABAs, newest first. Useful for debugging "did that message actually arrive". This is **not** the way to consume inbound messages, see [Inbound events you receive](#inbound-events-you-receive) for that. | Param | Type | Default | Notes | |---|---|---|---| | `limit` | integer, 1 to 100 | 20 | Max rows | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/webhook_events?limit=20" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "id": "b7d4e2f1-0a3c-4d5e-8f6a-9b0c1d2e3f4a", "received_at": "2026-04-19T12:04:11Z", "signature_valid": true, "event_type": "messages", "waba_id": "102290129340398", "processed": true, "process_error": null, "sender_wa_id": "919000000000", "body_preview": "Where is my order AC-10294?" } ] ``` Full raw payloads are deliberately not exposed here, because they carry customer message bodies and phone numbers. `event_type` mirrors Meta's webhook field name, for example `messages`, `message_template_status_update`, `account_update`, `phone_number_quality_update` or `calls`. ### The Meta-facing webhook Meta delivers every event for your WABA to one CallMissed endpoint: ``` https://api.callmissed.com/api/v1/webhooks/whatsapp ``` **You do not configure this.** Connecting a number subscribes the CallMissed app to your WABA's webhooks, and this URL is already registered on Meta's side for every live WABA. It is documented here so you recognise it in Meta's dashboard, not because you need to set it. `GET` is Meta's one-time verification handshake. It echoes `hub.challenge` as a plain-text body when the verify token matches, and returns `403` otherwise. `POST` is the event receiver. Every request is authenticated by `X-Hub-Signature-256`, an HMAC-SHA256 of the raw body keyed on the app secret. Verification is unconditional: an unsigned or mis-signed request is archived for audit and then rejected with `403`, because a forged `delivered` status would otherwise drive billing. A valid request is archived, acknowledged immediately, and processed in the background, so a slow model never causes Meta to retry. ```json { "status": "ok" } ``` The bodies Meta posts here are its own webhook payloads (`messages`, `statuses`, `message_template_status_update`, `account_update`, `phone_number_quality_update`, `calls` and so on). You read what arrived through [`GET /webhook_events`](#raw-webhook-events), and you consume the messages themselves through your own subscription below. ### Inbound events you receive You never poll for inbound messages. Register an HTTPS endpoint and CallMissed pushes to it. #### Subscribe ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/hooks/callmissed", "events": ["message.received"] }' ``` `message.received` is the event the WhatsApp channel emits. See [Webhooks](https://docs.callmissed.com/docs/webhooks) for the full catalogue of event types across the platform, delivery retries and replay. #### The request you receive ``` POST /hooks/callmissed HTTP/1.1 Content-Type: application/json X-CallMissed-Event: message.received X-CallMissed-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 X-CallMissed-Delivery: 4c8d2e10-6b7a-4f3d-9e21-0a5b6c7d8e9f ``` ```json { "event": "message.received", "data": { "conversation_id": "2f6c9a11-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "bot_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "channel": "whatsapp", "from": "919000000000", "message_id": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=", "type": "text", "text": "Where is my order AC-10294?" }, "timestamp": "2026-04-19T12:04:11.512340+00:00" } ``` | Field | Type | Notes | |---|---|---| | `event` | string | Always `message.received` for this subscription | | `timestamp` | string | ISO 8601 UTC, when the event was dispatched | | `data.conversation_id` | UUID | The CallMissed conversation thread | | `data.bot_id` | UUID | The bot that owns the conversation | | `data.channel` | string | `whatsapp` | | `data.from` | string | The customer's WhatsApp id, digits only, no `+` | | `data.message_id` | string | Meta's `wamid` for the inbound message | | `data.type` | string | WhatsApp message type, for example `text`, `image`, `audio`, `interactive`, `button`, `location` | | `data.text` | string, nullable | Body text. Null for non-text types | #### Verify the signature `X-CallMissed-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the **raw request body**, keyed with the webhook's secret. Compare in constant time and reject a mismatch. ```python import hashlib, hmac def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(f"sha256={expected}", header or "") ``` ```javascript import crypto from "node:crypto"; function verify(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); return ( header?.length === expected.length && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)) ); } ``` Return `2xx` quickly. Do your own work after acknowledging. ### Analytics Three read-only aggregations over the last N days. All require `whatsapp:read`, and `days` is bounded to 1 to 90. #### Delivery funnel `GET /api/v1/whatsapp/analytics/funnel` · scope `whatsapp:read` | Param | Type | Default | Notes | |---|---|---|---| | `days` | integer, 1 to 90 | 7 | Window size | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/analytics/funnel?days=7" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "days": 7, "inbound": 412, "outbound": 508, "delivered": 341, "read": 146, "replied": 96, "delivery_rate": 0.671, "read_rate": 0.428 } ``` | Field | Type | Notes | |---|---|---| | `days` | integer | Echoes the window | | `inbound` | integer | Inbound message events from customers | | `outbound` | integer | Messages you sent: conversation replies plus campaign sends | | `delivered` | integer | Real delivered acks, from message status plus campaign delivery counters | | `read` | integer | Real read acks, from the same two sources | | `replied` | integer | Conversations you sent at least one agent reply in during the window | | `delivery_rate` | float | `delivered / outbound`, 3 decimals, clamped to `[0, 1]` | | `read_rate` | float | `read / delivered`, 3 decimals, clamped to `[0, 1]` | The counts come from real delivery data, not from a ratio. Where a figure genuinely cannot be sourced it stays `0` rather than being estimated. If no WABA is connected, every counter is `0`. #### Event time series `GET /api/v1/whatsapp/analytics/timeseries` · scope `whatsapp:read` One row per day and event type, ready to stack in a chart. | Param | Type | Default | Notes | |---|---|---|---| | `days` | integer, 1 to 90 | 14 | Window size | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/analytics/timeseries?days=14" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "days": 14, "points": [ { "date": "2026-04-18", "event_type": "messages", "count": 63 }, { "date": "2026-04-18", "event_type": "message_template_status_update", "count": 2 }, { "date": "2026-04-19", "event_type": "messages", "count": 71 } ] } ``` | Field | Type | Notes | |---|---|---| | `points[].date` | string | `YYYY-MM-DD` in UTC | | `points[].event_type` | string | Meta webhook field name, or `unknown` | | `points[].count` | integer | Events that day | #### Cost breakdown `GET /api/v1/whatsapp/analytics/costs` · scope `whatsapp:read` | Param | Type | Default | Notes | |---|---|---|---| | `days` | integer, 1 to 90 | 30 | Window size | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/analytics/costs?days=30" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "days": 30, "llm_tokens": 1284300, "llm_credits": 998.4412, "whatsapp_message_credits": 512.0, "whatsapp_call_credits": 43.75, "total_credits": 1554.1912, "ledger_source": "wa_usage_events" } ``` | Field | Type | Notes | |---|---|---| | `days` | integer | Echoes the window | | `llm_tokens` | integer | Tokens the agent consumed. Context only, it does not drive any credit figure | | `llm_credits` | float | Real LLM spend, from the usage ledger | | `whatsapp_message_credits` | float | Real WhatsApp message spend, priced per delivered message | | `whatsapp_call_credits` | float | Real WhatsApp call spend | | `total_credits` | float | Sum of the three credit figures | | `ledger_source` | string | How the figures were sourced, so you can tell a per-event ledger match from an aggregate | Every credit figure here traces to a per-event billing record, so it reconciles with what was actually deducted. For the wallet balance and the platform-wide usage feed, see [Credits & Rate Limits](https://docs.callmissed.com/docs/credits-rate-limits). ## Sending Messages Source: https://docs.callmissed.com/docs/whatsapp-messages Every WhatsApp send endpoint: text, template, media, interactive, location, reaction, contact cards, read receipts, and media upload and download. Every send endpoint lives under `https://api.callmissed.com/api/v1/whatsapp`, takes a JSON body, needs the `whatsapp:send` scope (media upload needs `whatsapp:write`, media reads need `whatsapp:read`), and identifies the sending number with `phone_id` or `phone_number_id`. See [WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api#choosing-the-sending-number) for the selector and the shared error shape. > **Free-form sends need an open window.** Text, media, interactive and location sends only work inside the 24-hour customer service window that opens when the customer last messaged you. Outside it, send an approved [template](https://docs.callmissed.com/docs/whatsapp-templates). A closed-window send returns `422`. ### The common response Every send endpoint returns the same object: ```json { "wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA=", "wamids": ["wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSMkE5N0Y4RDcxMkYzQTJEMQA="], "contacts": [ { "input": "+919000000000", "wa_id": "919000000000" } ] } ``` | Field | Type | Notes | |---|---|---| | `wamid` | string | Meta's id for the first message. Keep it to correlate delivery and read status | | `wamids` | array of strings | Every id the request produced, in send order. More than one when a long text was split across messages | | `contacts` | array | WhatsApp's resolution of the recipient. Empty when WhatsApp returns none | Track delivery against `wamids`, not `wamid`: WhatsApp reports status per message, so a split reply produces several status events. Sends are persisted into the matching conversation thread, so anything you send over the API shows up in the dashboard inbox alongside the agent's own replies. Reactions are the exception, since a reaction is a property of the message it targets rather than a bubble of its own. ### Before WhatsApp is called Two gates run on every send, before any request reaches Meta. **Tenant scope.** The sending number is resolved against your workspace. A number you do not own returns `404`, identically to one that does not exist. **Credit check.** The charge for a WhatsApp message lands after Meta delivers it, so a send you cannot pay for cannot be undone. Sends are therefore priced up front, against the same rate card the delivery charge uses, and refused with `402` when the balance will not cover them. Nothing is sent and nothing is charged. ```json { "detail": "Not enough credits to send this message, so nothing was sent and nothing was charged. It needs at least 7.51 credits and you have 2.00 spendable (balance 12.00, 10.00 held for running campaigns) -- short by 5.51. Top up your balance and try again." } ``` Template sends are priced from the recipient's region and the template's category, which differ by more than tenfold across markets, so the quoted figure is specific to the message you tried to send. Reactions are not credit-gated, because they are not billed. ### Send a text message `POST /api/v1/whatsapp/messages` · scope `whatsapp:send` | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` | UUID | One of | CallMissed's number id | | `phone_number_id` | string, max 64 | One of | Meta's number id | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164, for example `+919000000000` | | `text` | string, 1 to 65536 chars | Yes | Message body. WhatsApp caps a single message at 4096 characters, so a longer body is split across several messages and every id comes back in `wamids` | | `preview_url` | boolean | No | Render a link preview for the first URL. Default `false` | ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "text": "Your order AC-10294 shipped this morning. Track it at https://acme.example.com/t/AC-10294", "preview_url": true }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/whatsapp" headers = {"Authorization": "Bearer cm_your_api_key"} resp = httpx.post( f"{BASE}/messages", headers=headers, json={ "phone_number_id": "1234567890", "to": "+919000000000", "text": "Your order AC-10294 shipped this morning.", "preview_url": False, }, ) resp.raise_for_status() print(resp.json()["wamid"]) ``` ```javascript [Node] const res = await fetch("https://api.callmissed.com/api/v1/whatsapp/messages", { method: "POST", headers: { Authorization: "Bearer cm_your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ phone_number_id: "1234567890", to: "+919000000000", text: "Your order AC-10294 shipped this morning.", }), }); if (!res.ok) throw new Error((await res.json()).detail); const { wamid } = await res.json(); ``` **Failures** | Code | Meaning | |---|---| | `400` | Neither `phone_id` nor `phone_number_id` was supplied | | `401` | The number's Meta token is invalid or expired. Reconnect the number | | `402` | Not enough credits, or a workspace budget cap would be exceeded. Nothing was sent | | `403` | API key is missing `whatsapp:send` | | `404` | The sending number is not on your workspace | | `409` | The number is disconnected, or is not registered on the WhatsApp Business Platform | | `422` | The 24-hour window is closed, the display name is not approved yet, or WhatsApp rejected the payload | | `429` | Per-user-pair send rate limit. Retry with backoff | ### Send a template message `POST /api/v1/whatsapp/messages/template` · scope `whatsapp:send` The only way to message someone outside the 24-hour window. The template must already be `APPROVED`. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `template_name` | string, 1 to 512 chars | Yes | The approved template's name | | `language_code` | string, max 12 | No | Template locale. Default `en_US` | | `components` | array of objects | No | Header, body and button variable values, passed to WhatsApp unchanged | `components` follows WhatsApp's own shape, so any combination WhatsApp supports works: header media, body variables, URL button suffixes. Authentication templates (one-time codes) are sent the same way, with the code as a body or button parameter. ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/template \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "template_name": "order_shipped", "language_code": "en_US", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Priya" }, { "type": "text", "text": "AC-10294" } ] }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [{ "type": "text", "text": "AC-10294" }] } ] }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/whatsapp" headers = {"Authorization": "Bearer cm_your_api_key"} resp = httpx.post( f"{BASE}/messages/template", headers=headers, json={ "phone_number_id": "1234567890", "to": "+919000000000", "template_name": "order_shipped", "language_code": "en_US", "components": [ { "type": "body", "parameters": [ {"type": "text", "text": "Priya"}, {"type": "text", "text": "AC-10294"}, ], } ], }, ) resp.raise_for_status() print(resp.json()["wamid"]) ``` Returns the common send response. `422` if the template name or locale does not resolve to an approved template on the WABA, and `402` if the priced send exceeds your spendable balance. ### Send media `POST /api/v1/whatsapp/messages/media` · scope `whatsapp:send` Reference the file by an uploaded `media_id` (recommended, reusable for 30 days) or by a public `link` that WhatsApp fetches and caches briefly. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `kind` | enum | Yes | `image`, `audio`, `video`, `document` or `sticker` | | `media_id` | string, max 64 | One of | From [upload media](#upload-media) | | `link` | string, max 2048 | One of | A public URL to the file | | `caption` | string, max 1024 | No | Honoured for `image`, `video` and `document` only, ignored otherwise | | `filename` | string, max 255 | No | Display filename for documents | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/media \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "kind": "document", "media_id": "1079235482913746", "filename": "invoice-AC-10294.pdf", "caption": "Your invoice" }' ``` **Failures** | Code | Meaning | |---|---| | `400` | The MIME type does not match the file. Check the extension and `Content-Type` | | `413` | The file exceeds 100 MB | | `422` | The 24-hour window is closed, or WhatsApp rejected the media | ### Send an interactive message `POST /api/v1/whatsapp/messages/interactive` · scope `whatsapp:send` Reply buttons, a list menu, a call-to-action URL button, or a Flow. `interactive_type` selects the variant. **Common fields** | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `interactive_type` | enum | Yes | `button`, `list`, `cta_url` or `flow` | | `body_text` | string, 1 to 1024 chars | Yes | The main message body | | `footer_text` | string, max 60 | No | Small footer line | | `header` | object | No | Header block, passed through to WhatsApp. For `list` only its `text` is used | **Variant fields** | `interactive_type` | Required | Shape | |---|---|---| | `button` | `buttons` | 1 to 3 objects, each `{ "id": string (1-256), "title": string (1-20) }` | | `list` | `button_text`, `sections` | `button_text` max 20. Each section is `{ "title": string (1-24), "rows": [{ "id": string (1-200), "title": string (1-24), "description"?: string (max 72) }] }`, at least one row per section | | `cta_url` | `button_text`, `button_url` | `button_url` max 2048 | | `flow` | `flow_cta`, and exactly one of `flow_id` / `flow_name` | The flow fields below | **Flow fields** (`interactive_type: "flow"` only) | Field | Type | Required | Notes | |---|---|---|---| | `flow_cta` | string, 1 to 30 chars | Yes | The button label that opens the flow. Emojis are not supported | | `flow_id` | string, max 64 | Exactly one of | The published flow's id | | `flow_name` | string, max 200 | Exactly one of | The flow's name. Cannot be combined with `flow_id` | | `flow_action` | enum | No | `navigate` (default) or `data_exchange` | | `flow_action_payload` | object | For `navigate` | Must carry `screen`, the first screen to open. On `data_exchange` the first screen comes from your endpoint's response instead | | `flow_token` | string, max 512 | No | Your own identifier for this flow session, echoed back to you with the customer's submission | | `flow_mode` | enum | No | `published` (default) or `draft`, to send an unpublished flow while you are still building it | ```bash [Buttons] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "interactive_type": "button", "body_text": "Your order is out for delivery. Is someone home to receive it?", "footer_text": "Acme Coffee", "buttons": [ { "id": "home_yes", "title": "Yes, deliver" }, { "id": "home_no", "title": "Reschedule" } ] }' ``` ```bash [List] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "interactive_type": "list", "body_text": "What would you like help with?", "button_text": "Pick a topic", "sections": [ { "title": "Orders", "rows": [ { "id": "track", "title": "Track an order", "description": "Live delivery status" }, { "id": "return", "title": "Start a return" } ] }, { "title": "Account", "rows": [{ "id": "invoice", "title": "Get an invoice" }] } ] }' ``` ```bash [CTA URL] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "interactive_type": "cta_url", "body_text": "Your invoice is ready.", "button_text": "View invoice", "button_url": "https://acme.example.com/invoices/AC-10294" }' ``` ```bash [Flow] curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/interactive \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "interactive_type": "flow", "body_text": "Book your tasting session in a few taps.", "footer_text": "Acme Coffee", "flow_id": "1122334455667788", "flow_cta": "Book a slot", "flow_action": "navigate", "flow_action_payload": { "screen": "PICK_DATE" }, "flow_token": "booking-4471" }' ``` Returns the common send response. The customer's tap arrives back on your webhook as an inbound message with `type: "interactive"` or `type: "button"`. A completed flow arrives as an interactive reply carrying your `flow_token` alongside the screen data the customer submitted, so use `flow_token` to tie the submission back to the order, booking or ticket you sent it for. **Failures** | Code | Meaning | |---|---| | `400` | `buttons` missing for `button`, or `button_text` and `sections` missing for `list`, or `button_text` and `button_url` missing for `cta_url`, or for `flow`: `flow_cta` missing, neither or both of `flow_id` / `flow_name` supplied, or `flow_action_payload.screen` missing while `flow_action` is `navigate` | | `422` | The 24-hour window is closed, or WhatsApp rejected the layout | ### Send an order details message `POST /api/v1/whatsapp/messages/order_details` · scope `whatsapp:send` An itemised bill the customer can pay from the chat with UPI. Needs a payment configuration on the WABA first, see [WhatsApp Payments](https://docs.callmissed.com/docs/whatsapp-payments). India and UPI only: any other `payment_type` returns `501`. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `reference_id` | string, max 35 | Yes | Your order reference. Letters, digits, `_`, `-` and `.` only, and unique per order details message. This is the key an [order status](#send-an-order-status-update) update quotes to settle the bill | | `goods_type` | string | Yes | `digital-goods` or `physical-goods` | | `payment_configuration` | string, 1 to 60 chars | Yes | The `configuration_name` of the payment configuration to charge into | | `total_amount` | object | Yes | `{ "value": integer, "offset": 100 }` | | `order` | object | Yes | The line items and money breakdown, below | | `body_text` | string, 1 to 1024 chars | Yes | The message body above the bill | | `footer_text` | string, max 60 | No | Small footer line | | `header` | object | No | Image header, passed through to WhatsApp | | `beneficiaries` | array of objects | For shipped physical goods | India addresses only, see the shape below | | `preferred_payment_methods` | array of objects | No | At most one, `[{ "method": "gpay" }]`. One of `gpay`, `phonepe`, `paytm`, `amazonpay`, `cred`, `mobikwik` | | `payment_type` | string | No | Default `upi`. Anything else returns `501` | | `currency` | string | No | Default `INR`, the only accepted value | **Money is integer minor units.** Every amount is `{ "value": …, "offset": 100 }`, where `value` is paise and `offset` must be `100`, so ₹499.00 is `{ "value": 49900, "offset": 100 }`. Floats are not accepted, because binary floating point cannot represent decimal currency exactly and this is a bill. **The `order` object** | Field | Type | Required | Notes | |---|---|---|---| | `items` | array, at least 1 | Yes | Each item is `{ "name": string (1-60), "amount": Amount, "quantity": integer >= 1 }`, plus optional `sale_amount`, `retailer_id`, `image: { "link": … }`, `country_of_origin`, `importer_name`, `importer_address` | | `subtotal` | object | Yes | Amount. Must equal the sum of the line items | | `tax` | object | Yes | Amount, with an optional `description` (max 60) | | `shipping` | object | No | Amount | | `discount` | object | No | Amount | | `catalog_id` | string | No | When the items come from a catalog. Cannot be combined with a custom item `image` | | `expiration` | object | No | `{ "timestamp": …, "description": string (max 120) }`. `timestamp` is UTC epoch seconds and must be at least 300 seconds in the future | | `type` | string | No | Only `quick_pay` is accepted, which shows a single "Pay Now" button | | `status` | string | No | Only `pending` is accepted on an order details message | `total_amount.value` must equal `subtotal + tax + shipping - discount`. Using a custom item `image` limits the order to 10 items. **The `beneficiaries` shape** — required for shipped physical goods, and India-only: | Field | Type | Notes | |---|---|---| | `name` | string, 1 to 200 | | | `address_line1` | string, 1 to 100 | `address_line2` optional, same cap | | `city` / `state` / `country` | string | `country` must be `India` | | `postal_code` | string | A 6-digit PIN code | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/order_details \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "reference_id": "AC-10294", "goods_type": "physical-goods", "payment_configuration": "acme-upi", "body_text": "Here is your order. Pay with any UPI app to confirm it.", "footer_text": "Acme Coffee", "total_amount": { "value": 61800, "offset": 100 }, "order": { "type": "quick_pay", "status": "pending", "items": [ { "name": "Ratnagiri Dark Roast 500g", "amount": { "value": 55000, "offset": 100 }, "quantity": 1 } ], "subtotal": { "value": 55000, "offset": 100 }, "tax": { "value": 6800, "offset": 100, "description": "GST 12%" }, "expiration": { "timestamp": 1776000000, "description": "Pay within 30 minutes" } }, "preferred_payment_methods": [{ "method": "gpay" }] }' ``` Returns the common send response. > **Always follow up with an order status update.** The customer's order screen keeps showing "Order pending" until you send one, so an order that was paid still looks unpaid. **Failures** | Code | Meaning | |---|---| | `400` | A money rule failed (`offset` not `100`, total does not equal subtotal plus tax plus shipping minus discount, subtotal does not equal the line items), an invalid `reference_id` charset, an unknown `goods_type` or `status`, more than one `preferred_payment_methods` entry, or an unlisted payment app | | `402` | Not enough credits. Nothing was sent | | `422` | The 24-hour window is closed, or WhatsApp rejected the order | | `501` | `payment_type` is not `upi`. Only India and UPI are supported | ### Send an order status update `POST /api/v1/whatsapp/messages/order_status` · scope `whatsapp:send` The update that settles a bill. It moves the customer's order screen off "Order pending" and updates the buttons on the original order details message. Send one on every transaction update. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `reference_id` | string, max 35 | Yes | The same reference you sent the order details message with | | `status` | enum | Yes | `pending`, `processing`, `partially-shipped`, `shipped`, `completed` or `canceled`. `partially_shipped` and `cancelled` are accepted and normalised | | `body_text` | string, 1 to 1024 chars | Yes | The message body | | `description` | string, max 120 | No | A line of detail under the status | | `footer_text` | string, max 60 | No | Small footer line | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/order_status \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "reference_id": "AC-10294", "status": "shipped", "body_text": "Your order is on its way and should arrive by Thursday.", "description": "Picked up by the courier this morning", "footer_text": "Acme Coffee" }' ``` Returns the common send response. `400` for an unknown `status` or a `reference_id` outside the allowed charset, and `422` when the window is closed or WhatsApp rejected the update. ### Send a location `POST /api/v1/whatsapp/messages/location` · scope `whatsapp:send` | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `latitude` | float, -90 to 90 | Yes | | | `longitude` | float, -180 to 180 | Yes | | | `name` | string, max 200 | No | Location label | | `address` | string, max 300 | No | Street address shown under the name | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/location \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "latitude": 19.076, "longitude": 72.8777, "name": "Acme Coffee Bandra", "address": "Linking Road, Bandra West, Mumbai 400050" }' ``` ### Send a reaction `POST /api/v1/whatsapp/messages/reaction` · scope `whatsapp:send` | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `message_id` | string, 1 to 128 chars | Yes | The `wamid` of the message to react to | | `emoji` | string, max 8 | No | The emoji. An empty string removes an existing reaction. Default empty | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/reaction \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "message_id": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=", "emoji": "👍" }' ``` ### Send contact cards `POST /api/v1/whatsapp/messages/contacts` · scope `whatsapp:send` | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `to` | string, 5 to 20 chars | Yes | Recipient in E.164 | | `contacts` | array of objects, 1 to 10 | Yes | WhatsApp contact objects. Each needs a `name` block with at least `formatted_name`, or `first_name` plus `last_name` | | `context_message_id` | string, max 128 | No | `wamid` of the inbound message this replies to | | `biz_opaque_callback_data` | string, max 256 | No | Opaque string echoed back on status events for your own correlation | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/messages/contacts \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "contacts": [ { "name": { "formatted_name": "Acme Support", "first_name": "Acme", "last_name": "Support" }, "phones": [{ "phone": "+918080247309", "type": "WORK", "wa_id": "918080247309" }], "emails": [{ "email": "support@acme.example.com", "type": "WORK" }] } ], "biz_opaque_callback_data": "escalation-4471" }' ``` ### Mark a message as read `POST /api/v1/whatsapp/messages/{message_id}/read` · scope `whatsapp:send` `{message_id}` is the `wamid` of the inbound message. Shows blue ticks, and optionally a typing bubble while you prepare a reply. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The sending number | | `typing_indicator` | boolean | No | Show a typing bubble. Default `false` | ```bash curl -X POST "https://api.callmissed.com/api/v1/whatsapp/messages/wamid.HBgMOTE5MDAwMDAwMDAwFQIAEhggQjc0RTI5RDNBMjJDNjE4RgA=/read" \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "typing_indicator": true }' ``` **Response (200 OK)** ```json { "success": true } ``` The agent does this automatically for messages it answers. ### Media #### Upload media `POST /api/v1/whatsapp/media` · scope `whatsapp:write` Multipart upload. The MIME type is read from the file part's `Content-Type`, so set it explicitly, and it is validated against WhatsApp's allowlist before the request reaches Meta. The returned `media_id` is reusable for 30 days and is scoped to the number you uploaded it against. Maximum upload size is 100 MB, and this path is rate limited more tightly than the rest of the API. | Form field | Type | Required | Notes | |---|---|---|---| | `file` | file | Yes | The media file. Must carry a `Content-Type` | | `phone_id` | UUID | One of | CallMissed's number id | | `phone_number_id` | string | One of | Meta's number id | ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/whatsapp/media \ -H "Authorization: Bearer cm_your_api_key" \ -F 'phone_number_id=1234567890' \ -F 'file=@invoice-AC-10294.pdf;type=application/pdf' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/whatsapp" headers = {"Authorization": "Bearer cm_your_api_key"} with open("invoice-AC-10294.pdf", "rb") as fh: resp = httpx.post( f"{BASE}/media", headers=headers, data={"phone_number_id": "1234567890"}, files={"file": ("invoice-AC-10294.pdf", fh, "application/pdf")}, ) resp.raise_for_status() media_id = resp.json()["media_id"] ``` **Response (200 OK)** ```json { "media_id": "1079235482913746", "mime_type": "application/pdf", "size_bytes": 84213 } ``` Pass `media_id` to [send media](#send-media). **Failures** | Code | Meaning | |---|---| | `400` | No `Content-Type` on the file part, or the MIME type does not match the bytes | | `413` | The file exceeds 100 MB | #### Resolve inbound media to a URL `GET /api/v1/whatsapp/media/{media_id}` · scope `whatsapp:read` Turns a media id into a temporary download URL. Mostly used for **inbound** media, where the webhook gives you a media id and you want the file. The URL is valid for about five minutes and requires WhatsApp's own auth, so fetch it immediately or use [the content proxy](#stream-inbound-media-bytes) instead. | Query param | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The owning number | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/media/1079235482913746?phone_number_id=1234567890" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1079235482913746", "mime_type": "image/jpeg", "sha256": "b1946ac92492d2347c6235b4d2611184a3e0f5b1c2d3e4f5a6b7c8d9e0f1a2b3", "file_size": 84213 } ``` | Field | Type | Notes | |---|---|---| | `url` | string | Short-lived download URL | | `mime_type` | string | Falls back to `application/octet-stream` | | `sha256` | string, nullable | Checksum, when WhatsApp provides one | | `file_size` | integer, nullable | Bytes, when WhatsApp provides it | #### Stream inbound media bytes `GET /api/v1/whatsapp/media/{media_id}/content` · scope `whatsapp:read` Streams the raw file back through CallMissed with the upstream content type, so your browser or mobile client can render inbound images without handling short-lived URLs or WhatsApp credentials. Responses carry `Cache-Control: private, max-age=300`. | Query param | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The owning number | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/media/1079235482913746/content?phone_number_id=1234567890" \ -H "Authorization: Bearer cm_your_api_key" \ --output inbound-image.jpg ``` Returns the file bytes on `200`, or `404` when the media id no longer resolves. ## Message Templates Source: https://docs.callmissed.com/docs/whatsapp-templates Create, list, delete and sync WhatsApp message templates, including authentication templates and the AI drafting endpoint. A message template is pre-approved copy you can send **outside** the 24-hour customer service window. Order updates, delivery notices, reminders and one-time codes are all template sends. Templates are created on WhatsApp, reviewed by Meta, and mirrored locally so you can list and filter them without a Meta round trip. All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`. ### Lifecycle - **Create**: `POST /templates` validates the copy locally, then submits it to WhatsApp - **Review**: Meta reviews it. The template sits at `PENDING` - **Approved**: A status webhook flips it to `APPROVED` and it becomes sendable Statuses you will see: `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`, `DISABLED`, `IN_APPEAL`. Only `APPROVED` templates can be sent. A rejected template carries a `rejection_reason`. ### Choosing the WABA Template endpoints act on a WhatsApp Business Account rather than a phone number. Supply **exactly one**: | Field | Type | Where it comes from | |---|---|---| | `account_id` | UUID | The `id` from `GET /accounts` | | `waba_id` | string, max 64 | Meta's WABA id | Omitting both returns `400` with `"Either account_id (UUID) or waba_id (Meta) is required"`. On `GET /templates` these are optional filters instead. ### Create a template `POST /api/v1/whatsapp/templates` · scope `whatsapp:write` | Field | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA to create under | | `name` | string, 1 to 512 chars | Yes | Must match `^[a-z0-9_]+$`: lowercase letters, digits and underscores only | | `category` | string | Yes | `MARKETING`, `UTILITY` or `AUTHENTICATION` | | `language` | string, 2 to 12 chars | Yes | Locale, for example `en_US`, `hi`, `es_MX` | | `components` | array of objects, at least 1 | Yes | Header, body, footer and button spec. Must include a `BODY` | | `parameter_format` | string | No | `POSITIONAL` or `NAMED`, selecting the variable syntax | | `allow_category_change` | boolean | No | Let Meta re-categorise the template. Defaults to on, so only send this to opt out | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "name": "order_shipped", "category": "UTILITY", "language": "en_US", "components": [ { "type": "HEADER", "format": "TEXT", "text": "Your order is on its way" }, { "type": "BODY", "text": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days.", "example": { "body_text": [["Priya", "AC-10294"]] } }, { "type": "FOOTER", "text": "Acme Coffee" } ] }' ``` `components` is forwarded to WhatsApp unchanged, so any component type WhatsApp supports works, including button blocks. `BODY`, `FOOTER`, text `HEADER` and the three marketing formats below ([carousel](#carousel-templates), [limited-time offer](#limited-time-offer-templates), [coupon code](#coupon-code-templates)) are checked locally first; everything else is validated by Meta. **Response (200 OK)** ```json { "template_id": "1234567890123456", "status": "PENDING", "template": { "id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "template_id": "1234567890123456", "name": "order_shipped", "language": "en_US", "category": "UTILITY", "status": "PENDING", "quality_score": "UNKNOWN", "rejection_reason": null, "components": [], "last_meta_synced_at": null, "created_at": "2026-04-19T12:00:00Z", "updated_at": "2026-04-19T12:00:00Z" } } ``` | Field | Type | Notes | |---|---|---| | `template_id` | string, nullable | Meta's template id | | `status` | string | Initial lifecycle state, typically `PENDING` | | `template` | object | The mirrored row, described in [the template object](#the-template-object) | The local row is written only after WhatsApp accepts the create, so a rejection leaves nothing behind. #### Validation before submission Copy is checked locally first, so a guaranteed rejection does not cost a Meta round trip. Each of these returns `400` with the reason: | Rule | Message you get | |---|---| | Name outside `^[a-z0-9_]+$` | `template name must match ^[a-z0-9_]+$ (lowercase letters, digits, and underscores only)` | | No `BODY` component | `A BODY component is required.` | | Empty `BODY` text | `The BODY component requires non-empty text.` | | `BODY` over 1024 characters | `BODY text exceeds 1024 characters.` | | `FOOTER` over 60 characters | `FOOTER text exceeds 60 characters.` | | `{{N}}` variables with no example | `A component with {{N}} variables requires an 'example.body_text' array.` | | Example count does not match the variable count | `The example provides 1 value(s) but the text has 2 {{N}} variable(s).` | | An `example` on a component with no variables | `Omit the 'example' object on a component with no {{N}} variables -- Meta rejects an empty example.` | `example.body_text` is an **array of arrays**: one inner array holding a sample value per variable. A text `HEADER` with variables uses `example.header_text`, a flat array. #### Authentication templates One-time-code templates have a different body shape. Meta owns the verification copy, so you must **not** send `BODY` text: ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "name": "acme_login_code", "category": "AUTHENTICATION", "language": "en_US", "components": [ { "type": "BODY", "add_security_recommendation": true } ] }' ``` The only body option is the boolean `add_security_recommendation`, and there is no `example` because there is no sender-supplied variable in the body. Sending `BODY` text on an `AUTHENTICATION` template returns `400` telling you the verification-code copy is fixed by Meta, and a non-boolean `add_security_recommendation` returns `400` as well. Any additional button or footer options come from Meta's authentication-template reference and are passed through unchanged. Once approved, send the code through [`POST /messages/template`](https://docs.callmissed.com/docs/whatsapp-messages#send-a-template-message), passing it as the body or button parameter. #### Carousel templates A carousel pairs a normal message `BODY` with a swipeable row of cards, each with its own media header and buttons. Add a `CAROUSEL` component alongside the `BODY`. Carousels are **`MARKETING` only**. Under any other category the create returns `400` naming the format. | Rule | Detail | |---|---| | `cards` | 2 to 10. The count is fixed at creation: an approved template can only send the number of cards it was created with | | Card `HEADER` | Required on every card, and always media. `format` is `IMAGE` or `VIDEO` | | Card header media | `example.header_handle` must be a non-empty array holding an uploaded media handle | | Card `BODY` | Optional, but if one card has it every card must. Text max 160 characters, far shorter than the 1024-character message body. Variables need an `example` object | | Card `BUTTONS` | Optional, at most 2 per card, of type `QUICK_REPLY`, `URL` or `PHONE_NUMBER` | | Uniform structure | Every card must carry the same components in the same order, and the same button types. Cards render at a shared height, so a body or button on one card is required on all | | Top-level `BODY` | Still required, alongside the `CAROUSEL` component | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "name": "summer_blends_carousel", "category": "MARKETING", "language": "en_US", "components": [ { "type": "BODY", "text": "Hi {{1}}, our cold brew blends are 20% off this week.", "example": { "body_text": [["Priya"]] } }, { "type": "CAROUSEL", "cards": [ { "components": [ { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ1"] } }, { "type": "BODY", "text": "Ratnagiri Dark Roast, notes of cocoa and dried fig." }, { "type": "BUTTONS", "buttons": [ { "type": "QUICK_REPLY", "text": "Send me a sample" }, { "type": "URL", "text": "Shop now", "url": "https://acme.example.com/dark-roast" } ] } ] }, { "components": [ { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ2"] } }, { "type": "BODY", "text": "Chikmagalur Medium Roast, bright and citrus-forward." }, { "type": "BUTTONS", "buttons": [ { "type": "QUICK_REPLY", "text": "Send me a sample" }, { "type": "URL", "text": "Shop now", "url": "https://acme.example.com/medium-roast" } ] } ] } ] } ] }' ``` Every rule above is checked before submission, and the `400` names the card index and the field, so you do not have to reverse-engineer a generic rejection. #### Limited-time offer templates A limited-time offer adds an offer banner with an optional countdown. Add a `LIMITED_TIME_OFFER` component. `MARKETING` only. | Rule | Detail | |---|---| | `limited_time_offer` | Required object: `{ "text": string (max 16), "has_expiration": boolean }`. `text` is the offer label | | `BODY` | Max 600 characters on this format, stricter than the usual 1024 | | `HEADER` | Optional, but when present must be `IMAGE` or `VIDEO`. A text header is not supported | | `FOOTER` | Not supported at all. Sending one returns `400` | | `BUTTONS` | Only `COPY_CODE` and `URL`. When both are present the `COPY_CODE` button must be declared first, because it is fixed at button index 0 and the URL button at index 1 | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "name": "monsoon_offer", "category": "MARKETING", "language": "en_US", "components": [ { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["4::aW1hZ2UvanBlZw==:ARZ1"] } }, { "type": "BODY", "text": "Hi {{1}}, take 20% off your next bag of coffee.", "example": { "body_text": [["Priya"]] } }, { "type": "LIMITED_TIME_OFFER", "limited_time_offer": { "text": "20% off", "has_expiration": true } }, { "type": "BUTTONS", "buttons": [ { "type": "COPY_CODE", "example": "MONSOON20" }, { "type": "URL", "text": "Shop now", "url": "https://acme.example.com/shop" } ] } ] }' ``` `has_expiration: true` renders a countdown, whose expiry is supplied per send as a component parameter on [`POST /messages/template`](https://docs.callmissed.com/docs/whatsapp-messages#send-a-template-message). `components` is forwarded to WhatsApp unchanged on a template send, so the parameter shape is WhatsApp's own. #### Coupon code templates A `COPY_CODE` button gives the customer a one-tap copy of a discount code. It works on its own marketing template, and is also the button an LTO template uses. `MARKETING` only. | Rule | Detail | |---|---| | Button shape | `{ "type": "COPY_CODE", "example": "" }`. The button's label is fixed, so there is no `text` to set | | `example` | Required, a sample coupon code, max 20 characters. The same cap applies to the code you pass at send time | | Count | At most one `COPY_CODE` button per template | | Companions | A `QUICK_REPLY` button may accompany it | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "name": "welcome_coupon", "category": "MARKETING", "language": "en_US", "components": [ { "type": "BODY", "text": "Welcome to Acme, {{1}}. Here is 15% off your first order.", "example": { "body_text": [["Priya"]] } }, { "type": "BUTTONS", "buttons": [ { "type": "COPY_CODE", "example": "WELCOME15" }, { "type": "QUICK_REPLY", "text": "Browse blends" } ] } ] }' ``` The `example` is a sample for review, not the code you ship. The real code goes in a `coupon_code` button parameter per send, capped at the same 20 characters, so one approved template can issue a different code to every customer. An `AUTHENTICATION` template's `{ "type": "OTP", "otp_type": "COPY_CODE" }` button is a different component on a different template family and is not subject to these rules. ### List templates `GET /api/v1/whatsapp/templates` · scope `whatsapp:read` Reads the local mirror, newest updated first. All parameters are optional filters. | Param | Type | Default | Notes | |---|---|---|---| | `account_id` | UUID | none | Filter to one connected account | | `waba_id` | string, max 64 | none | Filter by Meta WABA id | | `status` | string | none | `APPROVED`, `PENDING`, `REJECTED`, `PAUSED`, `DISABLED`, `IN_APPEAL`. Case-insensitive | | `category` | string | none | `MARKETING`, `UTILITY` or `AUTHENTICATION`. An unknown value returns `400` | | `language` | string, max 12 | none | Filter by locale | | `limit` | integer, 1 to 500 | 100 | Max rows | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/templates?status=APPROVED&limit=100" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "id": "3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "template_id": "1234567890123456", "name": "order_shipped", "language": "en_US", "category": "UTILITY", "status": "APPROVED", "quality_score": "GREEN", "rejection_reason": null, "components": [ { "type": "BODY", "text": "Hi {{1}}, order {{2}} shipped today and should arrive in 2 to 3 days." }, { "type": "FOOTER", "text": "Acme Coffee" } ], "last_meta_synced_at": "2026-04-19T13:02:44Z", "created_at": "2026-04-19T12:00:00Z", "updated_at": "2026-04-19T13:02:44Z" } ] ``` The mirror is kept current by status webhooks and an hourly reconciliation sweep. For up-to-the-second consistency, call [sync](#sync-from-whatsapp) first. #### The template object | Field | Type | Notes | |---|---|---| | `id` | UUID | CallMissed's id. Use it on get and delete | | `account_id` | UUID | The owning WABA | | `template_id` | string, nullable | Meta's template id. Null if Meta never confirmed the create | | `name` | string | Template name | | `language` | string | Locale | | `category` | string | `MARKETING`, `UTILITY` or `AUTHENTICATION` | | `status` | string | Lifecycle state | | `quality_score` | string | Meta's quality signal, for example `GREEN` or `UNKNOWN` | | `rejection_reason` | string, nullable | Why Meta rejected it | | `components` | array of objects | The approved component spec | | `last_meta_synced_at` | datetime, nullable | Last reconciliation against Meta | | `created_at` / `updated_at` | datetime | ISO 8601 UTC | ### Get one template `GET /api/v1/whatsapp/templates/{template_uuid}` · scope `whatsapp:read` `{template_uuid}` is the `id` field, not Meta's `template_id`. Returns the template object, or `404` if it is not on your workspace. ```bash curl https://api.callmissed.com/api/v1/whatsapp/templates/3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e \ -H "Authorization: Bearer cm_your_api_key" ``` ### Delete a template `DELETE /api/v1/whatsapp/templates/{template_uuid}` · scope `whatsapp:write` Deletes on WhatsApp and drops the local row. Returns `204 No Content` with an empty body. ```bash curl -X DELETE https://api.callmissed.com/api/v1/whatsapp/templates/3f9a1c20-7d8e-4b1a-9c2f-5e6a7b8c9d0e \ -H "Authorization: Bearer cm_your_api_key" ``` If the template was already deleted in WhatsApp Manager, the local row is cleaned up anyway. A template that never got a Meta id is simply dropped locally. > **Deleting an approved template starts a 30-day cooldown** before the same **name** can be reused. Reusing it sooner fails at create time. ### Sync from WhatsApp `POST /api/v1/whatsapp/templates/sync` · scope `whatsapp:write` Pulls every template for a WABA from WhatsApp and upserts the local mirror. An hourly sweep does this automatically, so call it when you have just edited templates in WhatsApp Manager and want them reflected immediately. | Field | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA to reconcile | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/templates/sync \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398" }' ``` **Response (200 OK)** ```json { "waba_id": "102290129340398", "fetched": 12, "inserted": 2, "updated": 10 } ``` | Field | Type | Notes | |---|---|---| | `waba_id` | string | The WABA that was reconciled | | `fetched` | integer | Templates WhatsApp returned | | `inserted` | integer | New local rows | | `updated` | integer | Existing rows refreshed | Templates WhatsApp no longer returns are not deleted by this call. The background sweep owns that. ### Draft a template with AI `POST /api/v1/whatsapp/ai/draft_template` · scope `whatsapp:read` Turns a plain-language intent into a Meta-compliant draft, with an approval-risk assessment. It is read-only: nothing is submitted to WhatsApp, so review the draft and then post it to [create](#create-a-template) yourself. The generation is billed to your workspace. | Field | Type | Required | Notes | |---|---|---|---| | `intent` | string, 10 to 1000 chars | Yes | What the template should say and when it is sent | | `language` | string, 2 to 8 chars | No | Default `en` | | `category` | string | No | Force `UTILITY`, `MARKETING` or `AUTHENTICATION`. Omit to let the model choose | | `emojis` | boolean | No | Allow emojis in the body. Default `false`, which is safest for approval | | `include_header` | boolean | No | Allow a header line. Default `true` | | `include_footer` | boolean | No | Allow a footer line. Default `true` | | `tone` | string, max 40 | No | For example `friendly`, `formal`, `concise` | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/ai/draft_template \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "intent": "Tell a customer their coffee subscription renews in three days and they can skip or change the blend before then.", "language": "en", "category": "UTILITY", "tone": "friendly" }' ``` **Response (200 OK)** ```json { "name": "subscription_renewal_reminder", "category": "UTILITY", "language": "en", "body": "Hi {{1}}, your Acme coffee subscription renews on {{2}}. Reply SKIP to pause this delivery or CHANGE to pick a different blend.", "header_text": "Your subscription renews soon", "footer_text": "Acme Coffee", "components": [ { "type": "HEADER", "format": "TEXT", "text": "Your subscription renews soon" }, { "type": "BODY", "text": "Hi {{1}}, your Acme coffee subscription renews on {{2}}. Reply SKIP to pause this delivery or CHANGE to pick a different blend.", "example": { "body_text": [["Priya", "22 April"]] } }, { "type": "FOOTER", "text": "Acme Coffee" } ], "approval_risk": "low", "rejection_risks": [], "compliance_notes": "Transactional reminder tied to an existing subscription, so UTILITY is the correct category." } ``` | Field | Type | Notes | |---|---|---| | `name` | string | Suggested template name, already matching Meta's naming rules | | `category` | string | The category chosen or forced | | `language` | string | Echoes the requested locale | | `body` / `header_text` / `footer_text` | string, nullable for header and footer | The drafted copy | | `components` | array of objects | Ready to post to `POST /templates` as-is | | `approval_risk` | string | The model's read on how likely Meta is to approve it | | `rejection_risks` | array of strings | Specific things that could get it rejected. Empty when none were found | | `compliance_notes` | string, nullable | Why the category and wording were chosen | `422` when the model cannot produce a valid draft. Shorten or clarify the intent and retry. ## Campaigns Source: https://docs.callmissed.com/docs/whatsapp-campaigns Bulk template sends: create a campaign, upload recipients with per-recipient variables, launch it, and track delivery. A campaign sends one approved template to many recipients, each with their own variable values, throttled so WhatsApp does not rate-limit you. It is the right tool for an order-status blast, a restock notice or a renewal reminder. For a single send, use [`POST /messages/template`](https://docs.callmissed.com/docs/whatsapp-messages#send-a-template-message) instead. All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`. ### Lifecycle - **Create**: `POST /campaigns` returns a campaign in `draft` - **Add recipients**: `POST /campaigns/{id}/recipients` in batches of up to 10,000 - **Launch**: `POST /campaigns/{id}/launch` prices the list, holds the credits, and starts the worker - **Track**: `GET /campaigns/{id}` returns live counters and a recipient sample **Campaign statuses:** `draft`, `scheduled`, `running`, `completed`, `cancelled`, `failed`. **Recipient statuses:** `pending`, `sent`, `delivered`, `read`, `failed`, `skipped`. Recipients can only be added while the campaign is `draft`. Once it is `running` the worker is already claiming rows. ### Create a campaign `POST /api/v1/whatsapp/campaigns` · scope `whatsapp:write` | Field | Type | Required | Notes | |---|---|---|---| | `phone_number_id` | UUID | Yes | The **CallMissed** phone id (the `id` from `GET /phone_numbers`), not Meta's id | | `name` | string, 1 to 255 chars | Yes | Your label for the campaign | | `template_name` | string, 1 to 255 chars | Yes | An approved template's name | | `template_language` | string, 2 to 16 chars | No | Template locale. Default `en` | | `template_components` | array of objects | No | The component **shape**, with `{{N}}` placeholders left in. Default empty | | `scheduled_at` | datetime | No | When you intend to run it. Recorded on the row; launching is still an explicit call | `template_components` is a shape, not a finished payload. Leave the `{{1}}`, `{{2}}` tokens in the parameter text and the worker substitutes each recipient's `variables` before sending. Non-text parameters, such as a header image, are passed through untouched. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "name": "April restock notice", "template_name": "back_in_stock", "template_language": "en_US", "template_components": [ { "type": "body", "parameters": [ { "type": "text", "text": "{{1}}" }, { "type": "text", "text": "{{2}}" } ] } ] }' ``` **Response (201 Created)** ```json { "id": "6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "name": "April restock notice", "template_name": "back_in_stock", "template_language": "en_US", "status": "draft", "scheduled_at": null, "started_at": null, "completed_at": null, "total": 0, "sent": 0, "delivered": 0, "read": 0, "failed": 0, "created_at": "2026-04-19T12:00:00Z" } ``` | Field | Type | Notes | |---|---|---| | `id` | UUID | The campaign id | | `account_id` | UUID | The WABA it sends from | | `phone_number_id` | UUID | The sending number | | `status` | string | Campaign status | | `scheduled_at` / `started_at` / `completed_at` | datetime, nullable | Timestamps, ISO 8601 UTC | | `total` | integer | Recipients added | | `sent` / `delivered` / `read` / `failed` | integer | Live counters, updated by the worker and by delivery webhooks | `404` with `"phone_number_id not found"` if the number is not on your workspace. ### Add recipients `POST /api/v1/whatsapp/campaigns/{campaign_id}/recipients` · scope `whatsapp:write` Up to 10,000 per call. Paginate for larger lists. | Field | Type | Required | Notes | |---|---|---|---| | `recipients` | array, max 10000 | Yes | The batch | | `recipients[].to_phone` | string, 8 to 32 chars | Yes | Any format. Non-digits are stripped, and the result must be 8 to 15 digits | | `recipients[].variables` | object of string to string | No | Values keyed by placeholder number, so `{"1": "Priya"}` fills `{{1}}` | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/recipients \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "recipients": [ { "to_phone": "+91 90000 00000", "variables": { "1": "Priya", "2": "Ethiopia Guji" } }, { "to_phone": "919000000001", "variables": { "1": "Arun", "2": "Colombia Huila" } }, { "to_phone": "12", "variables": { "1": "Broken" } } ] }' ``` **Response (200 OK)** ```json { "inserted": 2, "skipped_invalid": 1, "skipped_duplicate": 0, "total_now": 2 } ``` | Field | Type | Notes | |---|---|---| | `inserted` | integer | Recipients added | | `skipped_invalid` | integer | Numbers that were not 8 to 15 digits after stripping | | `skipped_duplicate` | integer | Numbers already on the campaign, or repeated inside the batch | | `total_now` | integer | The campaign's recipient total after this call | Bad rows are counted and skipped rather than failing the batch, so a 10,000-row paste with a few broken cells still lands the good ones. Adding to a campaign that is not `draft` returns `409` with `Cannot add recipients to a campaign in status=running`. ### Launch `POST /api/v1/whatsapp/campaigns/{campaign_id}/launch` · scope `whatsapp:write` No body. Flips the campaign to `running` and starts the send worker. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/launch \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the campaign object with `status: "running"` and `started_at` set. #### The credit hold Before anything is sent, the whole pending recipient list is priced against the real rate card, per recipient, using the template's category and each number's region. Those credits are then **held**, so a campaign launched a second later cannot spend them. If the balance will not cover it the launch is refused with `402` and the campaign stays `draft`, retryable after a top-up. Nothing was sent and nothing was charged. ```json { "detail": "Not enough credits to launch this campaign. It needs about 8631.40 credits for 1200 recipients and you are short by 431.40. Top up your balance and try again." } ``` Pricing varies by more than tenfold across markets, so a mixed India and Germany list is priced per recipient rather than at a blended rate. If the campaign's template has not been synced locally, it is priced as `MARKETING`, the most expensive category, so a campaign can never start underfunded. **Failures** | Code | Meaning | |---|---| | `400` | The campaign has no pending recipients to send to | | `402` | Not enough credits for the priced recipient list. The campaign stays `draft` | | `404` | No such campaign on your workspace | | `409` | The campaign is not `draft` or `scheduled`, for example it is already `running` | Concurrent launch calls are serialised, so a double-click cannot start two workers and double-send. ### Cancel `POST /api/v1/whatsapp/campaigns/{campaign_id}/cancel` · scope `whatsapp:write` No body. Works from `draft`, `scheduled` or `running`. A running worker notices within one send, so a few in-flight messages may still go out. ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e/cancel \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the campaign object with `status: "cancelled"` and `completed_at` set. `409` from any other status, for example one already `completed`. ### List campaigns `GET /api/v1/whatsapp/campaigns` · scope `whatsapp:read` | Param | Type | Default | Notes | |---|---|---|---| | `limit` | integer, 1 to 100 | 50 | Max rows, newest first | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/campaigns?limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns an array of campaign objects. ### Get one campaign `GET /api/v1/whatsapp/campaigns/{campaign_id}` · scope `whatsapp:read` The campaign object plus a sample of up to 50 recent recipient rows, most recently updated first. This is the progress endpoint: poll it while a campaign runs. ```bash curl https://api.callmissed.com/api/v1/whatsapp/campaigns/6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "id": "6d1e8b3a-2c4f-4a5b-8e9d-0f1a2b3c4d5e", "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "phone_number_id": "9c2b7e30-1d8a-4c5f-9b3d-2f4a6e8b1c2d", "name": "April restock notice", "template_name": "back_in_stock", "template_language": "en_US", "status": "running", "scheduled_at": null, "started_at": "2026-04-19T12:05:02Z", "completed_at": null, "total": 1200, "sent": 418, "delivered": 402, "read": 191, "failed": 3, "created_at": "2026-04-19T12:00:00Z", "recipients_sample": [ { "id": "aa11bb22-cc33-4d44-8e55-6f7788990011", "to_phone": "919000000000", "status": "delivered", "wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSN0MyRDFBOEY0RTVCOTAxMgA=", "error": null, "sent_at": "2026-04-19T12:05:44Z", "last_status_at": "2026-04-19T12:05:51Z" }, { "id": "bb22cc33-dd44-4e55-9f66-7788990011aa", "to_phone": "919000000002", "status": "failed", "wamid": null, "error": "Request rejected by Meta - check the recipient and payload.", "sent_at": null, "last_status_at": "2026-04-19T12:05:47Z" } ] } ``` | Field | Type | Notes | |---|---|---| | `recipients_sample[].id` | UUID | Recipient row id | | `recipients_sample[].to_phone` | string | Normalised to digits only | | `recipients_sample[].status` | string | `pending`, `sent`, `delivered`, `read`, `failed` or `skipped` | | `recipients_sample[].wamid` | string, nullable | Meta's message id once sent | | `recipients_sample[].error` | string, nullable | Why this recipient failed | | `recipients_sample[].sent_at` | datetime, nullable | When the send left | | `recipients_sample[].last_status_at` | datetime | Last status change | The sample is capped at 50 rows and is not paginated. Use the counters on the campaign itself for totals. ## Payments Source: https://docs.callmissed.com/docs/whatsapp-payments Take UPI payments inside a WhatsApp chat: create and manage payment configurations on a WABA, then send order details and order status messages. WhatsApp Payments lets a customer pay an itemised bill from the chat itself with any UPI app. Two pieces: a **payment configuration** on the WABA that says where the money lands, and the two [order messages](https://docs.callmissed.com/docs/whatsapp-messages#send-an-order-details-message) that bill the customer and then settle the order. All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`. > **India and UPI only.** These endpoints implement the India flow, with `payment_type: "upi"` and `INR`. Any other `payment_type` on a send returns `501`, because other regions use a different request shape rather than a variation of this one. ### How it fits together - **Configure**: `POST /payment_configurations` registers a UPI VPA or a payment gateway on the WABA - **Link**: For a gateway, the merchant opens the returned `oauth_url` to finish linking. A VPA is usable immediately - **Bill**: `POST /messages/order_details` sends the itemised bill. The customer pays in their UPI app - **Settle**: `POST /messages/order_status` moves the order off "Order pending" ### Choosing the WABA A payment configuration belongs to a **WhatsApp Business Account**, not to a phone number, so these endpoints take the same account selector as [templates](https://docs.callmissed.com/docs/whatsapp-templates#choosing-the-waba). Supply exactly one: | Field | Type | Where it comes from | |---|---|---| | `account_id` | UUID | The `id` from `GET /accounts` | | `waba_id` | string, max 64 | Meta's WABA id | The account is always resolved against your workspace, so naming a WABA you do not own returns the same `404` as one that does not exist. The two order sends are per-**number** instead, and take `phone_id` or `phone_number_id` like every other send. ### Providers `provider_name` picks how the money is collected. | `provider_name` | What it is | Ready when | |---|---|---| | `upi_vpa` | A UPI VPA handle you own, linked directly | Immediately | | `razorpay` | Payment gateway | After the merchant completes the OAuth link | | `payu` | Payment gateway | After the merchant completes the OAuth link | | `zaakpay` | Payment gateway | After the merchant completes the OAuth link | A gateway configuration exists as soon as you create it but **cannot take a payment** until the merchant visits the `oauth_url` the create returns. Until then, an order details message quoting it will not be payable. ### Create a payment configuration `POST /api/v1/whatsapp/payment_configurations` · scope `whatsapp:write` | Field | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA to configure | | `configuration_name` | string, 1 to 60 chars | Yes | The name you quote as `payment_configuration` when sending an order | | `provider_name` | string, 1 to 32 chars | Yes | One of the providers above | | `merchant_vpa` | string, max 256 | For `upi_vpa` | The VPA handle to collect into | | `merchant_category_code` | string, max 32 | No | Your MCC | | `purpose_code` | string, max 32 | No | Purpose code, where your provider requires one | | `redirect_url` | string, max 2048 | No | Where to send the merchant after they finish the OAuth link | ```bash [UPI VPA] curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "configuration_name": "acme-upi", "provider_name": "upi_vpa", "merchant_vpa": "acmecoffee@okhdfcbank" }' ``` ```bash [Gateway] curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "configuration_name": "acme-razorpay", "provider_name": "razorpay", "redirect_url": "https://acme.example.com/payments/linked" }' ``` **Response (200 OK)** ```json { "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "configuration_name": "acme-razorpay", "success": true, "oauth_url": "https://business.example.com/payments/link?token=...", "expiration": 1776000000 } ``` | Field | Type | Notes | |---|---|---| | `account_id` | UUID | The WABA's CallMissed id | | `waba_id` | string | Meta's WABA id | | `configuration_name` | string | Echoes the name you created | | `success` | boolean | Whether the configuration was created | | `oauth_url` | string, nullable | Present for a gateway provider only. The merchant must visit it to finish linking | | `expiration` | integer, nullable | When that link stops working | ### List payment configurations `GET /api/v1/whatsapp/payment_configurations` · scope `whatsapp:read` Read live, with no cached fallback, so you never see a status we stored earlier and never refreshed. | Query param | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA to read | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/payment_configurations?waba_id=102290129340398" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "payment_configurations": [ { "configuration_name": "acme-upi", "status": "Active", "provider_name": "upi_vpa", "provider_mid": null, "merchant_vpa": "acmecoffee@okhdfcbank", "merchant_category_code": { "code": "5814", "description": "Restaurants" }, "purpose_code": null, "created_timestamp": 1774000000, "updated_timestamp": 1774000000 } ] } ``` #### The payment configuration object | Field | Type | Notes | |---|---|---| | `configuration_name` | string | The name you quote when sending an order | | `status` | string, nullable | `Active`, `Needs_Connecting` or `Needs_Testing`. Only `Active` can take a payment | | `provider_name` | string, nullable | The provider it was created with | | `provider_mid` | string, nullable | The gateway's merchant id, where the provider issues one | | `merchant_vpa` | string, nullable | The VPA handle, for a `upi_vpa` configuration | | `merchant_category_code` | string or object, nullable | Reported either as a plain code or as `{ code, description }` | | `purpose_code` | string or object, nullable | Same, when set | | `created_timestamp` / `updated_timestamp` | integer, nullable | Epoch seconds | Fields are broadly optional because the read endpoints and the status webhook each report a different subset. ### Get one payment configuration `GET /api/v1/whatsapp/payment_configurations/{configuration_name}` · scope `whatsapp:read` | Query param | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA to read | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-upi?waba_id=102290129340398" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns a single [payment configuration object](#the-payment-configuration-object), or `404` when no configuration on that WABA carries the name. Poll this after creating a gateway configuration to see it move to `Active` once the merchant has finished linking. ### Regenerate the OAuth link `POST /api/v1/whatsapp/payment_configurations/{configuration_name}/oauth_link` · scope `whatsapp:write` The link a gateway create returns expires. This is how a merchant who never finished linking, or whose link went stale, gets a fresh one without recreating the configuration. | Field | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA | | `redirect_url` | string | No | Where to send the merchant afterwards | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-razorpay/oauth_link \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "102290129340398", "redirect_url": "https://acme.example.com/payments/linked" }' ``` **Response (200 OK)** ```json { "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "configuration_name": "acme-razorpay", "oauth_url": "https://business.example.com/payments/link?token=...", "expiration": 1776000000 } ``` Only meaningful for a gateway provider. A `upi_vpa` configuration has nothing to link. ### Delete a payment configuration `DELETE /api/v1/whatsapp/payment_configurations/{configuration_name}` · scope `whatsapp:write` | Query param | Type | Required | Notes | |---|---|---|---| | `account_id` / `waba_id` | UUID / string | One of | The WABA | ```bash curl -X DELETE "https://api.callmissed.com/api/v1/whatsapp/payment_configurations/acme-razorpay?waba_id=102290129340398" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "account_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "waba_id": "102290129340398", "configuration_name": "acme-razorpay", "success": true } ``` > **Stop sending first.** Make sure no new order messages quote this configuration before you unlink it, or those bills will have nowhere to collect into. ### Billing a customer The two sends live with the rest of the send reference: - [Send an order details message](https://docs.callmissed.com/docs/whatsapp-messages#send-an-order-details-message) — the itemised bill, with the money rules and the full `order` shape - [Send an order status update](https://docs.callmissed.com/docs/whatsapp-messages#send-an-order-status-update) — the update that settles it Both need the `whatsapp:send` scope and both are window-limited like any other free-form send. Tie the two together with `reference_id`: it is unique per order details message, and quoting it on an order status update is what moves that specific order off "Order pending". ## Calling Source: https://docs.callmissed.com/docs/whatsapp-calling Voice calls over WhatsApp: enable calling, request permission, place a business-initiated call answered by your agent, and read call logs with transcripts. WhatsApp Calling lets a customer call your business number, and lets you call them, over WhatsApp itself rather than the phone network. Calls are answered by the same agent that handles the number's messages, with the same voice, model and system prompt, so a call produces a transcript and per-turn AI cost exactly like any other voice session. All endpoints are under `https://api.callmissed.com/api/v1/whatsapp`. ### Before you can call Three things have to be true, and each has its own failure code: 1. **Calling is enabled on the number.** Turn it on with [call settings](#call-settings), or in WhatsApp Manager. Otherwise sends fail with `409` telling you calling is not enabled. 2. **The number's messaging limit is 2000 or above.** WhatsApp requires it. Below that you get `409`. 3. **The customer granted call permission.** Inbound calls need nothing, but a business-initiated call is permission-gated and returns `403` without one. ### Call settings #### Read settings `GET /api/v1/whatsapp/calling/settings` · scope `whatsapp:read` | Query param | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The number | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/calling/settings?phone_number_id=1234567890" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns WhatsApp's own settings object for the number, unchanged. #### Update settings `POST /api/v1/whatsapp/calling/settings` · scope `whatsapp:write` Send only the fields you want to change. At least one is required, or you get `400` with `"No calling settings provided"`. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The number | | `status` | string | No | `ENABLED` or `DISABLED`. The master switch for calling on this number | | `call_icon_visibility` | string, max 32 | No | Where WhatsApp shows the call button | | `callback_permission_status` | string | No | `ENABLED` or `DISABLED` | | `call_hours` | object | No | Your calling hours, forwarded to WhatsApp unchanged | | `sip` | object | No | SIP configuration, forwarded unchanged | | `audio` | object | No | Audio configuration, forwarded unchanged | | `voicemail` | object | No | Voicemail configuration, forwarded unchanged | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/settings \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "status": "ENABLED", "callback_permission_status": "ENABLED" }' ``` The nested objects are passed to WhatsApp exactly as you send them and validated there, so any option WhatsApp supports works without waiting on a CallMissed release. Returns WhatsApp's response. ### Call permission WhatsApp requires an explicit grant from the customer before a business may call them. A grant is `temporary` or `permanent`; temporary grants expire, so re-check before relying on one. #### Check one user's permission `GET /api/v1/whatsapp/calling/permissions` · scope `whatsapp:read` | Query param | Type | Required | Notes | |---|---|---|---| | `user` | string, 5 to 20 chars | Yes | The customer's WhatsApp id | | `phone_id` / `phone_number_id` | UUID / string | One of | Your number | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/calling/permissions?phone_number_id=1234567890&user=919000000000" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns WhatsApp's permission object, which carries a `permission.status` of `no_permission`, `temporary` or `permanent`. The result is also recorded locally, so a number that has granted permission shows up in [allowed numbers](#list-allowed-numbers) even before you call it. #### Ask for permission `POST /api/v1/whatsapp/calling/permission-request` · scope `whatsapp:send` Sends the customer an interactive message asking them to allow calls. It only works inside an open 24-hour customer service window. Outside it, send an approved `call_permission_request` template instead. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | Your number | | `to` | string, 5 to 20 chars | Yes | The customer in E.164 | | `body_text` | string, 1 to 1024 chars | Yes | Why you want to call | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/permission-request \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "body_text": "Can we call you about order AC-10294? It will take about two minutes." }' ``` **Response (200 OK)** ```json { "wamid": "wamid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQjE1RDNBOEY0RTVCOTAxMgA=" } ``` The customer's answer arrives as an inbound event, and their permission state is reflected on the next permission check. #### List allowed numbers `GET /api/v1/whatsapp/calling/allowed-numbers` · scope `whatsapp:read` Numbers that have granted call permission, so you can pick one and dial. WhatsApp exposes no bulk lookup, so this is derived from the permission state recorded whenever you check permission, request it, or place a call. Deduplicated per number, most recent first. | Query param | Type | Default | Notes | |---|---|---|---| | `phone_id` | UUID | none | Restrict to one of your numbers | | `limit` | integer, 1 to 500 | 100 | Max rows | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/calling/allowed-numbers?limit=100" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "to_number": "919000000000", "permission_status": "permanent", "last_call_at": "2026-04-19T12:31:08Z" } ] ``` | Field | Type | Notes | |---|---|---| | `to_number` | string | The customer's number | | `permission_status` | string | `temporary` or `permanent` | | `last_call_at` | datetime, nullable | When we last saw this number | A `temporary` grant expires. Re-check with the permissions endpoint before relying on one. ### Place a call `POST /api/v1/whatsapp/calling/initiate` · scope `whatsapp:send` Places a business-initiated call. Permission is verified first, the credits are held, the media bridge is provisioned, and the agent picks up when the customer answers. | Field | Type | Required | Notes | |---|---|---|---| | `phone_id` / `phone_number_id` | UUID / string | One of | The number to call from | | `to` | string, 5 to 20 chars | Yes | The customer in E.164 | | `reason` | string, max 512 | No | Your own note on why the call was placed. Stored on the call log, not sent to WhatsApp | ```bash curl -X POST https://api.callmissed.com/api/v1/whatsapp/calling/initiate \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number_id": "1234567890", "to": "+919000000000", "reason": "Delivery address could not be verified for AC-10294" }' ``` **Response (200 OK)** ```json { "id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012", "session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "status": "INITIATED" } ``` | Field | Type | Notes | |---|---|---| | `id` | UUID | The CallMissed call row | | `session_id` | UUID | The linked voice session, where the transcript and AI cost land | | `status` | string | Always `INITIATED` at this point | WhatsApp's own `call_id` does not exist yet. It is minted moments later as the call is placed, and appears on the call log once the handshake completes. Correlate by `session_id` until then. **Failures** | Code | Meaning | |---|---| | `402` | Not enough credits to cover the call. Nothing was placed | | `403` | The customer has not granted call permission. Send a permission request first | | `404` | The calling number is not on your workspace | | `409` | Calling is not enabled on the number, or its messaging limit is below 2000 | | `503` | The calling media bridge is unavailable right now | #### The credit hold The network leg is charged when the call ends, so an unfundable call cannot be undone once placed. Before the call is provisioned, its worst-case cost is held: the agent's own maximum call duration, priced at the recipient's regional per-minute rate. A shortfall returns `402` and nothing is placed, no room is created and WhatsApp is never asked to dial. Over-holding is self-correcting. When the call ends, the real charge settles and the remainder is released. Inbound calls are not held at all, because WhatsApp does not charge for user-initiated calls. ### Call logs #### List calls `GET /api/v1/whatsapp/calling/calls` · scope `whatsapp:read` Most recent first. | Query param | Type | Default | Notes | |---|---|---|---| | `phone_id` | UUID | none | Restrict to one of your numbers | | `limit` | integer, 1 to 200 | 50 | Max rows | | `offset` | integer, 0 to 100000 | 0 | Pagination offset | ```bash curl "https://api.callmissed.com/api/v1/whatsapp/calling/calls?limit=50&offset=0" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json [ { "id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012", "call_id": "wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=", "direction": "BUSINESS_INITIATED", "status": "COMPLETED", "from_wa_id": "1234567890", "to_number": "919000000000", "duration_seconds": 96, "cost_credits": 4.8, "voice_session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "permission_status": "permanent", "created_at": "2026-04-19T12:31:08Z", "ai_cost_credits": 2.1374 } ] ``` | Field | Type | Notes | |---|---|---| | `id` | UUID | The CallMissed call row | | `call_id` | string | WhatsApp's call id. Use it on the detail and terminate endpoints | | `direction` | string | `BUSINESS_INITIATED` or `USER_INITIATED` | | `status` | string | Lifecycle state, for example `INITIATED`, `RINGING`, `ACCEPTED`, `COMPLETED`, `TERMINATED`, `FAILED`. Stored as WhatsApp reports it, so new values can appear | | `from_wa_id` | string, nullable | The calling side | | `to_number` | string, nullable | The called side | | `duration_seconds` | integer, nullable | Call length. Falls back to the voice session's duration when the call was ended by the agent | | `cost_credits` | float, nullable | The WhatsApp network leg only. Zero for inbound calls, which WhatsApp does not charge for | | `voice_session_id` | UUID, nullable | The linked voice session | | `permission_status` | string, nullable | The permission snapshot when the call was placed | | `created_at` | datetime, nullable | ISO 8601 UTC | | `ai_cost_credits` | float, nullable | Speech, model and voice cost for the call. Separate from `cost_credits` | Permission-check marker rows are excluded, so this list is real calls only. #### Get one call with its transcript `GET /api/v1/whatsapp/calling/calls/{call_id}` · scope `whatsapp:read` `{call_id}` is WhatsApp's call id. Only `A-Z a-z 0-9 _ . : = -` are accepted in the path, up to 128 characters. ```bash curl "https://api.callmissed.com/api/v1/whatsapp/calling/calls/wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "id": "c4a7f210-3b8e-4d1f-9a2c-5e6b7d8f9012", "call_id": "wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=", "direction": "BUSINESS_INITIATED", "status": "COMPLETED", "from_wa_id": "1234567890", "to_number": "919000000000", "duration_seconds": 96, "cost_credits": 4.8, "voice_session_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "permission_status": "permanent", "created_at": "2026-04-19T12:31:08Z", "ai_cost_credits": 2.1374, "transcript": [ { "turn_index": 0, "user_transcript": null, "agent_response": "Hi, this is Acme Coffee calling about order AC-10294.", "interrupted": false }, { "turn_index": 1, "user_transcript": "Yes, go ahead.", "agent_response": "We could not verify the delivery address. Is flat 4B still correct?", "interrupted": false } ] } ``` Every field from the list response, plus: | Field | Type | Notes | |---|---|---| | `transcript[].turn_index` | integer | Turn order, starting at 0 | | `transcript[].user_transcript` | string, nullable | What the customer said | | `transcript[].agent_response` | string, nullable | What the agent said | | `transcript[].interrupted` | boolean | Whether the customer spoke over the agent | `transcript` is empty until the agent has persisted turns, so it is normally empty while a call is still running. `404` if the call is not on your workspace. #### Terminate a live call `POST /api/v1/whatsapp/calling/calls/{call_id}/terminate` · scope `whatsapp:send` No body. Hangs up on WhatsApp's side and immediately marks the local row `TERMINATED`, so your call log updates without waiting for the webhook. ```bash curl -X POST "https://api.callmissed.com/api/v1/whatsapp/calling/calls/wacid.HBgMOTE5MDAwMDAwMDAwFQIAERgSQTBGOEQ3MTJGM0EyRDFDNQA=/terminate" \ -H "Authorization: Bearer cm_your_api_key" ``` **Response (200 OK)** ```json { "success": true } ``` `404` if the call is not on your workspace. ## Flows Source: https://docs.callmissed.com/docs/whatsapp-flows Build native in-chat forms — create a Flow from its screen JSON, publish it, and read the submissions customers send back. ### Overview A **Flow** is a multi-screen form WhatsApp renders **inside the conversation** — no browser, no link-out. Customers pick dates, confirm an address or answer a survey without leaving the chat, and the submission comes back to you as structured JSON. Typical uses: cash-on-delivery confirmation, address capture, lead qualification, appointment booking, and post-conversation surveys. ### Lifecycle ``` create (DRAFT) ──▶ publish (PUBLISHED) ──▶ send ──▶ read responses ``` 1. **Create** with a `flow_json` screen document and one or more categories. It starts as `DRAFT`. 2. **Publish** it. One way, and after publishing the screen document is frozen — a change means a new Flow. 3. **Send** it with [`POST /api/v1/whatsapp/messages/interactive`](https://docs.callmissed.com/docs/whatsapp-messages#flow) using `interactive_type: "flow"`. That is the billed, window-aware send path for every interactive message. 4. **Read** the submissions here. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, read responses | `wa_flows:read` | | Create, publish, delete | `wa_flows:write` | Your tenant also needs a connected WhatsApp number and Business Account. Without one you get `409 No connected WhatsApp number for tenant`. ### Statuses `DRAFT`, `PUBLISHED`, `DEPRECATED`, `BLOCKED`, `THROTTLED`. Only the first two are ever set from this API; the rest can appear when WhatsApp changes a Flow's state on its side. ### Categories Every Flow declares 1–8 categories: `SIGN_UP`, `SIGN_IN`, `APPOINTMENT_BOOKING`, `LEAD_GENERATION`, `CONTACT_US`, `CUSTOMER_SUPPORT`, `SURVEY`, `OTHER`. ### The flow object ```json { "id": "f1a2…", "tenant_id": "a0b1…", "flow_id": "1122334455667788", "name": "Appointment booking", "categories": ["APPOINTMENT_BOOKING"], "status": "PUBLISHED", "flow_json": { "version": "7.0", "screens": [] }, "endpoint_uri": null, "created_at": "2026-08-09T09:00:00Z", "updated_at": "2026-08-09T09:30:00Z" } ``` > There are **two ids**. `id` is the CallMissed record and is what every path parameter on this page takes. `flow_id` is WhatsApp's id — that is the one you pass to the send endpoint. `endpoint_uri` decides the Flow's kind: | `endpoint_uri` | Kind | Behaviour | | --- | --- | --- | | `null` | **Static** | Every screen is defined up front in `flow_json` | | Set | **Endpoint-backed** | Screens are served from your endpoint at runtime via `data_exchange` | Start static. It needs no server, no encryption key and no runtime availability on your side. ### GET `/api/v1/commerce/flows` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `status` | `string` | One of the five statuses | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ### POST `/api/v1/commerce/flows` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank | | `categories` | `string[]` | Yes | 1–8 entries from the category list | | `flow_json` | `object` | Yes | The screen document. At most 10 MB serialised | | `endpoint_uri` | `string` | No | At most 512 characters. Omit for a static Flow | ```bash curl -X POST https://api.callmissed.com/api/v1/commerce/flows \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Appointment booking", "categories": ["APPOINTMENT_BOOKING"], "flow_json": { "version": "7.0", "screens": [] } }' ``` Returns `201` with `status: "DRAFT"`. The Flow is created at WhatsApp **first**, then mirrored locally — so a rejected screen document never leaves a phantom record behind. WhatsApp's validation message is passed through verbatim, which is what you want when a screen definition is malformed. ### GET `/api/v1/commerce/flows/{flow_uuid}` One Flow by its CallMissed `id`. `404 Flow not found`. ### POST `/api/v1/commerce/flows/{flow_uuid}/publish` No body. Moves `DRAFT` to `PUBLISHED`. ```bash curl -X POST https://api.callmissed.com/api/v1/commerce/flows/f1a2…/publish \ -H "Authorization: Bearer cm_your_api_key" ``` One way, and irreversible. Publishing freezes the screen document — iterate while the Flow is still a draft. WhatsApp validates the whole document at this point, so this is where a structural mistake surfaces. ### DELETE `/api/v1/commerce/flows/{flow_uuid}` Returns `204`. WhatsApp refuses to delete a `PUBLISHED` Flow and its refusal is passed through. If the Flow is already gone upstream, the local record is still cleared, so a stale mirror can always be tidied. ### Responses A customer's submission arrives on your inbound webhook and is recorded here, correlated by the `flow_token` you set when sending. Recording is idempotent per WhatsApp message id, so a webhook redelivery never doubles a submission. #### GET `/api/v1/commerce/flows/responses` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `flow_id` | `string` | WhatsApp's flow id, at most 64 characters | | `flow_token` | `string` | At most 128 characters — the identifier you sent | | `contact_id` | `UUID` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```json [ { "id": "r9c8…", "tenant_id": "a0b1…", "flow_id": "1122334455667788", "flow_token": "booking-4471", "wa_message_id": "wamid.HBg…", "contact_id": "4411…", "conversation_id": "c0ff…", "response": { "date": "2026-08-22", "slot": "10:30", "branch": "Kothrud" }, "created_at": "2026-08-17T07:41:00Z" } ] ``` `response` is the screen data the customer submitted, exactly as your `flow_json` defined the field names. Filtering by your own `flow_token` is the reliable way to tie a submission back to the order, booking or ticket you sent it for — set it to something meaningful when you send. #### GET `/api/v1/commerce/flows/responses/{response_id}` One submission by its CallMissed id. `404 Flow response not found`. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `wa_flows:read` / `wa_flows:write` | | `404` | Flow or response not in your tenant | | `409` | No connected WhatsApp number or Business Account for your tenant | | `422` | Blank name, no categories, an unknown category, or a `flow_json` over 10 MB | | `502` | WhatsApp accepted the call but returned no flow id | Errors originating at WhatsApp keep their status code and message, so a validation failure reads the same as it would against the Cloud API directly. Creating, publishing, deleting and reading Flows do not consume credits. Sending a Flow message is billed on the [interactive message endpoint](https://docs.callmissed.com/docs/whatsapp-messages#flow). ## Orders Source: https://docs.callmissed.com/docs/whatsapp-orders Read the orders customers place from your WhatsApp catalog — filter by status, contact or date, and fetch line items. ### Overview When a customer builds a cart from your WhatsApp catalog and sends it, the order is recorded against your tenant. These endpoints read those orders and their line items. Orders are created by the customer's action on WhatsApp — there is no create endpoint here. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` Reading orders requires `wa_commerce:read`. ```json { "detail": "API key missing required scope: wa_commerce:read. Add it under the key's 'Permissions' section in your dashboard." } ``` ### Statuses `pending`, `processing`, `partially_shipped`, `shipped`, `completed`, `canceled`. `completed` and `canceled` are terminal. ### The order object ```json { "id": "o1a2…", "tenant_id": "a0b1…", "conversation_id": "c0ff…", "contact_id": "4411…", "wa_message_id": "wamid.HBg…", "catalog_id": "998877665544", "reference_id": "ORD-4471", "status": "pending", "currency": "INR", "subtotal": 2499.0, "note": "Please deliver after 6pm", "created_at": "2026-08-17T07:20:00Z", "updated_at": "2026-08-17T07:20:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `wa_message_id` | `string` | The WhatsApp message that carried the cart | | `reference_id` | `string \| null` | Your own bill reference, once one has been attached | | `subtotal` | `number` | Sum of the line items, in `currency` | | `note` | `string \| null` | Free-text the customer typed with the order | ### GET `/api/v1/commerce/orders` Newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | No | One of the six statuses | | `contact_id` | `UUID` | No | One customer's orders | | `created_from` | `datetime` | No | Inclusive lower bound on `created_at` | | `created_to` | `datetime` | No | Inclusive upper bound on `created_at` | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/commerce/orders?status=pending&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` An unknown status returns `422 status must be one of: pending, processing, partially_shipped, shipped, completed, canceled`. ### GET `/api/v1/commerce/orders/{order_id}` The order plus its line items, oldest first. ```json { "id": "o1a2…", "status": "pending", "currency": "INR", "subtotal": 2499.0, "items": [ { "id": "i9b8…", "product_retailer_id": "SKU-114", "quantity": 2, "item_price": 999.0, "currency": "INR", "created_at": "2026-08-17T07:20:00Z" }, { "id": "i7c6…", "product_retailer_id": "SKU-220", "quantity": 1, "item_price": 501.0, "currency": "INR", "created_at": "2026-08-17T07:20:00Z" } ] } ``` `product_retailer_id` is your own SKU as it appears in the catalog — join on it to look the product up in your system. `404 Order not found` for an unknown id or another tenant's order. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `wa_commerce:read` | | `404` | `Order not found` | | `422` | Unknown `status` value | Reading orders does not consume credits. ## Migrate from Meta Cloud API Source: https://docs.callmissed.com/docs/migrate-from-meta Point an existing WhatsApp Cloud API integration at CallMissed by changing only the host and the token. Same path, same request bodies, same response and error envelopes. ### Overview If you already send WhatsApp messages through Meta's Cloud API (directly, or through a BSP that mirrors it), you can move to CallMissed by changing **two things**: the **host** and the **token**. The path, the request bodies, the success envelope and the error envelope are Meta's own — your existing code keeps working. ```diff - https://graph.facebook.com/v21.0/{phone-number-id}/messages + https://api.callmissed.com/api/v1/whatsapp/{phone-number-id}/messages - Authorization: Bearer EAAG... # Meta access token + Authorization: Bearer cm_your_api_key # CallMissed API key ``` Your **phone number ID** stays in the path. CallMissed maps it to your registered number and scopes everything to your tenant. API-key callers need the `whatsapp:send` scope. > **This is a compatibility surface, not a separate product.** Everything that is a *policy* rather than a wire format — tenant isolation, credit pre-flight, template category resolution, error mapping — is the same code path as the native WhatsApp API, so behaviour and billing are identical. New integrations can use the [native WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api); this page is for moving an existing Cloud API codebase with minimal edits. ### Send a message ``` POST /api/v1/whatsapp/{phone-number-id}/messages Authorization: Bearer cm_your_api_key Content-Type: application/json ``` The request body is Meta's, verbatim. `messaging_product` must be `"whatsapp"`. Supported `type` values: `text`, `template`, `image`, `audio`, `video`, `document`, `sticker`, `interactive`, `location`, `contacts`, `reaction`. ```bash [cURL] curl -X POST \ https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "919876543210", "type": "text", "text": { "body": "Hello from CallMissed" } }' ``` ```python [Python] import httpx httpx.post( "https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages", headers={"Authorization": "Bearer cm_your_api_key"}, json={ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "919876543210", "type": "text", "text": {"body": "Hello from CallMissed"}, }, ) ``` ```javascript [Node.js] await fetch( "https://api.callmissed.com/api/v1/whatsapp/123456789012345/messages", { method: "POST", headers: { Authorization: "Bearer cm_your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ messaging_product: "whatsapp", recipient_type: "individual", to: "919876543210", type: "text", text: { body: "Hello from CallMissed" }, }), }, ); ``` #### Success response Meta's success shape, unchanged: ```json { "messaging_product": "whatsapp", "contacts": [{ "input": "919876543210", "wa_id": "919876543210" }], "messages": [{ "id": "wamid.HBgL..." }] } ``` ### Two deliberate differences Everything matches Meta except these two, and both exist so a Meta-written client keeps working correctly: 1. **A text body over 4096 characters is rejected, not split.** The native CallMissed endpoint splits a long body across several messages and returns every id in `wamids`. Meta hard-rejects instead, and a Meta-written client has no `wamids` field to read — so this surface matches Meta's rejection with error code **`100`** and the message *"Param text.body must be at most 4096 characters long."* 2. **Meta's numeric error code is preserved.** A migrated integration branches on `error.code` (e.g. `131047` → fall back to a template, `130429` → back off). So this surface returns the real code — exactly what Meta would have told you. ### Error envelope Errors use Meta's shape (not CallMissed's usual `{"detail": "..."}`), and the HTTP status matches Meta: ```json { "error": { "message": "(#131047) Message failed to send because more than 24 hours have passed since the customer last replied to this number.", "type": "OAuthException", "code": 131047, "error_data": { "messaging_product": "whatsapp", "details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number." }, "fbtrace_id": "A1b2C3..." } } ``` The 24-hour-window error (`131047`) comes back as a real code and as HTTP `400`, so your existing "send a template instead" branch fires unchanged. ### What is unchanged on your side - **Your seven existing `/messages/*` calls, if any, still work** and still return CallMissed's `{"detail": "..."}` shape. This compat surface is additive — it does not replace them. - **Inbound messages and delivery statuses** still arrive through your [webhook subscriptions](https://docs.callmissed.com/docs/whatsapp-api). This page covers sending; receiving is unchanged. - **Templates, media, interactive, location, contacts and reactions** all take Meta's payloads for those types. ### When to use the native API instead If you are building fresh, the [native WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api) and [Sending Messages](https://docs.callmissed.com/docs/whatsapp-messages) give you CallMissed's own richer response shape and helpers. The Meta-compat surface exists purely to make an *existing* Cloud API integration a two-line migration. --- # Email ## Email API Source: https://docs.callmissed.com/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://app.callmissed.com/api-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](https://docs.callmissed.com/docs/email-domains#sender-addresses). - **Your app**: Add a domain, publish the DNS records we generate - **CallMissed**: Verify ownership, SPF and both DKIM records, then accept sends from that domain - **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](https://docs.callmissed.com/docs/email-logs#pricing). ### The pages in this section - [Domains & Senders](https://docs.callmissed.com/docs/email-domains): Add a domain, publish DNS, verify, and the donotreply sender rule - [Send Email](https://docs.callmissed.com/docs/email-send): POST /api/v1/email/send with every field, header, response and the Brevo migration - [Templates](https://docs.callmissed.com/docs/email-templates): Reusable subject and body with per-send substitution values - [Scheduled & Batch Sending](https://docs.callmissed.com/docs/email-scheduled): Send later with scheduledAt, or many recipient sets in one call - [Receive Email](https://docs.callmissed.com/docs/email-inbound): Claim addresses on a verified domain, read inbound mail, or have it forwarded to your app - [Delivery, Suppressions & Usage](https://docs.callmissed.com/docs/email-logs): Send log, suppression list, engagement metrics, spend, and pricing - [Email Webhooks](https://docs.callmissed.com/docs/email-webhooks): Subscribe your endpoint to bounces and complaints, signed and logged per attempt - [Limits, Quotas & Errors](https://docs.callmissed.com/docs/email-limits): 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. ```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](https://docs.callmissed.com/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](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-domains#sender-addresses). ## Domains & Senders Source: https://docs.callmissed.com/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` | ```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` (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 **four** checks (domain ownership, SPF, DKIM and DKIM2) and a domain may send only once **all four** report verified. `POST /domains/{id}/verify` returns the refreshed `domain`, `verified` (a single boolean over all four) 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](https://docs.callmissed.com/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`: every `DomainOut` field plus `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](https://docs.callmissed.com/docs/email-send#open-and-click-tracking); read the numbers back from [engagement metrics](https://docs.callmissed.com/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`, `created_at`. `DomainSettingsOut`, returned by `PATCH /domains/{id}`, is `DomainOut` plus `track_opens` and `track_clicks`. `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 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 | | 422 | schema array `detail` | An unsupported field in a `PATCH` body | | 502 | `reason` nested under `detail` | `acs_unavailable`: 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](https://docs.callmissed.com/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`). ```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**: the upstream call 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) | | 502 | string `detail` | The sender list could not be read, or the sender could not be registered or removed. Note this `502` 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](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-send) once a domain reports verified. - [Receive Email](https://docs.callmissed.com/docs/email-inbound) if you also published the `MX` record. ## Send Email Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-domains#sender-addresses). The example below uses `cc`, one URL attachment and one base64 attachment, `tags`, and the `Idempotency-Key` header: ```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](https://docs.callmissed.com/docs/email-domains#sender-addresses) | | `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](https://docs.callmissed.com/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](https://docs.callmissed.com/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)](https://docs.callmissed.com/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.

\"Acme\"", "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 25 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}`](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-logs#engagement-metrics) stay meaningful across a window where you flipped a toggle. Read the results from [engagement metrics](https://docs.callmissed.com/docs/email-logs#engagement-metrics). **Headers.** | Header | Notes | |--------|-------| | `Authorization` | `Bearer cm_...`, the key needs the **email** permission (required) | | `Idempotency-Key` | Optional. A repeat with the same key returns the first send's result without sending or charging again | ### Semantics - **cc vs bcc.** `cc` recipients are written to the `Cc` header and delivered; `bcc` recipients are delivered but never appear in any header. - **De-duplication.** Each of `to`, `cc` and `bcc` is de-duplicated case-insensitively, then the three are merged into one recipient set. An address listed in both `to` and `cc` is dropped from the `Cc` header and delivered, and billed, once; a `bcc` address already covered by `to` or `cc` is likewise dropped. - **Suppression.** Recipients on your [suppression list](https://docs.callmissed.com/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)](https://docs.callmissed.com/docs/email-scheduled#batch-messageversions). - **Size limit: 25 MB per message.** The fully assembled message (headers, both bodies, and every attachment **after base64 encoding**) must stay under 25 MB, else `422 message_too_large`. Base64 inflates attachment bytes by roughly 1.37x, so the practical raw-attachment budget is nearer **18 MB**, less whatever the bodies take. The same 25 MB figure caps a `url` attachment while it is being fetched. - **Validation.** Recipient addresses are validated first: a malformed address, or one containing control characters, is rejected with `422` before any charge. That rejection is a **schema** error, so its body is the validation-array shape, not `{"reason": …}`. See [Errors](https://docs.callmissed.com/docs/email-limits#response-shapes). - **Idempotency.** Send the same `Idempotency-Key` on a retry to guarantee the message is sent and charged at most once; the original response is replayed. The message is DKIM-signed with the From domain's key and handed to delivery. ### Response A successful call returns `202 Accepted`: ```json { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "messageId": "<1a2b3c4d@acme.com>", "messageIds": ["<1a2b3c4d@acme.com>"], "status": "sent", "suppressed": ["blocked@example.com"] } ``` | Field | Type | Notes | |-------|------|-------| | `id` | string | CallMissed send id, use it with `GET /api/v1/email/sends` | | `message_id` | string | RFC 5322 `Message-ID` of the sent message | | `messageId` | string | Brevo-compatible; equal to `message_id` | | `messageIds` | string[] | Brevo-compatible; `[message_id]` | | `status` | string | `sent` when accepted for delivery | | `suppressed` | string[] | Recipients dropped by your suppression list | View delivery history and spend: see [Delivery Log & Usage](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-limits). | Status | Reason | Meaning | |--------|--------|---------| | 402 | `payment_required` | Not enough credit balance to cover the send | | 403 | `email_not_enabled` | The API key lacks the email permission | | 403 | `domain_not_verified` | The From domain is registered but hasn't passed verification | | 403 | `all_recipients_suppressed` | Every recipient is on your suppression list | | 422 | `empty_body` | Neither `text` nor `html` (nor a template body) was present | | 422 | `too_many_recipients` | Over 50 recipients on a single send | | 422 | `message_too_large` | The assembled message exceeds 25 MB | | 422 | `unresolvable_template_vars` | The subject or body references `{{ contact.something }}`, which nothing can populate. Pass the value in `params` instead | | 429 | `rate_limited` / `monthly_cap_exceeded` / `quota_exceeded` | A plan or domain ceiling was hit | | 502 | `relay_failed` | The message could not be accepted for delivery | | 503 | `sender_propagating` | The `from` address was just registered as a sender and is not live yet. Retry shortly; no further setup is needed | ### Switching from Brevo The endpoint accepts Brevo `sendTransacEmail` payloads unchanged (`sender`, recipient objects, `replyTo`, `htmlContent`/`textContent`, `attachment` with `url` or `content`, `tags`, and the `Idempotency-Key` header) and returns `messageId` / `messageIds` alongside our native fields. To migrate, point your client at `https://api.callmissed.com/api/v1/email/send` and send `Authorization: Bearer cm_...`. ```bash curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "sender": { "email": "donotreply@acme.com", "name": "Acme Ops" }, "to": [{ "email": "customer@example.com", "name": "Ada" }], "subject": "Your receipt", "htmlContent": "

Thanks for your order.

", "textContent": "Thanks for your order.", "replyTo": { "email": "support@acme.com", "name": "Acme Support" } }' ``` Two things do **not** carry over unchanged. Your Brevo `sender` must be an address on a verified domain of yours, and its local part must be a registered sender (see [Sender Addresses](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-scheduled#batch-messageversions). ## Email Templates Source: https://docs.callmissed.com/docs/email-templates Save a reusable subject and body once, then send it with per-recipient substitution values. ### Templates Save a reusable subject + body once, then send it with per-recipient values. Templates are tenant-scoped and managed with the same `cm_` key (email permission). | Endpoint | Purpose | |----------|---------| | `POST /api/v1/email/templates` | Create a template (`201`). Duplicate `name` for the same account → `409` | | `GET /api/v1/email/templates` | List your templates, newest first. `limit` (1–200, default 50) and `offset` (≥0, default 0) | | `GET /api/v1/email/templates/{id}` | Fetch one (`404` if not yours) | | `PUT /api/v1/email/templates/{id}` | Partial update, only supplied fields change | | `DELETE /api/v1/email/templates/{id}` | Delete a template (`204`) | ### Create a template **`POST /api/v1/email/templates`** | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string | Yes | 1–255 chars; unique per account | | `subject` | string | Yes | Base subject (overridable per send) | | `html` | string | Yes | Base HTML body | | `text` | string | No | Base plain-text body | | `default_sender` | string | No | Used when the send omits `from` / `sender` | | `default_reply_to` | string | No | Used when the send omits `reply_to` | | `tags` | array | No | String tags for your own categorisation | | `is_active` | boolean | No | Defaults to `true`; an inactive template can't be sent | The template object returns `id`, `name`, `subject`, `html`, `text`, `default_sender`, `default_reply_to`, `tags`, `is_active`, `created_at`, `updated_at`. The `id` is a UUID. `PUT /templates/{id}` takes the same fields, all optional, and applies only the ones you actually send. Two write-time rejections apply to both create and update, and both come back as a `422` with a plain string `detail`: - A control character in `subject`, `default_sender` or `default_reply_to`. Those values are rendered into raw headers at send time. - A body or subject that references `{{ contact.anything }}`. Nothing can populate that namespace, so the reference would render as an empty string and ship a broken message. Pass the value in `params` instead. The same reference on a send is rejected as `422 unresolvable_template_vars`. ### Using a template on send Add `templateId` and `params` to `POST /api/v1/email/send`: ```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](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-limits#response-shapes). ## Scheduled & Batch Sending Source: https://docs.callmissed.com/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](https://docs.callmissed.com/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. `batchId` and `scheduledAt` appear only on a scheduled enqueue. #### 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. ```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 `502 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](https://docs.callmissed.com/docs/email-logs). ```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 | | 502 | `relay_failed` | Every version of a batch failed. Flat shape, carries `id` | Shapes and the full reason table: [Limits, Quotas & Errors](https://docs.callmissed.com/docs/email-limits). ## Receive Email Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-domains#add--verify-a-domain)), then claim receiving addresses. Mail to an unknown address is refused; there is no catch-all. ```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": null, "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 `null` when none were recorded. 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](https://docs.callmissed.com/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](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-limits#response-shapes). ### Related - [Domains & Senders](https://docs.callmissed.com/docs/email-domains) for the `MX` record and verification. - [Sender Addresses](https://docs.callmissed.com/docs/email-domains#sender-addresses): claim the same address you put in `reply_to` if you want replies to come back through the API. ## Delivery, Suppressions & Usage Source: https://docs.callmissed.com/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](https://docs.callmissed.com/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. ```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 | | `delivered_at` | string \| null | Set from delivery feedback | | `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 | 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. #### 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. ```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](https://docs.callmissed.com/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](https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-limits#response-shapes). ## Email Webhooks Source: https://docs.callmissed.com/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](https://docs.callmissed.com/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 | ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/email/webhooks \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"] }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} created = httpx.post(f"{BASE}/webhooks", headers=h, json={ "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"], }).json() secret = created["secret"] # store this now: it is never returned again webhook_id = created["webhook"]["id"] ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const created = await fetch(`${BASE}/webhooks`, { method: "POST", headers, body: JSON.stringify({ url: "https://your-app.com/hooks/email", description: "Bounce + complaint handler", events: ["email.bounced", "email.complained"], }), }).then((r) => r.json()); const secret = created.secret; // store this now: it is never returned again ``` The `201` response wraps the subscription and the secret: ```json { "webhook": { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"], "secret_prefix": "whsec_A1b2C3", "is_active": true, "created_at": "2026-08-13T09:41:02.118Z" }, "secret": "whsec_A1b2C3d4E5f6..." } ``` **The `secret` is returned once, here, and never again.** Every later read exposes only `secret_prefix`. Store it when you create the subscription; if you lose it, delete the subscription and create a new one. | `WebhookOut` field | Type | Notes | |--------------------|------|-------| | `id` | string (UUID) | Use it with `PATCH` / `DELETE` and as the `webhook_id` filter on the delivery log | | `url` | string | Where we POST | | `description` | string \| null | Your label | | `events` | array \| null | The subscribed events. `null` means every event | | `secret_prefix` | string | The first characters of the secret, for identifying which secret a subscription holds | | `is_active` | boolean | `false` stops deliveries; the URL and secret are kept | | `created_at` | string | When it was registered | ### Events | Event | Fires when | Status | |-------|-----------|--------| | `email.bounced` | A recipient's mail server rejected the message. The address is also added to your [suppression list](https://docs.callmissed.com/docs/email-logs#suppressions) | **Live** | | `email.complained` | A recipient marked the message as spam. The address is suppressed too | **Live** | | `email.sent` | The message was accepted for delivery | Subscribable; not emitted yet | | `email.delivered` | Delivery to the recipient's mailbox was confirmed | Subscribable; not emitted yet | | `email.opened` | A tracked message was opened | Subscribable; not emitted yet | | `email.received` | Inbound mail arrived at one of your receiving addresses | Subscribable; not emitted yet | You can subscribe to any of the six today. The four marked *not emitted yet* are accepted so your subscription does not have to be rewritten when they start firing — until then they simply deliver nothing. For inbound mail right now, use the per-address `forward_url` on [Receive Email](https://docs.callmissed.com/docs/email-inbound), which is live; for opens and clicks, read the aggregates from [engagement metrics](https://docs.callmissed.com/docs/email-logs#engagement-metrics). #### Payload Every delivery is a POST with this envelope: ```json { "type": "email.bounced", "created_at": "2026-08-13T09:41:02.118431+00:00", "data": { "email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "recipient": "customer@example.com", "detail": "550 5.1.1 recipient address rejected", "domain": "acme.com" } } ``` | Field | Notes | |-------|-------| | `type` | The event name | | `created_at` | When we generated the event, ISO 8601 UTC. This value **is** covered by the signature, so it is the timestamp to trust for an age check | | `data.email_id` | The send id from `POST /send`; `null` if the event could not be matched to a send | | `data.message_id` | The RFC 5322 `Message-ID` | | `data.recipient` | The address that bounced or complained | | `data.detail` | The reported reason, when one was given | | `data.domain` | Your sending domain the message went out on | `email.bounced` and `email.complained` carry the shape above. #### Headers ``` Content-Type: application/json X-CallMissed-Signature: sha256= X-CallMissed-Event: email.bounced X-CallMissed-Delivery: 3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d ``` `X-CallMissed-Delivery` is the delivery id, so a row in the [delivery log](#delivery-log) can be matched to the request your handler saw. Use it to make your handler idempotent: a retried delivery reuses the same id. ### Verifying the signature The digest is `HMAC-SHA256(secret, raw_request_body)`. Compute it over the **raw bytes** you received, before any JSON parsing, and compare with a constant-time function. Re-serialising the parsed JSON will not reproduce the signed bytes. ```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](https://docs.callmissed.com/docs/email-limits#response-shapes). ### Related - [Delivery, Suppressions & Usage](https://docs.callmissed.com/docs/email-logs) for the send log, the suppression list and engagement metrics. - [Receive Email](https://docs.callmissed.com/docs/email-inbound) for inbound mail, which is forwarded per address rather than through this subsystem. - [Domains & Senders](https://docs.callmissed.com/docs/email-domains#tracking-and-sending-toggles) to turn open and click tracking on. ## Limits, Quotas & Errors Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/email-send); batch limits under [Batch (messageVersions)](https://docs.callmissed.com/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 | 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 `502` from the domain routes: ```json { "detail": { "error": "acme.com has not completed DNS verification", "reason": "domain_not_verified" } } ``` **2. `502 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 `502`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: ```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 | | 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 assembled message exceeds 25 MB | | 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 | | 502 | `relay_failed` | The message could not be accepted for delivery. Uses the flat shape above | | 502 | `acs_unavailable` | Domain provisioning or verification is temporarily unavailable, retry the domain call | | 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](https://docs.callmissed.com/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. --- # Knowledge ## Knowledge Base & RAG Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/keys); scopes do not apply to JWT callers, whose access is role-based. **Base paths:** `https://api.callmissed.com/api/v1/knowledge` for RAG, and `https://api.callmissed.com/api/v1/bots/{bot_id}/knowledge` for the flat store. #### Which layer to use | You want to… | Use | | --- | --- | | Ground an LLM reply in your documents | **Knowledge API (RAG)** | | Run semantic search over your content | **Knowledge API (RAG)** | | Keep a plain list of documents attached to a bot | **Bot knowledge base** | | Upload DOCX, or a file up to 20 MB | **Bot knowledge base** | New integrations should use the Knowledge API. The flat store predates it and is kept for existing bots — it still counts toward the storage total in your usage rollup. ### Bot Knowledge Base A flat, non-vector list of entries on one bot. The bot must belong to your tenant or the request returns `404`. `GET /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:read` Returns every entry for the bot, newest first. `POST /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:write` | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string (1–255) | Yes | Label for the entry | | `content` | string (1–100000) | Yes | The raw text | | `metadata` | object | No | Arbitrary JSON you can read back | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"name":"Refund policy","content":"Refunds are issued within 7 days of purchase."}' ``` **Response (201 Created)** — a knowledge entry: ```json { "id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "name": "Refund policy", "content": "Refunds are issued within 7 days of purchase.", "metadata": null, "file_url": null, "file_size_bytes": null, "format": null, "status": "indexed", "error_message": null, "created_at": "2026-07-20T09:15:00Z" } ``` #### Upload a document `POST /api/v1/bots/{bot_id}/knowledge/upload` · scope `knowledge:write` A multipart upload of a single `file`. Accepted extensions are **PDF, DOCX, and TXT**, up to **20 MB**. Text is extracted server-side and stored in `content`. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge/upload \ -H "Authorization: Bearer cm_your_key" \ -F 'file=@handbook.pdf' ``` The entry comes back with `format` set to the extension, `file_size_bytes` set, and `status` either `indexed` (text extracted) or `failed` with an `error_message`. A scanned PDF with no text layer yields `failed` — there is no OCR. #### Delete an entry `DELETE /api/v1/bots/{bot_id}/knowledge/{entry_id}` · scope `knowledge:write` · returns `204 No Content` ### Knowledge API (RAG) #### How ingestion works Ingestion is **synchronous** — the response returns only once the source is fully indexed, so there is no job to poll: - **Extract**: Text is taken as-is, fetched from a URL, or pulled out of a PDF - **Chunk**: Split into ~600-token chunks with a 100-token overlap - **Embed & store**: Each chunk is embedded to a 768-dimension vector and indexed for cosine similarity Embedding tokens are billed against your [credits](https://docs.callmissed.com/docs/credits-rate-limits). If your balance cannot cover the embedding, the source is saved with `status: "failed"` and an `error_message` saying so — top up and re-ingest. #### The source object | Field | Type | Notes | |-------|------|-------| | `id` | uuid | Source identifier | | `tenant_id` | uuid | Owning tenant | | `bot_id` | uuid | Bot the source is attached to | | `kind` | string | `text`, `pdf`, or `url` | | `title` | string \| null | Your label; defaults to the URL for URL sources | | `uri` | string \| null | Source URL, or the uploaded filename for PDFs | | `status` | string | `pending` → `ingesting` → `ready`, or `failed` | | `error_message` | string \| null | Set when `status` is `failed` | | `byte_size` | int \| null | Size of the extracted text in bytes | | `token_count` | int \| null | Total tokens embedded | | `chunk_count` | int | Number of retrievable chunks | | `created_at` | datetime | | | `ingested_at` | datetime \| null | Set when indexing completes | All three ingest endpoints return the same envelope — the source plus a flat summary: ```json { "source": { "id": "…", "kind": "text", "status": "ready", "chunk_count": 12, "…": "…" }, "status": "ready", "chunk_count": 12, "token_count": 7043 } ``` #### Ingest text `POST /api/v1/knowledge/sources` · scope `knowledge:write` · returns `201` | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid | Yes | Must be a bot in your tenant | | `title` | string (1–512) | Yes | Label for the source | | `content` | string | Yes | The text to index; max **5 MB** of UTF-8 | ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/knowledge/sources \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "title": "Shipping FAQ", "content": "We ship across India in 3-5 business days. Express delivery reaches metro cities next day." }' ``` ```python [Python] import httpx resp = httpx.post( "https://api.callmissed.com/api/v1/knowledge/sources", headers={"Authorization": "Bearer cm_your_key"}, json={ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "title": "Shipping FAQ", "content": "We ship across India in 3-5 business days.", }, ) result = resp.json() print(result["status"], result["chunk_count"]) # "ready" 1 ``` #### Ingest a URL `POST /api/v1/knowledge/sources/url` · scope `knowledge:write` · returns `201` The server fetches the page itself, strips HTML to text, and indexes the result. | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid | Yes | Must be a bot in your tenant | | `url` | string | Yes | A bare domain works — `https://` is added when no scheme is present | | `title` | string (≤512) | No | Defaults to the URL | ```bash curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/url \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"bot_id":"'$BOT_ID'","url":"acme.in/help/returns","title":"Returns policy"}' ``` > **Only public URLs.** Requests that resolve to private or internal address ranges are rejected with `400 URL rejected`. The fetch caps the download at **2 MB**, times out after **30 seconds**, and follows at most **5 redirects** — each hop is re-validated. A page that yields no extractable text returns `400`, so JavaScript-rendered pages with no server-side HTML will not ingest. #### Ingest a PDF `POST /api/v1/knowledge/sources/pdf` · scope `knowledge:write` · returns `201` A multipart upload. Unlike the text and URL endpoints, the fields are **form fields**, not JSON. | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid (form) | Yes | Must be a bot in your tenant | | `title` | string (form) | Yes | Label for the source | | `file` | file | Yes | PDF only, max **5 MB** | ```bash curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/pdf \ -H "Authorization: Bearer cm_your_key" \ -F "bot_id=$BOT_ID" \ -F 'title=Product catalogue' \ -F 'file=@catalogue.pdf' ``` > A scanned PDF is an image, not text. With no text layer to extract, the upload returns `400` — there is no OCR in v1. #### List sources `GET /api/v1/knowledge/sources` · scope `knowledge:read` | Query | Type | Default | Notes | |-------|------|---------|-------| | `bot_id` | uuid | — | Filter to one bot; omit to list the whole tenant | | `limit` | int (1–200) | `50` | Page size | | `offset` | int (0–10000) | `0` | Page offset | Returns `{ "items": [...], "total": 42 }`, newest first, where `total` is the count before paging. `GET /api/v1/knowledge/sources/{source_id}` returns a single source, or `404` if it is not in your tenant. #### Delete a source `DELETE /api/v1/knowledge/sources/{source_id}` · scope `knowledge:write` · returns `204 No Content` Deleting a source also deletes all of its chunks, which removes it from retrieval immediately. ### Semantic search `POST /api/v1/knowledge/search` · scope `knowledge:read` Runs the retrieval step on its own. Use it to tune `k` and `min_score`, or to build your own RAG prompt. | Field | Type | Default | Notes | |-------|------|---------|-------| | `query` | string (1–4096) | — | Required. The text to match against | | `bot_id` | uuid | `null` | Restrict to one bot; omit to search every source in the tenant | | `k` | int (1–50) | `6` | How many chunks to return | | `min_score` | float (0–1) | `0.0` | Drop chunks below this score | ```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](https://docs.callmissed.com/docs/chat-completion) and the server retrieves for you, prepending the matched chunks to the system message before the model runs. | Field | Type | Default | Notes | |-------|------|---------|-------| | `bot_id` | string | — | Enables retrieval against that bot's sources | | `knowledge_top_k` | int (1–50) | `6` | Chunks to retrieve | | `knowledge_min_score` | float (0–1) | `0.0` | Minimum score to include | ```bash curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "knowledge_top_k": 4, "knowledge_min_score": 0.6, "messages": [{"role": "user", "content": "Do you deliver to Pune?"}] }' ``` Behaviour worth knowing: - Retrieval runs against the **latest user message** in `messages`, not the system prompt or conversation history. - Retrieved context is merged into your existing system message, or inserted as one if you did not send any. - If retrieval fails, the completion still runs — just without context, rather than returning an error. - A `bot_id` belonging to another tenant is treated as "no knowledge" instead of returning `404`. Cost is one embedding call for the query plus the added context tokens, billed at your model's normal input rate. ### Limits | | Bot knowledge base | Knowledge API (RAG) | | --- | --- | --- | | Storage | Raw documents | Chunks + vectors | | Formats | PDF, DOCX, TXT, plain text | Plain text, PDF, URL | | Max upload | 20 MB | 5 MB (2 MB for a URL fetch) | | Max text per entry | 100,000 characters | 5 MB of UTF-8 | | Chunking | None | ~600 tokens, 100-token overlap | | Semantic search | No | Yes | | Scoping | One bot | Ingest per bot; search per bot or tenant-wide | ### Status codes | Code | Meaning | |------|---------| | `201` | Source ingested | | `204` | Source or entry deleted | | `400` | No extractable text, a non-PDF upload, an unsupported format, or a rejected URL | | `403` | Key is missing the `knowledge:read` / `knowledge:write` scope | | `404` | Bot or source not found in your tenant | | `413` | Content or upload exceeds the size cap | | `502` | Indexing failed after the text was extracted — safe to retry | A `502` means extraction succeeded but embedding did not, so nothing was stored. Retry the same request. See [Errors](https://docs.callmissed.com/docs/errors) for the standard error shape. --- # Images & Search ## Image Generation Source: https://docs.callmissed.com/docs/image-generation Generate images from a text prompt. OpenAI-compatible endpoint. ### Overview Generate images from a text prompt. The request and response shape match OpenAI's `images.generate`, so any existing OpenAI SDK works by pointing `base_url` at `https://api.callmissed.com/v1`. **Endpoint:** `POST /v1/images/generations` Images come back as base64-encoded PNG (or JPEG, depending on the model) in the `data[].b64_json` field. - **Your app**: Send a `prompt`, `model`, and `size` to `POST /v1/images/generations` - **CallMissed gateway**: Route to the image provider and deduct per-image credits - **Image model**: Render the image from your prompt - **Your app**: Decode `data[].b64_json` (base64 PNG/JPEG) and save or display it ### Basic Usage ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1", ) res = client.images.generate( model="flux-2-klein-9b", prompt="A golden retriever in a sunlit library, cinematic bokeh", n=1, size="1024x1024", ) # res.data[0].b64_json → base64 image ``` ```javascript [JavaScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const res = await client.images.generate({ model: "flux-2-klein-9b", prompt: "A golden retriever in a sunlit library, cinematic bokeh", n: 1, size: "1024x1024", }); // res.data[0].b64_json → base64 image ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/images/generations \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "flux-2-klein-9b", "prompt": "A golden retriever in a sunlit library", "n": 1, "size": "1024x1024" }' ``` ### Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `model` | string | — | Model ID (see below). Required. | | `prompt` | string | — | Text description. 1–4000 characters. Required. | | `n` | integer | 1 | Number of images. 1–4. Billed per image. | | `size` | string | 1024x1024 | Width×height, e.g. `1024x1024`, `768x768`, `1024x1536`. | | `response_format` | string | `b64_json` | Only `b64_json` supported today. | | `negative_prompt` | string | — | Concepts to avoid (e.g. "lowres, blurry"). | | `seed` | integer | random | Reproducibility. Same seed + prompt + model → same image. | | `steps` | integer | auto | Denoising steps, 1–50. Higher = slower + more detail. | #### Response ```json { "created": 1731234567, "data": [ { "b64_json": "", "revised_prompt": null } ] } ``` ### Models | ID | Creator | Plan | Speed | Best for | |----|---------|------|-------|----------| | `flux-2-klein-9b` | Black Forest Labs | Free | Slow | Final output, print, marketing | | `flux-2-dev` | Black Forest Labs | Free | Slow | Maximum fidelity, hero imagery | | `lucid-origin` | Leonardo | Free | Medium | Cinematic, concept art | | `phoenix-1.0` | Leonardo | Free | Medium | Photorealistic portraits | | `sdxl-lightning` | ByteDance | Free | Fast | Prototyping, iteration | | `dreamshaper-8-lcm` | Lykon | Free | Fast | Stylised illustrations | | `flux-2-pro` | Black Forest Labs | Paid | Medium | Flagship FLUX fidelity | | `flux-1.1-pro` | Black Forest Labs | Paid | Fast | Production-grade at lower cost | | `gpt-image-2` | OpenAI | Paid | Medium | Accurate on-image text, marketing visuals | | `gpt-image-1.5` | OpenAI | Paid | Medium | Precise editing, logo/face preservation | | `nano-banana-pro` *(maintenance)* | Google | Paid | Medium | Infographics, accurate typography | | `nano-banana-2` *(maintenance)* | Google | Paid | Fast | Multimodal (text + reference images) | Free-plan keys can call the six **Free** rows. Every **Paid** row needs Starter or above; a free key gets `403 model_not_available`. `nano-banana-2` and `nano-banana-pro` are **under maintenance** — requests return HTTP 503. ### Sizes Common presets: `512x512`, `768x768`, `1024x1024`, `1024x1536`, `1536x1024`. Any width/height from 64 to 4096 is accepted, but providers may clamp or round down to their supported values. ### Pricing Flat per-image price, converted to credits at 1 credit = ₹1. | Model | USD per image | Credits per image | |-------|---------------|-------------------| | `gpt-image-2` | $0.25 | 25 | | `gpt-image-1.5` | $0.25 | 25 | | `nano-banana-pro` *(maintenance)* | $0.134 | 13.4 | | `flux-2-dev` | $0.12 | 12 | | `flux-2-klein-9b` | $0.10 | 10 | | `flux-2-pro` | $0.10 | 10 | | `phoenix-1.0` | $0.10 | 10 | | `lucid-origin` | $0.08 | 8 | | `nano-banana-2` *(maintenance)* | $0.067 | 6.7 | | `flux-1.1-pro` | $0.05 | 5 | | `sdxl-lightning` | $0.04 | 4 | | `dreamshaper-8-lcm` | $0.04 | 4 | Prices are for a standard-resolution (1K) image. Credits are deducted **after** the upstream call returns successfully. A failed generation does not cost credits. ### List History Retrieve images previously generated with your API key, newest first. Useful for galleries and audit trails. **Endpoint:** `GET /v1/images/history` ```python [Python] import requests resp = requests.get( "https://api.callmissed.com/v1/images/history", headers={"Authorization": "Bearer cm_your_key"}, params={"limit": 20}, ) data = resp.json() for item in data["data"]: print(item["id"], item["model"], item["url"]) # Paginate with the returned cursor if data["next_cursor"]: next_page = requests.get( "https://api.callmissed.com/v1/images/history", headers={"Authorization": "Bearer cm_your_key"}, params={"limit": 20, "before": data["next_cursor"]}, ).json() ``` ```bash [cURL] curl "https://api.callmissed.com/v1/images/history?limit=20" \ -H "Authorization: Bearer cm_your_key" ``` | Query param | Type | Default | Description | |-------------|------|---------|-------------| | `limit` | integer | `20` | Rows to return (1–100). | | `before` | string | — | ISO-8601 cursor — return rows created before this timestamp. Use `next_cursor` from the previous page. | **Response:** ```json { "data": [ { "id": "a1b2c3d4-...", "url": "https://...signed-url...", "model": "nano-banana-pro", "size": "1024x1024", "prompt": "a red bicycle on a beach", "negative_prompt": null, "revised_prompt": null, "seed": 42, "steps": 28, "created": 1760000000 } ], "next_cursor": "2026-04-12T10:00:00+00:00" } ``` `url` is a short-lived signed link — download or re-host promptly. The API key must have `image` permission (otherwise `403 permission_denied`). `next_cursor` is `null` on the last page. ### Errors | HTTP | Code | Meaning | |------|------|---------| | 400 | `invalid_request_error` | Bad prompt / size / n. Check the parameter table. | | 402 | `insufficient_credits` | Balance below the request's cost. Top up in the dashboard. | | 403 | `permission_denied` | API key lacks `image` permission. Edit the key in the dashboard. | | 404 | `model_not_found` | Unknown model ID. | | 429 | `quota_exceeded` | Monthly plan cap hit. Upgrade tier. | | 400 | `invalid_request` | Invalid parameters (e.g. unsupported size). Upstream validation error passed through. | | 502 | `upstream_error` | Network failure reaching the image provider. No credits debited — safe to retry. | ## Web Search API Source: https://docs.callmissed.com/docs/web-search Search the live web through a single endpoint. Two modes — shorter (Serper / Google) and detailed (Exa / neural). Flat ₹1 per search. ### Overview One endpoint. By default we serve **Serper web search** (fast, current, citation-backed results); you can also pick **shorter** or **detailed** modes, or set an explicit `provider`. We route for you, charge a flat ₹1 per search, and return a normalised response shape. **Endpoint:** `POST /v1/search` **Auth:** `Authorization: Bearer cm_your_key` — the key must have the `search` permission (or `*`). **Cost:** 1 credit (= ₹1) per successful search, regardless of mode or number of results. Failed upstream calls are not charged. - **Your app**: Send a `query` + `mode` to `POST /v1/search` - **CallMissed gateway**: Check the `search` permission and pick the provider for the mode - **Search provider**: Default **Serper** web search · override with `provider` - **Your app**: Receive a normalized result list and get charged ₹1 only on success ### Basic Usage ```python [Python] import httpx r = httpx.post( "https://api.callmissed.com/v1/search", headers={"Authorization": "Bearer cm_your_key"}, json={ "query": "latest Indian AI startups raising funding", "mode": "shorter", # or "detailed" / "auto" "num_results": 10, }, timeout=15, ) print(r.json()["results"][:3]) ``` ```javascript [JavaScript] const res = await fetch("https://api.callmissed.com/v1/search", { method: "POST", headers: { "Authorization": "Bearer cm_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ query: "latest Indian AI startups raising funding", mode: "shorter", // or "detailed" / "auto" num_results: 10, }), }); const data = await res.json(); console.log(data.results.slice(0, 3)); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/search \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "query": "latest Indian AI startups raising funding", "mode": "shorter", "num_results": 10 }' ``` ### Modes | `mode` | Underlying | Best for | p50 latency | |------|------|------|------| | `shorter` | Serper web search | fast, current, citation-backed results | ~1–2s | | `detailed` | Exa search | richer answers with cited sources | ~1–3s | | `auto` | tenant default → platform default (Serper) | let CallMissed pick | depends | By default all modes use **Serper web search**. You can override with `provider: "serper" | "exa" | "firecrawl" | "linkup"` directly; when both `mode` and `provider` are set, `provider` wins. `exa`/`serper`/`firecrawl`/`linkup` are all available and act as automatic fallbacks for resilience — if one provider errors, the request transparently retries another so you always get a result. All providers return the same normalised shape and the same flat ₹1 per search. Operators can set the **tenant default** from **Settings → Web search default**. ### Request Body | Field | Type | Default | Notes | |---|---|---|---| | `query` | string | (required) | 1–2000 chars | | `mode` | string | `"auto"` | `auto` / `shorter` / `detailed` | | `provider` | string | — | Optional raw override: `serper` / `exa` / `firecrawl` / `linkup`. Wins over `mode` | | `num_results` | int | `10` | 1–50 | | `search_type` | string | mode default | Exa: `auto`/`fast`/`instant`/`deep-lite`/`deep`. Serper: `search`/`news`/`images` | | `include_domains` | string[] | — | detailed mode only | | `exclude_domains` | string[] | — | detailed mode only | | `start_published_date` | `YYYY-MM-DD` | — | detailed mode only | | `end_published_date` | `YYYY-MM-DD` | — | detailed mode only | | `include_content` | bool | `false` | detailed mode only — fetch page text + highlights | | `gl` | string | — | shorter mode only — country ISO (e.g. `in`, `us`) | | `hl` | string | — | shorter mode only — language ISO | | `tbs` | string | — | shorter mode only — time filter, e.g. `qdr:d` (past day) | ### Response Shape Responses are **normalised across providers** — same keys regardless of which backend ran the query. ```json { "query": "latest Indian AI startups raising funding", "mode": "shorter", "provider": "serper", "results": [ { "title": "Acme AI raises $...", "url": "https://example.com/article", "snippet": "Acme AI announced a funding round led by...", "content": "Full text if include_content=true, else null", "published_date": "2026-04-10T00:00:00.000Z", "score": 0.92, "source": "example.com" } ], "answer": "Optional grounded answer (provider-dependent)", "images": null, "credits_used": 1, "balance": 495.0, "request_id": "search-a3f8c1d2e0b9", "latency_ms": 920 } ``` ### Pricing & Credits - **Flat rate:** 1 credit per successful search. 1 credit = ₹1. - Failed requests (upstream 5xx, rate limits, etc.) are **not charged**. - The charge is visible immediately in the `credits_used` + `balance` fields on the response, and in your credit history in the dashboard. - Per-key budget caps and the tenant monthly budget cap both apply — hitting either returns HTTP 402 `insufficient_credits`. ### Permissions API keys must have `search` (or `*`) in their service permissions. Edit a key in your dashboard (↳ **Profile → API Keys → Edit**) and tick the **Search** chip. You can also restrict which **search providers** a single key may call by editing **Allowed web-search providers** on the same edit panel. A key restricted to `serper` can still use the endpoint, but calls with `mode: "detailed"` (or `provider: "exa"`) will return **HTTP 403 `search_provider_not_allowed`** before any upstream request is made. ### Errors Error envelope matches the rest of `/v1`: ```json { "error": { "message": "...", "type": "...", "code": "..." } } ``` | Status | Code | Meaning | |---|---|---| | 400 | `invalid_request_error` | Missing or malformed body | | 401 | `invalid_api_key` | Bad or revoked key | | 402 | `insufficient_credits` | Balance < 1 credit or monthly budget exhausted | | 403 | `permission_denied` | Key lacks `search` permission | | 403 | `search_provider_not_allowed` | Key's Allowed search providers excludes the requested provider | | 429 | `rate_limit_exceeded` | Per-key RPM exceeded | | 503 | `provider_error` | Upstream search provider unavailable | --- # API Reference ## Authentication Source: https://docs.callmissed.com/docs/authentication Every API request authenticates with a CallMissed API key. ### API Key Every request authenticates with an API key. Create one from your Profile page in the dashboard — keys are prefixed with `cm_` and are passed as a Bearer token: ``` Authorization: Bearer cm_your_api_key_here ``` API keys never expire but can be revoked at any time. #### Anthropic SDK (x-api-key header) When using the [Anthropic-compatible endpoint](https://docs.callmissed.com/docs/anthropic-api) (`/v1/messages`), you can also authenticate with the `x-api-key` header: ``` x-api-key: cm_your_api_key_here ``` Both header styles work on the Anthropic endpoint — use whichever your SDK sends by default. ### Permissions vs. scopes API keys carry two independent access controls. #### Service permissions Permissions decide which AI services a key may call. They are enforced on the inference endpoints — a key without the matching permission gets `403 permission_denied`. Set any combination of `llm`, `stt`, `tts`, `search`, `image`, or `*` for all (the default for new keys). | Permission | Gates | |------------|-------| | `llm` | `/v1/chat/completions`, `/v1/messages` (+ `/anthropic/v1/messages`) | | `stt` | `/v1/audio/transcriptions`, `/v1/audio/translations` | | `tts` | `/v1/audio/speech` | | `search` | `/v1/search` | | `image` | `/v1/images/generations` | #### Resource scopes Scopes gate the resource endpoints under `/api/v1/` — bots, conversations, knowledge, webhooks. Unlike permissions, scopes default to **empty = no resource access** — you opt in explicitly. | Scope | Gates | |-------|-------| | `bots:read` / `bots:write` | View vs. create/update/delete bots | | `conversations:read` / `conversations:write` | View vs. update conversations | | `knowledge:read` / `knowledge:write` | View vs. add/remove knowledge entries | | `webhooks:write` | Manage outbound webhook subscriptions | | `whatsapp:read` / `whatsapp:write` | Read vs. manage WhatsApp messaging | | `*` | All resource scopes | ##### Gateway scopes | Scope | Gates | |-------|-------| | `usage:read` | [Usage summaries, logs and CSV export](https://docs.callmissed.com/docs/usage-api) | | `prompts:read` / `prompts:write` | [Stored prompts, versions, labels and presets](https://docs.callmissed.com/docs/gateway-prompts). Rendering counts as a read | | `cache:read` / `cache:write` | [Cache statistics vs. purging](https://docs.callmissed.com/docs/gateway-cache) | | `provider_keys:read` / `provider_keys:write` | [Your own provider credentials](https://docs.callmissed.com/docs/provider-keys) | ##### CRM scopes | Scope | Gates | |-------|-------| | `companies:read` / `companies:write` | [Companies](https://docs.callmissed.com/docs/crm-companies) | | `crm_notes:read` / `crm_notes:write` | [Notes](https://docs.callmissed.com/docs/crm-notes-tasks) | | `crm_tasks:read` / `crm_tasks:write` | [Tasks](https://docs.callmissed.com/docs/crm-notes-tasks) | | `crm_deals:read` / `crm_deals:write` | [Deals **and** pipelines](https://docs.callmissed.com/docs/crm-deals) — one pair covers both | | `crm_timeline:read` | [The activity timeline](https://docs.callmissed.com/docs/crm-lead-scores#timeline). Read-only, no write half | | `crm_custom_fields:read` / `crm_custom_fields:write` | [Custom field definitions and values](https://docs.callmissed.com/docs/crm-custom-fields) | | `crm_views:read` / `crm_views:write` | [Saved views](https://docs.callmissed.com/docs/crm-custom-fields#saved-views) | | `crm_search:read` / `crm_search:write` | [Search and duplicates](https://docs.callmissed.com/docs/crm-import-export) vs. merging | | `crm_bulk:write` | [Bulk update and delete](https://docs.callmissed.com/docs/crm-import-export#bulk-operations). Write-only, no read half | | `crm_csv:read` / `crm_csv:write` | [CSV export vs. import](https://docs.callmissed.com/docs/crm-import-export#csv) | | `crm_scores:read` / `crm_scores:write` | [Lead scoring rules and recompute](https://docs.callmissed.com/docs/crm-lead-scores) | ##### Support desk scopes | Scope | Gates | |-------|-------| | `support_tickets:read` / `support_tickets:write` | [Tickets](https://docs.callmissed.com/docs/support-tickets) | | `sla:read` / `sla:write` | [SLA policies, ticket clocks and breaches](https://docs.callmissed.com/docs/support-sla) | | `support_ops:read` / `support_ops:write` | [Macros, tags and routing rules](https://docs.callmissed.com/docs/support-ops) | | `csat:read` / `csat:write` | [Surveys and results](https://docs.callmissed.com/docs/csat). The customer's response page needs no credential at all | ##### Commerce and voice-agent scopes | Scope | Gates | |-------|-------| | `wa_commerce:read` / `wa_commerce:write` | [WhatsApp orders](https://docs.callmissed.com/docs/whatsapp-orders) | | `wa_flows:read` / `wa_flows:write` | [WhatsApp Flows](https://docs.callmissed.com/docs/whatsapp-flows) | | `evals:read` / `evals:write` | [Eval suites, cases and runs](https://docs.callmissed.com/docs/voice-evals) | | `experiments:read` / `experiments:write` | [A/B experiments](https://docs.callmissed.com/docs/voice-experiments). Assignment needs write | | `squads:read` / `squads:write` | [Agent squads](https://docs.callmissed.com/docs/voice-squads). Handoff simulation needs only read | Watch for the scopes whose read and write halves do not line up with the HTTP verb. `POST /api/v1/gateway/prompts/{id}/render`, `POST /api/v1/support/ops/routing-rules/evaluate` and `POST /api/v1/voice/squads/{id}/simulate-handoff` are all `POST` requests that write nothing, so they need only the **read** scope. > A key created for plain inference (the common case) needs only service permissions — leave scopes empty. ## API Keys Source: https://docs.callmissed.com/docs/keys What an API key controls: service permissions, resource scopes, budget caps and logging. > Keys are created, rotated and revoked from the **dashboard** — you cannot manage keys with a key. 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 dashboard can re-reveal it after an emailed one-time code, or you can roll a new one. ### 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/messages` (+ `/anthropic/v1/messages`) | | `stt` | `/v1/audio/transcriptions`, `/v1/audio/translations` | | `tts` | `/v1/audio/speech` | | `search` | `/v1/search` | | `image` | `/v1/images/generations` | | `*` | All services (the default for a new key) | ### 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 bots | | `conversations:read` / `conversations:write` | View vs. update conversations | | `knowledge:read` / `knowledge:write` | View vs. add/remove knowledge entries | | `webhooks:write` | Manage outbound webhook subscriptions | | `whatsapp:read` / `whatsapp:write` / `whatsapp:send` | Read vs. manage vs. send WhatsApp messaging | | `*` | All 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 until you raise it — your account balance is untouched by other keys. | | **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. ### Rotating and revoking Roll a key from the dashboard whenever it may have been exposed. Revocation takes effect on the next request; 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. ## Bots Source: https://docs.callmissed.com/docs/bots Create and manage AI communication agents, verify channel deployment, and load their knowledge base. ### Overview A **bot** is a configured AI agent bound to a channel. The `type` is fixed at creation and determines how the bot is reached: | type | Channel | | --- | --- | | `whatsapp` | WhatsApp text conversations | | `whatsapp_voice` | WhatsApp voice notes | | `inbound_call` | Phone calls customers place to you | | `outbound_call` | Calls the bot places (see [Voice](https://docs.callmissed.com/docs/voice) for availability) | | `ivr` | Smart IVR menu flows | ### Credential class: dashboard JWT **or** a `cm_` key with scopes Both credentials work. ``` Authorization: Bearer # or Authorization: Bearer cm_your_api_key ``` | Endpoints | Scope needed by a `cm_` key | | --- | --- | | List, get, tool catalog | `bots:read` | | Create, update, delete, toggle, deploy verify | `bots:write` | | Knowledge list | `knowledge:read` | | Knowledge add, upload, delete | `knowledge:write` | A dashboard JWT bypasses the scope check. One extra role rule applies to JWT callers: `POST /{bot_id}/deploy/verify` requires **owner or admin**. Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`; validation failures are `422`. A missing scope returns `403` naming the scope. ### Credential redaction in `config` `config` is a free-form JSON object. On the way out, any key whose name looks like a credential (`access_token`, `api_key`, `auth_token`, `app_secret`, `secret`, `password`, `token`, `verify_token`, and similar, matched case-insensitively at any depth) is replaced with `"[REDACTED]"`. The stored value is unchanged; you simply cannot read it back. Two `config` sub-keys are schema-validated at write time and return `422` when malformed: `analysis_variables` (post-call extraction) and `input_variables` (per-call `{{token}}` declarations). Everything else in `config` is stored as-is. --- ### GET /api/v1/bots Lists your tenant's bots, newest first. No parameters. ```bash curl https://api.callmissed.com/api/v1/bots \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "name": "Support Bot", "type": "whatsapp", "system_prompt": "You are a friendly support agent for Acme.", "config": { "phone_number_id": "109876543210987", "access_token": "[REDACTED]" }, "is_active": true, "created_at": "2026-06-06T12:00:00Z", "updated_at": "2026-06-06T12:00:00Z", "conversation_count": 42 } ] ``` `403` when a `cm_` key lacks `bots:read`. ### GET /api/v1/bots/tool-catalog Agent tools a bot can enable. Write the chosen `name` values into `config.tools`; the runtime resolves them from the registry. No parameters. Requires `bots:read`. ```bash curl https://api.callmissed.com/api/v1/bots/tool-catalog \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "name": "get_order_status", "description": "Look up the status of a customer order by id.", "category": "commerce", "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] } } ] ``` `parameters` is a JSON Schema object, empty (`{"type": "object", "properties": {}}`) for tools that take no arguments. ### POST /api/v1/bots Returns `201`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1-255 chars | | `type` | `string` | Yes | One of `whatsapp`, `inbound_call`, `outbound_call`, `ivr`, `whatsapp_voice`. Immutable after creation | | `system_prompt` | `string` | No | Max 50000 chars, default `""` | | `config` | `object \| null` | No | Free-form JSON. `analysis_variables` and `input_variables` are validated | ```bash curl -X POST https://api.callmissed.com/api/v1/bots \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Support Bot", "type": "whatsapp", "system_prompt": "You are a friendly support agent for Acme. Keep replies under 3 sentences.", "config": { "tools": ["get_order_status"] } }' ``` ```json { "id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "name": "Support Bot", "type": "whatsapp", "system_prompt": "You are a friendly support agent for Acme. Keep replies under 3 sentences.", "config": { "tools": ["get_order_status"] }, "is_active": true, "created_at": "2026-08-04T11:00:00Z", "updated_at": "2026-08-04T11:00:00Z", "conversation_count": 0 } ``` `403` without `bots:write`; `422` on an unknown `type`, a name outside 1-255 chars, a prompt over 50000 chars, or a malformed `analysis_variables` / `input_variables` block. ### GET `/api/v1/bots/{bot_id}` Same object shape as one row of the list, with an accurate `conversation_count`. Requires `bots:read`. ```bash curl https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab \ -H "Authorization: Bearer cm_your_api_key" ``` `404` `Bot not found` when the id is not in your tenant; `422` when it is not a valid UUID. ### PUT `/api/v1/bots/{bot_id}` Partial update. Omitted fields are unchanged. `type` cannot be changed. Requires `bots:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string \| null` | No | 1-255 chars | | `system_prompt` | `string \| null` | No | Max 50000 chars | | `config` | `object \| null` | No | **Replaces** the whole object, not a deep merge | ```bash curl -X PUT https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"system_prompt": "You are a concise support agent for Acme."}' ``` Returns the updated bot. Note that `conversation_count` is `0` in the update response; read the bot to get the real count. `403` without `bots:write`; `404` when not found; `422` on validation failure. ### DELETE `/api/v1/bots/{bot_id}` Returns `204` with an empty body. Requires `bots:write`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab \ -H "Authorization: Bearer cm_your_api_key" ``` `403` without `bots:write`; `404` when not found. ### POST `/api/v1/bots/{bot_id}/toggle` Flips `is_active`. No request body. Requires `bots:write`. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/toggle \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the bot with the new `is_active`. `403` without `bots:write`; `404` when not found. ### POST `/api/v1/bots/{bot_id}/deploy/verify` Live connectivity check against the bot's channel, using credentials stored in the bot's own `config`. No request body. Requires `bots:write`; a JWT caller must also be **owner or admin**. Which credentials are read depends on the bot `type`: | Bot type | Reads from `config` | Checked against | | --- | --- | --- | | `whatsapp`, `whatsapp_voice` | `phone_number_id`, `access_token` | Meta Graph API | | `inbound_call`, `outbound_call`, `ivr` | `account_sid`, `auth_token` | Twilio REST API | The result is also persisted back into `config` as `channel_verified`, `last_verified_at`, and `error_message`. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/deploy/verify \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "channel_verified": true, "last_verified_at": "2026-08-04T11:05:33.129004+00:00", "error_message": null } ``` A failed check is still `200`, with `channel_verified: false` and a reason such as `Missing phone_number_id or access_token`, `WhatsApp API returned 401`, `Twilio API returned 401`, or `Provider API timeout — try again`. | Status | Cause | | --- | --- | | `403` | Key missing `bots:write`, or `Only owners/admins can verify deployments` for a JWT caller | | `404` | `Bot not found` | --- ## Knowledge base Entries attached to one bot. See [Knowledge Base](https://docs.callmissed.com/docs/knowledge) for retrieval behaviour. ### GET `/api/v1/bots/{bot_id}/knowledge` Lists entries for the bot, newest first. Requires `knowledge:read`. ```bash curl https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/knowledge \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "k9e8d7c6-b5a4-4321-9876-543210fedcba", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "name": "Refund policy", "content": "Refunds are issued within 7 business days...", "metadata": { "source": "handbook" }, "file_url": null, "file_size_bytes": null, "format": null, "status": "indexed", "error_message": null, "created_at": "2026-07-11T09:00:00Z" } ] ``` `403` without `knowledge:read`; `404` `Bot not found`. ### POST `/api/v1/bots/{bot_id}/knowledge` Adds a text entry. Returns `201`. Requires `knowledge:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1-255 chars | | `content` | `string` | Yes | 1-100000 chars | | `metadata` | `object \| null` | No | Free-form JSON | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/knowledge \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Refund policy", "content": "Refunds are issued within 7 business days of the return being received.", "metadata": { "source": "handbook" } }' ``` Returns the created entry. `403` without `knowledge:write`; `404` `Bot not found`; `422` when `content` is empty or over 100000 chars. ### POST `/api/v1/bots/{bot_id}/knowledge/upload` Uploads a document as `multipart/form-data` and extracts its text synchronously. Returns `201`. Requires `knowledge:write`. | Part | Type | Required | Constraints | | --- | --- | --- | --- | | `file` | file | Yes | Extension must be `pdf`, `docx`, or `txt`. Max **20 MB** (20,971,520 bytes) | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/knowledge/upload \ -H "Authorization: Bearer cm_your_api_key" \ -F "file=@handbook.pdf" ``` ```json { "id": "k1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "name": "handbook.pdf", "content": "Acme employee handbook\n\n1. Refunds...", "metadata": null, "file_url": null, "file_size_bytes": 184203, "format": "pdf", "status": "indexed", "error_message": null, "created_at": "2026-08-04T11:12:00Z" } ``` Check `status` on the response. When extraction yields nothing usable the entry is still created with `status: "failed"` and an `error_message` such as `No text content could be extracted`. The `name` is the uploaded filename. | Status | Cause | | --- | --- | | `400` | `File exceeds 20MB limit`, or `Unsupported format: . Use PDF, DOCX, or TXT.` | | `403` | Key missing `knowledge:write` | | `404` | `Bot not found` | ### DELETE `/api/v1/bots/{bot_id}/knowledge/{entry_id}` Returns `204` with an empty body. Requires `knowledge:write`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/knowledge/k9e8d7c6-b5a4-4321-9876-543210fedcba \ -H "Authorization: Bearer cm_your_api_key" ``` `403` without `knowledge:write`; `404` `Knowledge entry not found` when the entry is not on that bot in your tenant. ## Conversations Source: https://docs.callmissed.com/docs/conversations Read conversation history and messages, move threads through statuses, draft AI replies, and take a thread over from the bot. ### Overview A **conversation** is one thread between a customer and a bot on a channel, with an ordered list of **messages**. Every conversation belongs to a bot and is scoped to your tenant. Conversations are created by the platform when a customer reaches one of your bots. There is no create or delete endpoint on this surface. ### Credential class: dashboard JWT **or** a `cm_` key with scopes Both credentials work. ``` Authorization: Bearer # or Authorization: Bearer cm_your_api_key ``` | Endpoints | Scope needed by a `cm_` key | | --- | --- | | List, get, messages | `conversations:read` | | Status update, AI draft, mark read, autoreply toggle | `conversations:write` | A dashboard JWT bypasses the scope check. No extra role rule applies here: any member of the tenant can use every endpoint on this page. Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`; validation failures are `422`. A missing scope returns `403` naming the scope. ### Enumerations | Field | Values | | --- | --- | | `channel` | `whatsapp`, `voice`, `web` | | `status` | `active`, `completed`, `escalated`, `failed` | | `role` (message) | `user` for the customer, `assistant` for the bot or agent | | `status` (message) | `sent`, `delivered`, `read`, `failed`. Inbound rows carry `sent` | --- ### GET /api/v1/conversations Lists conversations, newest first. Requires `conversations:read`. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `channel` | `string` | No | `whatsapp`, `voice`, or `web`. An unknown value returns `422` with a generic invalid-input message | | `status` | `string` | No | `active`, `completed`, `escalated`, or `failed`. Same behaviour on an unknown value | | `bot_id` | `UUID` | No | Restrict to one agent | | `limit` | `integer` | No | `1 <= limit <= 500`, default `200` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/conversations?channel=whatsapp&status=active&limit=200" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "c0ffee00-1111-2222-3333-444455556666", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "channel": "whatsapp", "external_id": "+919876543210", "status": "active", "duration_seconds": null, "metadata": null, "ai_autoreply_enabled": true, "created_at": "2026-08-04T11:55:00Z", "updated_at": "2026-08-04T12:01:00Z", "bot_name": "Support Bot", "last_message": "Where is my order?", "unread_count": 2, "last_read_at": "2026-08-04T11:58:00Z" } ] ``` | Field | Type | Notes | | --- | --- | --- | | `external_id` | `string` | The customer's channel identity, for example the WhatsApp phone number | | `duration_seconds` | `integer \| null` | Populated for voice threads | | `metadata` | `object \| null` | Free-form JSON attached by the platform | | `ai_autoreply_enabled` | `boolean` | Whether the bot still answers automatically on this thread | | `bot_name` | `string` | `""` when the bot has been deleted | | `last_message` | `string \| null` | Preview of the most recent message | | `unread_count` | `integer` | `user` messages created after `last_read_at` | | `last_read_at` | `string \| null` | `null` means the thread was never opened | ### GET `/api/v1/conversations/{conversation_id}` One conversation, same shape as a list row. Requires `conversations:read`. ```bash curl https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666 \ -H "Authorization: Bearer cm_your_api_key" ``` `404` `Conversation not found` when the id is not in your tenant; `422` when it is not a valid UUID. ### GET `/api/v1/conversations/{conversation_id}/messages` The full thread in chronological order. No pagination parameters: the whole thread is returned. Requires `conversations:read`. ```bash curl https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666/messages \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "m1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user", "content": "Where is my order?", "message_type": "text", "tokens_used": null, "status": "sent", "created_at": "2026-08-04T11:55:00Z" }, { "id": "m2b3c4d5-e6f7-4081-92a3-b4c5d6e7f809", "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "assistant", "content": "Let me check that for you.", "message_type": "text", "tokens_used": 18, "status": "delivered", "created_at": "2026-08-04T11:55:02Z" } ] ``` `404` `Conversation not found`; `403` without `conversations:read`. ### PUT `/api/v1/conversations/{conversation_id}/status` Moves a thread between statuses. Requires `conversations:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | Yes | Exactly one of `active`, `completed`, `escalated`, `failed` | ```bash curl -X PUT https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666/status \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"status": "completed"}' ``` Returns the updated conversation. In this response `bot_name` is `""` and the preview fields are not recomputed; re-read the conversation if you need them. `403` without `conversations:write`; `404` when not found; `422` on an invalid status value. ### POST `/api/v1/conversations/{conversation_id}/read` Marks the thread read as of now by bumping `last_read_at`. Idempotent, safe to call on every inbox open. No required body. Requires `conversations:write`. ```bash curl -X POST https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666/read \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the conversation with `unread_count` recomputed after the bump (normally `0`). `403` without `conversations:write`; `404` when not found. ### POST `/api/v1/conversations/{conversation_id}/autoreply` Per-thread gate on the bot's automatic replies. Set `enabled: false` when a human takes the thread over; the inbound handler then skips the bot reply until it is flipped back. Requires `conversations:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `enabled` | `boolean` | Yes | - | ```bash curl -X POST https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666/autoreply \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"enabled": false}' ``` Returns the conversation with the new `ai_autoreply_enabled`. `403` without `conversations:write`; `404` when not found. ### POST `/api/v1/conversations/{conversation_id}/ai-draft` Generates a suggested reply for a human to review. **It never sends anything.** Pair it with `autoreply: false` to use the model as a co-pilot on a thread a human has taken over. Requires `conversations:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `instruction` | `string \| null` | No | Max 1000 chars. Extra steering for this draft only, for example `shorter` or `apologise for the delay` | ```bash curl -X POST https://api.callmissed.com/api/v1/conversations/c0ffee00-1111-2222-3333-444455556666/ai-draft \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"instruction": "apologise for the delay and offer to check the tracking"}' ``` ```json { "draft": "Sorry about the wait. Let me pull up your tracking details right now and get back to you in a minute.", "used_default_prompt": false } ``` `used_default_prompt` is `true` when the linked bot has no system prompt, in which case a built-in support-agent persona is used. The draft is clamped to 4096 characters so it is always sendable on WhatsApp. **This call is billed** at the same LLM token rates as the auto-reply path. | Status | Cause | | --- | --- | | `400` | `No messages in this conversation yet — nothing to draft from.` | | `403` | Key missing `conversations:write` | | `404` | `Conversation not found` | | `502` | `AI draft failed; please retry.` or `AI returned an empty draft — please retry.` | ## Webhooks Source: https://docs.callmissed.com/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 | Read endpoints work for any member. Create, update, delete, test, and replay require **owner or admin** (`403 Only owners/admins can manage webhooks` otherwise) | 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. | Category | Events | | --- | --- | | Conversations & messages | `conversation.started`, `conversation.ended`, `message.received`, `message.sent` | | Voice sessions | `voice_session.started`, `voice_session.ended`, `voice_session.failed` | | Telephony call lifecycle | `call.started`, `call.completed`, `call.failed` | | 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` | ### Signing and verification Every delivery, including the test delivery, carries: ``` Content-Type: application/json X-CallMissed-Signature: sha256= ``` 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, header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(f"sha256={expected}", header) ``` The full `secret` is returned **only once**, in the `POST` create response. Every later read masks it as `first4 + "****" + last4`. Store it when you create the subscription. ### 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. No parameters. ```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`. ### 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 | At least one entry, 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 | | `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}/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` string (truncated to 500 chars). The 10-second request timeout applies. `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` | | `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` | ## Status API Source: https://docs.callmissed.com/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`. All three 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 plus the catalogue of route groups customers integrate with. 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 (Twilio)" }, { "name": "Transactional email", "group": "notifications", "status": "operational", "description": "Account notifications and receipts" } ], "user_api_surface": [ { "group": "openai_compat", "title": "OpenAI- & Anthropic-compatible APIs (Bearer API key)", "prefixes": ["/v1", "/anthropic/v1"] } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `overall` | `string` | Derived from the `infrastructure` group only: `down` if any infra row is down, else `degraded` if any is degraded, else `operational` | | `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 | | `user_api_surface[]` | `array` | Route-prefix catalogue, each entry with `group`, `title`, and `prefixes` | To gate a job on platform health, poll this and require `overall == "operational"`. ### 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`. ## Usage API Source: https://docs.callmissed.com/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 | ### 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_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 }, { "service": "tts", "requests": 1422, "cost_usd": 2.44, "input_tokens": 0, "output_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 USD | | `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` | | `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, "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" } ] } ``` `cost_usd` is your price. Failed requests are recorded with `cost_usd: 0.0` — an error is never billed. #### Attributing spend Send `X-Session-Id` and `X-Trace-Id` headers on your inference calls, then filter here by `session_id` or `trace_id` to attribute spend to one of your own customers, tenants or workflows. ### 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,audio_seconds,cost_usd,request_id,api_key_id,error_message[,trace_id][,session_id][,metadata_json] ``` `error_message` is truncated to 500 characters. Text cells are escaped so a spreadsheet cannot interpret a value as a formula. ### 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. ## Prompt Management Source: https://docs.callmissed.com/docs/gateway-prompts Store prompts server-side, version them, label a release, pin model parameters as presets, and render a template without calling a model. ### Overview Prompt management moves your system prompts out of your deployment and into the gateway, so you can change wording without shipping code. - A **prompt** is a named container. - A **version** is an immutable snapshot of a template, its declared variables, and optionally a model and parameter set. Versions are append-only — you never edit one, you add the next. - A **label** (`production`, `staging`, …) points at exactly one version. Moving the label is the release. - A **preset** pins a model plus a parameter block, optionally bound to a prompt version, so several call sites share one configuration. Rendering happens server-side with `POST /{prompt_id}/render`. It substitutes variables and returns the text — it never calls a model and never costs credits. Feed the result into [`POST /v1/chat/completions`](https://docs.callmissed.com/docs/chat-completion) yourself. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Every `GET`, plus `POST /{prompt_id}/render` | `prompts:read` | | Every `POST`, `PATCH`, `DELETE` that changes stored data | `prompts:write` | Render is a read: it writes nothing, so it only needs `prompts:read`. ### Limits | Thing | Limit | | --- | --- | | Prompt name | 255 characters | | Description | 500 characters | | Template body | 65,536 characters | | Declared variables | 100 per version | | Variables sent to render | 100 keys, each string value at most 8,000 characters | | Label | 64 characters | | Pinned `params` | 32,000 characters when serialised | | List pagination | `limit` `1..200` (default `50`), `offset` `0..100000` | ### Prompts #### GET `/api/v1/gateway/prompts` Newest first. Accepts `limit` and `offset`. ```json [ { "id": "7c1e…", "tenant_id": "a0b1…", "name": "support-greeting", "description": "Opening turn for the WhatsApp support agent", "current_version": 4, "created_at": "2026-08-01T09:00:00Z", "updated_at": "2026-08-16T11:20:00Z" } ] ``` `current_version` is the version number the most recent `POST /versions` created — it is what `render` uses when you pass neither `version` nor `label`. #### POST `/api/v1/gateway/prompts` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank. Unique per tenant | | `description` | `string` | No | At most 500 characters | Returns `201` with the prompt. A duplicate name returns `409 A prompt named '…' already exists`. #### GET / PATCH / DELETE `/api/v1/gateway/prompts/{prompt_id}` `PATCH` accepts `name` and `description` only — `current_version` is server-managed. `DELETE` returns `204` and cascades the prompt's versions; presets that pointed at one of those versions keep their pinned model and params, with `prompt_version_id` set to `null`. `404 Prompt not found` for an unknown or another tenant's id. ### Versions #### GET `/api/v1/gateway/prompts/{prompt_id}/versions` Highest version first. Accepts `limit` (`1..200`, default `50`) and `offset`. #### POST `/api/v1/gateway/prompts/{prompt_id}/versions` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `template` | `string` | Yes | 1–65,536 characters, not blank | | `variables` | `string[]` | No | At most 100. Each must match `^[A-Za-z_][A-Za-z0-9_.]*$`. Defaults to the names the template references | | `model` | `string` | No | At most 128 characters | | `params` | `object` | No | Pinned inference parameters (see below) | | `label` | `string` | No | 1–64 characters | ```bash curl -X POST https://api.callmissed.com/api/v1/gateway/prompts/7c1e…/versions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "template": "You are {{brand}} support. Answer in {{language}}. Be brief.", "model": "kimi-k2.6", "params": { "temperature": 0.3, "max_tokens": 400 }, "label": "production" }' ``` ```json { "id": "3f9a…", "prompt_id": "7c1e…", "version": 5, "template": "You are {{brand}} support. Answer in {{language}}. Be brief.", "variables": ["brand", "language"], "model": "kimi-k2.6", "params": { "temperature": 0.3, "max_tokens": 400 }, "label": "production", "created_by_user_id": null, "created_at": "2026-08-17T10:04:00Z" } ``` Creating a version bumps the prompt's `current_version`. Version numbers are allocated as `max + 1`; under heavy concurrency the API retries and, if it still cannot allocate, returns `409 Could not allocate a version number — retry`. Reusing a label moves it: the previous holder loses it in the same transaction. #### GET `/api/v1/gateway/prompts/{prompt_id}/versions/{version}` One version by its integer number. `404 Prompt version not found`. #### POST `/api/v1/gateway/prompts/{prompt_id}/versions/{version}/label` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `label` | `string` or `null` | Yes | 1–64 characters. `null` clears the label | This is the release switch: promote a tested version by pointing `production` at it. `409 Label '…' is already in use` only fires when the label is held elsewhere and cannot be moved. ### Rendering #### POST `/api/v1/gateway/prompts/{prompt_id}/render` Requires `prompts:read`. **No model is called and no credits are charged.** | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `variables` | `object` | No | At most 100 keys. Values may be string, number, boolean or `null`; strings at most 8,000 characters | | `version` | `integer` | No | `1..1000000` | | `label` | `string` | No | 1–64 characters | Resolution order: `version`, then `label`, then the prompt's `current_version`. ```bash curl -X POST https://api.callmissed.com/api/v1/gateway/prompts/7c1e…/render \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "label": "production", "variables": { "brand": "Acme", "language": "Hindi" } }' ``` ```json { "prompt_id": "7c1e…", "version": 5, "rendered": "You are Acme support. Answer in Hindi. Be brief.", "missing_variables": [], "model": "kimi-k2.6", "params": { "temperature": 0.3, "max_tokens": 400 } } ``` `missing_variables` lists placeholders the template referenced that you did not supply. Rendering does **not** fail on a missing variable — check the array and decide whether to proceed. Errors: `404 Prompt not found`, `404 Prompt version not found`, `404 No version labelled '…'`, `404 Prompt has no versions yet`. ### Presets A preset is a named model + parameter block. It is the reusable half of a version, useful when several prompts should share one decoding configuration. `PresetOut`: `id`, `tenant_id`, `name`, `model`, `params`, `prompt_version_id`, `created_at`, `updated_at`. | Method | Path | Scope | | --- | --- | --- | | `GET` | `/api/v1/gateway/prompts/presets` | `prompts:read` | | `POST` | `/api/v1/gateway/prompts/presets` | `prompts:write` | | `PATCH` | `/api/v1/gateway/prompts/presets/{preset_id}` | `prompts:write` | | `DELETE` | `/api/v1/gateway/prompts/presets/{preset_id}` | `prompts:write` | | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, unique per tenant | | `model` | `string` | Yes | 1–128 characters | | `params` | `object` | No | Defaults to `{}`. Allowlisted keys only | | `prompt_version_id` | `UUID` | No | Must be a version in your tenant | #### Pinnable parameters `temperature`, `max_tokens`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `repetition_penalty`, `seed`, `stop`, `logit_bias`, `logprobs`, `top_logprobs`, `n`, `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `structured_outputs`, `reasoning`, `reasoning_effort`, `provider`, `models`, `route`. Bounds match the chat completion request (for example `temperature` `0..2`, `n` `1..5`). Anything outside the list returns `422 Unsupported parameter(s): …`; an out-of-range value returns `422 Invalid parameter value: …`. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `prompts:read` / `prompts:write` | | `404` | Prompt, version, label or preset not found in your tenant | | `409` | Duplicate prompt or preset name, label already in use, version allocation lost a race | | `422` | Blank name/template, bad variable name, unsupported or out-of-range pinned parameter, oversized `params` | Nothing on this page consumes credits. ## Response Cache Source: https://docs.callmissed.com/docs/gateway-cache Inspect and purge the gateway's cached completions — hit counts, cached token totals, and targeted or full invalidation. ### Overview The gateway can serve a repeated completion from cache instead of re-running the model. This endpoint group lets you see what your tenant currently has cached and throw it away when the underlying facts change. Every entry is tenant-scoped. You never see, count or purge another tenant's cache. A typical use: your knowledge base changed, so the cached answers about it are now wrong. Purge the cache and let the next request repopulate it. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | `GET /stats` | `cache:read` | | `DELETE` (all or one key) | `cache:write` | ### GET `/api/v1/gateway/cache/stats` Counts only live, unexpired entries. ```bash curl https://api.callmissed.com/api/v1/gateway/cache/stats \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "entries": 1284, "total_hits": 9317, "bytes": 4218904, "cached_prompt_tokens": 2841002, "cached_completion_tokens": 512884 } ``` | Field | Type | Notes | | --- | --- | --- | | `entries` | `integer` | Live cached responses | | `total_hits` | `integer` | Times an entry has been served from cache | | `bytes` | `integer` | Approximate stored size | | `cached_prompt_tokens` | `integer` | Input tokens a cache hit avoided re-sending | | `cached_completion_tokens` | `integer` | Output tokens a cache hit avoided re-generating | The two token totals are the value the cache has returned so far — multiply them by the model's rate to see what you saved. ### DELETE `/api/v1/gateway/cache` Purges every cached entry for your tenant. ```bash curl -X DELETE https://api.callmissed.com/api/v1/gateway/cache \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "deleted": 1284 } ``` Safe but not free: the next request for each purged prompt runs the model again and is billed normally. ### DELETE `/api/v1/gateway/cache/{cache_key}` Purges one entry. | Parameter | Type | Constraints | | --- | --- | --- | | `cache_key` | `string` | 64 lowercase hex characters (a SHA-256 digest) | ```bash curl -X DELETE https://api.callmissed.com/api/v1/gateway/cache/3b9f…c1 \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "deleted": 1 } ``` A key that is not 64 hex characters returns `422`. A well-formed key with no live entry returns `404 Cache entry not found`. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `cache:read` / `cache:write` | | `404` | `Cache entry not found` | | `422` | `cache_key` is not a 64-character hex digest | Reading stats and purging never consume credits. ## Bring Your Own Key Source: https://docs.callmissed.com/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 `openai`, `anthropic`, `google`, `sarvam`, `deepgram`, `elevenlabs`, and the other providers accepted by the create endpoint. Provider ids are lowercase. An unsupported value returns `422` and lists the accepted set. ### 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 openai 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": "openai", "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": "openai", "key": "sk-…", "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": "openai", "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. ## Companies Source: https://docs.callmissed.com/docs/crm-companies The account object — create, search and link companies, the domain uniqueness rule, and how contacts attach to them. ### Overview A **company** (an account) is the organisation a contact belongs to. It is the spine the rest of the CRM hangs off: deals point at a company, notes and tasks attach to one, and the [timeline](https://docs.callmissed.com/docs/crm-lead-scores#timeline) rolls up its activity. Contacts link to a company through the contact's own `company_id`. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get | `companies:read` | | Create, update, delete | `companies:write` | ### The company object ```json { "id": "5c6d…", "tenant_id": "a0b1…", "name": "Acme Retail", "domain": "acme.com", "phone": "+919876543210", "website": "https://acme.com", "industry": "Retail", "size": "51-200", "notes": "Two brands, one WABA.", "external_ids": { "shopify": "gid://shopify/Customer/991" }, "metadata": null, "created_at": "2026-08-04T10:00:00Z", "updated_at": "2026-08-16T09:00:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `domain` | `string \| null` | The company's primary email/web domain. **Unique per tenant** when set | | `external_ids` | `object \| null` | Your own foreign keys into other systems. Free-form JSON | | `metadata` | `object \| null` | Free-form JSON attached by the platform | ### GET `/api/v1/companies` Newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `q` | `string` | No | At most 255 characters. Case-insensitive substring match against `name` **or** `domain` | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/companies?q=acme&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ### POST `/api/v1/companies` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank | | `domain` | `string` | No | At most 255 characters. Unique per tenant | | `phone` | `string` | No | At most 32 characters | | `website` | `string` | No | At most 512 characters | | `industry` | `string` | No | At most 128 characters | | `size` | `string` | No | At most 32 characters | | `notes` | `string` | No | At most 10,000 characters | | `external_ids` | `object` | No | Free-form JSON | ```bash curl -X POST https://api.callmissed.com/api/v1/companies \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Retail", "domain": "acme.com", "industry": "Retail" }' ``` Returns `201`. > `domain` is the natural key. Reusing one returns `409 A company with this domain already exists` — that is what stops two syncs from creating the same account twice. Look the domain up with `?q=` before creating, or use [duplicate detection and merge](https://docs.callmissed.com/docs/crm-import-export#duplicates-and-merge) to clean up after the fact. ### GET / PATCH / DELETE `/api/v1/companies/{company_id}` `PATCH` accepts the same fields, all optional, with the same bounds. `DELETE` returns `204`. `404 Company not found` for an unknown or another tenant's id. ### Errors | Status | When | | --- | --- | | `403` | Key is missing `companies:read` / `companies:write` | | `404` | Company not in your tenant | | `409` | `A company with this domain already exists` | | `422` | `name must not be blank`, or a field over its length limit | Nothing on this page consumes credits. ## Notes & Tasks Source: https://docs.callmissed.com/docs/crm-notes-tasks Attach freeform notes to a contact, company or deal, and track follow-up work with due dates, assignees and an overdue flag. ### Overview **Notes** are freeform text attached to a contact, company or deal. **Tasks** are the work someone still has to do — with a title, an optional due date, an assignee and an optional link to a record. Both show up in the [timeline](https://docs.callmissed.com/docs/crm-lead-scores#timeline) for the record they attach to. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Read notes | `crm_notes:read` | | Write notes | `crm_notes:write` | | Read tasks | `crm_tasks:read` | | Write tasks | `crm_tasks:write` | --- ## Notes ### The note object ```json { "id": "aa22…", "tenant_id": "a0b1…", "entity_type": "company", "entity_id": "5c6d…", "body": "Renewal call went well — wants a Hindi voice agent.", "author_user_id": "b1f2…", "created_at": "2026-08-16T12:00:00Z", "updated_at": "2026-08-16T12:00:00Z" } ``` `author_user_id` is the dashboard user who wrote it, and is **`null` when the note was created with an API key** — a key is not a person. ### GET `/api/v1/crm/notes` Newest first. Both entity parameters are **required** — notes are always read in the context of one record. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `entity_id` | `UUID` | Yes | Must exist in your tenant | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/crm/notes?entity_type=company&entity_id=5c6d…" \ -H "Authorization: Bearer cm_your_api_key" ``` ### POST `/api/v1/crm/notes` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `entity_id` | `UUID` | Yes | Must exist in your tenant | | `body` | `string` | Yes | 1–10,000 characters, not blank | Returns `201`. `404 Company not found` (or Contact / Deal) when the target is not yours. ### PATCH / DELETE `/api/v1/crm/notes/{note_id}` `PATCH` edits **`body` only** — a note cannot be re-pointed at a different record, so the audit trail stays honest. `DELETE` returns `204`. --- ## Tasks ### The task object ```json { "id": "bb33…", "tenant_id": "a0b1…", "title": "Send the renewal quote", "description": "Include the Hindi voice add-on.", "status": "open", "due_at": "2026-08-19T10:00:00Z", "completed_at": null, "assignee_user_id": "b1f2…", "entity_type": "company", "entity_id": "5c6d…", "created_by_user_id": "b1f2…", "created_at": "2026-08-16T12:05:00Z", "updated_at": "2026-08-16T12:05:00Z", "is_overdue": false } ``` | Field | Type | Notes | | --- | --- | --- | | `status` | `string` | `open` or `done` | | `completed_at` | `datetime \| null` | **Server-managed** — not accepted on create or update | | `is_overdue` | `boolean` | Computed: `due_at` is in the past **and** `status` is `open`. A done task is never overdue | ### GET `/api/v1/crm/tasks` Ordered by `due_at` ascending with undated tasks last, then newest first — so the list reads as a work queue. | Parameter | Type | Constraints | | --- | --- | --- | | `status` | `string` | `open` or `done` | | `assignee_user_id` | `UUID` | One person's queue | | `entity_type` | `string` | `contact`, `company` or `deal` | | `entity_id` | `UUID` | | | `overdue` | `boolean` | | | `due_before` | `datetime` | | | `due_after` | `datetime` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/crm/tasks?status=open&overdue=true" \ -H "Authorization: Bearer cm_your_api_key" ``` ### POST `/api/v1/crm/tasks` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `title` | `string` | Yes | 1–255 characters, not blank | | `description` | `string` | No | At most 10,000 characters | | `status` | `string` | No | `open` (default) or `done` | | `due_at` | `datetime` | No | | | `assignee_user_id` | `UUID` | No | Must be a user in your tenant | | `entity_type` | `string` | No | `contact`, `company` or `deal` | | `entity_id` | `UUID` | No | Must exist in your tenant | `entity_type` and `entity_id` must be **sent together** — one without the other returns `422 entity_type and entity_id must be sent together`. A task with neither is a standalone to-do. ### PATCH `/api/v1/crm/tasks/{task_id}` Same fields, all optional. The entity link is validated against the **merged** result, so you can move a task from a contact to a company in one call without tripping the pairing rule. ### POST `/api/v1/crm/tasks/{task_id}/complete` No body. Marks the task done and stamps `completed_at`. **Idempotent** — completing an already-done task returns it unchanged. Safe to retry. ### GET / DELETE `/api/v1/crm/tasks/{task_id}` `DELETE` returns `204`. `404 Task not found`. --- ### Errors | Status | When | | --- | --- | | `403` | Key is missing the matching `crm_notes:*` / `crm_tasks:*` scope | | `404` | Note, task, or the linked record is not in your tenant | | `422` | Blank body/title, unknown `entity_type` or `status`, an entity pair sent half-filled, or an assignee outside your tenant | Nothing on this page consumes credits. ## Deals & Pipelines Source: https://docs.callmissed.com/docs/crm-deals Model your sales process as pipelines and stages, then move deals through them — with automatic won/lost status and close stamping. ### Overview A **pipeline** is one sales process. Its **stages** are the ordered steps, each with a `position`, an optional win probability, and flags marking it as the won or lost terminus. A **deal** sits in exactly one stage of one pipeline. Moving a deal onto a stage flagged `is_won` or `is_lost` sets the deal's `status` and stamps `closed_at` for you — you do not manage those by hand. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` Pipelines and deals share one scope pair. | Operation | Scope | | --- | --- | | Read pipelines, stages, deals | `crm_deals:read` | | Create, update, move, delete | `crm_deals:write` | --- ## Pipelines ```json { "id": "11aa…", "tenant_id": "a0b1…", "name": "Inbound", "is_default": true, "created_at": "2026-08-01T09:00:00Z", "updated_at": "2026-08-01T09:00:00Z" } ``` Exactly one pipeline may be the default. Setting `is_default: true` clears the flag on the others in the same transaction. | Method | Path | Notes | | --- | --- | --- | | `GET` | `/api/v1/crm/pipelines` | Default first, then oldest first. `limit` `1..200` (default `50`), `offset` `0..100000` | | `POST` | `/api/v1/crm/pipelines` | `name` 1–255 characters, unique per tenant; `is_default` default `false` | | `GET` | `/api/v1/crm/pipelines/{pipeline_id}` | | | `PATCH` | `/api/v1/crm/pipelines/{pipeline_id}` | `name`, `is_default` | | `DELETE` | `/api/v1/crm/pipelines/{pipeline_id}` | `204`. `409 Move or delete the deals here first` if it still holds deals | ### Stages ```json { "id": "22bb…", "tenant_id": "a0b1…", "pipeline_id": "11aa…", "name": "Negotiation", "position": 3, "probability": 60, "is_won": false, "is_lost": false, "created_at": "2026-08-01T09:01:00Z", "updated_at": "2026-08-01T09:01:00Z" } ``` #### GET `/api/v1/crm/pipelines/{pipeline_id}/stages` Ordered by `position`. **No pagination** — a pipeline's stage list is always returned whole. #### POST `/api/v1/crm/pipelines/{pipeline_id}/stages` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank | | `position` | `integer` | No | `0 <= position <= 10000`. Omit to append after the current last stage | | `probability` | `number` | No | `0 <= probability <= 100`, as a percentage | | `is_won` | `boolean` | No | Default `false` | | `is_lost` | `boolean` | No | Default `false` | A stage cannot be both — `422 A stage cannot be both won and lost`. #### PATCH `/api/v1/crm/pipelines/stages/{stage_id}` · DELETE `/api/v1/crm/pipelines/stages/{stage_id}` `DELETE` returns `204`, or `409 Move or delete the deals here first` when deals still sit on it. #### PATCH `/api/v1/crm/pipelines/{pipeline_id}/stages/reorder` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `stage_ids` | `UUID[]` | Yes | 1–100 entries. Must be **every** stage of this pipeline, exactly once | New `position` is the array index. A partial or duplicated list returns `422` and changes nothing, so two concurrent reorders cannot interleave. --- ## Deals ### The deal object ```json { "id": "33cc…", "tenant_id": "a0b1…", "title": "Acme — 3 voice agents", "value": 240000.0, "currency": "INR", "pipeline_id": "11aa…", "stage_id": "22bb…", "contact_id": "4411…", "company_id": "5c6d…", "owner_user_id": "b1f2…", "status": "open", "expected_close_date": "2026-09-15", "closed_at": null, "last_activity_at": "2026-08-16T12:05:00Z", "created_at": "2026-08-05T09:00:00Z", "updated_at": "2026-08-16T12:05:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `status` | `string` | `open`, `won` or `lost`. Driven by the stage — see below | | `currency` | `string` | ISO 4217, three letters, upper-cased. Default `INR` | | `closed_at` | `datetime \| null` | Stamped when the deal first lands on a won or lost stage, and **never re-stamped** | | `last_activity_at` | `datetime \| null` | Bumped on every write to the deal | ### GET `/api/v1/crm/deals` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `pipeline_id` | `UUID` | | | `stage_id` | `UUID` | | | `status` | `string` | `open`, `won` or `lost` | | `owner_user_id` | `UUID` | | | `contact_id` | `UUID` | | | `company_id` | `UUID` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ### POST `/api/v1/crm/deals` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `title` | `string` | Yes | 1–255 characters, not blank | | `pipeline_id` | `UUID` | Yes | Must be your pipeline | | `stage_id` | `UUID` | No | Must belong to that pipeline. Omit to start at the lowest-position stage | | `value` | `number` | No | `0 <= value <= 1e12` | | `currency` | `string` | No | Exactly 3 letters, default `INR` | | `contact_id` | `UUID` | No | | | `company_id` | `UUID` | No | | | `owner_user_id` | `UUID` | No | Must be a user in your tenant | | `expected_close_date` | `date` | No | | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/deals \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "title": "Acme — 3 voice agents", "pipeline_id": "11aa…", "value": 240000, "company_id": "5c6d…", "expected_close_date": "2026-09-15" }' ``` Creating in a pipeline that has no stages yet returns `422 This pipeline has no stages yet` — add stages first. ### POST `/api/v1/crm/deals/{deal_id}/move` | Field | Type | Required | | --- | --- | --- | | `stage_id` | `UUID` | Yes | The one call you want for a kanban drag. The stage must belong to the deal's pipeline — `422 Stage does not belong to this pipeline` otherwise. #### What a move does to `status` | Destination stage | Effect | | --- | --- | | `is_won` | `status` becomes `won`, `closed_at` stamped if unset | | `is_lost` | `status` becomes `lost`, `closed_at` stamped if unset | | Neither | `status` returns to `open` and `closed_at` is cleared | So re-opening a deal is just moving it back to a working stage. ### GET / PATCH / DELETE `/api/v1/crm/deals/{deal_id}` `PATCH` accepts the editable fields, all optional, and applies the same stage rules. `DELETE` returns `204`. --- ### Errors | Status | When | | --- | --- | | `403` | Key is missing `crm_deals:read` / `crm_deals:write` | | `404` | Deal, pipeline, stage, contact, company or owner not in your tenant | | `409` | Duplicate pipeline name, or a pipeline/stage that still holds deals | | `422` | Blank name/title, a stage outside the deal's pipeline, a stage flagged both won and lost, an incomplete `reorder` list, or an empty pipeline | Nothing on this page consumes credits. ## Custom Fields & Saved Views Source: https://docs.callmissed.com/docs/crm-custom-fields Extend contacts, companies and deals with typed custom fields, and store filtered views your team can share. ### Overview **Custom fields** add typed columns to `contact`, `company` and `deal` records without changing the API shape of those objects. A field is defined once, then given a value per record. **Saved views** store a filter, sort and column set for an object type, either private to you or shared with the team. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Read field definitions and values | `crm_custom_fields:read` | | Create, update, delete definitions and values | `crm_custom_fields:write` | | Read saved views | `crm_views:read` | | Create, update, delete saved views | `crm_views:write` | --- ## Custom fields ### Field definitions ```json { "id": "44dd…", "tenant_id": "a0b1…", "entity_type": "company", "key": "account_tier", "label": "Account tier", "field_type": "select", "options": ["bronze", "silver", "gold"], "is_required": false, "position": 2, "created_at": "2026-08-02T09:00:00Z", "updated_at": "2026-08-02T09:00:00Z" } ``` | Field type | Accepted `value` | | --- | --- | | `text` | A non-blank string, at most 2,000 characters | | `number` | A finite number | | `boolean` | `true` or `false` | | `date` | An ISO 8601 date | | `select` | One of the definition's `options` | #### GET `/api/v1/crm/custom-fields` Ordered by entity type, then position, then key. | Parameter | Type | Constraints | | --- | --- | --- | | `entity_type` | `string` | `contact`, `company` or `deal` | | `limit` | `integer` | `1 <= limit <= 200`, **default `100`** | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | #### POST `/api/v1/crm/custom-fields` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `key` | `string` | Yes | At most 64 characters, matching `^[a-z][a-z0-9_]{0,63}$`. Unique per entity type | | `label` | `string` | Yes | 1–255 characters, not blank | | `field_type` | `string` | Yes | One of the five types above | | `options` | `string[]` | Conditional | **Required for `select`, rejected otherwise.** At most 100 entries, each at most 128 characters, no blanks or duplicates | | `is_required` | `boolean` | No | Default `false` | | `position` | `integer` | No | `0 <= position <= 10000`, default `0` | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/custom-fields \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "company", "key": "account_tier", "label": "Account tier", "field_type": "select", "options": ["bronze", "silver", "gold"] }' ``` A duplicate key returns `409 A custom field with this key already exists for this entity type`. #### PATCH `/api/v1/crm/custom-fields/{def_id}` Accepts `label`, `options`, `is_required` and `position`. > `key`, `entity_type` and `field_type` are **immutable** — changing a field's type would silently invalidate every stored value. Create a new field and migrate instead. #### DELETE `/api/v1/crm/custom-fields/{def_id}` Returns `204` and **cascades to every value stored for that field**. ### Field values ```json { "id": "55ee…", "tenant_id": "a0b1…", "field_def_id": "44dd…", "entity_type": "company", "entity_id": "5c6d…", "value": "gold", "key": "account_tier", "label": "Account tier", "field_type": "select", "created_at": "2026-08-04T10:00:00Z", "updated_at": "2026-08-16T09:00:00Z" } ``` The row carries the definition's `key`, `label` and `field_type` alongside the value, so one call renders a record's custom section without a second lookup. #### GET `/api/v1/crm/custom-fields/values` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `entity_id` | `UUID` | Yes | | | `limit` | `integer` | No | `1 <= limit <= 200`, **default `100`** | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | Ordered by the definition's `position`, then `key` — the order you defined for display. #### PUT `/api/v1/crm/custom-fields/values` Upsert. Always returns `200`, whether it created or replaced. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `field_def_id` | `UUID` | Yes | | | `entity_id` | `UUID` | Yes | Must exist in your tenant | | `entity_type` | `string` | No | Optional cross-check against the definition | | `value` | any | Yes | Typed by the definition | ```bash curl -X PUT https://api.callmissed.com/api/v1/crm/custom-fields/values \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "field_def_id": "44dd…", "entity_id": "5c6d…", "value": "gold" }' ``` > To clear a field, **delete the value** — sending `value: null` returns `422 value must not be null; delete the value to clear it`. The distinction keeps "never set" and "deliberately empty" from collapsing into one state. Type mismatches return a specific `422`: `value must be a number`, `value must be an ISO 8601 date`, `value must be one of the field's options`, and so on. A simultaneous write from elsewhere returns `409 This field was updated concurrently; retry`. #### DELETE `/api/v1/crm/custom-fields/values/{value_id}` Returns `204`. --- ## Saved views ```json { "id": "66ff…", "tenant_id": "a0b1…", "entity_type": "deal", "name": "My open enterprise deals", "filters": { "status": "open", "value_gte": 100000 }, "sort": { "field": "expected_close_date", "dir": "asc" }, "columns": ["title", "value", "stage_id", "expected_close_date"], "layout": "kanban", "is_shared": false, "created_by_user_id": "b1f2…", "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-10T09:00:00Z" } ``` `filters` is free-form JSON — the API stores and returns it; your client decides what the keys mean. ### Visibility A view is reachable only when `is_shared` is `true`, **or** you created it. A teammate's private view returns `404`, not `403` — its existence is not disclosed. > API keys have no user identity, so a view created with a key has `created_by_user_id: null`. **Set `is_shared: true` on views you want a key to read back**, otherwise the key will not see them. ### GET `/api/v1/crm/saved-views` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `entity_type` | `string` | `contact`, `company`, `deal` or `task` | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ### POST `/api/v1/crm/saved-views` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company`, `deal` or `task` | | `name` | `string` | Yes | 1–255 characters, unique per entity type | | `filters` | `object` | No | Default `{}`. At most 16,000 characters serialised | | `sort` | `object` | No | `{ "field": "...", "dir": "asc" \| "desc" }`. `field` at most 64 characters, `dir` defaults to `desc` | | `columns` | `string[]` | No | At most 100, each non-blank and at most 128 characters | | `layout` | `string` | No | `table` (default) or `kanban` | | `is_shared` | `boolean` | No | Default `false` | ### GET / PATCH / DELETE `/api/v1/crm/saved-views/{view_id}` `PATCH` accepts every field except `entity_type` — a view belongs to one object type for life. `DELETE` returns `204`. --- ### Errors | Status | When | | --- | --- | | `403` | Key is missing `crm_custom_fields:*` / `crm_views:*` | | `404` | Definition, value or view not visible to you | | `409` | Duplicate field key or view name, or a concurrent value write | | `422` | Bad key pattern, `options` on a non-select field (or missing on a select), a value that does not match the field type, `value: null`, or oversized `filters` | Nothing on this page consumes credits. ## Search, Bulk & CSV Source: https://docs.callmissed.com/docs/crm-import-export Cross-object search, duplicate detection and merge, all-or-nothing bulk updates, and CSV import and export with dry-run validation. ### Overview Four data-management surfaces on top of the CRM objects: - **Search** — one query across contacts, companies, deals, notes and tasks. - **Duplicates & merge** — find records that are the same thing twice, then fold them together. - **Bulk** — update or delete up to 500 records in one all-or-nothing call. - **CSV** — export a filtered set, or import with a dry run first. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Search, duplicates | `crm_search:read` | | Merge | `crm_search:write` | | Bulk update, bulk delete | `crm_bulk:write` | | CSV export | `crm_csv:read` | | CSV import | `crm_csv:write` | `crm_bulk` has **no read half** — there is nothing to read, only two destructive writes. --- ## Search ### GET `/api/v1/crm/search` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `q` | `string` | Yes | 2–320 characters | | `types` | `string` | No | Comma-separated subset of `contact,company,deal,note,task`. Default: all five | | `limit` | `integer` | No | `1 <= limit <= 50`, default `10`. **Per type, not overall** | ```bash curl "https://api.callmissed.com/api/v1/crm/search?q=acme&types=contact,company&limit=5" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "contacts": [ { "id": "4411…", "type": "contact", "title": "Asha Menon", "subtitle": "asha@acme.com" } ], "companies": [ { "id": "5c6d…", "type": "company", "title": "Acme Retail", "subtitle": "acme.com" } ], "deals": [], "notes": [], "tasks": [] } ``` Every key is always present — an object type with no hits returns an empty array rather than being omitted, so your client never has to guard for a missing key. With `limit=50` and all five types you get at most 250 rows. ### GET `/api/v1/crm/search/duplicates` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company` | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50`. Counts **groups**, not records | ```json { "entity_type": "contact", "groups": [ { "reason": "email", "key": "asha@acme.com", "ids": ["4411…", "9922…"], "count": 2 } ] } ``` `reason` is what made them look alike: `email`, `phone`, `name` for contacts, and `domain` or `name` for companies. `key` is the normalised value they share. ### POST `/api/v1/crm/search/merge` Folds duplicates into one surviving record. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company` | | `primary_id` | `UUID` | Yes | The record that survives. Must not appear in `duplicate_ids` | | `duplicate_ids` | `UUID[]` | Yes | 1–20 entries, no repeats | ```json { "entity_type": "contact", "primary_id": "4411…", "merged_ids": ["9922…"], "fields_filled": ["email", "company_id"], "repointed": { "notes": 4, "tasks": 1, "deals": 2 } } ``` `fields_filled` lists the fields that were **blank on the primary** and taken from a duplicate — merging never overwrites a value the primary already had. `repointed` counts the related rows moved onto the primary. > Merging is **destructive and irreversible**. It runs in a single transaction: either every duplicate is folded in and removed, or nothing changes. Preview with `/duplicates` first, and keep `duplicate_ids` small so a mistake is small. --- ## Bulk operations Both endpoints take up to **500 ids** (de-duplicated before the cap is checked) and are **all-or-nothing**: every id is verified to be in your tenant before a single row is touched. ```json { "entity_type": "task", "requested": 3, "affected": 3 } ``` `requested` is the raw count you sent; `affected` is the de-duplicated count actually changed. ### POST `/api/v1/crm/bulk/update` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company`, `deal` or `task` | | `ids` | `UUID[]` | Yes | 1–500 entries | | `changes` | `object` | Yes | Non-empty. At most 16,000 characters serialised | #### What `changes` may contain | Entity | Settable keys | | --- | --- | | `task` | `status` (`open` / `done`), `assignee_user_id` (nullable), `due_at` (nullable) | | `deal` | `stage_id` (**not** nullable), `owner_user_id` (nullable), `status` (`open` / `won` / `lost`) | | `contact` | `company_id` (nullable) | | `company` | `industry` (nullable), `size` (nullable) | Anything outside the list — including `id`, `tenant_id`, `created_at`, `updated_at` and `created_by_user_id` — returns `422` naming the key and the allowed set. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/bulk/update \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "task", "ids": ["bb33…", "cc44…"], "changes": { "status": "done", "assignee_user_id": null } }' ``` If any id is missing you get `404 2 of 50 task ids were not found in this tenant; nothing was changed` — the count tells you how badly your list has drifted, and nothing was half-applied. ### POST `/api/v1/crm/bulk/delete` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company`, `deal` or `task` | | `ids` | `UUID[]` | Yes | 1–500 entries | `409 One or more company records are still referenced; nothing was deleted` when something points at them — clear the references first. --- ## CSV ### GET `/api/v1/crm/csv/export` | Parameter | Type | Required | Applies to | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `q` | `string` | No | Contacts and companies. At most 320 characters | | `company_id` | `UUID` | No | | | `contact_id` | `UUID` | No | Deals | | `pipeline_id` | `UUID` | No | Deals | | `stage_id` | `UUID` | No | Deals | | `owner_user_id` | `UUID` | No | Deals | | `status` | `string` | No | Deals — `open`, `won` or `lost` | ```bash curl "https://api.callmissed.com/api/v1/crm/csv/export?entity_type=contact&q=acme" \ -H "Authorization: Bearer cm_your_api_key" \ -o contacts.csv ``` Streams `text/csv` with `Content-Disposition: attachment; filename="contacts-YYYYMMDD.csv"`. Capped at **50,000 rows** — narrow the filters if you need more. Every cell is escaped so a spreadsheet cannot execute a value as a formula. #### Export columns | Entity | Columns | | --- | --- | | `contact` | `id, name, email, phone, company_id, whatsapp_opt_in, email_opt_in, sms_opt_in, consent_source, created_at, updated_at` | | `company` | `id, name, domain, phone, website, industry, size, notes, created_at, updated_at` | | `deal` | `id, title, value, currency, status, pipeline_id, stage_id, contact_id, company_id, owner_user_id, expected_close_date, closed_at, last_activity_at, created_at, updated_at` | ### POST `/api/v1/crm/csv/import` `multipart/form-data`. | Part | Type | Required | Constraints | | --- | --- | --- | --- | | `file` | file | Yes | UTF-8 CSV with a header row. At most **5 MB** and **10,000 rows** | | `entity_type` | text | Yes | `contact`, `company` or `deal` | | `dry_run` | text | No | **Defaults to `true`** | > `dry_run` defaults to **true**. A plain import call validates and reports without writing anything — send `dry_run=false` explicitly to commit. Run the dry pass first, read the errors, then commit the same file. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/csv/import \ -H "Authorization: Bearer cm_your_api_key" \ -F "file=@contacts.csv" \ -F "entity_type=contact" \ -F "dry_run=true" ``` ```json { "entity_type": "contact", "dry_run": true, "total": 1200, "created": 1140, "updated": 52, "skipped": 8, "errors": [ { "row": 42, "field": "email", "message": "value must be an email address" } ], "errors_truncated": false } ``` `row` is the 1-based line in the file, so the **first data row is `2`** — it lines up with what a spreadsheet shows. At most 100 errors are reported; `errors_truncated` tells you there were more. #### Upsert keys | Entity | Matched on, in order | | --- | --- | | `contact` | `id`, else phone, else email | | `company` | `id`, else `domain` | | `deal` | `id` only | #### Import columns | Entity | Accepted columns | | --- | --- | | `contact` | `id, name, email, phone, company_id, company_domain, whatsapp_opt_in, email_opt_in, sms_opt_in, consent_source` | | `company` | `id, name, domain, phone, website, industry, size, notes` | | `deal` | `id, title, value, currency, status, pipeline_id, stage_id, contact_id, company_id, owner_user_id, expected_close_date` | `company_domain` on a contact row resolves to an existing company by domain — handy when your source system has no CallMissed ids. A header with no recognised columns returns `422` and lists what was expected. A concurrent change during the write returns `409 Import conflicted with a concurrent change; nothing was written. Retry.` --- ### Errors | Status | When | | --- | --- | | `403` | Key is missing the matching `crm_search:*` / `crm_bulk:write` / `crm_csv:*` scope | | `404` | An id in your list is not in your tenant — nothing was changed | | `409` | Records still referenced, or a concurrent import | | `422` | Query too short, unknown `types`, over 500 ids, a non-updatable change key, a file over 5 MB or 10,000 rows, or an unrecognised CSV header | Nothing on this page consumes credits. ## Lead Scoring & Timeline Source: https://docs.callmissed.com/docs/crm-lead-scores Score contacts and companies from behavioural signals with your own rules, read the per-rule breakdown, and pull a unified activity timeline. ### Overview **Lead scoring** turns behaviour into a number. You define rules — each a signal, an operator, a value and a point award — and the API sums the ones that match a record, producing a score and an A–D grade with a per-rule breakdown so you can see exactly why. **The timeline** is the other half of the same question: a single merged feed of everything that has happened to a contact or company. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Read rules and scores | `crm_scores:read` | | Create/update/delete rules, recompute | `crm_scores:write` | | Read the timeline | `crm_timeline:read` | --- ## Lead scoring ### Signals Signals are a **closed allowlist** per entity type — an unknown signal is `422`, never a silently-false rule. #### Contact | Signal | Kind | | --- | --- | | `has_email`, `has_phone`, `whatsapp_opt_in`, `email_opt_in`, `sms_opt_in`, `has_company` | boolean | | `conversation_count`, `deal_count`, `deal_value_total`, `days_since_last_conversation` | number | #### Company | Signal | Kind | | --- | --- | | `has_domain`, `has_phone` | boolean | | `industry`, `size` | string | | `contact_count`, `deal_count`, `deal_value_total` | number | ### Operators `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `is_set`, `is_not_set` — but **which ones are legal depends on the signal's kind**: | Kind | Allowed operators | | --- | --- | | boolean | `eq`, `neq`, `is_set`, `is_not_set` | | number | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `is_set`, `is_not_set` | | string | `eq`, `neq`, `contains`, `is_set`, `is_not_set` | `is_set` and `is_not_set` take no `value` — one sent is dropped. ### Grades | Grade | Score | | --- | --- | | A | 75 and above | | B | 50–74 | | C | 25–49 | | D | below 25 | ### The rule object ```json { "id": "77aa…", "tenant_id": "a0b1…", "entity_type": "contact", "name": "Opted in on WhatsApp", "signal": "whatsapp_opt_in", "operator": "eq", "value": true, "points": 20, "is_active": true, "created_at": "2026-08-06T09:00:00Z", "updated_at": "2026-08-06T09:00:00Z" } ``` ### GET `/api/v1/crm/lead-scores/rules` Oldest first — the order you built them in. | Parameter | Type | Constraints | | --- | --- | --- | | `entity_type` | `string` | `contact` or `company` | | `is_active` | `boolean` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ### POST `/api/v1/crm/lead-scores/rules` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company` | | `name` | `string` | Yes | 1–255 characters, unique per entity type | | `signal` | `string` | Yes | From the allowlist for that entity type | | `operator` | `string` | Yes | Legal for the signal's kind | | `value` | any | Conditional | Required unless the operator is `is_set` / `is_not_set`. String values at most 255 characters | | `points` | `integer` | Yes | `-1000 <= points <= 1000`. Negative points subtract | | `is_active` | `boolean` | No | Default `true` | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/lead-scores/rules \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "contact", "name": "Stale — no contact in 30 days", "signal": "days_since_last_conversation", "operator": "gt", "value": 30, "points": -15 }' ``` Errors name the exact problem, for example `operator 'contains' is not valid for the boolean signal 'has_email'. Allowed: eq, neq, is_set, is_not_set`. ### PATCH / DELETE `/api/v1/crm/lead-scores/rules/{rule_id}` `PATCH` takes every field except `entity_type`, all optional. The signal / operator / value triple is re-validated against the **merged** result, so you cannot change the signal in one call and leave an illegal operator behind. --- ### Reading scores #### GET `/api/v1/crm/lead-scores` Highest score first — the ranked list. | Parameter | Type | Constraints | | --- | --- | --- | | `entity_type` | `string` | `contact` or `company` | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | #### GET `/api/v1/crm/lead-scores/{entity_type}/{entity_id}` ```json { "id": "88bb…", "tenant_id": "a0b1…", "entity_type": "contact", "entity_id": "4411…", "score": 62, "grade": "B", "breakdown": [ { "rule_id": "77aa…", "name": "Opted in on WhatsApp", "signal": "whatsapp_opt_in", "operator": "eq", "value": true, "points": 20 }, { "rule_id": "99cc…", "name": "Has an open deal", "signal": "deal_count", "operator": "gte", "value": 1, "points": 42 } ], "computed_at": "2026-08-17T05:00:00Z" } ``` `breakdown` lists only the rules that **matched**, which is what makes a score explainable to a salesperson. `404 No score has been computed for this record` when the record has never been scored — call recompute first. #### POST `/api/v1/crm/lead-scores/recompute` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company` | | `entity_ids` | `UUID[]` | Yes | 1–**200** entries | ```json { "entity_type": "contact", "requested": 3, "computed": 3 } ``` Idempotent, and ids are de-duplicated before the 200 cap is checked. If any id is not yours you get `404 2 of 50 contact ids were not found in this tenant; nothing was scored` — nothing is partially computed. Scores are **not** recomputed automatically after you change a rule. Re-run the affected records yourself. --- ## Timeline ### GET `/api/v1/crm/timeline` A merged, newest-first feed of everything attached to one record. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company`. **Deals are not supported here** | | `entity_id` | `UUID` | Yes | | | `types` | `string` | No | Comma-separated subset of `conversation,message,call,note,task,deal`. Omit for all | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/crm/timeline?entity_type=contact&entity_id=4411…&types=note,task" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "note:aa22…", "type": "note", "occurred_at": "2026-08-16T12:00:00Z", "title": "Note added", "summary": "Renewal call went well — wants a Hindi voice agent.", "actor": "Ravi K", "ref_id": "aa22…", "meta": {} } ] ``` | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Prefixed composite such as `note:` — unique across types, good as a render key | | `ref_id` | `UUID` | The underlying record's own id, for a follow-up fetch | | `summary` | `string \| null` | Truncated to 280 characters | | `actor` | `string \| null` | Who caused it, when known | | `meta` | `object` | Type-specific extras. Defaults to `{}` | > An unknown or another tenant's `entity_id` returns an **empty array, not a 404**. The timeline never confirms whether a record exists — do not use it as an existence check. Read-only: there is no write scope and no way to post to a timeline. It is assembled from the underlying records. --- ### Errors | Status | When | | --- | --- | | `403` | Key is missing `crm_scores:*` / `crm_timeline:read` | | `404` | Rule not found, no score computed yet, or an id outside your tenant during recompute | | `409` | Duplicate rule name | | `422` | Unknown signal, an operator illegal for that signal's kind, a value of the wrong type, `points` outside `-1000..1000`, over 200 recompute ids, or an unknown timeline type | Nothing on this page consumes credits. ## Support Tickets Source: https://docs.callmissed.com/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](https://docs.callmissed.com/docs/support-sla) and CSAT reporting trustworthy. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get | `support_tickets:read` | | Create, update, assign, status, delete | `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` 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. `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`. ### 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. `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` unassigns and returns the ticket to the queue | ### POST `/api/v1/support/tickets/{ticket_id}/status` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | Yes | One of the five statuses | **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 | ### DELETE `/api/v1/support/tickets/{ticket_id}` Returns `204`. `404 Ticket not found`. ### 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, or `unassigned` combined with `assignee_user_id` | Nothing on this page consumes credits. ## SLA Policies Source: https://docs.callmissed.com/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](https://docs.callmissed.com/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` 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` | ### 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. The "must promise something" rule is re-checked against the **merged** result, so you cannot clear both minute fields in two steps. ### DELETE `/api/v1/support/sla/policies/{policy_id}` Returns `204`. `404 SLA policy not found`. ### GET `/api/v1/support/sla/status/{ticket_id}` The live clock for one ticket. ```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 | Policy selection: a policy scoped to the ticket's priority wins; otherwise the catch-all applies; otherwise nothing does. `404 Ticket not found`. ### GET `/api/v1/support/sla/breaches` Tickets currently past a deadline, oldest first. | Parameter | Type | Constraints | | --- | --- | --- | | `priority` | `string` | At most 16 characters | | `include_resolved` | `boolean` | Default `false` | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```json [ { "ticket_id": "e5d4…", "subject": "Refund not received", "status": "open", "priority": "urgent", "created_at": "2026-08-17T06:10:00Z", "sla": { "policy_id": "d1c2…", "policy_name": "Urgent — 15 min first response", "first_response_breached": true, "resolution_breached": false, "minutes_remaining": -32 } } ] ``` > **Pagination behaves differently here.** Because breach is computed rather than stored, `limit` and `offset` page over the **tickets scanned**, not the breaches returned. A page can come back shorter than `limit`, or empty, and still have more behind it. Keep advancing `offset` until a page returns zero scanned rows rather than stopping at the first short 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, or `end` not later than `start` | Nothing on this page consumes credits. ## Macros, Tags & Routing Source: https://docs.callmissed.com/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, when it matches, applies actions. ### 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 | 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` | ### 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` | `409 A macro named '…' already exists` on a duplicate name. ### PATCH / DELETE `/api/v1/support/ops/macros/{macro_id}` `PATCH` takes the same fields, all optional; sending `actions: null` clears the actions. `DELETE` returns `204`. ### 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}}`, where the name starts with a letter or underscore. Values are truncated to 500 characters. > **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'`. --- ## 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 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` Ordered by `position`. Takes `is_active`, `limit` (`1..500`, default `100`) and `offset` (`0..100000`). ### 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`. ### 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 | New `position` is the array index. 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 condition that asks about it** — 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. ### PATCH / DELETE `/api/v1/support/ops/routing-rules/{rule_id}` Same fields as create, all optional. `DELETE` returns `204`. --- ### 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` | Over 20 conditions, an unknown condition field or operator, a bad colour, an unknown key in `actions`, an incomplete `reorder` list | Nothing on this page consumes credits. ## CSAT & NPS Surveys Source: https://docs.callmissed.com/docs/csat Mint a survey link after a conversation, host the response page yourself against the public token endpoints, and read CSAT and NPS statistics. ### Overview Create a survey, send its link to the customer, and read the score back. Two halves, and they authenticate differently: | Half | Endpoints | Credential | | --- | --- | --- | | **Management** — mint surveys, read results | `/api/v1/csat/surveys`, `/api/v1/csat/stats` | Your `cm_` key | | **Response** — what the customer's browser hits | `/api/v1/csat/r/{token}` | The token in the URL. No API key | The response endpoints are public on purpose: they are what your survey page calls from the customer's device, where an API key must never be present. The token *is* the credential, and it authorises exactly one survey. Two scales are supported: `csat_5` (satisfaction, 1–5) and `nps_10` (recommendation, 0–10). --- ## Management endpoints ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, stats | `csat:read` | | Create, delete | `csat:write` | ### The survey object ```json { "id": "aa11…", "tenant_id": "a0b1…", "conversation_id": "c0ff…", "ticket_id": "e5d4…", "contact_id": "4411…", "channel": "whatsapp", "token": "0Yb3k…", "question": "How satisfied were you with this conversation?", "scale": "csat_5", "sent_at": null, "expires_at": "2026-08-24T00:00:00Z", "created_at": "2026-08-17T07:00:00Z", "updated_at": "2026-08-17T07:00:00Z" } ``` `token` is minted server-side — you cannot supply or choose it. Treat it as a secret: anyone holding it can answer that survey once. ### POST `/api/v1/csat/surveys` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `channel` | `string` | Yes | `whatsapp`, `email`, `web` or `voice` | | `scale` | `string` | No | `csat_5` (default) or `nps_10` | | `conversation_id` | `UUID` | No | Must exist in your tenant | | `contact_id` | `UUID` | No | Must exist in your tenant | | `ticket_id` | `UUID` | No | Stored as an opaque reference, not validated | | `question` | `string` | No | At most 255 characters. Defaults to the scale's standard wording | | `expires_at` | `datetime` | No | Omit for a link that never expires | ```bash curl -X POST https://api.callmissed.com/api/v1/csat/surveys \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "channel": "whatsapp", "scale": "nps_10", "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "expires_at": "2026-08-24T00:00:00Z" }' ``` Returns `201`. Build the customer's link from the returned `token` — point it at your own survey page, which then calls the public endpoints below. ### GET `/api/v1/csat/surveys` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `channel` | `string` | One of the four channels | | `answered` | `boolean` | `true` = has a response, `false` = still open | | `created_after` | `datetime` | Inclusive | | `created_before` | `datetime` | Exclusive | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ### GET `/api/v1/csat/stats` | Parameter | Type | Constraints | | --- | --- | --- | | `days` | `integer` | `1 <= days <= 365`, default `30` | ```json { "days": 30, "surveys_sent": 1840, "responses": 611, "response_rate": 0.3321, "average_rating": 4.31, "promoters": 302, "passives": 118, "detractors": 61, "nps": 49.75 } ``` | Field | Notes | | --- | --- | | `response_rate` | Fraction in `0..1`, not a percentage. `0.0` when nothing was sent | | `average_rating` | Across all scales. `null` when there are no responses | | `promoters` / `passives` / `detractors` | `nps_10` only. Promoter is 9–10, passive 7–8, detractor 0–6 | | `nps` | `(promoters − detractors) / nps_responses × 100`. `null` when there are no `nps_10` answers in the window | ### GET / DELETE `/api/v1/csat/surveys/{survey_id}` `GET` returns one survey. `DELETE` returns `204` and removes its response along with it. `404 Survey not found`. --- ## Public response endpoints **No `Authorization` header. No scope.** These are the two calls your survey page makes from the customer's browser or app. The token is validated globally: an unknown, deleted or malformed token returns the same `404 Survey not found`, so the endpoints cannot be used to discover which tokens exist. Both endpoints are rate limited per client. Handle `429` by asking the customer to retry shortly. ### GET `/api/v1/csat/r/{token}` Fetch what to render. ```bash curl https://api.callmissed.com/api/v1/csat/r/0Yb3k… ``` ```json { "question": "How likely are you to recommend us to a friend or colleague?", "scale": "nps_10", "answered": false, "expired": false } ``` The projection is deliberately minimal — no tenant, contact, conversation or ticket id is exposed to the customer's device. | Field | Type | Notes | | --- | --- | --- | | `question` | `string` | Falls back to the scale's standard wording | | `scale` | `string` | `csat_5` or `nps_10` — decides the rating range you render | | `answered` | `boolean` | Show a thank-you instead of the form when `true` | | `expired` | `boolean` | `true` still returns `200` here, so you can show a "this survey has closed" page rather than a hard error | ### POST `/api/v1/csat/r/{token}` Submit the answer. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `rating` | `integer` | Yes | `1`–`5` for `csat_5`, `0`–`10` for `nps_10` | | `comment` | `string` | No | At most 2,000 characters. An empty string is stored as no comment | ```bash curl -X POST https://api.callmissed.com/api/v1/csat/r/0Yb3k… \ -H "Content-Type: application/json" \ -d '{ "rating": 9, "comment": "Fast and clear, thanks." }' ``` ```json { "status": "recorded" } ``` The acknowledgement is contentless by design — a submitter learns nothing about your tenant, the survey or your score distribution. **One answer per survey**, enforced by a uniqueness constraint rather than a convention. A second submit, including a double-tap race, returns `409` and never a `500`. | Status | Detail | Meaning | | --- | --- | --- | | `404` | `Survey not found` | Unknown, deleted or malformed token | | `410` | `This survey has closed.` | Past `expires_at` | | `422` | `rating must be between {low} and {high}` | Rating outside the survey's scale | | `409` | `This survey has already been answered.` | Already submitted | | `429` | `Too many requests — try again shortly.` | Rate limited | Checks run in that order, so an expired survey reports `410` rather than leaking whether it was already answered. --- ### Errors on the management half | Status | When | | --- | --- | | `403` | Key is missing `csat:read` / `csat:write` | | `404` | Survey, conversation or contact not in your tenant | | `422` | Unknown channel, blank question, or `days` outside `1..365` | Nothing on this page consumes credits. --- # Resources ## Error Codes Source: https://docs.callmissed.com/docs/errors Every HTTP status and error code the CallMissed API returns, what causes it, and how to recover. ### Error Format All errors return a JSON body with a stable machine-readable `code` and a human message. We never leak upstream provider errors or stack traces to clients. ```json { "error": { "code": "insufficient_credits", "message": "Your credit balance is too low to complete this request.", "type": "billing_error" } } ``` The OpenAI-compatible endpoints (`/v1/*`) return the standard OpenAI error envelope so existing SDK error handling works unchanged. ### HTTP Status Codes | Status | Meaning | Typical cause | | --- | --- | --- | | `200` | OK | Success | | `400` | Bad Request | Malformed JSON, invalid parameter value | | `401` | Unauthorized | Missing/invalid `Authorization` header or expired token | | `402` | Payment Required | Insufficient credits or monthly budget cap reached | | `403` | Forbidden | Key lacks the required scope/permission, or domain not allowlisted | | `404` | Not Found | Resource ID does not exist or belongs to another tenant | | `409` | Conflict | Duplicate resource, or replayed `Idempotency-Key` with a different body | | `422` | Unprocessable Entity | Schema validation failed (bad enum, out-of-range number) | | `429` | Too Many Requests | Rate limit exceeded — back off and retry | | `500` | Internal Server Error | Unexpected server error — safe to retry once | | `501` | Not Implemented | Endpoint exists but the feature is not yet live (e.g. embeddings) | | `503` | Service Unavailable | Upstream provider temporarily unavailable | ### Common Error Codes | Code | Status | Meaning | | --- | --- | --- | | `invalid_api_key` | 401 | The `cm_` key is unknown or revoked | | `token_expired` | 401 | JWT access token expired — refresh it | | `insufficient_credits` | 402 | Top up credits to continue | | `budget_exceeded` | 402 | Monthly credit budget cap reached | | `permission_denied` | 403 | Key is missing the required service permission | | `search_provider_not_allowed` | 403 | Key's allowed web-search providers excludes the requested provider | | `domain_not_allowed` | 403 | Request origin is not in the key's domain allowlist | | `not_found` | 404 | Resource does not exist in your tenant | | `rate_limit_exceeded` | 429 | Slow down — see `Retry-After` | | `provider_error` | 503 | Upstream model/provider failed | ### Retrying - On **429**, honor the `Retry-After` header (seconds) and use exponential backoff. - On **500/503**, retry once or twice with jittered backoff. To make a mutating request safely retryable, send an `Idempotency-Key` header — replays with the same key and body return the original result instead of duplicating the action. - On **402/403**, do **not** retry — fix the underlying credit/permission issue first. ## Glossary Source: https://docs.callmissed.com/docs/glossary Definitions for the core CallMissed concepts and terminology used throughout these docs. ### Platform | Term | Definition | | --- | --- | | **Tenant** | Your organization. All users, bots, keys, and data are isolated per tenant. | | **Bot** | A configured AI agent (WhatsApp, inbound/outbound call, IVR) with a system prompt and optional knowledge base. | | **Channel** | The surface a bot runs on — WhatsApp or voice (Twilio / LiveKit). | | **Conversation** | A thread of messages between an end user and a bot on a channel. | | **API Key** | A secret prefixed `cm_` used for server-to-server auth, with scopes, domain locks, and per-key limits. | | **Permission** | A service an API key may call — `llm`, `stt`, `tts`, `search`, `image`, or `*`. Enforced on the inference endpoints; default `*`. | | **Scope** | A platform resource an API key may access — e.g. `bots:read`, `conversations:write`, `knowledge:read`, `webhooks:write`, `whatsapp:write`. Defaults to empty (no resource access). | | **Webhook** | An HTTPS endpoint CallMissed calls on events; payloads are HMAC-SHA256 signed. | | **Idempotency-Key** | A header that makes a mutating request safely retryable — replays return the original result. | ### Billing | Term | Definition | | --- | --- | | **Credit** | The universal billing unit. **1 credit = ₹1.** Every API call deducts credits based on usage. | | **Plan** | Your subscription tier — free, starter, pro, or enterprise — which sets limits and model access. | | **Budget cap** | An optional monthly credit limit; requests over the cap are rejected with `budget_exceeded`. | | **Credit pack** | A purchasable bundle of credits for top-ups. | ### AI Services | Term | Definition | | --- | --- | | **LLM** | Large Language Model — powers chat completions and the Anthropic Messages API. | | **STT** | Speech-to-Text — transcription, translation, real-time, and batch. | | **TTS** | Text-to-Speech — voice synthesis. | | **RAG** | Retrieval-Augmented Generation — semantic search over ingested knowledge passed as context. | | **Diarization** | Labeling who spoke when in a transcript (speaker separation). | | **Voice Agent** | A real-time STT→LLM→TTS pipeline over WebRTC (LiveKit). | | **OpenAI-compatible** | Our `/v1` endpoints accept the same request shapes as the OpenAI API — change only the base URL and key. | ## Changelog Source: https://docs.callmissed.com/docs/changelog Latest updates, new features, and improvements to the CallMissed API. ### August 2026 #### Embeddings, usage API, CRM, support desk and voice-agent operations - **Embeddings** — `POST /v1/embeddings`, OpenAI-compatible. `text-embedding-3-small` (1536 dims, $0.02 / 1M input tokens) and `text-embedding-3-large` (3072 dims, $0.13 / 1M). Batches of up to 128 inputs, optional `dimensions` shortening and `base64` output. Both are free-plan callable, taking the **free tier to 27 models across five categories**. Gated by the key's `llm` permission. See [Embeddings](https://docs.callmissed.com/docs/embeddings). - **Usage API** — `GET /v1/usage/summary`, `/logs` and `/logs.csv` return your own metering rows for the last 90 days, filterable by service, model, key, `session_id` and `trace_id`. Scope `usage:read`. See [Usage API](https://docs.callmissed.com/docs/usage-api). - **Gateway tooling** — server-side [prompt management](https://docs.callmissed.com/docs/gateway-prompts) with versions, labels, presets and free rendering; [response cache](https://docs.callmissed.com/docs/gateway-cache) stats and purge; and [bring your own provider key](https://docs.callmissed.com/docs/provider-keys) with liveness verification and a write-only secret. - **CRM** — [companies](https://docs.callmissed.com/docs/crm-companies), [notes and tasks](https://docs.callmissed.com/docs/crm-notes-tasks), [deals and pipelines](https://docs.callmissed.com/docs/crm-deals), [custom fields and saved views](https://docs.callmissed.com/docs/crm-custom-fields), [search, bulk and CSV](https://docs.callmissed.com/docs/crm-import-export), and [lead scoring with a unified timeline](https://docs.callmissed.com/docs/crm-lead-scores). - **Support desk** — [tickets](https://docs.callmissed.com/docs/support-tickets) with server-managed lifecycle stamps, [SLA policies](https://docs.callmissed.com/docs/support-sla) with business hours and live breach reporting, [macros, tags and routing rules](https://docs.callmissed.com/docs/support-ops) with a dry-run evaluator, and [CSAT/NPS surveys](https://docs.callmissed.com/docs/csat) with a public, token-authenticated response surface. - **Voice-agent operations** — [eval suites](https://docs.callmissed.com/docs/voice-evals) (up to 50 cases per run, credit-charged), [A/B experiments](https://docs.callmissed.com/docs/voice-experiments) with deterministic assignment, and [agent squads](https://docs.callmissed.com/docs/voice-squads) with handoff simulation and credit-charged agent drafting. - **WhatsApp** — [Flows](https://docs.callmissed.com/docs/whatsapp-flows) (create, publish, read submissions) and [catalog orders](https://docs.callmissed.com/docs/whatsapp-orders). #### New models — conversational Indic LLM, Saaras V4 STT, Flux TTS - **`sarvam-105b-conversations`** — 105B MoE tuned for conversation and voice. 128K context, tool calling, streaming, hybrid thinking. Free-tier, same $0.35 in / $0.35 out per 1M as `sarvam-105b`. See [Indic Models](https://docs.callmissed.com/docs/models-indic). - **`saaras:v4`** — Sarvam STT with five output modes (transcribe, translate, verbatim, transliterate, code-mix) across 24 languages. Free-tier at $0.30 / hour. See [Speech to Text](https://docs.callmissed.com/docs/speech-to-text). - **`deepgram-flux-tts`** — streaming-first TTS built for voice agents: turn-based synthesis with prosody carried across turns. 11 English voices including `priya` (Indian-accented English, the default). English only, no expressive controls. Paid plans, $0.45 / 10K characters. See [Voices](https://docs.callmissed.com/docs/tts-voices). - **Free tier** — now 27 models (11 LLM, 4 STT, 4 TTS, 6 image, 2 embedding). #### Model catalog update — retired models - **Retired LLM IDs** — the following model IDs are no longer served: `openai/gpt-5.4-pro`, `openai/gpt-5.4`, `openai/gpt-5.4-mini`, `openai/gpt-5.4-nano`, `anthropic/claude-opus-4.6`, `anthropic/claude-sonnet-4.6`, `anthropic/claude-haiku-4.5`, `x-ai/grok-4.20`, `qwen/qwen3.5-plus`, `qwen/qwen3.5-flash`, `mistralai/mistral-small-2603`, and the `auto` auto-router. - **Migration** — use the first-party flagships (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `grok-4.3`) or the direct-routed free tier (`kimi-k2.6`, `kimi-k2.7-code`, `glm-5.2`, `gpt-oss-120b`, `mistral-small-3.1`). See [Models](https://docs.callmissed.com/docs/models). - **Free tier** — now 24 models (11 LLM). The `auto` free auto-router is retired; pick a free model explicitly. - **Endpoints unchanged** — `POST /v1/chat/completions` and the Anthropic-compatible `POST /v1/messages` both continue to work and accept every current catalog ID. ### June 2026 #### v1.6.0 — LiveKit Voice, Image Generation & Web Search - **LiveKit voice sessions** — `POST /v1/voice/sessions` returns a LiveKit token + URL; CallMissed handles the STT→LLM→TTS pipeline. List, fetch, fetch transcript (`json | txt | srt`), and end sessions under `/v1/voice/sessions`. A public, capped browser demo lives at `POST /v1/voice/demo`. The legacy `/ws/voice-agent` WebSocket still works for backward compatibility. See [Voice Session API](https://docs.callmissed.com/docs/voice-sessions-api). - **Image Generation API** — `POST /v1/images/generations` (OpenAI-compatible). Free models include `flux-2-klein-9b`, `flux-2-dev`, `lucid-origin`, `phoenix-1.0`, `sdxl-lightning`, `dreamshaper-8-lcm`; paid models include `flux-2-pro`, `flux-1.1-pro`, `nano-banana-2`, and `nano-banana-pro`. See [Image Generation](https://docs.callmissed.com/docs/image-generation). - **Web Search API** — `POST /v1/search` defaults to Serper web search (Exa, Firecrawl also available); flat 1 credit per query. See [Web Search](https://docs.callmissed.com/docs/web-search). - **Knowledge RAG** — vector knowledge sources at `/api/v1/knowledge/sources` (ingest text, URL, or PDF; chunked + embedded) with semantic search at `POST /api/v1/knowledge/search`. ### May 2026 #### v1.5.0 — First-Party Models, Account Security & WhatsApp Platform - **First-party models** — deployments callable by bare ID: `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `grok-4.3`, `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash`, plus first-party STT (`whisper`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize`) and TTS (`gpt-4o-mini-tts`). - **More STT/TTS** — `whisper-large-v3-turbo` (99 langs), `nova-3` (diarization), `aura-2-en` / `aura-2-es`, and `melotts` — all free-tier. - **Kimi K2.6** — `kimi-k2.6` added to the direct-routed free tier alongside `kimi-k2.5`. - **TOTP 2FA & passkeys** — two-factor auth (authenticator apps + backup codes) and passkeys for dashboard sign-in. Active sessions can be reviewed and revoked from the dashboard. - **WhatsApp platform** — Embedded Signup onboarding, message templates, broadcast campaigns, and delivery analytics under `/api/v1/whatsapp/*`. See [WhatsApp API](https://docs.callmissed.com/docs/whatsapp-api). - **Billing surfaces** — coupon redemption, downloadable PDF invoices, and a credit ledger broken down by transaction type, all in the dashboard. - **Audit log** — a sensitive-action audit feed in the dashboard. ### April 2026 #### v1.4.0 — Anthropic API Compatibility & Audio Translation - **Anthropic Messages API** — New `POST /v1/messages` endpoint. Use the Anthropic SDK with CallMissed by changing only the `base_url`. Full streaming support with Anthropic SSE lifecycle (`message_start`, `content_block_delta`, `message_stop`). - **Dual auth headers** — Anthropic endpoint accepts both `x-api-key` and `Authorization: Bearer` headers - **Model aliasing** — A bare model name on the Anthropic endpoint resolves against the CallMissed catalog - **Audio Translation** — New `POST /v1/audio/translations` endpoint. Translate audio in 24 languages to English text. OpenAI SDK compatible (`client.audio.translations.create()`) - **Token counting** — `POST /v1/messages/count_tokens` for input token estimation - **Anthropic rate limit headers** — `anthropic-ratelimit-requests-limit`, `anthropic-ratelimit-requests-remaining`, etc. #### v1.3.0 — Voice Agent & Ultra-Low-Latency Pipeline - **Voice Agent WebSocket** — Real-time STT→LLM→TTS pipeline over `/ws/voice-agent`. PCM audio in, streaming MP3 out. LLM and TTS run concurrently for minimum latency. - **PCM AudioWorklet capture** — Raw PCM s16le at 16kHz, no container overhead - **Streaming MP3 playback** — MediaSource API appends and plays chunks as they arrive - **Profile management** — Save and update user profile from the dashboard #### v1.2.0 — Security, Google OAuth & Plan Enforcement - **Sign in with Google** — Google sign-in for the dashboard. Auto-creates the organisation and user, and links to an existing account by email. - **OTP Authentication** — Email-based OTP for passwordless login and password reset - **Plan limit enforcement** — Server-side usage caps per plan tier (free/starter/pro/enterprise). API returns `429 quota_exceeded` when limits reached. Usage headers (`X-RateLimit-*`, `X-Usage-Warning`) on every response. - **Per-API-key rate limiting** — 60 req/min per key - **Security hardening** — across the API surface - **Model catalog update** — OpenAI gpt-5.4 family, Anthropic Claude 4.6, Google Gemini 3.1, xAI Grok 4.20, Qwen 3.5, Mistral Small - **Knowledge Base file upload** — Upload PDF, DOCX, TXT files (max 20 MB) with auto text extraction - **Bot deployment verification** — Verify WhatsApp/Twilio channel connectivity from the dashboard - **Settings verification** — Verify WhatsApp, Twilio, and Indic LLM API connectivity - **Contact form** — Public `POST /api/v1/contact` endpoint with email notifications - **Dual-domain support** — `.com` and `.in` TLDs for all apps #### v1.1.0 — Platform Playground & SEO - **Playground rebuild** — LLM (streaming + non-streaming), STT (file upload + mic), TTS (37 voices across 11 Indian languages), Voice Agent demo - **Call Analytics API** — Upload audio files for batch STT with diarization and LLM-powered analysis - **SEO pages** — 6 product pages, legal pages, company pages on the landing site - **Sitemaps** — All 4 apps have sitemap.ts for SEO #### v1.0.0 — Initial Release - **Chat Completion API** — OpenAI-compatible endpoint with streaming, tool calls, and function calling - **Speech to Text** — `saaras:v3` with 22 Indic language support - **Text to Speech** — `bulbul:v3` (37 voices across 11 Indian languages) - **WhatsApp Bot** — Full WhatsApp Business API integration - **Voice Calling** — Twilio-based inbound voice with WebSocket streaming - **Multi-tenant** — Complete tenant isolation with role-based access - **API Keys** — Scoped API keys with usage tracking - **Webhook Delivery** — Outbound webhooks with retry and HMAC signing - **Analytics Dashboard** — Real-time conversation and usage analytics - **Model catalog** — LLM, STT, TTS and image models from one OpenAI-compatible endpoint ## Pricing Source: https://docs.callmissed.com/docs/pricing Simple, transparent pricing. Pay only for what you use. ### Overview Visit our [Pricing Page](https://callmissed.com/pricing) for detailed plan comparisons and per-API pricing. For API-specific pricing and rate limits, see the [Credits & Rate Limits](https://docs.callmissed.com/docs/credits-rate-limits) page. For enterprise pricing, [talk to us](https://docs.callmissed.com/docs/talk-to-us). #### Plan Limits Each plan tier has monthly **call caps** that are enforced server-side — LLM, STT, TTS, and image generation: | Resource | Free | Starter | Pro | Enterprise | |----------|------|---------|-----|------------| | LLM calls | 100 | 5,000 | 50,000 | No cap | | STT calls | 50 | 2,500 | 25,000 | No cap | | TTS calls | 50 | 2,500 | 25,000 | No cap | | Image generations | 50 | 500 | 5,000 | No cap | When you exceed one of these call caps, the API returns a `429` error with `code: "quota_exceeded"`. Every API response includes usage headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `X-Usage-Warning` at 80% and 95% usage. Your actual spend is always governed by your credit balance and any monthly budget cap you set — the call caps above are an additional guardrail on top of that. ##### Included allowances Every plan also comes with the following allowances. These are shown on your plan for reference and are **not** enforced as hard caps — going past one does not block an API request. Only the monthly call caps above, your credit balance, your monthly budget cap, your per-key request rate, and model access are enforced. | Allowance | Free | Starter | Pro | Enterprise | |-----------|------|---------|-----|------------| | Conversations | 50 | 1,000 | 10,000 | No cap | | Storage | 100 MB | 1 GB | 10 GB | Unlimited | | Team members | 2 | 5 | 20 | Unlimited | **Enterprise ($200/mo)** grants 26,000 bonus credits/month, the highest rate limit (10,000 req/min), and priority support. It has no monthly call quota ("No cap") — usage is metered pay-as-you-go from your credits at the same per-model rates as every other plan. Need more than Enterprise? [Talk to us](https://docs.callmissed.com/docs/talk-to-us) for a custom volume deal. ## Talk to Us Source: https://docs.callmissed.com/docs/talk-to-us Get in touch with the CallMissed team for support, enterprise inquiries, or feedback. ### Support For general support and questions: - **Email**: support@callmissed.com (support), sales@callmissed.com (sales), karan@callmissed.com (careers, legal) - **WhatsApp / Call**: [+91 80802 47309](https://wa.me/918080247309) ### Enterprise Need custom rate limits, dedicated infrastructure, or volume pricing? - **Email**: sales@callmissed.com - We'll set up a call to discuss your requirements ### Community - **LinkedIn**: [linkedin.com/company/callmissed](https://www.linkedin.com/company/callmissed) — product updates and announcements - **Instagram**: [@callmissed.in](https://www.instagram.com/callmissed.in) — behind the scenes - **Facebook**: [facebook.com/callmissed](https://www.facebook.com/share/1CzfT8bf78/) ## Call Analytics Pipeline Source: https://docs.callmissed.com/docs/call-analytics A production-ready call analytics pipeline on the CallMissed API — batch STT with diarization, speaker-wise parsing, and LLM-powered analysis. ### Overview This cookbook demonstrates a robust, production-ready call analytics pipeline on the CallMissed API. It uses the **Call Analytics endpoint** for batch speech-to-text with diarization, parses speaker-wise transcripts, and runs LLM-powered analysis across 9 dimensions — all behind a single `cm_` API key. ### Business Value - Improve agent effectiveness - Understand customer sentiment - Detect operational issues early - Spot upsell/cross-sell opportunities **Where is it useful:** - **E-commerce / D2C** — Understand refund requests, delivery concerns, or dissatisfaction with product quality. - **Contact Centers / BPOs** — Automate call reviews to improve training and ensure compliance at scale. - **Healthcare & Insurance** — Analyze patient queries, support delays, and sentiment in sensitive service calls. ### 1. Get an API Key 1. Create a key in the [dashboard](https://app.callmissed.com/api-keys) — it looks like `cm_live_...`. 2. Set it as an environment variable so it never lands in source control: ```bash export CALLMISSED_API_KEY="cm_live_..." ``` ### 2. Analyze a Recording A single `POST` to `/api/v1/analytics/calls/analyze` uploads the audio, runs diarized STT, and returns the transcript, per-speaker timing, and analysis in one response. ```python import os import requests API_KEY = os.environ["CALLMISSED_API_KEY"] BASE_URL = "https://api.callmissed.com/api/v1" with open("call_recording.mp3", "rb") as audio: resp = requests.post( f"{BASE_URL}/analytics/calls/analyze", headers={"Authorization": f"Bearer {API_KEY}"}, files={"file": audio}, data={"language": "hi-IN"}, # omit for auto-detect timeout=300, ) resp.raise_for_status() result = resp.json() ``` **Supported formats:** WAV, MP3, MP4, M4A, OGG, FLAC, WebM, AAC, AMR (max 100 MB). ### 3. CallAnalytics Class Wrap the two endpoints (`analyze` + `question`) in a small reusable class: ```python class CallAnalytics: def __init__(self, api_key: str, base_url: str = "https://api.callmissed.com/api/v1"): self.base_url = base_url self.headers = {"Authorization": f"Bearer {api_key}"} self.result = None def analyze(self, audio_path: str, language: str | None = None) -> dict: data = {"language": language} if language else {} with open(audio_path, "rb") as audio: resp = requests.post( f"{self.base_url}/analytics/calls/analyze", headers=self.headers, files={"file": audio}, data=data, timeout=300, ) resp.raise_for_status() self.result = resp.json() return self.result def answer_question(self, question: str) -> str: resp = requests.post( f"{self.base_url}/analytics/calls/question", headers={**self.headers, "Content-Type": "application/json"}, json={"transcript": self.result["transcript"], "question": question}, ) resp.raise_for_status() return resp.json()["answer"] def get_summary(self) -> str: return self.result["summary"] ``` ### 4. Full Workflow ```python import os analytics = CallAnalytics(api_key=os.environ["CALLMISSED_API_KEY"]) analytics.analyze("/path/to/your/audio/file.mp3", language="hi-IN") print(analytics.answer_question("Was the customer satisfied with the resolution?")) print(analytics.get_summary()) ``` ### 5. Sample Output ``` ### Speaker Identification - Customer: SPEAKER_00 (Adam Wilson) - Agent: SPEAKER_01 (Sam from Coaching Downs) ### Customer Type - Existing customer — has a previous order ### Resolution - Escalated to corporate office. Email update promised in 2-4 business days. ``` The analysis covers 9 dimensions: sentiment, intent, resolution, agent performance, escalation risk, key topics, action items, compliance, and satisfaction score. ### 6. Resources - **Batch STT reference**: [Batch STT](https://docs.callmissed.com/docs/stt-batch) - **Speech to Text overview**: [Speech to Text](https://docs.callmissed.com/docs/speech-to-text) - **Talk to us**: [Support & Enterprise](https://docs.callmissed.com/docs/talk-to-us)