# CallMissed — full site knowledge (llms-full.txt) > AI communication platform: AI voice agents, WhatsApp automation, and a unified OpenAI-compatible LLM/STT/TTS API — built for multilingual (English + 22 Indian language) customer conversations. Docs: https://docs.callmissed.com · API: https://api.callmissed.com ## Blog (latest posts, raw markdown per link) - [Twilio Voice Pricing in 2026: What an AI Agent Really Costs](https://www.callmissed.com/blog/twilio-voice-pricing-2026-ai-agent-really) — Understand Twilio Voice Pricing with US rate tables, reproducible AI-call budgets, hidden cost checks, and practical ways to compare voice stacks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/twilio-voice-pricing-2026-ai-agent-really.md)) - [Goodcall pricing vs CallMissed: Which Fits You? (2026)](https://www.callmissed.com/blog/goodcall-pricing-vs-callmissed-which-fits-2026) — Compare Goodcall pricing with CallMissed's 2026 rates, billing units, language support and practical tests to choose the right AI phone agent. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/goodcall-pricing-vs-callmissed-which-fits-2026.md)) - [Credit-based billing for AI SaaS: Implementation Guide](https://www.callmissed.com/blog/credit-based-billing-ai-saas-implementation-guide) — Credit-based billing for AI SaaS with atomic ledgers, payment webhooks, cost-to-credit calculations, and tests that prevent double charges. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/credit-based-billing-ai-saas-implementation-guide.md)) - [Free AI APIs for Prototyping: Voice App Buyer’s Guide](https://www.callmissed.com/blog/free-ai-apis-prototyping-voice-app-buyer) — Compare free AI APIs for prototyping voice apps by trial credits, audio limits, language support and phone costs to choose a workable stack. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/free-ai-apis-prototyping-voice-app-buyer.md)) - [Gemma 4 vs Mistral Small 3.1: On-Device Use Compared](https://www.callmissed.com/blog/gemma-4-vs-mistral-small-3-1) — Gemma 4 vs Mistral Small 3.1: compare local memory needs, model variants, tool calling and sourced costs to choose an on-device setup. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/gemma-4-vs-mistral-small-3-1.md)) - [LLM API aggregator: Fallback and Routing Buyer's Guide](https://www.callmissed.com/blog/llm-api-aggregator-fallback-routing-buyer-guide) — Compare LLM API aggregator options by fallback controls, deployment, pricing and failure tests to choose a practical fit for your team. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/llm-api-aggregator-fallback-routing-buyer-guide.md)) - [What Is Lucid Origin? Specs, Pricing and Comparisons](https://www.callmissed.com/blog/lucid-origin-specs-pricing-comparisons) — What Is Lucid Origin? Compare documented specs, provider pricing, Phoenix alternatives and a repeatable test plan before choosing an image API. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/lucid-origin-specs-pricing-comparisons.md)) - [Claude Fable 5.2 Effort Levels: Cost and When to Switch](https://www.callmissed.com/blog/claude-fable-5-2-effort-levels) — Claude Fable 5.2 effort levels explained for coding agents: a decision table, a cost-per-task test and which launch numbers are vendor-reported. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-2-effort-levels.md)) - [Claude Fable 5.2 for Voice Agents: Worth the Cost?](https://www.callmissed.com/blog/claude-fable-5-2) — Claude Fable 5.2 cost and latency for voice agents and support AI, with per-call estimates and where a frontier model earns its price. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-2.md)) - [Fable 5.2 vs GPT-6 Astra vs Gemini 4 Carbon: Benchmarks](https://www.callmissed.com/blog/fable-5-2-vs-gpt-6-astra) — Fable 5.2 vs GPT-6 Astra and Gemini 4 Carbon, read from Anthropic's Nov 9 table: vendor-reported scores, caveats and a workload-based shortlist. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/fable-5-2-vs-gpt-6-astra.md)) - [Claude Fable 5 Data Retention: Fable 5.2 Compliance Guide](https://www.callmissed.com/blog/claude-fable-5-data-retention) — Claude Fable 5 data retention explained: the 30-day Covered Model rule, fallback API logging and the Enterprise Frontier Safeguards path to ZDR. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-data-retention.md)) - [Claude Fable 5 Orchestrator: Advisor vs Orchestrator Guide](https://www.callmissed.com/blog/claude-fable-5-orchestrator) — Claude Fable 5 orchestrator patterns with Haiku 5.5 and Sonnet 5.5: routing rules, an architecture diagram and a cost table with stated assumptions. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-orchestrator.md)) - [Claude Fable 5.2 release date: Nov 9, 2026 Access FAQ](https://www.callmissed.com/blog/claude-fable-5-2-release-date) — Claude Fable 5.2 release date confirmed: check platform access, API model ID, token pricing, and official sources for November 9, 2026. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-2-release-date.md)) - [Fable 5.2 vs Opus 5.5: Which Claude Should Developers Use?](https://www.callmissed.com/blog/fable-5-2-vs-opus-5-5) — Fable 5.2 vs Opus 5.5 for developers: Anthropic's own advice, vendor-reported coding benchmarks, cost per task and when each model is the better pick. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/fable-5-2-vs-opus-5-5.md)) - [Claude Fable 5.2 vs Fable 5.1: Should You Upgrade?](https://www.callmissed.com/blog/claude-fable-5-2-vs-fable-5-1) — Claude Fable 5.2 vs Fable 5.1 compared on price, speed, benchmarks and safeguards, plus a migration checklist to decide if upgrading is worth it. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-2-vs-fable-5-1.md)) - [Claude Fable 5.2 API Pricing: $8/$40 Rates and Migration](https://www.callmissed.com/blog/claude-fable-5-2-api-pricing) — Claude Fable 5.2 API pricing is $8 input and $40 output per million tokens. See fast mode, US-only costs, cost examples and a 5.1 migration checklist. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-2-api-pricing.md)) - [How to connect your LLM: A Developer Voice & Chat Guide](https://www.callmissed.com/blog/connect-llm-developer-voice-chat-guide) — Learn to connect your LLM to a shared voice and chat backend with streaming, tool calls, secure sessions, retries, and measurable costs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/connect-llm-developer-voice-chat-guide.md)) - [WhatsApp Business API for Customer Support: India 2026](https://www.callmissed.com/blog/whatsapp-business-api-customer-support-india-2026) — Use WhatsApp Business API for Customer Support to set up Indian-language workflows, estimate costs, and manage reliable human handoffs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-business-api-customer-support-india-2026.md)) - [Kimi K3.1 Pro weights: Customer-Support Evaluation](https://www.callmissed.com/blog/kimi-k3-1-pro-weights) — Evaluate Kimi K3.1 Pro weights with support-ticket tests for accuracy, safe tool use, latency, and cost before choosing API or self-hosting. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/kimi-k3-1-pro-weights.md)) - [LiveKit custom LLM providers: A Step-by-Step Guide](https://www.callmissed.com/blog/livekit-custom-llm-providers-step-by-guide) — Connect LiveKit custom LLM providers with base_url, Python examples, streaming adapters, and fixes for authentication and tool-calling errors. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/livekit-custom-llm-providers-step-by-guide.md)) - [Switch LLM providers without rewriting your agent code](https://www.callmissed.com/blog/switch-llm-providers-without-rewriting-agent-code) — Switch LLM providers using a stable API interface, runnable Python examples, configuration diffs, compatibility tests, and troubleshooting. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/switch-llm-providers-without-rewriting-agent-code.md)) - [Multilingual customer support bot: India build guide](https://www.callmissed.com/blog/multilingual-customer-support-bot-india-build-guide) — Multilingual customer support bot development for India: get runnable Python, WhatsApp setup, Hinglish tests, voice evaluation and handoff. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/multilingual-customer-support-bot-india-build-guide.md)) - [unified LLM API gateway: Claude, Gemini and GPT Compared](https://www.callmissed.com/blog/unified-llm-api-gateway-claude-gemini-gpt) — Compare unified LLM API gateway options for Claude, Gemini and GPT, with pricing checks, compatibility tests and clear recommendations by use case. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/unified-llm-api-gateway-claude-gemini-gpt.md)) - [Indian Language Voice AI Agents: Affordable Picks 2026](https://www.callmissed.com/blog/indian-language-voice-ai-agents-affordable-picks) — Compare Indian Language Voice AI Agents by published pricing, phone costs and language coverage to shortlist an affordable platform for India. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/indian-language-voice-ai-agents-affordable-picks.md)) - [Mistral Large 4 review: Customer Support Evaluation](https://www.callmissed.com/blog/mistral-large-4-review) — Use this Mistral Large 4 review to assess support accuracy, licensing, deployment costs, and escalation quality with a reproducible test plan. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/mistral-large-4-review.md)) - [WhatsApp helpdesk software: India Buyer Guide 2026](https://www.callmissed.com/blog/whatsapp-helpdesk-software-india-buyer-guide-2026) — Compare WhatsApp helpdesk software for Indian B2C teams on ticket ownership, human escalation, voice continuity and total cost before buying. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-helpdesk-software-india-buyer-guide-2026.md)) - [Indian Language TTS: Add Telugu and Kannada to Your App](https://www.callmissed.com/blog/indian-language-tts-add-telugu-kannada-app) — Add Indian Language TTS with runnable backend and playback code for Telugu and Kannada, secure API keys, native-script tests, and fixes. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/indian-language-tts-add-telugu-kannada-app.md)) - [OpenAI Realtime WebRTC API: Speech-to-Speech Setup Guide](https://www.callmissed.com/blog/openai-realtime-webrtc-api-speech-setup-guide) — Set up the OpenAI Realtime WebRTC API with browser and server code, secure credentials, audio playback, and fixes for common connection errors. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/openai-realtime-webrtc-api-speech-setup-guide.md)) - [ElevenLabs alternatives India 2026: Indian Voice Guide](https://www.callmissed.com/blog/elevenlabs-alternatives-india-2026-indian-voice-guide) — Compare ElevenLabs alternatives India for Indian English, Hindi and Hinglish, with sourced pricing, pronunciation tests and use-case advice. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/elevenlabs-alternatives-india-2026-indian-voice-guide.md)) - [Vapi vs Retell vs Bland for Voice AI: 2026 Comparison](https://www.callmissed.com/blog/vapi-vs-retell-bland-voice-ai-2026) — Compare Vapi vs Retell and Bland on pricing components, developer workflows, and voice-agent trade-offs, with a reproducible testing framework. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/vapi-vs-retell-bland-voice-ai-2026.md)) - [Kimi K3.1 Pro Highspeed vs Pro: Customer Support Guide](https://www.callmissed.com/blog/kimi-k3-1-pro-highspeed) — Evaluate Kimi K3.1 Pro Highspeed against Pro for support accuracy, response times and cost per resolved ticket, with a practical routing checklist. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/kimi-k3-1-pro-highspeed.md)) - [Dessa Speech-Only Voice Model: Voice-Agent Developer Guide](https://www.callmissed.com/blog/dessa-speech-only-voice-model-agent-developer) — Explore how the Dessa speech-only voice model could fit voice agents, with architecture guidance, access checks, testing steps and safety trade-offs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/dessa-speech-only-voice-model-agent-developer.md)) - [AI Voice Agent CRM Integration India: Zoho, HubSpot & More](https://www.callmissed.com/blog/ai-voice-agent-crm-integration-india-zoho) — CRM integration for AI voice agents in India: map call data, connect Zoho or HubSpot, prevent duplicates and handle webhook retries safely. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-voice-agent-crm-integration-india-zoho.md)) - [AI Voice Agent for Insurance India: Rules and Cost (2026)](https://www.callmissed.com/blog/ai-voice-agent-insurance-india-rules-cost) — Buyer's guide to an AI voice agent for insurance India and NBFC lead follow-up: which rules apply, what needs a licensed human, and per-minute cost. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-voice-agent-insurance-india-rules-cost.md)) - [AI Voice Agent vs IVR in India: Replace, Keep or Combine](https://www.callmissed.com/blog/ai-voice-agent-vs-ivr-india-replace) — AI voice agent vs IVR for Indian callers: a decision table, worked cost example and hybrid pattern to replace, keep or combine your phone menu. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-voice-agent-vs-ivr-india-replace.md)) - [AI Voice Agent for Clinics India: Hindi Booking Guide 2026](https://www.callmissed.com/blog/ai-voice-agent-clinics-india-hindi-booking) — Evaluate an AI receptionist for clinics in India: Hindi booking tests, calendar integration, staff escalation and ₹4/min Standard AI costs, with phone carriage extra. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-voice-agent-clinics-india-hindi-booking.md)) - [How to Test an AI Voice Agent: Hinglish QA Checklist](https://www.callmissed.com/blog/test-ai-voice-agent-hinglish-qa-checklist) — Learn how to test an AI voice agent before launch: a Hinglish test set, repeat-run pass rates and a one-page scorecard with a go/no-go rule. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/test-ai-voice-agent-hinglish-qa-checklist.md)) - [Voxtral 3 Speech Models: What Voice Developers Evaluate](https://www.callmissed.com/blog/voxtral-3-speech-models) — Evaluate Voxtral 3 Speech Models with repeatable tests for call latency, noisy audio, voice quality, tool use, licensing, and total cost. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/voxtral-3-speech-models.md)) - [WhatsApp Hidden AI Instructions: What Teams Must Audit](https://www.callmissed.com/blog/whatsapp-hidden-ai-instructions-teams-must-audit) — WhatsApp Hidden AI Instructions explained: review the October 2026 evidence, Meta’s bug response, and audits for business support bots. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-hidden-ai-instructions-teams-must-audit.md)) - [WhatsApp voice AI integration: Developer Setup Guide](https://www.callmissed.com/blog/whatsapp-voice-ai-integration-developer-setup-guide) — Implement WhatsApp voice AI integration with webhook setup, Python code, audio replies, live-call options, and practical troubleshooting checks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-voice-ai-integration-developer-setup-guide.md)) - [Buy AI Credits India: Best Developer Platforms 2026](https://www.callmissed.com/blog/buy-ai-credits-india-best-developer-platforms) — Buy AI Credits India with clear INR comparisons, payment options and workload-cost checks. Find the right developer API without misleading credit math. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/buy-ai-credits-india-best-developer-platforms.md)) - [GPT-4o API pricing 2026: Compare Compatible API Costs](https://www.callmissed.com/blog/gpt-4o-api-pricing-2026-compare-compatible) — Compare GPT-4o API pricing across compatible providers, calculate workload costs, and check model identity and fees before choosing an API. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/gpt-4o-api-pricing-2026-compare-compatible.md)) - [OpenRouter models: Which Support Indian Languages?](https://www.callmissed.com/blog/openrouter-models-which-support-indian-languages) — Compare OpenRouter models for Indian-language tasks, distinguish documented support from testing needs, and check pricing and API setup. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/openrouter-models-which-support-indian-languages.md)) - [Proxy-based LLM observability for Support API Teams](https://www.callmissed.com/blog/proxy-based-llm-observability-support-api-teams) — Use proxy-based LLM observability to trace support sessions, track token costs, diagnose streaming delays and errors, and protect customer data. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/proxy-based-llm-observability-support-api-teams.md)) - [AI receptionist vs human: Clinic & Salon Costs in India](https://www.callmissed.com/blog/ai-receptionist-vs-human-clinic-salon-costs) — Compare AI receptionist vs human costs for Indian clinics and salons, with staffing overheads, call-minute budgets, booking ROI and hybrid options. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-receptionist-vs-human-clinic-salon-costs.md)) - [Speech-to-text APIs for Indian Languages Compared 2026](https://www.callmissed.com/blog/speech-text-apis-indian-languages-compared-2026) — Compare Speech-to-text APIs for Indian languages with a reproducible accuracy test, Hinglish scoring, coverage checks and normalized per-minute costs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/speech-text-apis-indian-languages-compared-2026.md)) - [DeepSeek V4.1 Flash tool calls: Support Testing Guide](https://www.callmissed.com/blog/deepseek-v4-1-flash-tool-calls) — Evaluate DeepSeek V4.1 Flash tool calls with repeatable support tests for resolution accuracy, safe actions, latency, and cost. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/deepseek-v4-1-flash-tool-calls.md)) - [WhatsApp appointment reminders for clinics: India Guide](https://www.callmissed.com/blog/whatsapp-appointment-reminders-clinics-india-guide) — WhatsApp appointment reminders for clinics help patients confirm or reschedule. Learn multilingual scripts, consent checks and safe retry rules. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-appointment-reminders-clinics-india-guide.md)) - [OpenRouter alternatives: Production API Gateways Compared](https://www.callmissed.com/blog/openrouter-alternatives-production-api-gateways-compared) — Compare OpenRouter alternatives by model capabilities, total workload costs and reproducible latency tests to choose a production gateway. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/openrouter-alternatives-production-api-gateways-compared.md)) - [WhatsApp Business API providers: India SMB Guide 2026](https://www.callmissed.com/blog/whatsapp-business-api-providers-india-smb-guide) — Compare WhatsApp Business API providers on total costs, onboarding and automation to choose a practical option for your Indian SMB. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-business-api-providers-india-smb-guide.md)) - [Twilio Alternatives: CallMissed vs Twilio Costs in India](https://www.callmissed.com/blog/twilio-alternatives-callmissed-vs-costs-india) — Compare Twilio alternatives for AI calling in India with itemized costs, setup requirements, language support, and practical selection criteria. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/twilio-alternatives-callmissed-vs-costs-india.md)) - [COD confirmation voice bot script: WhatsApp and ROI](https://www.callmissed.com/blog/cod-confirmation-voice-bot-script-whatsapp-roi) — Use a COD confirmation voice bot script with WhatsApp handoffs, safe retry rules and an India-specific ROI model to measure delivery gains. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/cod-confirmation-voice-bot-script-whatsapp-roi.md)) - [AI calling agents for Indian SMBs: Support Compared](https://www.callmissed.com/blog/ai-calling-agents-indian-smbs-support-compared) — Compare AI calling agents on Indian-language support, monthly costs, escalation workflows and pilot results to shortlist your best fit. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-calling-agents-indian-smbs-support-compared.md)) - [Vapi alternatives: Retell vs CallMissed for Startups](https://www.callmissed.com/blog/vapi-alternatives-retell-vs-callmissed-startups) — Compare Vapi alternatives on total call costs, setup effort, Indian-language support and repeatable phone-agent tests to choose your startup stack. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/vapi-alternatives-retell-vs-callmissed-startups.md)) - [Best AI voice agents in India: 2026 Comparison Guide](https://www.callmissed.com/blog/best-ai-voice-agents-india-2026-comparison) — Compare AI voice agents in India by spoken-language coverage, Hindi/Marathi/Tamil tests, workflow fit and total INR costs. CallMissed flat AI rates start at ₹4/min; telephony extra. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/best-ai-voice-agents-india-2026-comparison.md)) - [Internet Phone Switch Review: Phreak.ing for Business?](https://www.callmissed.com/blog/internet-phone-switch-review-phreak-ing-business) — Evaluate an Internet Phone Switch for business calls with a Phreak.ing feature checklist, privacy trade-offs, and migration checks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/internet-phone-switch-review-phreak-ing-business.md)) - [Claude Haiku 5.5 Voice Agents vs Fable 5.5 Compared](https://www.callmissed.com/blog/claude-haiku-5-5-voice-agents) — Claude Haiku 5.5 voice agents are compared with confirmed Fable 5.1 across latency, API cost, context, tools, routing, and voice constraints. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-haiku-5-5-voice-agents.md)) - [Claude Fable 5.5 vs Bulbul V4 Flash: Voice AI Guide](https://www.callmissed.com/blog/claude-fable-5-5-vs-bulbul-v4-flash) — Claude Fable 5.5 vs Bulbul V4 Flash explains release status, model roles, latency, languages, pricing, streaming, and voice-agent design. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-fable-5-5-vs-bulbul-v4-flash.md)) - [DeepSeek V4.1 Flash Now on CallMissed API: 1M Context](https://www.callmissed.com/blog/deepseek-v4-1-flash) — DeepSeek V4.1 Flash is live on CallMissed with a 1,048,576-token window, tool calling and low, high, max reasoning effort. Model ID and code included. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/deepseek-v4-1-flash.md)) - [Bulbul V4 Flash Now on CallMissed API: Sarvam's Fast TTS](https://www.callmissed.com/blog/bulbul-v4-flash-now-callmissed-api-sarvam) — Bulbul V4 Flash is live on the CallMissed API and playground. See Sarvam's published specs, pricing, the V3 voice-name gotcha and a copy-paste call. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/bulbul-v4-flash-now-callmissed-api-sarvam.md)) - [Muse Wonder vs GPT-6 Astra vs Gemini 4 Argon for Voice](https://www.callmissed.com/blog/muse-wonder-vs-gpt-6-astra) — Muse Wonder vs GPT-6 Astra vs Gemini 4 Argon compared on price, context, audio support and latency, with a use-case table for voice and text-back. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/muse-wonder-vs-gpt-6-astra.md)) - [Hold-to-Talk Voice Assistant: Business Agent UX Guide](https://www.callmissed.com/blog/hold-talk-voice-assistant-business-agent-ux) — Use a Hold-to-Talk Voice Assistant to test clearer turn-taking, compare input modes, and design business agents with safer approvals and handoffs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/hold-talk-voice-assistant-business-agent-ux.md)) - [LLM comparison 2026: Best Models for Customer Support](https://www.callmissed.com/blog/llm-comparison-2026-best-models-customer-support) — Use this LLM comparison 2026 to assess support accuracy, safety, multilingual quality and cost per resolved ticket across hosted and open models. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/llm-comparison-2026-best-models-customer-support.md)) - [Cactus Whistle: 16.9 MB ASR for Voice-Agent Builders](https://www.callmissed.com/blog/cactus-whistle-16-mb-asr-voice-agent) — Explore Cactus Whistle with an audio-preparation guide, reproducible CPU test plan, and practical trade-offs for local voice-agent deployment. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/cactus-whistle-16-mb-asr-voice-agent.md)) - [When security concerns slow adoption: AI Support in India](https://www.callmissed.com/blog/when-security-concerns-slow-adoption-ai-support) — When security concerns slow adoption, use this India-focused guide to assess vendors, protect customer data and launch a measured AI support pilot. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/when-security-concerns-slow-adoption-ai-support.md)) - [Claude Haiku 5.5 latency: Voice-Agent Evaluation Guide](https://www.callmissed.com/blog/claude-haiku-5-5-latency) — Measure Claude Haiku 5.5 latency across speech, tools, and playback. Build reproducible tests for reasoning trade-offs and cost per successful call. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-haiku-5-5-latency.md)) - [LLM vendor evaluation checklist: Before Support Pilots](https://www.callmissed.com/blog/llm-vendor-evaluation-checklist-before-support-pilots) — Use this LLM vendor evaluation checklist to verify availability, data terms, tool safety, evidence, costs, and rollback before a support pilot. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/llm-vendor-evaluation-checklist-before-support-pilots.md)) - [Cost per resolved ticket: An LLM Support Evaluation Guide](https://www.callmissed.com/blog/cost-per-resolved-ticket-llm-support-evaluation) — Cost per resolved ticket reveals true AI support value. Compare models with realistic tickets, quality thresholds, and fully loaded costs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/cost-per-resolved-ticket-llm-support-evaluation.md)) - [LLM evaluation for customer support: An SMB Test Plan](https://www.callmissed.com/blog/llm-evaluation-customer-support-smb-test-plan) — LLM evaluation for customer support helps SMBs test accuracy, tool failures, escalation and true resolution costs before adopting a new model. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/llm-evaluation-customer-support-smb-test-plan.md)) - [LLM for intent classification: A Support Routing Guide](https://www.callmissed.com/blog/llm-intent-classification-support-routing-guide) — Use an LLM for intent classification to map support messages to queues, handle ambiguity, benchmark routing, and add safe human fallbacks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/llm-intent-classification-support-routing-guide.md)) - [AI voice agent data privacy: Questions Before Deployment](https://www.callmissed.com/blog/ai-voice-agent-data-privacy-questions-before) — AI voice agent data privacy starts with evidence. Get a checklist for storage, consent, retention, training permissions and prelaunch tests. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-voice-agent-data-privacy-questions-before.md)) - [Claude Sonnet 5.5 Review for Support and Voice Agents](https://www.callmissed.com/blog/claude-sonnet-5-5-review) — Claude Sonnet 5.5 Review: evaluate support accuracy, voice latency, and cost per resolved call with reproducible tests and migration checks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-sonnet-5-5-review.md)) - [AI Receptionist Pricing for Small Business: US vs India](https://www.callmissed.com/blog/ai-receptionist-pricing-small-business-us-vs) — Compare all-in AI receptionist pricing for small business in the US and India, covering per-minute fees and number rental. Verify quotes before buying. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-receptionist-pricing-small-business-us-vs.md)) - [Integrating LLM APIs: A Developer Guide to Voice and Chat](https://www.callmissed.com/blog/integrating-llm-apis-developer-guide-voice-chat) — Integrating LLM APIs securely helps you ship streaming chat and voice with server-side keys, interruption handling, and reliable retries. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/integrating-llm-apis-developer-guide-voice-chat.md)) - [WhatsApp customer support automation: India playbook](https://www.callmissed.com/blog/whatsapp-customer-support-automation-india-playbook) — Use WhatsApp customer support automation to set up compliant flows, handle Hindi and mixed-language queries, and route complex cases to agents. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/whatsapp-customer-support-automation-india-playbook.md)) - [Missed sales calls: An AI voice agent guide for India](https://www.callmissed.com/blog/missed-sales-calls-ai-voice-agent-guide) — Missed sales calls become actionable leads with AI answering. Learn Indian-language setup, human handoffs, CRM capture and an INR pilot cost model. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/missed-sales-calls-ai-voice-agent-guide.md)) - [AI Agent Execution: Why Support Needs Verification](https://www.callmissed.com/blog/ai-agent-execution-support-needs-verification) — Learn how AI agent execution evidence helps support teams trace handoffs, investigate errors, and distinguish signed records from correct outcomes. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-agent-execution-support-needs-verification.md)) - [BharatGen Text Model: What It Means for Voice Agents](https://www.callmissed.com/blog/bharatgen-text-model-it-means-voice-agents) — Understand where the BharatGen Text Model fits, compare speech-stack components, and plan practical multilingual voice-agent tests for Indian users. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/bharatgen-text-model-it-means-voice-agents.md)) - [AI Adoption in Indian MSMEs: Why SMBs Move Faster on Calls](https://www.callmissed.com/blog/ai-adoption-indian-msmes-smbs-move-faster) — Explore AI adoption in Indian MSMEs with a practical call-automation budget, data-readiness checklist and pilot metrics to evaluate business value. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-adoption-indian-msmes-smbs-move-faster.md)) - [AI Agent Access Control: A Customer-Support Safety Guide](https://www.callmissed.com/blog/ai-agent-access-control-customer-support-safety) — Implement AI agent access control with support permission matrices, customer-data boundaries, human approval rules and practical rollout checks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-agent-access-control-customer-support-safety.md)) - [Least Privilege for AI Agents: Customer Support Guide](https://www.callmissed.com/blog/least-privilege-ai-agents-customer-support-guide) — Implement least privilege for AI agents with support-tool permission matrices, tenant checks, approval gates and executable security tests. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/least-privilege-ai-agents-customer-support-guide.md)) - [Local Voice Agents: What IBM Granite 4.2 Means in 2026](https://www.callmissed.com/blog/local-voice-agents-ibm-granite-means-2026) — Learn how to build local voice agents with Granite 4.2, verify offline operation, measure latency, and compare self-hosted and managed setups. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/local-voice-agents-ibm-granite-means-2026.md)) - [Human-in-the-Loop for AI Voice Agents: Safety Playbook](https://www.callmissed.com/blog/human-loop-ai-voice-agents-safety-playbook) — Implement human-in-the-loop for AI voice agents with practical approval rules, calibrated routing, reliable handoffs, and measurable QA checks. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/human-loop-ai-voice-agents-safety-playbook.md)) - [Voice Agent Prompt Injection Testing: Tool-Call Checks](https://www.callmissed.com/blog/voice-agent-prompt-injection-testing-tool-call) — Use voice agent prompt injection testing to validate authorization before support tools run, test attacks, and prove blocked actions never execute. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/voice-agent-prompt-injection-testing-tool-call.md)) - [Extracting Structured Data From Messy Text: Support Guide](https://www.callmissed.com/blog/extracting-structured-data-from-messy-text-support) — Learn extracting structured data from messy text with schemas, few-shot examples, accuracy tests, and human review for safer support automation. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/extracting-structured-data-from-messy-text-support.md)) - [AI Productivity Stack: Avoid Support Tool Overload](https://www.callmissed.com/blog/ai-productivity-stack-avoid-support-tool-overload) — Build a lean AI productivity stack for customer support: audit redundant tools, consolidate safely, and measure resolution quality and total cost. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-productivity-stack-avoid-support-tool-overload.md)) - [Mercury 2.5 Diffusion LLM: 770 tok/s and Voice Latency](https://www.callmissed.com/blog/mercury-diffusion-llm-770-tok-voice-latency) — Learn how Mercury 2.5 Diffusion LLM throughput differs from voice response time, reconcile reported speeds, and benchmark your full agent pipeline. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/mercury-diffusion-llm-770-tok-voice-latency.md)) - [Agentic AI Governance: India Voice Support Safety Guide](https://www.callmissed.com/blog/agentic-ai-governance-india-voice-support-safety) — Use agentic AI governance to map Indian compliance duties, test multilingual voice support, assign oversight and launch with safer controls. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/agentic-ai-governance-india-voice-support-safety.md)) - [Kimi K3 API Pricing: Customer Support LLM Comparison](https://www.callmissed.com/blog/kimi-k3-api-pricing-customer-support-llm) — Evaluate Kimi K3 API pricing against support resolution quality, hidden operating costs and deployment demands with a practical testing framework. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/kimi-k3-api-pricing-customer-support-llm.md)) - [Hardware-Efficient Language Models for Support Costs](https://www.callmissed.com/blog/hardware-efficient-language-models-support-costs) — Use hardware-efficient language models to reduce support costs, compare deployment options, and measure savings without sacrificing resolution quality. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/hardware-efficient-language-models-support-costs.md)) - [Large Language Models: Customer Support's Future in 2026](https://www.callmissed.com/blog/large-language-models-customer-support-future-2026) — Learn how large language models can improve support with grounded answers, better ticket routing, release testing, and measurable automation outcomes. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/large-language-models-customer-support-future-2026.md)) - [Sarvam AI Capabilities: Local-Language Support in 2026](https://www.callmissed.com/blog/sarvam-ai-capabilities-local-language-support-2026) — Explore Sarvam AI capabilities for Indian-language support, with a practical workflow, testing framework, and data-residency checklist. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/sarvam-ai-capabilities-local-language-support-2026.md)) - [Gemini 3.8 Text-to-Speech: What Changes for AI Agents?](https://www.callmissed.com/blog/gemini-text-speech-changes-ai-agents) — Explore Gemini 3.8 text-to-speech for AI voice agents: compare model options, assess latency and costs, and plan a reliable multilingual pilot. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/gemini-text-speech-changes-ai-agents.md)) - [Tokens Too Cheap to Meter: What AI Support Still Costs](https://www.callmissed.com/blog/tokens-too-cheap-meter-ai-support-still) — Understand tokens too cheap to meter through a support-cost worksheet, resolution metrics, and practical ways to control spending as usage grows. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/tokens-too-cheap-meter-ai-support-still.md)) - [AI Agent Security Checklist for Customer Conversations](https://www.callmissed.com/blog/ai-agent-security-checklist-customer-conversations) — Use this AI agent security checklist to protect voice and WhatsApp conversations, enforce tool permissions, and test before launch. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/ai-agent-security-checklist-customer-conversations.md)) - [Prompt Injection Protection for AI Agents in Support](https://www.callmissed.com/blog/prompt-injection-protection-ai-agents-support) — Build prompt injection protection for AI agents with scoped permissions, approval thresholds, attack tests, and practical human-handoff controls. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/prompt-injection-protection-ai-agents-support.md)) - [Claude Opus 5.5 Pricing: Assess Support-AI Performance](https://www.callmissed.com/blog/claude-opus-pricing-assess-support-ai-performance) — Assess Claude Opus 5.5 pricing with ticket-level cost calculations, reproducible support benchmarks, latency tests, and rollout criteria. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-opus-pricing-assess-support-ai-performance.md)) - [GPT-6 Sol vs GPT-6 Luna: A Voice Support Decision Guide](https://www.callmissed.com/blog/gpt-sol-vs-luna-voice-support-decision) — Evaluate GPT-6 Sol vs GPT-6 Luna for support using verified API capabilities, voice latency tests, escalation criteria, and total resolution costs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/gpt-sol-vs-luna-voice-support-decision.md)) - [How to Build No-Code AI Agents for Customer Calls & Chat](https://www.callmissed.com/blog/build-no-code-ai-agents-customer-calls) — Learn how to build no-code AI agents with reusable call and chat templates, shared knowledge, human handoffs, and practical launch tests. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/build-no-code-ai-agents-customer-calls.md)) - [Claude Opus 5.5 for Agents: A Voice Deployment Guide](https://www.callmissed.com/blog/claude-opus-agents-voice-deployment-guide) — Evaluate Claude Opus 5.5 for Agents with a voice-first checklist covering verified claims, latency, fallback design, and per-call costs. ([markdown](https://api.callmissed.com/api/v1/blog/public/blogs/claude-opus-agents-voice-deployment-guide.md)) ## Answers (common questions, raw markdown per link) - [Nemotron 3 Super Explained: What It Is and Its Maker](https://www.callmissed.com/answers/nemotron-3-super) — Understand Nemotron 3 Super, NVIDIA's model architecture, reported specs, conflicting context limits, API pricing and benchmark caveats. ([markdown](https://api.callmissed.com/api/v1/blog/public/answers/nemotron-3-super.md)) - [Gemini 3.5 Flash Release Date and Capabilities: What’s Verified?](https://www.callmissed.com/answers/gemini-flash-release-date-capabilities-verified) — Gemini 3.5 Flash release date and capabilities: see what is verified, what remains unknown, and how to check official Google API access. ([markdown](https://api.callmissed.com/api/v1/blog/public/answers/gemini-flash-release-date-capabilities-verified.md)) ## Developer documentation (full content) --- ### Agent Memories URL: /docs/agent-memories > Read, teach, correct and erase the standing facts an agent remembers and the rules it follows on every reply, including the newest facts it carries into every turn without searching. > A memory is one short fact an agent was told to keep, or a rule it was told to follow. These endpoints are the operator's view of that store: see exactly what is remembered, teach a fact or a rule directly, correct one, turn one into the other, and delete anything that should not be there. :::cards /docs/bots | Bots | bot | The agent a memory belongs to, and its built-in tool list /docs/agent-tools | Agent Tools | wrench | The custom REST tools the same agent calls mid-conversation ::: ## The injection window: what the agent reads without asking Every memory stays searchable forever. The newest of each kind are handed to the agent at the start of a turn, so it acts on them without deciding to look anything up: up to **100 rules**, **500 facts** you saved (including ones the agent saved on your own turns), and **150 background facts**, each kind in its own window. Background facts are the ones you did not state: facts picked up from chats, and facts the agent saved during a conversation with someone else. The agent reads them as unverified information, never as your instructions. That set is the injection window, and `injected: true` marks it on every listed memory. The window is applied today on the linked personal WhatsApp channel. On every other channel a memory is still remembered and still found by `recall_memory`; it is simply not placed in the prompt ahead of time, so `injected` there tells you where a fact sits in the order rather than that it was already read. | | Inside the window | Outside it | | --- | --- | --- | | Newest ... | 100 rules, 500 saved facts, 150 chat-picked facts | Everything older | | How the agent gets it | Already in front of it, every turn | It calls `recall_memory` and the fact is found by meaning, not by keyword | | Reliability | Always applied | Applied when the conversation gives the agent a reason to search | So a fact does not stop existing when it falls out of the window. It stops being free. If a preference must hold on every single reply, keep it near the top by restating it: memories are ordered newest first, and a fresh statement of the same preference moves it back into the window. One more bound shapes each window. A memory is injected whole, never cut mid-sentence, and each block has a character budget: **20,000** for rules, **120,000** for saved facts and **30,000** for background facts. An unusually long run of long memories can fill a budget before its count is reached. `injected` is positional (the newest rows of each kind) and does not model the character budget. Write memories as one line each and the budget will not bite. ## Facts and rules Every memory has a `memory_type`. | | `fact` | `rule` | | --- | --- | --- | | What it is | Something the agent knows: "Ravi prefers calls after 6pm" | A standing instruction: "Always reply in English", "Never share my address" | | How the agent uses it | Injected while inside the window, otherwise found by `recall_memory` | Followed on every reply, including automatic replies to contacts on the linked personal WhatsApp channel | | Default | Yes, when `memory_type` is omitted | Only when asked for | Write a rule as one instruction, the way you would say it out loud. A fact can be made a rule and a rule moved back to a fact with `PATCH` (below), without deleting and re-teaching it. ## What is listed here A memory is stored in one of two forms, and this surface serves only the first. | | Explicit facts | Captured transcripts | | --- | --- | --- | | Where it comes from | The agent's `remember` tool, a chat command, facts picked up from chats, or a `POST` here | A finished call or chat, captured whole | | Listed by these endpoints | Yes | No | | Editable and deletable by these endpoints | Yes | No | | Reachable by the agent | Injected, then searched | Searched, through `recall_memory` | Transcripts are deliberately absent: a transcript is not something an operator can meaningfully review or correct one line at a time, so it stays retrieval-only rather than becoming a browsable message archive. ## Credential class: dashboard JWT **or** a `cm_` key ``` Authorization: Bearer cm_your_api_key ``` | Caller | Requirement | | --- | --- | | `cm_` API key | `bots:read` to list. `bots:write` to create, change and delete | | Dashboard JWT | Listing for any member. Creating, changing and deleting require **owner or admin** (`403 owner or admin role required` otherwise) | A memory is an attribute of a bot rather than a resource class of its own, so it reuses the `bots:*` scopes and adds none. Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`. ## The memory object ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "fact": "Wholesale orders ship Tuesdays only. Never promise a Friday dispatch.", "created_at": "2026-09-08T12:00:00Z", "injected": true, "captured_by": "remember_tool", "memory_type": "fact" } ``` | Field | Type | Notes | | --- | --- | --- | | `id` | `uuid` | Pass it to `DELETE` to forget this one fact | | `fact` | `string` | The memory **in full**. Read from the memory's stored text, not from the 120-character title kept for display, so a long fact comes back whole here | | `created_at` | `string` | When it was remembered. The list is ordered by this, newest first | | `injected` | `boolean` | `true` when it is inside its kind's window. See [the injection window](#the-injection-window-what-the-agent-reads-without-asking) | | `captured_by` | `string` | How the fact was captured: `remember_tool` when the agent saved it mid-conversation, `remember_command` when the owner saved it with a chat command, `console` when it was taught through `POST`, `chat_capture` when it was picked up from chats. `null` when the memory records no capture source | | `memory_type` | `string` | `fact` or `rule`. See [facts and rules](#facts-and-rules) | ## Memories belong to one agent A memory is scoped to a workspace **and** a bot. Two agents in the same workspace do not share a fact, and re-pointing a phone number at a different agent gives it a different set of memories, not the same set under a new name. Move a fact by `POST`ing it to the second agent. ## GET /api/v1/agent-memories Everything one agent has been told to remember, newest first. Requires `bots:read`. | Parameter | Type | Notes | | --- | --- | --- | | `bot_id` | `uuid` | **Required.** The agent whose memories to list | | `limit` | `integer` | 1 to 200, default 100 | | `memory_type` | `string` | Optional. `fact` or `rule` to list only that type. Omit it to list both | ```bash curl "https://api.callmissed.com/api/v1/agent-memories?bot_id=b1f2c3d4-5678-90ab-cdef-1234567890ab&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "memories": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "fact": "Wholesale orders ship Tuesdays only. Never promise a Friday dispatch.", "created_at": "2026-09-08T12:00:00Z", "injected": true, "captured_by": "remember_tool", "memory_type": "fact" } ], "max_injected": 500 } ``` `max_injected` is the size of the injection window. Read it instead of hardcoding 500, so a change to the window does not silently make your own display wrong. ## POST /api/v1/agent-memories Teach the agent a fact or a rule directly, without waiting for it to come up in a conversation. Returns `201` and the memory object. Requires `bots:write`, or an owner or admin JWT. | Field | Type | Notes | | --- | --- | --- | | `bot_id` | `uuid` | Required. The agent that should remember this | | `fact` | `string` | Required. 1 to 160,000 characters are accepted; surrounding whitespace is stripped and the first 16,000 characters are stored. For a rule, this is the instruction text | | `memory_type` | `string` | Optional. `fact` (default) or `rule` | ```bash curl -X POST https://api.callmissed.com/api/v1/agent-memories \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "fact": "Wholesale orders ship Tuesdays only. Never promise a Friday dispatch." }' ``` The new memory always comes back with `injected: true`, because it is the newest, and `captured_by: "console"`, which is how a taught fact is distinguished from one the agent chose to keep. Write one fact per call, in one line, the way you would say it out loud. The agent is given these verbatim, so "Wholesale orders ship Tuesdays only" steers a reply and a pasted paragraph of policy does not. Anything longer belongs in the [knowledge base](/docs/knowledge), which the agent searches instead of carrying. | Status | Meaning | | --- | --- | | `201` | Remembered | | `403` | The key lacks `bots:write`, or the JWT is neither owner nor admin | | `404` | `bot_id` is not an agent in this workspace. Nothing is written | | `422` | `fact` is empty after stripping, or longer than 160,000 characters, or `memory_type` is not `fact` or `rule` | To teach a rule, send the same request with `"memory_type": "rule"`: ```bash curl -X POST https://api.callmissed.com/api/v1/agent-memories \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "fact": "Never share my home address.", "memory_type": "rule" }' ``` ## PATCH `/api/v1/agent-memories/{memory_id}` Correct a memory's text, make a fact a rule or move a rule back to a fact, or both at once. Returns `200` and the updated memory object. Requires `bots:write`, or an owner or admin JWT. | Field | Type | Notes | | --- | --- | --- | | `fact` | `string` | Optional. The corrected text. Surrounding whitespace is stripped; the first 16,000 characters are kept | | `memory_type` | `string` | Optional. `fact` or `rule` | Send at least one of the two. ```bash curl -X PATCH https://api.callmissed.com/api/v1/agent-memories/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"memory_type": "rule"}' ``` To correct the wording instead: ```bash curl -X PATCH https://api.callmissed.com/api/v1/agent-memories/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"fact": "Wholesale orders ship Tuesdays and Thursdays."}' ``` | Status | Meaning | | --- | --- | | `200` | Changed | | `402` | Not enough credits to re-index the corrected text. The memory is unchanged | | `403` | The key lacks `bots:write`, or the JWT is neither owner nor admin | | `404` | No memory with this id in this workspace | | `422` | Neither field was sent, `fact` is empty or longer than 160,000 characters, or `memory_type` is not `fact` or `rule` | | `503` | The corrected text could not be re-indexed. The memory is unchanged | Either change keeps the memory's `created_at`, so it stays where it was in the order: correcting a typo never moves a memory into or out of its window. Corrected text is re-indexed in the same step; if that fails, nothing changes. ## DELETE `/api/v1/agent-memories/{memory_id}` Forget one fact or rule. Returns `204` and no body. Requires `bots:write`, or an owner or admin JWT. ```bash curl -X DELETE https://api.callmissed.com/api/v1/agent-memories/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "Authorization: Bearer cm_your_api_key" ``` Deletion is **idempotent**: `204` whether the memory was there or not, so a retry after a dropped connection is safe. It is also scoped to memories, so this route cannot be used to remove an ordinary knowledge document by guessing at its id. The fact and its search index go together; there is no way to read a deleted memory back. These facts are asserted about real people and are put in front of the agent, so treat this endpoint as the erasure path it is. A memory a customer asks you to remove is removed here, in full, not edited into something softer. To fix a wrong detail instead, use `PATCH`. ## Errors | Status | When | | --- | --- | | `401` | No credential, or one that is not valid | | `403` | The key is missing `bots:read` (listing) or `bots:write` (writing), or the dashboard JWT is not an owner or admin | | `404` | `bot_id` does not name an agent in this workspace, or `memory_id` (on `PATCH`) does not name a memory in it | | `422` | `bot_id` is missing or not a UUID, `limit` is outside 1 to 200, `fact` is empty or too long, or `memory_type` is not `fact` or `rule` | A `bot_id` belonging to another workspace returns `404`, never `403`. The ownership check runs before anything else, so a caller cannot tell "not yours" apart from "does not exist" and cannot use this endpoint to confirm that an id exists somewhere else. ## Billing A memory is embedded when it is written, so that the agent can find it later by meaning rather than by keyword. That embedding is billed like any other embedding on your account, at the rate on the [pricing page](/docs/pricing). Corrected text is re-embedded and billed the same way. Listing and deleting are free. A memory whose embedding did not complete still appears in the list, with `fact` falling back to its first 120 characters, because an un-embedded memory is exactly the thing you need to see rather than one that quietly vanishes. --- ### Agent Tools URL: /docs/agent-tools > Create, test and manage the custom REST tools your WhatsApp and voice agents call at reply time, using the same definition the console writes. > A custom tool is one of your own read-only HTTP endpoints, described so a model can call it mid-conversation. These routes are the API behind the console's **Tools** screen: create the definition, test it against your real endpoint, and attach it to one agent or to all of them. :::cards /docs/connect-your-store | Connect Your Store | store | The guide these endpoints automate, including how to bind a lookup to the customer /docs/bots | Bots | bot | Built-in tools, enabled by name through `config.tools` ::: ## Custom tools vs built-in tools Two different mechanisms, and this page covers only the first. | | Custom REST tools | Built-in tools | | --- | --- | --- | | What it is | Your endpoint, described by you | Ours: WhatsApp sends, CRM reads, storefront connectors | | Managed by | These endpoints | `config.tools` on the bot, listed by [`GET /api/v1/bots/tool-catalog`](/docs/bots) | | Attached by | `bot_id` on the tool row | Tool name in the bot's `config` | A tool with `bot_id: null` is available to **every** agent in the workspace. Set `bot_id` to scope it to one. ## Credential class: dashboard JWT **or** a `cm_` key ``` Authorization: Bearer cm_your_api_key ``` | Caller | Requirement | | --- | --- | | `cm_` API key | `bots:read` to list and read. `bots:write` to create, update, delete and test | | Dashboard JWT | Read for any member. Create, update, delete and test require **owner or admin** (`403 Only owners/admins can manage agent tools` otherwise) | Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`. A custom tool makes real outbound requests with credentials you store, exactly like a webhook. `bots:write` is a powerful scope: give it only to keys that need to author tools, and use a `bots:read` key for anything that only inspects them. ## The tool object ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "bot_id": null, "name": "search_products", "description": "Search the catalogue by keyword. Returns price, stock, the product link and an image URL.", "method": "GET", "url": "https://api.yourstore.com/agent/products/search", "header_params": [ { "name": "X-API-Key", "type": "string", "description": "", "required": false, "source": "static", "secret": true, "value": null } ], "path_params": [], "query_params": [ { "name": "q", "type": "string", "description": "what the customer is looking for", "required": true, "source": "llm", "secret": false, "value": null } ], "body_params": [], "response_mapping": {}, "timeout_seconds": 10, "phase": "on_call", "enabled": true, "has_secrets": true, "created_at": "2026-09-08T12:00:00Z", "updated_at": "2026-09-08T12:00:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `name` | `string` | 1-64 chars. Must start with a letter; letters, digits and underscores only. This is the name the model sees, so write it as a verb: `search_products`, `get_order_status`. Unique per workspace | | `description` | `string` | Max 2000 chars. The **only** thing telling the model when to reach for this tool. Write it as an instruction, not a label | | `method` | `string` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. Default `GET` | | `url` | `string` | Max 2048 chars, `http` or `https`, with a hostname. May contain `{placeholders}` in the path or query only | | `header_params` | `array` | Up to 20 per location. See [Parameters](#parameters) | | `path_params` | `array` | Every declared name must appear as a `{placeholder}` in the URL, and vice versa | | `query_params` | `array` | | | `body_params` | `array` | | | `response_mapping` | `object` | `{output_key: "dotted.path"}`, up to 20 entries. Empty means the raw body is returned | | `timeout_seconds` | `integer` | 1-30, default 10 | | `phase` | `string` | `on_call` (default) tools are offered to the model during the conversation. `pre_call` tools run before the call starts and fill call variables, see [Pre-call tools](#pre-call-tools). `post_call` is accepted but not run yet | | `enabled` | `boolean` | A disabled tool is never offered to the model | | `has_secrets` | `boolean` | Whether an encrypted value is stored. The values themselves are never returned | A secret parameter always serialises as `"secret": true, "value": null`. There is no endpoint that reads a stored secret back, including for the account that wrote it. ## Parameters Each parameter says where it goes on the wire (its **location**) and where its value comes from (its **source**). **Locations:** `header`, `path`, `query`, `body`. At most 20 each. **Sources:** | `source` | Who supplies the value | Use it for | | --- | --- | --- | | `llm` | The model, per call | The search term, the order number, anything the customer states | | `static` | You, once, in `value` | Your API key, a fixed `store_id`, a version pin | | `context` | The server, per turn, from the live conversation | The identity of the person actually in the chat | A `context` parameter's `value` is a **key**, not a literal, and must be one of `contact_phone`, `contact_name`, `contact_email`, `conversation_id`. The model cannot see or set it, and that is what makes an order lookup safe. If the conversation has no such value (a voice call with no linked contact), the parameter is omitted unless you mark it `required`, in which case the call fails cleanly instead of running an unscoped query. | Field | Type | Notes | | --- | --- | --- | | `name` | `string` | 1-64 chars, starting with a letter or digit; letters, digits, `_`, `.` and `-`. Unique within its location (headers case-insensitively). Two `llm` parameters may not share a name across locations | | `type` | `string` | `string`, `integer`, `number` or `boolean`. Header and path parameters must be `string` | | `description` | `string` | Truncated at 500 chars. Shown to the model for an `llm` parameter | | `required` | `boolean` | Default `false` | | `source` | `string` | `llm`, `static` or `context`. Default `llm` | | `value` | `string` | Required for `static` (max 2048 chars, no control characters) and for `context` (a context key). Ignored otherwise | | `secret` | `boolean` | Only meaningful with `static`. **Defaults to `true` for a static header**, because that is where API keys live. Elsewhere it defaults to `false` | These headers cannot be set, because the transport computes them or they would let a definition address one origin while connecting to another: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Upgrade`, `Keep-Alive`, `TE`, `Trailer`, `Expect`, `Proxy-Authorization`, `Proxy-Connection`. ## Endpoint requirements Your URL must be reachable on the public internet. Requests to private, loopback and cloud-metadata addresses are refused at save time **and** again on every request, including after each redirect, so a hostname re-pointed later does not get through either. At most 3 redirects are followed. A response is read up to **32 KB** and truncated past it, with `truncated: true` on the result. Return the handful of fields a customer actually asks about rather than a full catalogue document. `/docs/connect-your-store` covers how to shape a response for a model. ## GET /api/v1/agent-tools Lists the workspace's tools, newest first, up to 500. Requires `bots:read`. | Parameter | Type | Notes | | --- | --- | --- | | `bot_id` | `uuid` | Narrows to that agent's effective set: its own tools **plus** the workspace-wide ones | ```bash curl "https://api.callmissed.com/api/v1/agent-tools?bot_id=b1f2c3d4-5678-90ab-cdef-1234567890ab" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns an array of tool objects. ## POST /api/v1/agent-tools Creates a tool. Returns `201` and the tool object. Requires `bots:write`. Takes every field of the tool object except the server-set ones (`id`, `tenant_id`, `has_secrets`, `created_at`, `updated_at`), plus `bot_id`, `phase` and `enabled`. ```bash curl -X POST https://api.callmissed.com/api/v1/agent-tools \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "get_order_status", "description": "Look up the caller'\''s most recent orders and their delivery status. Use when a customer asks where their order is.", "method": "GET", "url": "https://api.yourstore.com/agent/orders", "header_params": [ { "name": "X-API-Key", "source": "static", "value": "your_endpoint_key" } ], "query_params": [ { "name": "phone", "source": "context", "value": "contact_phone", "required": true } ], "timeout_seconds": 10 }' ``` | Status | Meaning | | --- | --- | | `409` | A tool with that name already exists in this workspace | | `422` | The definition is invalid. The `detail` names the problem: an undeclared path placeholder, a duplicate model-facing name, a static parameter with no value, a forbidden header, a context key outside the allowlist, a timeout outside 1-30 | | `422` | Tool limit reached. A workspace holds up to 100 tools | | `404` | `bot_id` is not an agent in this workspace | ## GET `/api/v1/agent-tools/{tool_id}` One tool object. `404` if it belongs to another workspace. Requires `bots:read`. ## PATCH `/api/v1/agent-tools/{tool_id}` Every field is optional. Returns the updated tool object. Requires `bots:write`. The **whole** definition is re-validated on each update, because the URL and the path parameters constrain each other. Omitting a parameter list keeps the stored one; sending a list **replaces** it. ```bash curl -X PATCH https://api.callmissed.com/api/v1/agent-tools/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` To keep a stored secret, send the parameter with `"secret": true` and no `value`. To rotate it, send the new value. There is no way to read the old one first. ## DELETE `/api/v1/agent-tools/{tool_id}` Returns `204`. Requires `bots:write`. ## POST /api/v1/agent-tools/test Runs a definition that has not been saved, so you can check an endpoint before committing to it. Requires `bots:write`. Takes the same body as `POST /api/v1/agent-tools`, plus `arguments`: the values a model would have supplied for the `llm` parameters. ```bash curl -X POST https://api.callmissed.com/api/v1/agent-tools/test \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "search_products", "description": "Search the catalogue by keyword.", "method": "GET", "url": "https://api.yourstore.com/agent/products/search", "query_params": [ { "name": "q", "source": "llm", "required": true } ], "arguments": { "q": "line follower kit" } }' ``` ## POST `/api/v1/agent-tools/{tool_id}/test` The same run for a **saved** tool, using its stored secrets. Requires `bots:write`. ```bash curl -X POST https://api.callmissed.com/api/v1/agent-tools/7c9e6679-7425-40de-944b-e07fc1f90ae7/test \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "arguments": { "q": "line follower kit" } }' ``` ## Test results Both test endpoints return `200` with a result object, whether or not your endpoint answered. A failure of yours is reported in the body, not as an HTTP error on ours. ```json { "ok": true, "status": 200, "result": { "count": 1, "products": [{ "id": "kit-104", "price": 1180 }] }, "truncated": false } ``` | Field | Notes | | --- | --- | | `ok` | `true` when your endpoint answered `2xx` | | `status` | Your endpoint's status code, or `null` when no response was received | | `result` | The body, or the mapped subset when `response_mapping` is set | | `truncated` | `true` when the response exceeded 32 KB | | `error` | Present in place of `result` only when no usable response came back. See the vocabulary below | | `detail` | Present only with `invalid_arguments`, naming the offending argument | A `4xx` or `5xx` **from your endpoint** is not an `error`. It comes back as `ok: false` with `status` set and `result` holding what your endpoint said, so you can read the reason. Branching on `error` alone will miss it: check `ok`, then look at `status` and `result`. `error` comes from a fixed vocabulary so that nothing about the failure leaks back through the model. `status` is `null` with it unless the failure happened part-way through a redirect chain, in which case it carries the status of the last hop: | `error` | Meaning | | --- | --- | | `invalid_arguments` | An argument was missing, the wrong type, or too long | | `url_not_allowed` | The URL resolved somewhere a tool may not reach | | `timeout` | Your endpoint did not answer within `timeout_seconds` | | `request_failed` | The request could not be completed | | `too_many_redirects` | More than 3 redirects | A test runs outside any conversation, so there is no real customer for a `context` parameter to bind to. Obvious placeholders are sent instead: `+10000000000`, `Test Contact`, `test@example.com` and a zero UUID. Expect a correctly-built order lookup to answer "not found". Check the real behaviour from a live chat. ## At reply time A tool is offered to the model when all of these hold: `enabled` is `true`, `phase` is `on_call`, and the tool is either scoped to the agent handling the conversation or workspace-wide (`bot_id: null`). Connecting the tool is half the job. The agent's instructions decide whether it uses it well, and [Connect Your Store](/docs/connect-your-store) has a prompt you can adapt. ## Pre-call tools Set `"phase": "pre_call"` to look a caller up before the agent speaks. The tool runs once per call, before the greeting on an inbound call and before the call is placed on an outbound one, and its mapped result becomes `{{variable}}` values the greeting and prompt can use. A pre-call tool is never offered to the model. - **Request.** Use `static` or `context` parameters. The `contact_phone` context key is the caller on an inbound call and the person dialled on an outbound one. A required model-facing parameter is rejected with `422`, because no model fills the arguments. - **Response mapping.** Each `response_mapping` key becomes a variable of the same name. Keys must be valid variable names (letters, digits and underscores, not starting with a digit). Only plain values are used: objects, arrays and nulls are skipped. At most 50 variables are kept, each trimmed to 200 characters. - **Timeout.** The tool's `timeout_seconds` is used when it is between 1 and 5. Any other value, including the default of 10, falls back to 3 seconds. The whole pre-call step is capped at 4 seconds however many tools you attach. - **Precedence.** Variables passed explicitly on the call (for example `variables` when you place a call through the API) win over pre-call values, which win over the agent's declared defaults and the built-in values. - **Failure.** If a tool times out, errors or returns something unusable, the call continues without its variables. A pre-call tool never blocks or fails a call. - **Audit.** The session records which pre-call tools ran and whether each succeeded, with no response values. Use `POST /api/v1/agent-tools/{tool_id}/test` to check the endpoint answers; it uses placeholder context values, as described in [Test results](#test-results). --- ### Authentication URL: /docs/authentication > Every API request authenticates with a CallMissed API key. ## API Key Every request authenticates with an API key. Create one in the console under **Developer → API keys** — keys are prefixed with `cm_` and are passed as a Bearer token: ``` Authorization: Bearer cm_your_api_key_here ``` A key has no expiry unless you set one when you create it (1-365 days). An expired key gets `401` (`api_key_expired` on the inference endpoints), and an `api_key.expired` [webhook](/docs/webhooks) fires. Any key can be revoked at any time. ### Anthropic SDK (x-api-key header) When using the [Anthropic-compatible endpoint](/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. ### Managed Voice Agent WebSocket (Token header or subprotocol) The [Managed Voice Agent](/docs/managed-voice-agent) sockets — `/v1/agent/converse` and `/v2/voice/agent` — take the key as a `Token`, not a `Bearer`: ``` Authorization: Token cm_your_api_key_here ``` Browsers cannot set headers on a `WebSocket`, so the key may instead travel as a subprotocol. This is the only option for browser clients: ```js new WebSocket(url, ["token", "cm_your_api_key_here"]) ``` The key needs **all three** of `stt`, `tts` and `llm` permissions — the socket runs the full speech pipeline, so a key missing any one of them is refused at the handshake rather than part-way through a call. ## 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`, `email`, or `*` for all (the default for new keys). | Permission | Gates | |------------|-------| | `llm` | `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (+ `/anthropic/v1/messages`), `/v1/embeddings`, `/v1/batches` and `/v1/files` | | `stt` | `/v1/audio/transcriptions`, `/v1/audio/translations` | | `tts` | `/v1/audio/speech` | | `search` | `/v1/search` | | `image` | `/v1/images/generations`, `/v1/images/history` | | `email` | The [Email API](/docs/email) | Voice agents run the whole speech pipeline, so the [Voice Session API](/docs/voice-sessions-api) and the [Managed Voice Agent](/docs/managed-voice-agent) sockets need **all three** of `stt`, `tts` and `llm`. ### Resource scopes Scopes gate the resource endpoints under `/api/v1/` — agents, conversations, CRM, support, knowledge, webhooks and the rest. Unlike permissions, scopes default to **empty = no resource access** — you opt in explicitly. | Scope | Gates | |-------|-------| | `bots:read` / `bots:write` | [Agents](/docs/bots): view vs. create/update/delete | | `conversations:read` / `conversations:write` | [Conversations](/docs/conversations) and the [handoff queue](/docs/handoffs): view vs. update | | `knowledge:read` / `knowledge:write` | [Knowledge](/docs/knowledge): search and view vs. add/remove sources | | `webhooks:write` | [Webhook subscriptions and the delivery log](/docs/webhooks), reads included. There is no `webhooks:read` | | `whatsapp:read` / `whatsapp:write` / `whatsapp:send` | WhatsApp: read accounts, numbers and templates vs. manage them vs. send messages | | `campaigns:read` / `campaigns:write` | [Outbound calling campaigns](/docs/voice-campaigns) and the do-not-call list | | `telephony:read` / `telephony:write` | Phone numbers, compliance and calls: view vs. provision/update | | `integrations:read` / `integrations:write` | Connected integrations and sheet automations: view vs. connect/update/remove | | `*` | All resource scopes | #### Gateway scopes | Scope | Gates | |-------|-------| | `usage:read` | [Usage summaries, logs and CSV export](/docs/usage-api) | | `prompts:read` / `prompts:write` | [Stored prompts, versions, labels and presets](/docs/gateway-prompts). Rendering counts as a read | | `cache:read` / `cache:write` | [Cache statistics vs. purging](/docs/gateway-cache) | | `provider_keys:read` / `provider_keys:write` | [Your own provider credentials](/docs/provider-keys) | | `end_user_budgets:read` / `end_user_budgets:write` | [Per-end-user monthly budgets](/docs/gateway-controls) | #### CRM scopes | Scope | Gates | |-------|-------| | `contacts:read` / `contacts:write` | [Contacts](/docs/crm-contacts) | | `companies:read` / `companies:write` | [Companies](/docs/crm-companies) | | `crm_notes:read` / `crm_notes:write` | [Notes](/docs/crm-notes-tasks) | | `crm_tasks:read` / `crm_tasks:write` | [Tasks](/docs/crm-notes-tasks) | | `crm_deals:read` / `crm_deals:write` | [Deals **and** pipelines](/docs/crm-deals) — one pair covers both | | `crm_timeline:read` | [The activity timeline](/docs/crm-lead-scores#timeline). Read-only, no write half | | `crm_custom_fields:read` / `crm_custom_fields:write` | [Custom field definitions and values](/docs/crm-custom-fields) | | `crm_views:read` / `crm_views:write` | [Saved views](/docs/crm-custom-fields#saved-views) | | `crm_search:read` / `crm_search:write` | [Search and duplicates](/docs/crm-import-export) vs. merging | | `crm_bulk:write` | [Bulk update and delete](/docs/crm-import-export#bulk-operations). Write-only, no read half | | `crm_csv:read` / `crm_csv:write` | [CSV export vs. import](/docs/crm-import-export#csv) | | `crm_scores:read` / `crm_scores:write` | [Lead scoring rules and recompute](/docs/crm-lead-scores) | | `crm_leads:read` / `crm_leads:write` | [Leads and lead conversion](/docs/crm-leads) | | `crm_products:read` / `crm_products:write` | [Products **and** tax rates](/docs/crm-products) — one pair covers both | | `crm_quotes:read` / `crm_quotes:write` | [Quotes](/docs/crm-quotes) | | `crm_invoices:read` / `crm_invoices:write` | [Invoices and payments](/docs/crm-invoices) | | `crm_billing:read` / `crm_billing:write` | [Billing profile](/docs/crm-products#billing-profile) | #### Support desk scopes | Scope | Gates | |-------|-------| | `support_tickets:read` / `support_tickets:write` | [Tickets](/docs/support-tickets) | | `sla:read` / `sla:write` | [SLA policies, ticket clocks and breaches](/docs/support-sla) | | `support_ops:read` / `support_ops:write` | [Macros, tags and routing rules](/docs/support-ops) | | `csat:read` / `csat:write` | [Surveys and results](/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](/docs/whatsapp-orders) | | `wa_flows:read` / `wa_flows:write` | [WhatsApp Flows](/docs/whatsapp-flows) | | `evals:read` / `evals:write` | [Eval suites, cases and runs](/docs/voice-evals) | | `experiments:read` / `experiments:write` | [A/B experiments](/docs/voice-experiments). Assignment needs write | | `squads:read` / `squads:write` | [Agent squads](/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. --- ### Bots URL: /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](/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, get prompt, tool catalog, skill catalog, config schema, validate config, probe MCP servers, list/get versions | `bots:read` | | Create, update, put prompt, patch config, delete, toggle, deploy verify, create/edit/commit/roll back versions | `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. `PATCH /{bot_id}/config` goes further and **refuses** a credential-looking key outright with `422 Credentials cannot be stored in bot config`. `POST` and `PUT` still accept one, so prefer `PATCH` when a machine is writing the config. ## What is validated at write time Most of `config` is stored as-is, which is why [Build an agent programmatically](/docs/build-an-agent) exists: call `GET /bots/config-schema` to learn the keys, and `POST /bots/validate-config` to catch the rest before you write. These sub-keys are the exception — they are schema-checked on every write path (`POST`, `PUT`, `PATCH /config`) and return `422` when malformed: | Sub-key | Checked | | --- | --- | | `analysis_variables` | Post-call extraction schema | | `input_variables` | Per-call `{{token}}` declarations | | `stt_keyterms` | List of at most 100 unique strings, each 1-100 chars. Applied by the Deepgram Nova-3 and Flux models, the AssemblyAI models, `microsoft-speech-stt` and `gnani-prisma-v3.0`; other speech models ignore it | | `pronunciations` | List of at most 100 `{ "word", "say_as" }` objects, each 1-64 chars, unique `word`. The voice says `say_as` instead of `word` (whole-word, case-insensitive); transcripts keep the original text | | `web_widget_enabled` | Boolean | | `web_widget` | Object; `title` ≤ 100 chars, `greeting` ≤ 500 chars, `color` a hex colour | | `fallback_message` | String ≤ 4000 chars | | `mcp_servers` | List of at most 10 objects, each with a string `id` and an `https://` `url` | | `commands` | List of at most 30 `{name, instruction}`; names are lower-cased, must be unique, 1-32 chars of letters, digits, spaces, `-` or `_`; instructions 1-2000 chars | | `disabled_tools` | List of at most 50 tool names, each 1-100 chars, de-duplicated | | `button_actions` | List of at most 20 rules for WhatsApp button taps ([WhatsApp button actions](#whatsapp-button-actions)); `button` 1-25 chars and unique ignoring case, `action` one of `ai_reply`, `send_text`, `send_document`, `send_template`, plus that action's fields | | `callback_calls` | Object ([WhatsApp callback calls](#whatsapp-callback-calls)); `enabled` and `allow_other_number` booleans, `voice_bot_id` (required when enabled) and `from_number_id` ids of your own agent and active phone number, `hours_start` 0-23, `hours_end` 1-24 and later than `hours_start`, `max_per_day` 1-10. Stored with every field filled in | | `wa_details_template`, `wa_reengage_template` | Name of one of your approved WhatsApp templates ([Templates outside the 24-hour window](#whatsapp-templates-outside-the-24-hour-window)), with an optional `wa_details_template_language` / `wa_reengage_template_language`. Checked when you change it; `null` or `""` clears it and its language | | `followup_agent` | Object; `enabled` and `send_recap` booleans, `instructions` a string ≤ 4000 chars, `max_attempts` an integer 1-5 | | `call_personal_whatsapp` | Object ([Call personal WhatsApp](#call-personal-whatsapp)); `enabled`, a boolean, and `session_id`, a linked number's id or `null`. Stored as `{ "enabled": …, "session_id": … }`. Turning it on or changing `session_id` is owner-only (`403` otherwise) | | `transcript_retention_days`, `recording_retention_days` | Whole number of days, 1-3650. Applies to calls made after you save this; older calls follow your account's retention setting. The server records when each was saved as `transcript_retention_set_at` / `recording_retention_set_at` ([Voice data retention](/docs/voice-data-retention)) | | `trust_caller_id` | Boolean. When `true`, an inbound phone caller is identified by their caller ID ([Who the caller is](/docs/voice-agent-tools#who-the-caller-is)) | | `data_retention_mode` | `"standard"` or `"zdr"` ([Voice data retention](/docs/voice-data-retention)) | | `tts_fallbacks` | List of at most 3 `{ "tts_model", "voice" }` objects (`voice` optional). Each `tts_model` must be a voice model that speaks the agent's `language`, each `voice` one of that model's voices; no repeats ([Backup voices](/docs/voice-agent#backup-voices-and-spoken-numbers)) | Two normalisations happen silently on every write: `commands` and `disabled_tools` come back normalised (lower-cased, de-duplicated) rather than as you sent them, and the retired `voice_fallbacks` key is dropped. Everything else in `config` is stored verbatim. ### WhatsApp button actions `config.button_actions` decides what happens when a customer taps a quick-reply button (for example on a template you sent them) on a WhatsApp Business number linked to this agent. The action runs as configured, without the AI choosing it. ```json { "button_actions": [ {"button": "Send itinerary", "action": "send_document", "document": "itinerary", "text": "Here is the full itinerary"}, {"button": "Price and inclusions", "action": "send_text", "text": "The 5-day trip is Rs 24,999 per person, stay and breakfast included."}, {"button": "Talk to the team", "action": "send_template", "template": "callback_confirm", "language": "en", "then_ai_reply": true}, {"button": "Something else", "action": "ai_reply"} ] } ``` | Field | Required | Notes | | --- | --- | --- | | `button` | Yes | The button's text, 1-25 chars (WhatsApp's quick-reply limit). Matched against the tapped button's text or payload, ignoring case and extra spaces | | `action` | Yes | `ai_reply`, `send_text`, `send_document` or `send_template` | | `text` | `send_text`: yes (≤ 4096 chars). `send_document`: optional caption (≤ 1024 chars) | | | `document` | `send_document` | The name of one of the agent's [documents](#agent-documents) | | `template` | `send_template` | An approved template on the same WhatsApp account as the number. Only templates with no variables and no media header can be sent this way | | `language` | No (`send_template`) | The template's language code. Needed only when the template is approved in more than one language | | `then_ai_reply` | No | `true` lets the agent also reply after the action. Default `false`. Not allowed on `ai_reply` | A tap with no matching rule, or an `ai_reply` rule, is answered by the agent like any other message. If an action cannot be sent (the document or template is missing, a template needs values, or WhatsApp's 24-hour window has closed), the agent answers instead. ### WhatsApp callback calls `config.callback_calls` lets a WhatsApp agent arrange a phone call when a customer asks for one ("can someone call me at 5pm?"). While `enabled` is `true` the agent gets the `request_callback` tool. It queues a call to the customer's WhatsApp number, right away or at the time they ask for, placed by the voice agent you name from one of your phone numbers. That voice agent opens by saying why it is calling and is briefed with the customer's reason and the last messages of the chat. ```json { "callback_calls": { "enabled": true, "voice_bot_id": "c2d3e4f5-6789-4abc-8def-0123456789ab", "from_number_id": null, "hours_start": 9, "hours_end": 21, "max_per_day": 3, "allow_other_number": false } } ``` | Field | Default | Notes | | --- | --- | --- | | `enabled` | `false` | Offers `request_callback` to the agent on WhatsApp chats. It is not listed in `GET /bots/tool-catalog` and is never offered on a call | | `voice_bot_id` | — | The agent that places the call. Required when `enabled` | | `from_number_id` | `null` | One of your active phone numbers. `null` uses the number whose outbound agent is `voice_bot_id`, else your oldest active number | | `hours_start`, `hours_end` | `9`, `21` | Calls are placed only from `hours_start`:00 up to `hours_end`:00 in your workspace timezone. Asked for outside them, the agent asks the customer for another time | | `max_per_day` | `3` | Calls back per customer in any 24 hours | | `allow_other_number` | `false` | When `true`, the customer may ask to be called on another number in the same country as their WhatsApp number | What the agent checks before it promises a call: the number is not on your [do-not-call list](/docs/voice-campaigns) for calls, you have credit, no call to that number is already on the line, the time is within 7 days and inside the hours above, and the daily limit is not reached. A customer has at most one call waiting per agent: asking again with a new time moves it. The call is placed within about a minute of its time, with the same do-not-call, credit and destination checks as any other call. If it cannot start because all your lines are in use or the phone network errors, it is tried up to twice more; a customer who does not answer is not called again. See and cancel waiting calls with [the callback endpoints](#callback-calls). ### WhatsApp templates outside the 24-hour window WhatsApp delivers a plain message only within 24 hours of the customer last messaging or calling your WhatsApp number; outside that window only an approved template is delivered. A phone call never opens the window, so when a caller asks your agent to "send me the details on WhatsApp", the text can only go out inside a template. ```json { "wa_details_template": "requested_details", "wa_details_template_language": "en", "wa_reengage_template": "reply_to_continue" } ``` | Key | Notes | | --- | --- | | `wa_details_template` | An approved template with exactly one variable, in its body, and no media header or button variable. The text the agent sends fills that variable; with the template's own words it must fit WhatsApp's 1024-character body limit, and a longer text is refused so the agent can shorten it | | `wa_reengage_template` | Used when no details template is set: an approved template with no variable, or one body variable (filled with the customer's name). The details are not in it; the agent tells the customer they follow once they reply | | `wa_details_template_language`, `wa_reengage_template_language` | The template's language code. Needed only when the template is approved in more than one language | A write that sets or changes one returns `422` when the name is not one of your approved templates (in that language, when given), or when the template has a different shape. With neither set, the agent tells the customer it could not send the message on WhatsApp. ### Call personal WhatsApp `call_personal_whatsapp: { "enabled": true }` lets a calling agent message the caller on WhatsApp from your linked personal number during the call, with no template: `whatsapp_personal_message_caller` (a message, optionally with one file) and `whatsapp_personal_remind_caller` (one scheduled message for later). Both go only to the caller. Off by default, and nothing happens until a personal number is linked in the console. See [Messaging the caller from your personal WhatsApp](/docs/voice-agent-tools#messaging-the-caller-from-your-personal-whatsapp). A write with any other field, a non-boolean `enabled` or a `session_id` that is not an id returns `422`. Turning it on, or changing `session_id`, needs the workspace owner's own console session: an API key, another team member or an agency stand-in gets `403`, because the messages go from the owner's own phone. Turning it off is open to anyone who can edit the agent. With several personal numbers linked, `session_id` picks which one sends (`null` = the default); a named number that is not connected sends nothing. Linked personal numbers are listed only in the console. It is not copied when an agency provisions an agent into a client workspace. --- ## 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, "config_warnings": [] } ] ``` `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": "shopify_order_status", "label": "Look up a Shopify order", "summary": "Checks an order's payment, fulfilment and tracking by order number or email.", "description": "Look up a Shopify order's payment + fulfillment status and shipping tracking, by order number (e.g. 1001) or the customer's email.", "category": "general", "parameters": { "type": "object", "properties": { "order_number": { "type": "string", "description": "Order number, e.g. '1001' (with or without #)." }, "email": { "type": "string", "description": "Customer email to find their recent orders." } } }, "unavailable_on": [] } ] ``` `label` and `summary` are a short name and a one-line plain-English description for showing the tool to people; `description` is the text the model reads when deciding to call it. `parameters` is a JSON Schema object, empty (`{"type": "object", "properties": {}}`) for tools that take no arguments. `unavailable_on` lists the channels a tool cannot run on, and is `[]` for most tools: | Value | Meaning | | --- | --- | | `voice` | The call runtime never offers this tool on a phone or WhatsApp call. Enabling it on a calling agent does nothing; `POST /api/v1/bots/validate-config` returns an `incompatible` warning for it | | `whatsapp_personal` | Not offered on a linked personal WhatsApp account | Copy the `name` verbatim. A name that is not in this catalog is **dropped silently** when the agent starts — no error at write time, no error at call time, the tool simply is not there. ## GET /api/v1/bots/skill-catalog Skills a bot can enable. Write the chosen `name` values into `config.skills`. No parameters. Requires `bots:read`. A skill is a bundle, not a flag: switching one on adds the agent tools it names **and** injects that skill's own instructions into the agent's prompt when the model loads it mid-conversation. So it changes how the agent behaves, not only what it can do — which is why the catalog publishes both halves. **Skills apply to chat channels only.** A calling agent never reads `config.skills`: the key is resolved on the chat path, and nothing on a call looks at it. Put the tools a call needs directly in `config.tools`. ```bash curl https://api.callmissed.com/api/v1/bots/skill-catalog \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "name": "order_support", "description": "Look up Shopify orders, products, and customers, and start a draft order/checkout.", "tool_names": [ "shopify_order_status", "shopify_product_lookup", "shopify_customer_lookup", "shopify_create_draft_order" ], "channels": ["text"], "unavailable_on": ["voice"], "instructions_preview": "Order-support rules: - Use shopify_order_status to check an order...", "instructions_truncated": true, "note": "Chat channels only. A calling agent never reads config['skills'], so enabling this skill on one does nothing — put the tools it bundles in config['tools'] instead." } ] ``` | Field | Meaning | | --- | --- | | `tool_names` | The agent tools this skill adds when it loads. Each one is also in `GET /api/v1/bots/tool-catalog` | | `channels` | Where the skill is resolved. Always `["text"]` | | `unavailable_on` | Same vocabulary as the tool catalog. Always `["voice"]` | | `instructions_preview` | The start of the instruction text the skill injects, capped at 400 characters | | `instructions_truncated` | `true` when the real instructions are longer than the preview | An unknown skill name in `config.skills` is **dropped silently** at conversation time, exactly like an unknown tool name. Setting `skills` on a calling agent returns an `incompatible` warning from `POST /api/v1/bots/validate-config`. ## 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 20000 chars, default `""`. The voice runtime adds its own call-handling instructions on top | | `config` | `object \| null` | No | Free-form JSON. Only the sub-keys listed above are validated; everything else is stored as-is and reported in `config_warnings` | ```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": ["shopify_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": ["shopify_order_status"] }, "is_active": true, "created_at": "2026-08-04T11:00:00Z", "updated_at": "2026-08-04T11:00:00Z", "conversation_count": 0, "config_warnings": [] } ``` `403` without `bots:write`; `422` on an unknown `type`, a name outside 1-255 chars, a prompt over 20000 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 20000 chars. Written as **one flat string** — see the warning below | | `config` | `object \| null` | No | **Replaces** the whole object, not a deep merge | `system_prompt` here is the whole prompt as one block. A stored prompt is normally *section-structured* — the dashboard edits it as an objective, response guidelines and a conversation script — and writing a flat string through this route collapses those three boxes into one, which the owner then has to split apart by hand. Nothing reports it. Use `PUT /api/v1/bots/{bot_id}/prompt`, documented below, instead — unless you genuinely mean to replace the prompt with unstructured text. ```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. ## GET `/api/v1/bots/{bot_id}/prompt` The agent's instructions split into the four boxes its owner edits in the dashboard. Requires `bots:read`. `system_prompt` is stored as one string, but it is not written as one. The first three boxes are sections of that string, delimited by marker lines; the fourth, the first message, is `config.greeting`. This route is the published reader of that layout, so a program does not have to know the markers to edit one section. ```bash curl https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/prompt \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "objective": "You are the voice of Acme. Understand what the caller needs before offering anything.", "response_guidelines": "- Friendly and consultative. Short sentences.\n- One question at a time.\n- Never guess a price or a date.", "conversation_script": "## 1. Open\n\nGreet the caller and ask how you can help.", "first_message": "Hi, thanks for calling Acme. How can I help?", "freeform": null } ``` Exactly one side of the response is populated: | Field | Meaning | | --- | --- | | `objective` | Who the agent is and what it is trying to achieve. `null` when the prompt has no sections | | `response_guidelines` | How it should speak. `null` when the prompt has no sections | | `conversation_script` | The steps it works through, in order. `null` when the prompt has no sections | | `first_message` | The line it speaks when the call connects — `config.greeting`. `null` when there is none | | `freeform` | The **whole** prompt, when it carries no section markers. `null` for a section-structured prompt | A prompt written before the section editor existed — or one written flat through `PUT /api/v1/bots/{bot_id}` — comes back in `freeform` with the three blocks `null`. That is the honest reading, not an empty agent: the reader never invents sections that are not there. Reading is deliberately strict, so a prompt with text above the first marker, or with the same marker twice, is also reported as `freeform` rather than guessed at. `404` `Bot not found` when the id is not in your tenant; `403` without `bots:read`. ## PUT `/api/v1/bots/{bot_id}/prompt` Writes the prompt back in the same four boxes, with the markers the dashboard parses. Requires `bots:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `objective` | `string \| null` | No | Max 8000 chars. The three blocks together must fit in 20000 chars, or the call returns `422` | | `response_guidelines` | `string \| null` | No | Max 8000 chars | | `conversation_script` | `string \| null` | No | Max 8000 chars | | `first_message` | `string \| null` | No | Max 500 chars. Stored as `config.greeting` | | `freeform` | `string \| null` | No | Max 20000 chars — the same cap as `system_prompt`. The whole prompt as one block. **Cannot be combined** with the three fields above | ```bash curl -X PUT https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/prompt \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "objective": "You are the voice of Acme. Understand what the caller needs before offering anything.", "response_guidelines": "- Friendly and consultative. Short sentences.\n- One question at a time.", "conversation_script": "## 1. Open\n\nGreet the caller and ask how you can help.", "first_message": "Hi, thanks for calling Acme. How can I help?" }' ``` Returns the whole bot, the same shape `PUT /api/v1/bots/{bot_id}` returns, including `config_warnings`. Three rules decide what a request changes: - **Omitted means unchanged.** Sending only `first_message` edits the greeting and leaves the prompt alone. Sending only prompt fields leaves the greeting alone. - **Among the three blocks, omitted means empty.** They are written together, so a block you leave out is cleared. `GET` first, change the one block you mean, and send all three back. - **`freeform` and the blocks are mutually exclusive.** They are two spellings of the same field; sending both is `422`. Use `freeform` only for a prompt `GET` already returned as `freeform` — round-tripping one that way stores it byte-identically, with no markers injected. `first_message` goes through the same merge as `PATCH /api/v1/bots/{bot_id}/config`, so the config validator still runs and the findings come back in `config_warnings`. An empty or whitespace-only `first_message` **removes** `greeting` rather than storing a blank one, which is the difference between "no greeting" and "a greeting that is nothing". As with `PUT /api/v1/bots/{bot_id}`, rewriting the prompt on a non-WhatsApp-voice agent drops a leftover `config.voice_system_prompt`, so a retired spoken persona cannot keep answering calls. | Status | Cause | | --- | --- | | `403` | Key missing `bots:write` | | `404` | `Bot not found` | | `422` | `freeform` sent together with a section block, a field over its length limit, or a `first_message` the config validator rejects | ## PATCH `/api/v1/bots/{bot_id}/config` Changes individual `config` keys without resending the whole object — the safe write for an agent that only knows about the keys it cares about. `PUT` replaces `config` wholesale and will silently drop every key you left out; this does not. Requires `bots:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `values` | `object` | No | Keys to set or overwrite. At most 50 keys, each 1-100 characters. Defaults to `{}` | | `unset` | `array of string` | No | Keys to remove. At most 50, each 1-100 characters. Defaults to `[]` | The merge is: apply `values` over the stored config, drop every key named in `unset`, then validate the result. A key may not appear in both lists. ```bash curl -X PATCH https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/config \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "values": { "greeting": "Namaste! Acme support, how can I help?", "max_call_duration_seconds": 600 }, "unset": ["fallback_message"] }' ``` Returns the whole bot with the merged `config`: ```json { "id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "name": "Support Bot", "type": "inbound_call", "system_prompt": "You are a friendly support agent for Acme.", "config": { "voice_model": "gpt-oss-120b", "tts_model": "deepgram-aura-2", "stt_model": "deepgram-flux-general-en", "voice": "Thalia", "language": "en", "greeting": "Namaste! Acme support, how can I help?", "max_call_duration_seconds": 600 }, "is_active": true, "created_at": "2026-08-04T11:00:00Z", "updated_at": "2026-08-04T11:31:12Z", "conversation_count": 0, "config_warnings": [] } ``` As with `PUT`, `conversation_count` is `0` in this response; read the bot to get the real count. | Status | Cause | | --- | --- | | `403` | Key missing `bots:write` | | `404` | `Bot not found` | | `422` | `A config key cannot be set and removed together`, `Config keys must be unique`, `Credentials cannot be stored in bot config`, or a malformed value for one of the validated sub-keys above | Removing a key is not the same as setting it to `null`: `unset` deletes it, and the runtime then falls back to that key's documented default. Setting it to `null` stores a null and is read back as one. ## GET /api/v1/bots/config-schema The machine-readable description of `config`: every key the runtime reads, its type, its default, what happens on a real call when you leave it out, and — where the allowlist is closed — the values it accepts. Fetch this **before** composing a config rather than guessing key names. Requires `bots:read`. | Param | Type | Required | Notes | | --- | --- | --- | --- | | `bot_type` | `string` | No | One of `whatsapp`, `inbound_call`, `outbound_call`, `ivr`, `whatsapp_voice`. Narrows the result to the keys that apply to that channel, and builds `minimal_example` for it. Omit it to get every key. Any other value is `422` | ```bash curl "https://api.callmissed.com/api/v1/bots/config-schema?bot_type=inbound_call" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "bot_type": "inbound_call", "keys": [ { "key": "tts_model", "type": "string", "channels": ["voice"], "summary": "The model that speaks the agent's replies.", "default": "bulbul:v3", "if_omitted": "Falls back to the Indic-tuned default voice, which sounds wrong on an English line.", "allowed_values": ["aura-2-en", "aura-2-es", "bulbul:v3", "bulbul:v4-flash", "deepgram-aura-1", "deepgram-aura-2", "deepgram-flux-tts", "fish-audio-drama-3", "gnani-timbre-v3.0", "gpt-4o-mini-tts", "gradium-tts-beta", "inworld-tts-2", "inworld-tts-2-flash", "mai-voice-2.1", "mai-voice-2.1-flash", "melotts", "microsoft-neural-tts", "rime-coda", "sonic-3.6"], "values_depend_on": null, "bounds": null, "validated_on_write": false }, { "key": "voice", "type": "string", "channels": ["voice"], "summary": "Which speaker the chosen tts_model uses.", "default": null, "if_omitted": "The chosen tts_model's own default speaker is used.", "allowed_values": null, "values_depend_on": "tts_model", "bounds": null, "validated_on_write": false }, { "key": "max_call_duration_seconds", "type": "integer", "channels": ["voice"], "summary": "Hard cap on one call, in seconds.", "default": 600, "if_omitted": "The call runs to the platform ceiling.", "allowed_values": null, "values_depend_on": null, "bounds": [30.0, 14400.0], "validated_on_write": false } ], "minimal_example": { "name": "Support line", "type": "inbound_call", "system_prompt": "## Objective\n\nYou are the voice of [your business]…", "config": { "voice_model": "gemma-4-31b", "tts_model": "deepgram-aura-2", "stt_model": "deepgram-flux-general-en", "voice": "Thalia", "language": "en", "max_call_duration_seconds": 600, "greeting": "Hi {{callee_name}}, thanks for calling. How can I help you today?" } }, "notes": [ "Every key is optional. minimal_example is the smallest body that creates an agent that answers a call properly.", "Nothing here is enforced on write: a config with errors is still saved, and the findings come back in config_warnings.", "voice and language are only valid in combination with tts_model — send the stack together and validate it before you save it.", "GET /api/v1/models lists every model id; the voice list for a speech model is on its own model entry.", "system_prompt is not one block: the dashboard edits it as an objective, response guidelines and a conversation script… use GET/PUT /api/v1/bots/{bot_id}/prompt instead of writing a flat prompt." ], "voice_tiers": [ { "id": "t1", "label": "Standard", "price_credits_per_minute": 4.0, "billing": "flat", "minimum_seconds": 30.0, "…": "…" } ], "stt_languages": { "deepgram-flux-general-en": ["en"], "…": ["…"] }, "multilingual": { "stt": ["deepgram-flux-general-multi", "gnani-prisma-v3.0", "saaras:v4", "…"], "tts": ["bulbul:v3", "sonic-3.6", "…"] } } ``` `keys` is the full list for the requested channel — 41 keys for a call agent, 9 for a `whatsapp` agent, 46 with no `bot_type` — trimmed above to three. `notes` carries the five standing caveats, preceded, for any key whose allowlist runs past 60 values, by a line saying `allowed_values` was truncated and that `POST /validate-config` still checks against the whole list. For an agent that speaks (every type except `whatsapp`, and the unfiltered schema), the response carries three more top-level fields: | Field | Meaning | | --- | --- | | `voice_tiers` | The priced [voice tiers](/docs/voice-tiers) an agent can run on via `config.voice_tier` instead of its own STT/LLM/TTS stack — `id`, `label`, `blurb`, `price_credits_per_minute`, `speech_inputs`, `voices`, `voices_searchable`, `languages`, `multilingual`, `billing` (`flat`) and `minimum_seconds` | | `stt_languages` | For each speech-recognition model, the language codes it can transcribe, in that model's own code format (`hi-IN` or `hi`). Fold to the base subtag before comparing with a TTS language list | | `multilingual` | `{stt: [...], tts: [...]}` — the speech-recognition and speech models that can follow the caller's language when `language` is `"multi"` | Per-key fields: | Field | Meaning | | --- | --- | | `key` | The `config` key name, spelled exactly as the runtime reads it | | `type` | `string`, `integer`, `number`, `boolean`, `array` or `object` | | `channels` | Which channels read this key: `voice`, `text`, or both | | `summary` | One line on what the key does | | `default` | The effective value when the key is absent, or `null` when there is no single default | | `if_omitted` | What actually happens on a live call when you leave the key out — read this before deciding a key is optional | | `allowed_values` | The closed allowlist, or `null` when the value is free-form | | `values_depend_on` | The other key that decides this key's allowlist — `voice` depends on `tts_model`, for example. When this is set, `allowed_values` alone is not enough; validate the pair | | `bounds` | `[min, max]` for a numeric key, otherwise `null` | | `validated_on_write` | `true` when a bad value is rejected with `422` at write time. **When this is `false`, a bad value is accepted and fails at call time** — that is exactly what `POST /validate-config` is for | `minimal_example` is a complete, working body for `POST /api/v1/bots`. Post it as-is and you get an agent that answers a call and speaks. `403` when a `cm_` key lacks `bots:read`. ## POST /api/v1/bots/validate-config Checks a `config` object and tells you what is wrong with it. It writes nothing, touches no bot, and never returns an error for a bad config — the findings are the answer. Requires `bots:read`. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `config` | `object` | Yes | The config you are about to write. At most 200 keys | | `bot_type` | `string \| null` | No | Narrows the check to one channel's keys. Omit it to check against every key. Any value outside the five bot types is `422` | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/validate-config \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_type": "inbound_call", "config": { "tts_model": "deepgram-aura-2", "voice": "shubh", "max_duration_seconds": 600, "greeting": "Hi, thanks for calling." } }' ``` ```json { "ok": false, "findings": [ { "key": "max_duration_seconds", "severity": "warning", "code": "unknown_key", "message": "max_duration_seconds is not read from an agent's config — it is the name the call runtime receives. Set max_call_duration_seconds instead, or this setting does nothing.", "did_you_mean": ["max_call_duration_seconds"], "allowed": null }, { "key": "voice", "severity": "error", "code": "incompatible", "message": "'shubh' is not a speaker on deepgram-aura-2 — it belongs to bulbul:v3; set tts_model to it. On a standard voice call the session refuses to start; where a softer path applies, the caller hears a different speaker than you chose.", "did_you_mean": ["janus"], "allowed": ["agathe", "agustina", "alvaro", "ama", "amalthea", "andromeda"] } ] } ``` `ok` is `true` only when no finding has severity `error`. An empty `findings` array means the config is clean. A clean config returns: ```json { "ok": true, "findings": [] } ``` | Severity | Meaning | | --- | --- | | `error` | The call will not work, or will be audibly wrong — a voice the chosen engine cannot speak, a model id that does not exist, a value outside its bounds | | `warning` | Accepted, but probably not what you meant — an unknown key, a typo'd tool name, a missing `greeting` | | Code | Raised when | | --- | --- | | `unknown_key` | The key is not one the runtime reads. It is stored and ignored | | `unknown_value` | The value is not in that key's allowlist | | `wrong_type` | The value is the wrong JSON type for the key | | `out_of_range` | A numeric value is outside the key's `bounds`. It is clamped at call time | | `incompatible` | The value is invalid **given another key** — most often a `voice` that the chosen `tts_model` cannot speak | | `missing_recommended` | A key you almost certainly want is absent — no `greeting`, for instance, which leaves the caller with silence | `did_you_mean` is a list — the closest valid spellings, at most three, and `[]` when nothing is close enough. `allowed` is that key's allowlist when the list is closed, capped at 40 values, and `null` otherwise. `POST /bots`, `PUT /bots/{id}` and `PATCH /bots/{id}/config` do **not** reject a config that fails this check. They report it: their response carries a `config_warnings` array holding the same findings. Read it after every write — an empty array is the only confirmation that what you wrote will actually run. Read paths (`GET /bots`, `GET /bots/{id}`) always return `config_warnings: []`; ask `POST /validate-config` to check a stored config. `403` when a `cm_` key lacks `bots:read`. ## 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. While `is_active` is `false` the agent takes no phone calls: a caller to a number it answers hears a short "Sorry, this number isn't taking calls right now. Please try again later." and the call ends (unless a call menu, call flow, call queue or agent squad on that number answers it), call queues route past it, campaigns that use it pause, and placing a call with its `bot_id` returns `409`. It also stops replying on WhatsApp, Instagram and Facebook (messages still reach the inbox), declines WhatsApp calls to its numbers at once (Call Desk numbers still ring a person), and on a linked personal WhatsApp number it sends nothing, including reminders, while messages keep syncing. ## 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` | ## POST `/api/v1/bots/{bot_id}/mcp-servers/probe` Connects to each MCP server saved in the bot's `config.mcp_servers`, lists its tools, and reports whether it answered. Read-only: nothing is stored. Requires `bots:read`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `ids` | `array of string \| null` | No | Probe only the servers with these `id`s. At most 10. Omit the body (or send `null`) to probe every saved server | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/mcp-servers/probe \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"ids": ["crm"]}' ``` ```json { "servers": [ { "id": "crm", "host": "mcp.example.com", "ok": true, "tool_count": 3, "tools": ["find_contact", "create_lead", "log_call"], "error": null } ] } ``` | Field | Meaning | | --- | --- | | `id` | The server's `id` from `config.mcp_servers` | | `host` | Hostname only. The full URL and any header value you stored are never returned | | `ok` | `true` when the server answered `tools/list` | | `tool_count` / `tools` | How many tools it advertised, and their names (the list is capped at 40) | | `error` | On failure, the class of error only (for example `ConnectError` or `TimeoutException`), otherwise `null` | An unreachable server is still `200`, with `ok: false`. An `id` that is not saved on the bot is simply absent from `servers`. | Status | Cause | | --- | --- | | `403` | Key missing `bots:read` | | `404` | `Bot not found` | | `422` | More than 10 `ids` | --- # Versions Stage edits to an agent's `system_prompt` and `config` as a **draft**, publish them atomically, and roll back to any earlier published version. The bot row is always what serves live traffic: a draft changes nothing about what the agent says until you commit it. - **Version 1 is created lazily.** The first time you create a draft on a bot, a committed `Baseline` version holding the current live prompt and config is created first, so your first commit has something to roll back to. A bot that has never been versioned lists `[]`. - **One open draft per bot.** A second `POST` returns `409` naming the existing draft. - **Committed versions are immutable.** Rollback never rewrites history: it appends a **new** committed version carrying the old snapshot and publishes it. - **History is capped at 50 committed versions per bot.** After each commit or rollback the oldest committed versions beyond that are deleted; a draft is never pruned. - Writes made outside versioning — `PUT /api/v1/bots/{bot_id}`, `PATCH .../config`, `PUT .../prompt` — still change the live agent directly and do not create a version. The version object: | Field | Type | Meaning | | --- | --- | --- | | `id` | `uuid` | Version row id | | `tenant_id`, `bot_id` | `uuid` | Owner and agent | | `version_number` | `integer` | 1, 2, 3… per bot. Every path below addresses a version by this number | | `label` | `string \| null` | Your name for the version | | `system_prompt` | `string` | The prompt snapshot | | `config` | `object \| null` | The config snapshot, credential-redacted exactly like the bot's own `config` | | `status` | `string` | `draft` or `committed` | | `created_by`, `committed_by` | `uuid \| null` | Dashboard user who created / committed it; `null` when done with an API key | | `commit_message` | `string \| null` | Set on commit; `Rollback to vN` on a rollback version | | `created_at`, `updated_at`, `committed_at` | `datetime` | `committed_at` is `null` on a draft | ## GET `/api/v1/bots/{bot_id}/versions` Every version of the bot, newest first. Requires `bots:read`. ```bash curl https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/versions \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "0f6c2a51-3d1e-4b7a-9c55-1f2e3d4c5b6a", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "version_number": 2, "label": "Shorter greeting", "system_prompt": "You are a concise support agent for Acme.", "config": { "greeting": "Hi, Acme here." }, "status": "draft", "created_by": null, "committed_by": null, "commit_message": null, "created_at": "2026-09-20T10:00:00Z", "updated_at": "2026-09-20T10:02:00Z", "committed_at": null }, { "id": "8b1d7e40-6a2c-4f13-a8e9-2c3d4e5f6a7b", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "version_number": 1, "label": "Baseline", "system_prompt": "You are a friendly support agent for Acme.", "config": { "greeting": "Hi, thanks for calling Acme. How can I help?" }, "status": "committed", "created_by": null, "committed_by": null, "commit_message": "Baseline snapshot of the live agent", "created_at": "2026-09-20T10:00:00Z", "updated_at": "2026-09-20T10:00:00Z", "committed_at": "2026-09-20T10:00:00Z" } ] ``` `404` `Bot not found`. ## GET `/api/v1/bots/{bot_id}/versions/{version_number}` One version. Requires `bots:read`. `404` `Bot not found` or `Version not found`. ## POST `/api/v1/bots/{bot_id}/versions` Stages a draft. Every field defaults to the bot's **current live** value, so an empty body `{}` snapshots what is live and lets you edit it. Returns `201` with the draft. Requires `bots:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `label` | `string \| null` | No | 1-255 chars | | `system_prompt` | `string \| null` | No | Max 20000 chars. Defaults to the live prompt | | `config` | `object \| null` | No | The **whole** config for this version (not a merge). Defaults to the live config. The same sub-keys as `POST /api/v1/bots` are validated | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/versions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"label": "Shorter greeting", "system_prompt": "You are a concise support agent for Acme."}' ``` A `config` you send replaces the live config wholesale **when committed**, and the live config's credential fields (for example `access_token`) are not readable back through the API. Omit `config` to inherit the stored config, credentials included, rather than rebuilding it from a redacted read. | Status | Cause | | --- | --- | | `404` | `Bot not found` | | `409` | `A draft already exists (vN) — edit or commit it first` | | `422` | A field outside its bounds, or a malformed validated `config` sub-key | ## PATCH `/api/v1/bots/{bot_id}/versions/{version_number}` Edits the open draft. Omitted fields are unchanged; `config`, when sent, replaces the draft's config. Same field table as create. Requires `bots:write`. | Status | Cause | | --- | --- | | `404` | `Bot not found` or `Version not found` | | `409` | `Committed versions are immutable — roll back to it instead` | ## POST `/api/v1/bots/{bot_id}/versions/{version_number}/commit` Publishes the draft: copies its `system_prompt` and `config` onto the live agent and marks the version `committed`, in one transaction — a call in progress reads either the whole old agent or the whole new one. Requires `bots:write`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `commit_message` | `string \| null` | No | Max 2000 chars | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/versions/2/commit \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"commit_message": "Tighter greeting after call review"}' ``` Returns the committed version. | Status | Cause | | --- | --- | | `404` | `Bot not found` or `Version not found` | | `409` | `Version is already committed` | ## POST `/api/v1/bots/{bot_id}/versions/{version_number}/rollback` Rolls back by moving forward: appends a new committed version carrying version `version_number`'s snapshot, with `commit_message` `Rollback to vN`, and publishes it. The target version is untouched. No request body. Returns `201` with the new version. Requires `bots:write`. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/versions/1/rollback \ -H "Authorization: Bearer cm_your_api_key" ``` | Status | Cause | | --- | --- | | `404` | `Bot not found` or `Version not found` | | `409` | `Only a committed version can be rolled back to — commit the draft instead` | --- # Knowledge base Entries attached to one bot. See [Knowledge Base](/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. # Agent documents Files an agent can send to a customer on WhatsApp when asked, such as a trip itinerary, a brochure or a price list. Turn on the `send_document` tool on the agent (see [Voice agent tools](/docs/voice-agent-tools)); the agent passes the document's name and the file arrives in the chat. Free-form files deliver only inside WhatsApp's 24-hour customer-service window, so outside it the agent sends an approved template first. ## GET `/api/v1/bots/{bot_id}/documents` Lists the agent's documents, oldest first. Requires `bots:read`. ```bash curl https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/documents \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "d3a4b5c6-7e8f-4a90-b1c2-d3e4f5a6b7c8", "name": "itinerary", "filename": "Goa-5-day-itinerary.pdf", "content_type": "application/pdf", "byte_size": 238114, "created_at": "2026-10-09T10:15:00Z" } ] ``` `403` without `bots:read`; `404` `Bot not found`. ## POST `/api/v1/bots/{bot_id}/documents` Uploads a file as `multipart/form-data`. Returns `201`. Requires `bots:write`. | Part | Type | Required | Constraints | | --- | --- | --- | --- | | `file` | file | Yes | PDF, JPEG or PNG, checked by the file's contents rather than its extension or `Content-Type`. Max **20 MB** (20,971,520 bytes) | | `name` | `string` | No | Up to 64 chars. The name the agent picks the file by, stored lower-case with punctuation turned into spaces (`Trip Itinerary!` becomes `trip itinerary`). Defaults to the filename without its extension | An agent holds up to **20** documents, and each name is unique per agent. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/documents \ -H "Authorization: Bearer cm_your_api_key" \ -F "file=@Goa-5-day-itinerary.pdf" \ -F "name=itinerary" ``` ```json { "id": "d3a4b5c6-7e8f-4a90-b1c2-d3e4f5a6b7c8", "name": "itinerary", "filename": "Goa-5-day-itinerary.pdf", "content_type": "application/pdf", "byte_size": 238114, "created_at": "2026-10-09T10:15:00Z" } ``` `filename` is what the customer sees on WhatsApp. | Status | Cause | | --- | --- | | `400` | `File exceeds 20MB limit`; `Unsupported file. Use PDF, JPEG or PNG.`; `Give the document a name, e.g. itinerary`; `This agent already has a document named ''`; or `An agent can hold up to 20 documents` | | `403` | Key missing `bots:write` | | `404` | `Bot not found` | | `503` | `The file could not be stored, try again` | | `503` | `File storage is not configured` | ## DELETE `/api/v1/bots/{bot_id}/documents/{document_id}` Removes the document and its file. Returns `204` with an empty body. Requires `bots:write`. Deleting the agent removes its documents too. ```bash curl -X DELETE https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/documents/d3a4b5c6-7e8f-4a90-b1c2-d3e4f5a6b7c8 \ -H "Authorization: Bearer cm_your_api_key" ``` `403` without `bots:write`; `404` `Document not found` when the document is not on that agent in your tenant. # Callback calls Calls a WhatsApp customer asked the agent for, arranged by its `request_callback` tool ([WhatsApp callback calls](#whatsapp-callback-calls)). ## GET `/api/v1/bots/{bot_id}/callbacks` Lists the agent's callbacks, newest first. Requires `bots:read`. | Query | Type | Default | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | `50` | 1-200 | ```bash curl "https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/callbacks?limit=20" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "e4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "to_e164": "+919812345678", "status": "pending", "due_at": "2026-10-09T11:30:00Z", "attempts": 0, "reason": "Wants to discuss the Goa package", "error": null, "telephony_call_id": null, "call_status": null, "created_at": "2026-10-09T08:02:11Z" } ] ``` | Field | Notes | | --- | --- | | `status` | `pending` (waiting for `due_at`), `dialing`, `placed`, `failed` or `cancelled` | | `attempts` | Times a call was tried | | `error` | Why it failed, was cancelled, or is being retried | | `telephony_call_id`, `call_status` | The placed call and its status (`initiated`, `ringing`, `in_progress`, `completed`, `failed`, `no_answer`, `busy`) | `403` without `bots:read`; `404` `Bot not found`. ## POST `/api/v1/bots/{bot_id}/callbacks/{callback_id}/cancel` Cancels a callback that has not started dialing and returns it. Requires `bots:write`. Cancelling one that is already cancelled returns it unchanged. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/b1f2c3d4-5678-90ab-cdef-1234567890ab/callbacks/e4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8/cancel \ -H "Authorization: Bearer cm_your_api_key" ``` | Status | Cause | | --- | --- | | `403` | Key missing `bots:write` | | `404` | `Callback not found` | | `409` | `Only a callback that hasn't started dialing can be cancelled` | --- ### Build an Agent URL: /docs/build-an-agent > The ordered recipe for creating and configuring a voice or chat agent entirely over the API: fetch the config schema, validate the config, create the bot, attach knowledge, attach a number, place a test call, read the transcript. :::cards /docs/bots | Bots API | workflow | Full reference for every route used here /docs/telephony-api | Telephony API | hash | Buy a number and place calls /docs/agent-tools-mcp | Account MCP Server | wrench | The same flow as MCP tools ::: ## Who this page is for A program — your backend, a script, or an AI agent holding a `cm_` key — that has to build a working agent without a human clicking through the console. The hard part is not the routes. It is `config`: a JSON object whose keys the call runtime reads at call time, where most keys are **not** validated when you write them. A misspelled key is stored and ignored. A voice the chosen speech engine cannot speak is stored and then quietly replaced. Neither shows up as an error — they show up as a call that sounds wrong. The prompt has the same shape of problem, and it is covered in [Write the prompt in four boxes](#write-the-prompt-in-four-boxes) below. So the recipe below is not "create a bot". It is **ask what the keys are, check the values, then write** — and every step has a route behind it. Everything here works with `Authorization: Bearer cm_your_api_key`. The scope each route needs is named inline. Base URL for every example: `https://api.callmissed.com`. ## The recipe ::::steps ## Fetch the config schema Never hand-write a `config` from memory. Ask for the key list first — it is generated from the same constants the call runtime reads, so it cannot drift from reality. ```bash curl "https://api.callmissed.com/api/v1/bots/config-schema?bot_type=inbound_call" \ -H "Authorization: Bearer cm_your_api_key" ``` Scope: `bots:read`. Pass the `bot_type` you intend to create and you get only the keys that channel reads. Three fields in the response decide how you treat each key: | Field | Use it for | | --- | --- | | `if_omitted` | What actually happens on a live call when you skip the key. This is the field that tells you whether a key is genuinely optional | | `validated_on_write` | `false` means a wrong value is accepted by the write and fails later. Those are the keys worth validating | | `values_depend_on` | The key whose value narrows this key's allowlist. `voice` depends on `tts_model`, so neither can be checked alone | The response also carries `minimal_example`, a complete body that `POST /api/v1/bots` accepts and that produces an agent which answers and speaks. Start from that and change what you need, rather than building a config from scratch. ## Compose the config Take `minimal_example`, then apply your own choices: - Pick `tts_model` **before** `voice`. The voice allowlist is a property of the speech engine, not a global list. - Pick `stt_model` and `language` together. A recognition model built for one language set will not silently learn another. - Write a `greeting`. Without one the agent improvises its opening line, differently on every call. - Put tool names in `tools`, copied verbatim from `GET /api/v1/bots/tool-catalog`. On a calling agent, skip any tool whose `unavailable_on` contains `voice` — the call runtime never offers it. - `skills` is for chat agents only. It names bundles from `GET /api/v1/bots/skill-catalog`, each adding a group of tools plus its own instructions. A calling agent never reads the key. ```json { "name": "Acme Support", "type": "inbound_call", "system_prompt": "You are a friendly support agent for Acme. Keep answers under three sentences and offer to transfer if you cannot help.", "config": { "voice_model": "gpt-oss-120b", "tts_model": "deepgram-aura-2", "stt_model": "deepgram-flux-general-en", "voice": "Thalia", "language": "en", "max_call_duration_seconds": 600, "greeting": "Hi, thanks for calling Acme. How can I help?", "tools": ["search_knowledge_base", "update_contact"] } } ``` ## Validate it before you write it This is the step that separates a working agent from one that fails on its first real call. `POST /api/v1/bots/validate-config` writes nothing and changes nothing — it just tells you what is wrong. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/validate-config \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "bot_type": "inbound_call", "config": { "voice_model": "gpt-oss-120b", "tts_model": "deepgram-aura-2", "stt_model": "deepgram-flux-general-en", "voice": "Thalia", "language": "en", "max_call_duration_seconds": 600, "greeting": "Hi, thanks for calling Acme. How can I help?", "tools": ["search_knowledge_base", "update_contact"] } }' ``` ```json { "ok": true, "findings": [] } ``` Scope: `bots:read`. Treat any finding with `severity: "error"` as a stop — fix it and validate again. Treat `warning` as "read the message"; it is usually a typo the runtime will ignore rather than complain about. ## 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 @agent.json ``` Scope: `bots:write`. Returns `201` and the created bot; keep its `id`. The write does **not** reject a config that failed validation — it reports it. Read `config_warnings` on the response and confirm it is empty. If you skipped the validate step, this is your last chance to notice. ## Write the prompt in four boxes `system_prompt` is one string, but it is not written as one. The dashboard edits an agent's instructions in **four** boxes, and an operator who has tuned an agent has tuned all four: | Box | Where it lives | What belongs in it | | --- | --- | --- | | Objective | `system_prompt`, first section | Who the agent is and what it is trying to achieve on this call | | Response guidelines | `system_prompt`, second section | How it speaks: tone, length, what it must never guess | | Conversation script | `system_prompt`, third section | The steps it works through, in order | | First message | `config.greeting` | The line it speaks when the call connects | The first three are sections *inside* the single stored string, separated by marker lines. `GET`/`PUT /api/v1/bots/{bot_id}/prompt` is the published reader and writer of that layout, so you never have to know what the markers are: ```bash curl https://api.callmissed.com/api/v1/bots/{bot_id}/prompt \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "objective": "You are the voice of Acme. Understand what the caller needs before offering anything.", "response_guidelines": "- Friendly and consultative. Short sentences.\n- One question at a time.", "conversation_script": "## 1. Open\n\nGreet the caller and ask how you can help.", "first_message": "Hi, thanks for calling Acme. How can I help?", "freeform": null } ``` Change what you mean and `PUT` the same shape back. Scope: `bots:read` to read, `bots:write` to write. **The flat-prompt trap.** `POST /api/v1/bots` and `PUT /api/v1/bots/{bot_id}` take `system_prompt` as one block. Write a prompt that way to an agent that had sections and the four boxes collapse into one wall of text — the operator's structure is gone, they are not told, and the only way back is to split it apart by hand. It is the prompt equivalent of using `PUT` to change one `config` key. Send the three blocks to the prompt route instead; a flat write is correct only when the prompt genuinely has no sections, which is exactly the case `GET` reports by returning `freeform`. A prompt that carries no markers — one written before the section editor, or one written flat — comes back with `freeform` holding the whole thing and the three blocks `null`. Round-trip it through `freeform` and it is stored byte-identically, with nothing injected. Sending `freeform` and the blocks together is `422`: they are two spellings of the same field. Among the three blocks, a field you omit is **cleared**, because they are written together. Read first, change one, send all three. `first_message` is separate: omit it and the greeting is left alone, send an empty string and the greeting is removed. ## Attach knowledge Give the agent the facts it needs to answer. Each entry belongs to one bot. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/{bot_id}/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." }' ``` Scope: `knowledge:write`. For a document, `POST /api/v1/bots/{bot_id}/knowledge/upload` takes a PDF, DOCX or TXT as `multipart/form-data` and extracts the text synchronously. **Check `status` on the response.** An entry whose text could not be extracted is still created, with `status: "failed"` and an `error_message` — it will not answer anything. See [Knowledge Base](/docs/knowledge) for how entries are retrieved at reply time. ## Attach a phone number (voice agents) A voice agent with no number cannot be called. Link one of your active numbers to the bot: ```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 '{"alias": "Support line", "bot_id": "{bot_id}"}' ``` Scope: `telephony:write`. `GET /api/v1/telephony/numbers` lists what you already own; the [Telephony API](/docs/telephony-api) page covers buying one, including the KYC step. The number's own optional `config` object holds per-number overrides that **win over the bot's config on that number's calls**. Two numbers can share one agent and still greet differently. It is also a place a mistake hides: an override you forgot beats the config you just fixed. ## Place a test call ```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": "{bot_id}" }' ``` Scope: `telephony:write`. Call yourself, not a customer. The response carries the call `id` and a `voice_session_id` — that second id is how you read what was said. ## Read the transcript The transcript is the only check that tells you the agent actually worked: the right voice, the right language, the greeting spoken, the tools called. ```bash curl "https://api.callmissed.com/v1/voice/sessions/{voice_session_id}/transcript?format=json" \ -H "Authorization: Bearer cm_your_api_key" ``` Each turn carries `user_transcript`, `agent_response`, `interrupted`, the per-stage latencies (`stt_ms`, `first_token_ms`, `first_audio_ms`, `total_ms`) and the `llm_model` that answered. `format=txt` gives readable alternating lines if a human is reading it. If the `voice_session_id` on the call is still `null`, the call has not connected yet — poll `GET /api/v1/telephony/calls/{call_id}` until it is set. ## Iterate without clobbering Change one key with `PATCH /api/v1/bots/{bot_id}/config`, which merges: ```bash curl -X PATCH https://api.callmissed.com/api/v1/bots/{bot_id}/config \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"values": {"voice": "Asteria"}}' ``` Scope: `bots:write`. Use `PUT /api/v1/bots/{bot_id}` only when you genuinely mean to replace the whole `config` — it drops every key you leave out. The same rule holds for the prompt: `PUT /api/v1/bots/{bot_id}/prompt` edits it section by section, while `PUT /api/v1/bots/{bot_id}` replaces it with one flat block. :::: ## Common mistakes Five ways a `config` is accepted, stored, and returned to you unchanged — and then behaves wrongly. **None of them raises an error at write time.** Most raise nothing at call time either: the call just is not what you configured, and you find out from a customer. `POST /api/v1/bots/validate-config` reports all five before that happens. ### 1. A `voice` the chosen `tts_model` cannot speak The speaker list belongs to the speech model, not to the platform. `shubh` is a `bulbul` speaker; `thalia` is a `deepgram-aura-2` speaker. Send one with the other and the write succeeds, then the call does not: on the standard speech pipeline the engine rejects the speaker and the session **cannot start**, and on a managed voice model the agent's own default speaker is substituted — a different voice, possibly a different gender, with no warning anywhere. ```json { "tts_model": "deepgram-aura-2", "voice": "shubh" } ``` ```json { "key": "voice", "severity": "error", "code": "incompatible", "message": "'shubh' is not a speaker on deepgram-aura-2 — it belongs to bulbul:v3; set tts_model to it. On a standard voice call the session refuses to start; where a softer path applies, the caller hears a different speaker than you chose.", "did_you_mean": ["janus"], "allowed": ["agathe", "agustina", "alvaro", "ama", "amalthea", "andromeda"] } ``` **Fix:** set `tts_model` first, then pick `voice` from that model's list. The schema marks this with `"values_depend_on": "tts_model"` — a key flagged that way cannot be checked on its own, so validate the pair together. Matching is case-insensitive, so `Thalia` and `thalia` are the same speaker. ### 2. `language: "en"` on an Indic stack On a `bulbul` or `saaras` stack a bare `"en"` is not rejected — it is expanded to `"en-IN"`. Indian English, not the neutral English you asked for. The same rewrite on a language the stack does not serve at all is worse: you get a different language, silently. ```json { "tts_model": "bulbul:v3", "stt_model": "saaras:v4", "language": "en" } ``` ```json { "key": "language", "severity": "warning", "code": "incompatible", "message": "'en' is not a language 'saaras:v4' accepts; it is read as 'en-IN'. Send the full code so it is unambiguous.", "did_you_mean": ["en-IN"] } ``` **Fix:** always send the full code (`en-IN`, `hi-IN`) rather than a bare one. The finding is a `warning` when the rewrite is just the region being filled in, and an **`error`** when the resolved language is not the one you asked for. ### 3. `max_duration_seconds` instead of `max_call_duration_seconds` `max_duration_seconds` is a real field — on the [Voice Sessions API](/docs/voice-sessions-api) request body, which is a different surface. In `bot.config` the key is `max_call_duration_seconds`. Copy the wrong one across and the agent's config contains a duration cap that caps nothing. ```json { "key": "max_duration_seconds", "severity": "warning", "code": "unknown_key", "message": "max_duration_seconds is not read from an agent's config — it is the name the call runtime receives. Set max_call_duration_seconds instead, or this setting does nothing.", "did_you_mean": ["max_call_duration_seconds"] } ``` **Fix:** use `max_call_duration_seconds`, bounds `30`–`14400`. This is the general case, not a special one: **any** key the platform does not read is stored and ignored, and comes back as `unknown_key` with the nearest real key in `did_you_mean`. `variables` is the other name that looks right and does nothing — the key is `input_variables`. ### 4. A typo in `tools` or `skills` Tool and skill names are resolved against a registry when the agent starts. A name that is not in the registry is dropped — not logged to you, not rejected on write. The agent runs with one fewer capability than you think it has, and the only symptom is that it never uses the tool. ```json { "tools": ["get_order_statuss"], "skills": ["bookng"] } ``` ```json { "key": "tools", "severity": "warning", "code": "unknown_value", "message": "tools contains 'get_order_statuss', which is not a known name. It is ignored at call time.", "did_you_mean": ["shopify_order_status", "woocommerce_order_status"], "allowed": ["add_internal_note", "calcom_book", "calculator", "escalate_to_human", "…"] } ``` **Fix:** copy names from `GET /api/v1/bots/tool-catalog` and `GET /api/v1/bots/skill-catalog` rather than typing them. `allowed` on the finding is the live registry, so you can correct it without a second request. A name can also be real and still do nothing on this agent. Two findings say so, both `warning` / `incompatible`: - a tool whose `unavailable_on` contains `voice`, listed in a calling agent's `tools` — the call runtime drops its category, so the agent never sees it; - `skills` set on a calling agent at all — skills are resolved on chat channels only, so the named bundles are neither offered nor loaded on a call. ### 5. `tts_provider` set without `tts_model` `tts_provider` is the legacy speech switch, and it decides nothing: every speech stack is chosen by `tts_model`. (`elevenlabs` is not available, and a config naming it is refused with `422`.) Set `tts_provider` on its own — no `tts_model` alongside it — and the call does **not** switch to that provider. It speaks on the platform's default model instead, on whichever provider that model belongs to. ```json { "tts_provider": "gnani" } ``` ```json { "key": "tts_provider", "severity": "error", "code": "incompatible", "message": "tts_provider alone does not select a speech provider. With tts_model unset the call speaks on 'bulbul:v3' (sarvam), not gnani — the caller hears a different voice than you configured. Set tts_model to a gnani model.", "did_you_mean": ["gnani-timbre-v3.0"] } ``` Naming a provider that disagrees with the `tts_model` you *did* set is a softer version of the same mistake — the call still runs, on the model's real provider, and the finding is only a `warning`: ```json { "tts_provider": "sarvam", "tts_model": "deepgram-aura-2" } ``` ```json { "key": "tts_provider", "severity": "warning", "code": "incompatible", "message": "'deepgram-aura-2' is a deepgram model, so the call speaks on deepgram — tts_provider does not choose the provider. Change tts_model if you meant to speak on sarvam." } ``` And a value the platform does not serve at all — `tts_provider: "google"`, say — is also only a `warning`, because it was never going to be read either way: ```json { "key": "tts_provider", "severity": "warning", "code": "unknown_value", "message": "'google' is not a speech provider the platform serves. It is not read either way — the provider comes from tts_model.", "allowed": ["cartesia", "deepgram", "gnani", "sarvam"] } ``` A `tts_provider` that agrees with `tts_model` — `"tts_provider": "gnani"` with `"tts_model": "gnani-timbre-v3.0"` — produces no finding at all. **Fix:** prefer `tts_model`, which picks the provider for you, and leave `tts_provider` out entirely. If you do set it on an older config, set `tts_model` to a model from that same provider too — `tts_provider` alone is not enough to steer the call. ### And the one that is not a typo: no `greeting` Not a wrong value — an absent one. With no `greeting` the agent still speaks first, but not the line you meant: on the standard pipeline the model improvises an opener from the system prompt, and on a managed voice model a generic platform line is used. Neither is the brand sentence you would have written, and neither is stable between calls. ```json { "key": "greeting", "severity": "warning", "code": "missing_recommended", "message": "No greeting: the agent improvises its own opening line, or uses a generic one. Set a greeting to control the first thing a caller hears." } ``` This is why `if_omitted` is in the schema at all. Read it for every key you decide to skip. ## Doing this from an MCP client Every step above is also an [Account MCP server](/docs/agent-tools-mcp) tool, so an agent that speaks MCP does not need to write HTTP calls at all. ## What this page does not cover - **Buying a number and KYC** — see [Telephony API](/docs/telephony-api). - **Custom tools that call your own endpoints** — see [Agent Tools](/docs/agent-tools). - **How knowledge is retrieved at reply time** — see [Knowledge Base](/docs/knowledge). - **Browser and mobile voice, without a phone number** — see [Voice Sessions API](/docs/voice-sessions-api). --- ### Conversations URL: /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, search, 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. Requires `conversations:read`. By default the newest **conversation** comes first (`sort=created`) and you page with `offset`. Pass `sort=last_activity` to get the inbox order instead: the conversation with the newest **message** first. That order changes every time a message arrives, so it pages with a cursor, not an offset: send each row's `cursor` value from the last row of a page as `cursor=` to get the next page. | 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`. Only with `sort=created`: a non-zero offset with `sort=last_activity` returns `400` | | `sort` | `string` | No | `created` (default) or `last_activity` | | `cursor` | `string` | No | The `cursor` of the last row of the previous page. Only with `sort=last_activity`, otherwise `400`. A malformed cursor returns `400` | | `unread` | `boolean` | No | `true`: only threads with unread customer messages; `false`: only the rest | | `assigned` | `string` | No | `unassigned`, or a teammate's user id | | `label` | `string` | No | Threads carrying this label. Case-insensitive; surrounding spaces and invisible characters are ignored, as they are when labels are saved | ```bash # Inbox order, first page, then the next one curl "https://api.callmissed.com/api/v1/conversations?sort=last_activity&limit=50" \ -H "Authorization: Bearer cm_your_api_key" curl "https://api.callmissed.com/api/v1/conversations?sort=last_activity&limit=50&cursor=WyIyMDI2LTA4LTA0VDEyOjAxOjAwKzAwOjAwIiwiYzBmZmVlMDAiXQ" \ -H "Authorization: Bearer cm_your_api_key" ``` ```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?", "last_message_at": "2026-08-04T12:01:00Z", "unread_count": 2, "last_read_at": "2026-08-04T11:58:00Z", "last_message_preview": "Where is my order?", "last_message_type": "text", "last_message_direction": "in", "last_message_status": null, "display_name": "Asha Rao", "contact_name": "Asha Rao", "profile_name": "Asha", "cursor": null } ] ``` | 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_id` | `string \| null` | The agent the thread belongs to. `null` for a WhatsApp thread on an account with no agent: the message is stored and nothing replies | | `bot_name` | `string` | `""` when the bot has been deleted or the thread has no agent | | `last_message` | `string \| null` | The most recent message as stored | | `last_message_at` | `string \| null` | When the most recent message was sent. `null` when the thread has none | | `unread_count` | `integer` | `user` messages created after `last_read_at` | | `last_read_at` | `string \| null` | `null` means the thread was never opened | | `last_message_preview` | `string \| null` | What an inbox shows for the most recent message: its text, or for media and interactive messages the caption, file name or a label such as `Photo` or `Voice message (0:07)` | | `last_message_type` | `string \| null` | `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contacts`, `interactive`, `button`, `reaction`, `template` or `system` | | `last_message_direction` | `string \| null` | `in` from the customer, `out` from your business | | `last_message_status` | `string \| null` | Outbound only: `sent`, `delivered`, `read` or `failed` | | `display_name` | `string \| null` | The CRM contact's name, else the customer's WhatsApp profile name | | `contact_name` | `string \| null` | The linked CRM contact's name | | `profile_name` | `string \| null` | The name the customer set on WhatsApp | | `cursor` | `string \| null` | With `sort=last_activity`: pass the last row's value as `cursor=` for the next page. `null` otherwise | ## GET `/api/v1/conversations/search` Full-text search over your conversations' messages, newest first. Requires `conversations:read`. It searches what an inbox shows: a text message's words, or a media or interactive message's caption, file name, contact names or button titles. Every word you send must appear; each matches as a prefix, so `ord` finds `order`. Words match as typed, with no stemming, so Hindi, Hinglish and order numbers work. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `q` | `string` | Yes | 1 to 200 characters | | `limit` | `integer` | No | `1 <= limit <= 50`, default `20` | | `cursor` | `string` | No | `next_cursor` from the previous page | | `conversation_id` | `UUID` | No | Search within one conversation | ```bash curl "https://api.callmissed.com/api/v1/conversations/search?q=refund&limit=20" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "results": [ { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "message_id": "m1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "created_at": "2026-08-04T11:55:00Z", "direction": "in", "message_type": "text", "snippet": "Can I get a refund for order 5512?", "highlights": [{ "start": 12, "end": 18 }], "channel": "whatsapp", "external_id": "+919876543210", "display_name": "Asha Rao", "cursor": "WyIyMDI2LTA4LTA0VDExOjU1OjAwKzAwOjAwIiwibTFhMmIzYzQiXQ" } ], "next_cursor": null } ``` `highlights` are offsets into `snippet` in UTF-16 code units, the same as JavaScript string indices. `next_cursor` is `null` on the last page. | Status | Cause | | --- | --- | | `400` | `Invalid cursor`, or `Search is too broad. Add more letters or another word.` | | `403` | Key missing `conversations:read` | | `422` | `q` empty or over 200 characters, or `limit` out of range | ## 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 most recent `limit` messages of the thread, in chronological order (oldest first). To read further back, pass the `id` of the first message you received as `before_id` and repeat until a page comes back shorter than `limit`. Requires `conversations:read`. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 500`, default `200` | | `before_id` | `UUID` | No | Only messages older than this message. Must be a message in this conversation, otherwise `404` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0`. Skips that many messages back from the newest (or from `before_id`) | ```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`, or `Message not found` when `before_id` is not in this conversation; `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, same shape as a list row. `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, same shape as a list row, 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, same shape as a list row, 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` | | `503` | `AI draft failed; please retry.` or `AI returned an empty draft — please retry.` | --- ### Companies URL: /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](/docs/crm-lead-scores#timeline) rolls up its activity. Contacts link to a company through the contact's own `company_id` — list a company's people with [`GET /api/v1/contacts?company_id=…`](/docs/crm-contacts#get-apiv1contacts). ## 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" }, "tags": ["partner"], "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 | | `tags` | `string[]` | Your labels, in the order added. Up to 20, each at most 40 characters. See [tags](/docs/crm-import-export#tags) | | `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` | | `tag` | `string` (repeatable) | No | Only companies carrying **any** of these tags, e.g. `?tag=vip&tag=renewal`. Exact match. At most 20 | | `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" ``` ## GET `/api/v1/companies/count` Takes the same `q` and `tag` filters as the list and returns `{"total": 42}` — how many companies match. ## 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 | | `tags` | `string[]` | No | Up to 20, each at most 40 characters. Trimmed; blanks and repeats dropped | ```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](/docs/crm-import-export#get-apiv1crmsearchduplicates) 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. Only the fields you send are changed; `name` cannot be cleared (`422 name must not be blank`). `tags` replaces the whole list (`null` or `[]` clears it); use [bulk tags](/docs/crm-import-export#post-apiv1crmbulkadd-tags) to add or remove one tag across many companies. `DELETE` returns `204`. Contacts and deals that pointed at the company are kept, with their `company_id` cleared. `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`, more than 20 tags or a tag over 40 characters, or a field over its length limit | Nothing on this page consumes credits. --- ### Contacts URL: /docs/crm-contacts > The person object — create, search, update and delete contacts, the phone/email/WhatsApp uniqueness rule, consent flags, and the facts your agents remember about each contact. ## Overview A **contact** is one person you talk to — on a call, on WhatsApp, or by email. It is identified by a phone number, an email address or a WhatsApp business-scoped user id (BSUID), and optionally belongs to a [company](/docs/crm-companies) through `company_id`. Deals, notes and tasks attach to a contact, and the [timeline](/docs/crm-lead-scores#timeline) rolls up its activity. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, read memory | `contacts:read` | | Create, update, delete, erase memory | `contacts:write` | ## The contact object ```json { "id": "4411…", "tenant_id": "a0b1…", "company_id": "5c6d…", "phone": "+919876543210", "email": "asha@acme.com", "name": "Asha Menon", "external_ids": { "shopify": "gid://shopify/Customer/991" }, "whatsapp_opt_in": true, "email_opt_in": false, "sms_opt_in": false, "consent_source": "checkout_form", "consent_updated_at": "2026-08-04T10:00:00Z", "whatsapp_bsuid": null, "whatsapp_parent_bsuid": null, "whatsapp_username": null, "tags": ["vip", "renewal"], "metadata": null, "created_at": "2026-08-04T10:00:00Z", "updated_at": "2026-08-16T09:00:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `phone` | `string \| null` | **Unique per tenant** when set | | `email` | `string \| null` | **Unique per tenant** when set | | `whatsapp_bsuid` | `string \| null` | WhatsApp business-scoped user id, for a user who hides their phone number. **Unique per tenant** when set | | `whatsapp_parent_bsuid` | `string \| null` | The parent BSUID, when WhatsApp supplies one | | `whatsapp_username` | `string \| null` | WhatsApp username, stored lower-case without a leading `@` | | `whatsapp_opt_in`, `email_opt_in`, `sms_opt_in` | `boolean` | Consent per channel. Default `false` | | `consent_source` | `string \| null` | Where consent was captured, in your own words | | `consent_updated_at` | `datetime \| null` | **Server-managed.** Stamped when any opt-in flag or `consent_source` changes | | `external_ids` | `object \| null` | Your own foreign keys into other systems. Free-form JSON | | `tags` | `string[]` | Your labels, in the order added. Up to 20, each at most 40 characters. See [tags](/docs/crm-import-export#tags) | | `metadata` | `object \| null` | Free-form JSON attached by the platform | ## GET `/api/v1/contacts` Newest first unless you pass `sort`. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `q` | `string` | No | At most 320 characters. Case-insensitive substring match against `phone`, `email`, `name`, `whatsapp_username` **or** `whatsapp_bsuid` | | `company_id` | `UUID` | No | Only contacts linked to this company | | `lifecycle_stage` | `string` | No | `subscriber`, `lead`, `qualified`, `opportunity`, `customer` or `churned` | | `owner_user_id` | `UUID` | No | Only contacts owned by this teammate | | `lead_source` | `string` | No | Only contacts from this source (same values as a lead's `source`) | | `tag` | `string` (repeatable) | No | Only contacts carrying **any** of these tags, e.g. `?tag=vip&tag=renewal`. Exact match. At most 20 | | `sort` | `string` | No | `newest` (default), `oldest`, `name`, `updated`, or `score` — highest lead score first, unscored contacts last | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/contacts?q=acme.com&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns an array of contact objects. Each also carries `score` and `score_grade` — the contact's lead score — or `null` when it has never been scored. ## GET `/api/v1/contacts/count` Takes the same `q`, `company_id`, `lifecycle_stage`, `owner_user_id`, `lead_source` and `tag` filters as the list and returns `{"total": 1234}` — how many contacts match. ## POST `/api/v1/contacts` At least one of `phone`, `email` or `whatsapp_bsuid` is required — `422 a contact requires a phone, an email or a WhatsApp BSUID` otherwise. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `phone` | `string` | Conditional | At most 32 characters. Unique per tenant | | `email` | `string` | Conditional | At most 320 characters. Unique per tenant | | `whatsapp_bsuid` | `string` | Conditional | A 2-letter upper-case country code, a period, an optional `ENT.`, then 1–128 letters/digits, e.g. `US.13491208655302741918`. Unique per tenant | | `whatsapp_parent_bsuid` | `string` | No | Same format as `whatsapp_bsuid` | | `whatsapp_username` | `string` | No | 3–35 of `a-z`, `0-9`, `.`, `_`, with at least one letter; may not start with `www` or `.`, end with `.`, or contain `..`. A leading `@` is stripped and the value is lower-cased | | `name` | `string` | No | At most 255 characters | | `company_id` | `UUID` | No | Must be a company in your tenant | | `external_ids` | `object` | No | Free-form JSON | | `whatsapp_opt_in` | `boolean` | No | Default `false` | | `email_opt_in` | `boolean` | No | Default `false` | | `sms_opt_in` | `boolean` | No | Default `false` | | `consent_source` | `string` | No | At most 64 characters | | `tags` | `string[]` | No | Up to 20, each at most 40 characters. Trimmed; blanks and repeats dropped | ```bash curl -X POST https://api.callmissed.com/api/v1/contacts \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone": "+919876543210", "email": "asha@acme.com", "name": "Asha Menon", "company_id": "5c6d…", "whatsapp_opt_in": true, "consent_source": "checkout_form" }' ``` Returns `201` with the contact object. > `phone`, `email` and `whatsapp_bsuid` are natural keys. Reusing any of them returns `409 A contact with this phone, email or WhatsApp BSUID already exists`. Search with `?q=` before creating, or use [duplicate detection and merge](/docs/crm-import-export#get-apiv1crmsearchduplicates) to clean up after the fact. ## GET / PATCH / DELETE `/api/v1/contacts/{contact_id}` `PATCH` accepts the same fields, all optional, with the same bounds. Only the fields you send are changed. Send `"company_id": null` to un-link the contact from its company. `tags` replaces the whole list (`null` or `[]` clears it); to add or remove one tag across many contacts without touching their other tags, use [bulk tags](/docs/crm-import-export#post-apiv1crmbulkadd-tags). `DELETE` returns `204`. Deals and conversations that pointed at the contact are kept, with their contact link cleared. The contact's memory is erased with it. `404 Contact not found` for an unknown or another tenant's id. `404 Company not found` when `company_id` is not one of your companies. --- # Contact memory Your AI agents can remember durable facts about a contact across conversations. These endpoints let you see exactly what is stored and erase any of it. ## GET `/api/v1/contacts/{contact_id}/memory` Scope `contacts:read`. ```json { "contact_id": "4411…", "facts": [ { "key": "3f9a1c2b7d4e5f60", "fact": "Prefers to be contacted in Hindi", "confidence": 0.9, "source_conversation_id": "c0d1…", "created_at": "2026-08-10T09:00:00+00:00", "updated_at": "2026-08-16T12:00:00+00:00", "seen_count": 3, "injected": true } ], "max_facts": 50, "updated_at": "2026-08-16T12:00:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `facts` | `array` | Ranked in the order the agent sees them. Empty (not `404`) when nothing has been learned yet | | `facts[].key` | `string` | Stable handle for deleting this one fact | | `facts[].seen_count` | `integer` | How many separate conversations confirmed the fact | | `facts[].injected` | `boolean` | Whether the fact is among those the agent is currently given | | `max_facts` | `integer` | How many facts can be stored per contact | ## DELETE `/api/v1/contacts/{contact_id}/memory` Scope `contacts:write`. Erases every fact about the contact. Returns `204`, and is idempotent — erasing a contact with no memory is also `204`. ## DELETE `/api/v1/contacts/{contact_id}/memory/{fact_key}` Scope `contacts:write`. Erases one fact, addressed by its `key` (1–64 characters) from the `GET` response. Returns `204`, or `404 Fact not found` when the key matches nothing. --- ## Errors | Status | When | | --- | --- | | `403` | Key is missing `contacts:read` / `contacts:write` | | `404` | Contact, linked company, or memory fact not in your tenant | | `409` | `A contact with this phone, email or WhatsApp BSUID already exists` | | `422` | No phone, email or BSUID on create, a malformed BSUID or WhatsApp username, more than 20 tags or a tag over 40 characters, or a field over its length limit | Nothing on this page consumes credits. --- ### Custom Fields & Saved Views URL: /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. Longer than 2,000 characters is truncated | | `number` | A finite number (not a numeric string, not a boolean) | | `boolean` | `true` or `false` | | `date` | An ISO 8601 date or datetime string; stored as `YYYY-MM-DD` | | `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.** 1–100 entries, each truncated to 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"] }' ``` Returns `201`. 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`, all optional. `options` may only be sent for a `select` field (`422 options are only valid for a select field`). Changing `options` does not re-validate values already stored. > `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, on reads and writes alike. A shared view can be edited and deleted by anyone who can see it. > API keys have no user identity, so a view created with a key has `created_by_user_id: null`. Every API key in your tenant counts as the creator of such views and can read them back; dashboard users only see them once `is_shared` is `true`. ## 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` 1–64 characters matching `^[A-Za-z_][A-Za-z0-9_.]*$`; `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` | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/saved-views \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "deal", "name": "Open enterprise deals", "filters": { "status": "open" }, "sort": { "field": "expected_close_date", "dir": "asc" }, "layout": "kanban", "is_shared": true }' ``` Returns `201`. A name already used for that entity type returns `409 A deal view named '…' already exists`. ## 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. --- ### Deals & Pipelines URL: /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` | `201`. `name` 1–255 characters, unique per tenant (`409 A pipeline with this name already exists`); `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`, and its stages are deleted with it. `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`. Returns `201`. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/pipelines/11aa…/stages \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Won", "probability": 100, "is_won": true }' ``` ### PATCH `/api/v1/crm/pipelines/stages/{stage_id}` · DELETE `/api/v1/crm/pipelines/stages/{stage_id}` `PATCH` accepts `name`, `position`, `probability`, `is_won` and `is_lost`, all optional, with the same bounds. The won/lost rule is checked against the merged result. Changing a stage's flags does not re-close or re-open deals already on it. `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. Returns the stages in their new order. A list with repeats returns `422 stage_ids contains duplicates`; a list that misses a stage or includes one from another pipeline returns `422 stage_ids must list every stage in this pipeline, exactly once`. Either way nothing changes. --- # 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, "lost_reason": 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 lands on a won or lost stage. Moving between won and lost keeps the original time; re-opening clears it | | `lost_reason` | `string \| null` | Why the deal was lost, up to 255 characters. Kept only while `status` is `lost`; cleared when the deal is re-opened or won | | `last_activity_at` | `datetime \| null` | Set on create and bumped on every update or move | ## GET `/api/v1/crm/deals` Newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `pipeline_id` | `UUID` | | | `stage_id` | `UUID` | | | `status` | `string` | `open`, `won` or `lost`. Anything else is `422` | | `owner_user_id` | `UUID` | | | `contact_id` | `UUID` | | | `company_id` | `UUID` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ## GET `/api/v1/crm/deals/summary` Board totals for one pipeline, over every deal rather than one page: the count, value and weighted value of each stage (in board order, empty stages included) and of the whole pipeline. | Parameter | Type | Constraints | | --- | --- | --- | | `pipeline_id` | `UUID` | Required. Must be your pipeline, otherwise `404` | | `status` | `string` | `open`, `won` or `lost`. Anything else is `422` | | `owner_user_id` | `UUID` | | | `contact_id` | `UUID` | | | `company_id` | `UUID` | | `weighted_value` is `value × probability / 100` using the deal's stage probability. A stage with no probability counts at 100 when it is a won stage and 0 otherwise. Amounts in different currencies are never added together — `totals` has one entry per currency, sorted by currency. ```json { "pipeline_id": "11aa…", "count": 14, "totals": [{ "currency": "INR", "value": 2400000.0, "weighted_value": 610000.0 }], "stages": [ { "stage_id": "22bb…", "count": 6, "totals": [{ "currency": "INR", "value": 900000.0, "weighted_value": 180000.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 <= 999999999999.99` | | `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 | | | `lost_reason` | `string` | No | Up to 255 characters. Kept only if the deal starts on a lost stage | ```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" }' ``` Returns `201`. Creating straight onto a won or lost stage closes the deal immediately. 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 | | `lost_reason` | `string` | No. Up to 255 characters; recorded only when the stage is flagged `is_lost` | 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. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/deals/33cc…/move \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "stage_id": "22bb…" }' ``` Returns the updated deal. ### 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`, `closed_at` and `lost_reason` are 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 `title`, `value`, `currency`, `contact_id`, `company_id`, `owner_user_id`, `expected_close_date`, `stage_id`, `status` and `lost_reason`, all optional, with the same bounds as create. `pipeline_id` cannot be changed. - `stage_id` follows the same rules as a move: it must belong to the deal's pipeline, and it sets `status` and `closed_at` as in the table above. - `status` (`open`, `won` or `lost`) marks the deal closed or open **without moving it**. Sent together with `stage_id`, the explicit `status` wins. `won`/`lost` stamps `closed_at` if unset; `open` clears it. `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. --- ### Search, Bulk & CSV URL: /docs/crm-import-export > Cross-object search, duplicate detection and merge, all-or-nothing bulk updates and tagging, 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, tag or delete up to 500 records in one all-or-nothing call. - **Tags** — the labels in use on contacts, companies and leads, for autocomplete. - **CSV** — export a filtered set, preview a file's column mapping, 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 add/remove tags, bulk delete | `crm_bulk:write` | | Tag counts | `contacts:read`, `companies:read` or `crm_leads:read`, matching `entity_type` | | CSV export | `crm_csv:read` | | CSV inspect, CSV import | `crm_csv:write` | `crm_bulk` has **no read half** — there is nothing to read, only writes. --- # Search ## GET `/api/v1/crm/search` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `q` | `string` | Yes | 2–320 characters | | `types` | `string` | No | At most 128 characters. 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": [ { "id": "7e21…", "type": "note", "title": "Called Acme about the renewal…", "subtitle": "company", "parent_type": "company", "parent_id": "5c6d…" } ], "tasks": [] } ``` Every hit has the same shape. `parent_type` and `parent_id` are set only on notes and tasks: the record the note or task is attached to (`contact`, `company`, `deal`, and so on), so you can link the hit to that record. They are `null` on contacts, companies and deals, and on a task that is not attached to anything. 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 — a lower-cased email, the last 10 digits of a phone number, a lower-cased name with whitespace collapsed, or a domain with any `http(s)://`, leading `www.` and trailing `/` removed. A record can appear in more than one group. Groups are largest first, and `limit` caps the total across all reasons. ## 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": { "conversations": 3, "crm_deals": 2, "commerce_events": 0, "crm_notes": 4, "crm_tasks": 1, "contact_memory": 0 } } ``` `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. `external_ids` maps are combined key by key, with the primary winning any conflict. `repointed` counts the related rows moved onto the primary. For a contact merge the keys are `conversations`, `crm_deals`, `commerce_events`, `crm_notes`, `crm_tasks`, `contact_memory`, `crm_quotes`, `crm_invoices`, `crm_leads`, `support_tickets`, `csat_surveys`, `payment_requests`, `wa_flow_responses` and `wa_orders`; for a company merge they are `contacts`, `crm_deals`, `crm_notes`, `crm_tasks`, `crm_quotes`, `crm_invoices` and `crm_leads`. `404 Contact not found` (or Company) when the primary or any duplicate is not in your tenant — nothing is merged. > 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 Every bulk endpoint takes **1–500 ids** per request (repeated ids are collapsed) 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`, `task`, `lead` or `ticket` | | `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), `owner_user_id` (nullable), `lifecycle_stage` (nullable; `subscriber` / `lead` / `qualified` / `opportunity` / `customer` / `churned`), `lead_source` (nullable; same values as a lead's `source`) | | `company` | `industry` (nullable), `size` (nullable) | | `lead` | `owner_user_id` (nullable), `source` (**not** nullable) | | `ticket` | `assignee_user_id` (nullable), `priority` (`low` / `normal` / `high` / `urgent`) | A lead's or ticket's `status` is never bulk-settable — converting a lead and moving a ticket through its lifecycle each have their own endpoint. Bulk calls on `lead` also need the `crm_leads:write` scope, and on `ticket` the `support_tickets:write` scope, in addition to `crm_bulk:write`. 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. A value of the wrong type returns `422 Invalid value for ''`. Ids inside `changes` must also be yours: an unknown `assignee_user_id`, `owner_user_id`, `company_id` or `stage_id` returns `404 Assignee not found` (or Owner / Company / Stage). A deal `stage_id` must belong to the pipeline of **every** deal in `ids` — `422 Stage does not belong to this pipeline` otherwise — and it closes or re-opens the deals exactly as a [move](/docs/crm-deals#post-apiv1crmdealsdeal_idmove) does, unless you also send `status`. Setting a task's `status` stamps or clears `completed_at`; setting a deal's `status` stamps or clears `closed_at`. ```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`, `task`, `lead` or `ticket` | | `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. ## POST `/api/v1/crm/bulk/add-tags` Adds tags to every record in `ids`, keeping the tags each record already has. A tag a record already carries is skipped, so repeating a call changes nothing. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `lead` | | `ids` | `UUID[]` | Yes | 1–500 entries | | `tags` | `string[]` | Yes | 1–20 tags, each at most 40 characters. Trimmed; blanks and repeats dropped | Bulk calls on `lead` also need the `crm_leads:write` scope. A record can hold at most 20 tags. The limit is checked for each record as it is written, and if **any** selected record would go over it the whole call is refused — `422 3 of 50 contact records would have more than 20 tags; nothing was changed` — rather than trimmed. Remove some tags from those records first. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/bulk/add-tags \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "contact", "ids": ["4411…", "5522…"], "tags": ["vip"] }' ``` ## POST `/api/v1/crm/bulk/remove-tags` Same body as `add-tags`. Removes those tags from every record in `ids` and keeps the rest in order. A record that doesn't carry a tag is left as it is. --- # Tags Contacts, companies and leads each carry `tags`: a list of your own labels, such as `vip` or `renewal`. - At most **20 tags** per record and **40 characters** per tag. Longer tags are refused, not cut. - Tags are trimmed and runs of spaces become one space. Blank tags and repeats are dropped. - Matching is **exact**: `VIP` and `vip` are two different tags. - Set the whole list with `tags` on create or `PATCH`; add or remove single tags across many records with the bulk endpoints above. - Filter any of the three lists by tag with `?tag=` (repeatable, matches records carrying **any** of the tags). ## GET `/api/v1/crm/tags` The tags in use on one record type, most used first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `lead` | | `q` | `string` | No | Case-insensitive prefix, at most 40 characters | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | ```bash curl "https://api.callmissed.com/api/v1/crm/tags?entity_type=contact&q=v" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "tag": "vip", "count": 42 }, { "tag": "vendor", "count": 3 } ] ``` --- # CSV ## GET `/api/v1/crm/csv/export` | Parameter | Type | Required | Applies to | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact`, `company` or `deal` | | `q` | `string` | No | Contacts (matches phone, email, name) and companies (matches name, domain). At most 320 characters | | `company_id` | `UUID` | No | Contacts and deals | | `contact_id` | `UUID` | No | Deals | | `pipeline_id` | `UUID` | No | Deals | | `stage_id` | `UUID` | No | Deals | | `owner_user_id` | `UUID` | No | Contacts and deals | | `status` | `string` | No | Deals — `open`, `won` or `lost` | | `lifecycle_stage` | `string` | No | Contacts | | `lead_source` | `string` | No | Contacts | | `tag` | `string` (repeatable) | No | Contacts and companies — records carrying any of these tags. At most 20 | | `ids` | `UUID` (repeatable) | No | Any — only these records, e.g. `?ids=…&ids=…`. At most 100 | ```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/inspect` Preview how a file's columns map onto CallMissed fields before you import it. Writes no records and keeps no copy of the file. Requires `crm_csv:write`. `multipart/form-data`. | Part | Type | Required | Constraints | | --- | --- | --- | --- | | `file` | file | Yes | CSV with a header row, UTF-8 or Windows-1252 (Excel's default "CSV" save), comma, semicolon or tab separated. At most **5 MB** | | `entity_type` | text | Yes | `contact`, `company` or `deal` | | `use_ai` | text | No | Default `true`. When `true`, headers that no alias or value pattern matched are also tried with an AI pass, billed at the model's token rate. The pass is skipped when your credit balance is empty or your budget cap is reached, and the mapping then uses the rules alone. Send `false` for a purely rule-based mapping | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/csv/inspect \ -H "Authorization: Bearer cm_your_api_key" \ -F "file=@contacts.csv" \ -F "entity_type=contact" ``` ```json { "headers": ["Full Name", "Mobile No.", "Email ID", "Notes"], "sample_rows": [ { "Full Name": "Asha Menon", "Mobile No.": "+919876543210", "Email ID": "asha@acme.com", "Notes": "" } ], "proposals": [ { "header": "Full Name", "field": "name", "source": "alias" }, { "header": "Mobile No.", "field": "phone", "source": "alias" }, { "header": "Email ID", "field": "email", "source": "alias" }, { "header": "Notes", "field": null, "source": null } ], "unresolved": ["Notes"], "importable_fields": ["id", "name", "email", "phone", "company_id", "company_domain", "whatsapp_opt_in", "email_opt_in", "sms_opt_in", "consent_source"], "detected_encoding": "utf-8", "detected_delimiter": "," } ``` | Field | Notes | | --- | --- | | `headers` | Your header row, in its original spelling | | `sample_rows` | Up to 5 data rows, echoed for review | | `proposals[].source` | `alias` (exact name match), `heuristic` (matched on the sample values) or `ai`. `null` when unmapped | | `unresolved` | Headers still unmapped — map them yourself or leave them out | | `importable_fields` | Every valid mapping target for this `entity_type` | | `detected_encoding` | How the file was read: `utf-8` (with or without a BOM) or `windows-1252`. A `windows-1252` file was saved by Excel as plain "CSV", which has already replaced Hindi and other non-Latin text with `?`; save it as **CSV UTF-8** to keep it | | `detected_delimiter` | The separator found in the file: `,`, `;` or a tab | Turn the confirmed proposals into the `column_map` you send to `/import`, e.g. `{"Full Name": "name", "Mobile No.": "phone", "Email ID": "email"}`. ## POST `/api/v1/crm/csv/import` `multipart/form-data`. | Part | Type | Required | Constraints | | --- | --- | --- | --- | | `file` | file | Yes | CSV with a header row, UTF-8 or Windows-1252, comma, semicolon or tab separated. At most **5 MB** and **10,000 rows** | | `entity_type` | text | Yes | `contact`, `company` or `deal` | | `dry_run` | text | No | **Defaults to `true`** | | `column_map` | text | No | A JSON object string mapping your headers to import columns, e.g. `{"Mobile No.": "phone"}`. Usually built from [`/inspect`](#post-apiv1crmcsvinspect). Targets must be import columns for this `entity_type`, and two headers may not map to the same column | > `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": "company_domain", "message": "company_domain does not match a company in this account" } ], "errors_truncated": false, "detected_encoding": "utf-8", "detected_delimiter": "," } ``` `row` is the 1-based line in the file, so the **first data row is `2`** — it lines up with what a spreadsheet shows. Bad rows are counted in `skipped` and do not stop the rest of the file. At most 100 errors are reported; `errors_truncated` tells you there were more. The report has the same shape for a dry run and a real import, and carries the same `detected_encoding` and `detected_delimiter` as `/inspect`. ### Upsert keys | Entity | Matched on, in order | | --- | --- | | `contact` | `id`, else phone, else email | | `company` | `id`, else `domain` | | `deal` | `id` only — a row without `id` always creates | A matched row is updated, never duplicated. A contact row with none of `id`, `phone`, `email` (or a company row with neither `id` nor `domain`) is reported as an error rather than inserted. ### 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. Whole-file problems return `422` and nothing is written: a file that is neither UTF-8 nor Windows-1252 (a UTF-16 "Unicode Text" save is refused with a message to save as CSV UTF-8), an empty file or missing header, a header with no recognised columns (the message lists what was expected), more than 10,000 rows, more than 5 MB, an upload that is not a CSV content type, or an invalid `column_map`. 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, or the entity's read scope for tag counts | | `404` | An id in your list (or inside `changes`, or a merge id) 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 bulk tag call that would put a record over 20 tags, a deal stage from another pipeline, a file over 5 MB or 10,000 rows, an unrecognised CSV header, or a bad `column_map` | Nothing on this page consumes credits except the optional AI header-mapping pass (`use_ai`), which is billed at the model's token rate. --- ### Invoices & Payments URL: /docs/crm-invoices > Issue numbered invoices with tax, send them to customers, record payments, collect online through your connected payment gateway, and track what is overdue. ## Overview An **invoice** is a numbered request for payment. You can create one directly, or have it created from an accepted [quote](/docs/crm-quotes). Customers receive a hosted link where they can view the invoice, download a PDF and, where available, pay online. The quote to payment flow: 1. A [quote](/docs/crm-quotes) is accepted, which creates an invoice (or you create an invoice yourself as a `draft`). 2. **Issue** the invoice. It gets its number, your billing details are frozen onto it and status becomes `issued`. 3. **Send** it by email and/or WhatsApp. The customer receives a hosted link to view and pay. 4. Payments are recorded against it, either manually by you or automatically when the customer pays online. Status moves to `partially_paid` and then `paid`. 5. If the due date passes with money outstanding, the invoice becomes `overdue` automatically. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, summary, payments list, PDF | `crm_invoices:read` | | Create, edit, issue, send, record payment, create payment link, void, delete | `crm_invoices:write` | A key missing the scope gets `403`. ## Money and tax All money fields end in `_minor` and are **integers in minor units** of the invoice `currency` (paise for `INR`, cents for `USD`; `JPY` has none). `1180.50` is `118050`. Rates are in **basis points**: `1800` is 18%. Totals are computed by the server and are authoritative. Tax modes (CGST + SGST, IGST, standard or none) work as described in [Tax regimes](/docs/crm-products#tax-regimes). Tax is calculated only from the rates you configure; consult your tax advisor for how it applies to you. ## The invoice object ```json { "id": "d5e6…", "tenant_id": "a0b1…", "number": "INV/26-27/0001", "status": "partially_paid", "title": "Voice agents for Acme", "contact_id": "4411…", "company_id": "5c6d…", "deal_id": "33cc…", "quote_id": "a1b2…", "owner_user_id": "b1f2…", "currency": "INR", "issue_date": "2026-10-09", "due_date": "2026-10-24", "buyer": { "name": "Acme Traders", "state_code": "27", "country": "IN", "tax_id": "27AAAAA0000A1Z5" }, "seller": { "legal_name": "…", "tax_id": "…" }, "tax_mode": "in_intra", "place_of_supply": "27", "subtotal_minor": 2500000, "discount_minor": 0, "tax_minor": 450000, "total_minor": 2950000, "amount_paid_minor": 1000000, "amount_due_minor": 1950000, "tax_breakdown": [ { "label": "CGST 9%", "rate_bps": 900, "taxable_minor": 2500000, "tax_minor": 225000 }, { "label": "SGST 9%", "rate_bps": 900, "taxable_minor": 2500000, "tax_minor": 225000 } ], "lines": [], "notes": null, "terms": "Payment due within 15 days.", "public_url": "https://…/invoice/…", "payment_request_id": null, "sent_at": "2026-10-09T10:00:00Z", "viewed_at": null, "issued_at": "2026-10-09T10:00:00Z", "paid_at": null, "voided_at": null, "void_reason": null, "created_at": "2026-10-09T09:50:00Z", "updated_at": "2026-10-10T08:00:00Z" } ``` `amount_due_minor` is the amount still owed: `total_minor - amount_paid_minor`, or `0` on a `draft` or `void` invoice. `lines` is returned on create, get, update and the action responses, and omitted from list rows. `buyer`, `seller`, `tax_*`, `lines` and the totals follow the same shapes and rules as the [quote object](/docs/crm-quotes#the-quote-object). `public_url` is the hosted link for the customer; treat it as a secret. `number` is `null` on a draft. Numbers are assigned when the invoice is **issued**, so numbering has no gaps, and a voided invoice keeps its number. ### Status lifecycle | Status | Meaning | Moves to | | --- | --- | --- | | `draft` | Editable, no number | `issued`, `void` | | `issued` | Numbered and sent or ready to send | `partially_paid`, `paid`, `overdue`, `void` | | `partially_paid` | Some money received | `paid`, `overdue` | | `overdue` | Past `due_date` with a balance. Set automatically, or when a partial payment lands after the due date | `paid`, `void` | | `paid` | Fully paid | final | | `void` | Cancelled. Only allowed with no payments recorded | final | Any move not listed returns `422`. Header fields and lines can be edited **only in `draft`**. ## GET `/api/v1/crm/invoices` Newest first. Each row carries the header fields, `buyer`, `total_minor`, `due_date`, `amount_paid_minor`, `quote_id`, `sent_at`, a computed `amount_due_minor` and a computed `buyer_name`. It omits `lines`, `seller`, the tax fields and `public_url`. | Parameter | Type | Constraints | | --- | --- | --- | | `status` | `string` | One of the statuses above, otherwise `422` | | `contact_id` | `UUID` | | | `deal_id` | `UUID` | | | `q` | `string` | Substring on number, title and buyer name. Up to 255 characters | | `overdue` | `boolean` | Default `false`. `true` returns only `overdue` invoices plus `issued` or `partially_paid` ones already past their due date | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ## GET `/api/v1/crm/invoices/count` How many invoices the list would return across every page. Takes the same `status`, `contact_id`, `deal_id`, `q` and `overdue` filters as the list (no `limit` or `offset`). ```json { "total": 312 } ``` ## GET `/api/v1/crm/invoices/summary` Outstanding, overdue and recently paid totals, one row per currency because amounts in different currencies are never added together. `outstanding_minor` is the balance on `issued`, `partially_paid` and `overdue` invoices, `overdue_minor` is the overdue part of that, and `paid_30d_minor` is the payments recorded in the last 30 days. Rows are sorted by currency. ```json { "currencies": [ { "currency": "INR", "outstanding_minor": 1950000, "overdue_minor": 450000, "paid_30d_minor": 5900000 } ] } ``` ## POST `/api/v1/crm/invoices` Creates a `draft`. The body and line fields are the same as [creating a quote](/docs/crm-quotes#post-apiv1crmquotes), with these differences: there is no `lead_id` or `valid_until`; `due_date` (default `issue_date` plus `payment_terms_days`) and `quote_id` (optional, must be your quote or `404`) are accepted. `due_date` before `issue_date` returns `422`. Every line needs `name`, `quantity` and `unit_price_minor`. ```bash curl -X POST https://api.callmissed.com/api/v1/crm/invoices \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "4411…", "deal_id": "33cc…", "lines": [ { "name": "Voice agent setup", "quantity": 1, "unit_price_minor": 2500000, "tax_name": "GST 18%", "tax_rate_bps": 1800 } ] }' ``` Returns `201` with the full invoice, its `lines` and `public_url`. ## GET / PATCH / DELETE `/api/v1/crm/invoices/{invoice_id}` `PATCH` accepts the create fields, all optional (except `quote_id`); `lines`, when present, replaces every line and recalculates totals. An explicit `null` for `currency`, `issue_date` or `due_date` is ignored. `PATCH` and `DELETE` (`204`, no body) work only on a `draft`, otherwise `422`. ## Issue and send ### POST `/api/v1/crm/invoices/{invoice_id}/issue` `draft` to `issued`. Assigns the number, freezes your billing details and stamps `issued_at`. Returns the invoice. Any other status, or a draft with no lines, returns `422`. ### POST `/api/v1/crm/invoices/{invoice_id}/send` Same body and per-channel response as [sending a quote](/docs/crm-quotes#sending) (`channels`, `to_email`, `to_phone`, `message`). Sending a `draft` issues it first (`422` if it has no lines). An `issued`, `partially_paid`, `overdue` or `paid` invoice is delivered again without a status change; a `void` invoice returns `422`. The customer receives a hosted link where they can view the invoice, download the PDF and pay online when it is available. The same recipient rule for API keys (`403`) and daily send limit (`429`) apply as for [quotes](/docs/crm-quotes#sending). ## Payments ```json { "id": "e7f8…", "invoice_id": "d5e6…", "amount_minor": 1000000, "method": "bank_transfer", "reference": "UTR 4821", "paid_at": "2026-10-10T08:00:00Z", "payment_request_id": null, "recorded_by_user_id": null, "note": null, "created_at": "2026-10-10T08:01:00Z" } ``` ### POST `/api/v1/crm/invoices/{invoice_id}/payments` Records a payment received outside the platform. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `amount_minor` | `integer` | Yes | `> 0`, at most `10^15`, minor units. Cannot exceed the balance | | `method` | `string` | No | `cash`, `bank_transfer`, `upi`, `card`, `cheque` or `other`. Default `bank_transfer`. `online` is not accepted: online payments are recorded for you | | `paid_at` | `datetime` | No | Default now | | `reference` | `string` | No | Up to 128 characters | | `note` | `string` | No | Up to 500 characters | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/invoices/d5e6…/payments \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "amount_minor": 1000000, "method": "bank_transfer", "reference": "UTR 4821" }' ``` Returns `201` with the payment. The invoice moves to `partially_paid` (or stays or becomes `overdue` when the due date has passed and a balance remains), or to `paid` once the balance reaches zero (and `paid_at` is stamped; the linked contact's lifecycle stage becomes `customer`). A payment larger than the balance, or on a `draft`, `paid` or `void` invoice, returns `422`. When a payment settles the invoice, the `invoice.paid` [webhook event](/docs/webhooks) is emitted once the request commits. `GET /api/v1/crm/invoices/{invoice_id}/payments` (scope `crm_invoices:read`) lists the payments, oldest `paid_at` first. ### Online payment ### POST `/api/v1/crm/invoices/{invoice_id}/payment-link` Creates, or reuses when one is still live for the same balance, a payment link for the amount due through **your connected payment gateway**. When the customer pays, the payment is recorded on the invoice for you (method `online`), the status updates, and the `invoice.paid` [webhook event](/docs/webhooks) is emitted once the invoice is settled. Takes no body and returns `200`. While a link for the invoice is still being created, a second call returns `409`; retry after a few seconds. The gateway does not message the customer itself: share `url`, or [send the invoice](#issue-and-send), whose hosted page has a Pay button. Recording a payment or voiding the invoice cancels any unpaid link for it at the gateway, so an old link cannot collect a stale amount. If money still arrives that cannot be recorded (the invoice was settled or voided meanwhile), it is written as a [note](/docs/crm-notes-tasks) on the invoice so you can refund or reconcile it. Online payment is available for invoices in `INR` only, in `issued`, `partially_paid` or `overdue` status with a balance due, and only when you have connected your own Razorpay account. The invoice buyer needs an email or phone number. The amount due is capped at INR 5,00,000 per link; above it the call returns `422` and you should collect by bank transfer instead. For any other currency, share your bank details (they are printed on the invoice) and record the payment manually. ```json { "url": "https://…" } ``` ## Void and PDF - `POST /api/v1/crm/invoices/{invoice_id}/void` cancels an invoice. `reason` is **required** (1–500 characters, not blank). Allowed from `draft`, `issued` or `overdue`, and only with **no payments** recorded, otherwise `422`. Returns the invoice. - `GET /api/v1/crm/invoices/{invoice_id}/pdf` (scope `crm_invoices:read`) returns the invoice as `application/pdf`, rendered on demand. Documents with `tax_mode` `in_intra` or `in_inter` are titled "Tax Invoice". ## Overdue An invoice is past due from the day after its `due_date` in your workspace timezone: the `overdue=true` list filter, the summary and a partial payment use that date. The stored status of `issued` or `partially_paid` invoices moves to `overdue` automatically once the due date has passed in every timezone, so it can lag by up to a day. Recording the remaining payment moves an overdue invoice to `paid`; a partial payment on an overdue invoice leaves it `overdue`. --- ## Errors | Status | When | | --- | --- | | `403` | Key is missing `crm_invoices:read` / `crm_invoices:write`, or a key sends to a recipient other than the buyer, or emails an address that is not a CRM contact | | `404` | Invoice, quote, contact, company, deal, owner or product not in your tenant | | `409` | A payment link for the invoice is still being created | | `422` | Invalid status transition, editing or deleting a non-draft invoice, a line or invoice total above 10^15 minor units, issuing or sending a draft with no lines, sending a `void` invoice, overpayment, payment on a `draft`, `paid` or `void` invoice, voiding an invoice that has payments, a payment link on a non-payable or non-`INR` invoice, with no gateway connected, with no buyer email or phone, or above the ceiling, or a value outside the bounds above | | `429` | The workspace's daily send limit for that channel is used up | Nothing on this page consumes credits. Email and WhatsApp deliveries follow the normal charges of those channels. --- ### Lead Scoring & Timeline URL: /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 score is the plain sum of the points of every **active** rule that matched. It is not clamped, so negative rules can take it below zero. ## 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 }' ``` Returns `201`. A name already used for that entity type returns `409 A contact scoring rule named '…' already exists`. 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. `DELETE` returns `204`; both return `404 Scoring rule not found` for an id outside your tenant. 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: recomputing overwrites the record's previous score. Repeated ids are collapsed, and `requested` is the raw count you sent. 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. A company's feed also includes the activity of its contacts. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `entity_type` | `string` | Yes | `contact` or `company`. **Deals are not supported here** | | `entity_id` | `UUID` | Yes | | | `types` | `string` | No | At most 128 characters. Comma-separated subset of `conversation,message,call,note,task,deal,ticket`. 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", "summary": "Renewal call went well — wants a Hindi voice agent.", "actor": "b1f2…", "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: a user id for notes, tasks and deals, the sender's role for messages, the other party's number for calls. `null` when there is no attributable actor | | `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. --- ### Leads URL: /docs/crm-leads > Capture and qualify prospects before they are customers, track follow-ups, and convert a lead into a contact, company and deal in one call. ## Overview A **lead** is a prospect you have not yet qualified. It is lighter than a contact: a name, one way to reach them, where they came from, who owns the follow-up and what happens next. When a lead is worth pursuing, **convert** it — one call creates the contact, the company and (optionally) a deal, and links them back to the lead. Leads move through a fixed status lifecycle. Quotes can be raised against a lead, and notes and tasks can be attached to one (`entity_type: "lead"`, see [Notes & Tasks](/docs/crm-notes-tasks)). ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, stats | `crm_leads:read` | | Create, update, delete, convert | `crm_leads:write` | ## The lead object ```json { "id": "7a10…", "tenant_id": "a0b1…", "name": "Priya Nair", "email": "priya@acme.example", "phone": "+919800000000", "company_name": "Acme Traders", "job_title": "Operations Head", "source": "web_form", "status": "qualified", "owner_user_id": "b1f2…", "score": 40, "estimated_value_minor": 24000000, "currency": "INR", "next_follow_up_at": "2026-10-12T09:30:00Z", "last_contacted_at": "2026-10-08T11:00:00Z", "lost_reason": null, "message": "Need a voice agent for missed-call follow-ups.", "attribution": { "utm_source": "newsletter" }, "external_ref": null, "converted_contact_id": null, "converted_company_id": null, "converted_deal_id": null, "converted_at": null, "tags": ["enterprise"], "created_at": "2026-10-07T09:00:00Z", "updated_at": "2026-10-08T11:00:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `source` | `string` | `manual`, `web_form`, `book_demo`, `partner`, `whatsapp`, `voice`, `referral`, `import`, `api` or `other`. Default `manual` | | `status` | `string` | `new`, `contacted`, `qualified`, `unqualified`, `converted` or `lost`. Default `new` | | `estimated_value_minor` | `integer \| null` | **Integer minor units** of `currency` (paise for `INR`, cents for `USD`). `24000000` is INR 240,000.00 | | `currency` | `string` | ISO 4217, three letters, upper-cased. Default `INR` | | `score` | `integer` | `0` to `100`. Default `0` | | `external_ref` | `string \| null` | Your own de-duplication key, up to 64 characters. Set on create only. Unique per tenant when set | | `attribution` | `object \| null` | Free-form JSON about where the lead came from | | `converted_*` | `UUID \| null` | Filled in by [convert](#convert-a-lead) | | `tags` | `string[]` | Your labels, in the order added. Up to 20, each at most 40 characters. See [tags](/docs/crm-import-export#tags) | ### Status lifecycle | Status | Meaning | | --- | --- | | `new` | Just captured | | `contacted` | You have reached out | | `qualified` | Worth pursuing | | `unqualified` | Not a fit | | `lost` | Pursued, did not close. Record `lost_reason` | | `converted` | Set by the convert call only. Creating or updating a lead with `status: "converted"` is `422`, and a converted lead's status cannot be changed | ## GET `/api/v1/crm/leads` Newest first, unless you ask for `sort=score`. | Parameter | Type | Constraints | | --- | --- | --- | | `status` | `string` | One of the statuses above. Anything else is `422` | | `source` | `string` | One of the sources above | | `owner_user_id` | `UUID` | | | `q` | `string` | Up to 320 characters. Case-insensitive substring match on name, email, phone and company name | | `follow_up_before` | `datetime` | Leads whose `next_follow_up_at` is at or before this time | | `tag` | `string` (repeatable) | Leads carrying **any** of these tags, e.g. `?tag=enterprise&tag=renewal`. Exact match. At most 20 | | `sort` | `string` | `newest` (default) or `score` (highest `score` first, then newest). Anything else is `422` | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ## GET `/api/v1/crm/leads/stats` Total and counts of your leads by status. Every status is present, with `0` when there are none. Accepts the same `source`, `owner_user_id`, `q`, `follow_up_before` and `tag` filters as the list, so the counts describe exactly the leads that list would page through. ```json { "total": 40, "by_status": { "new": 14, "contacted": 9, "qualified": 5, "unqualified": 3, "converted": 7, "lost": 2 } } ``` ## POST `/api/v1/crm/leads` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank | | `email` | `string` | No | Up to 320 characters. Stored lower-cased | | `phone` | `string` | No | Up to 32 characters | | `company_name` | `string` | No | Up to 255 characters | | `job_title` | `string` | No | Up to 255 characters | | `source` | `string` | No | Default `manual` | | `status` | `string` | No | Any status except `converted`. Default `new`. Creating a lead as `contacted` without `last_contacted_at` stamps it with the current time | | `owner_user_id` | `UUID` | No | Must be a user in your tenant | | `score` | `integer` | No | `0` to `100`, default `0` | | `estimated_value_minor` | `integer` | No | `0` to `10^15`, minor units | | `currency` | `string` | No | Exactly 3 letters (upper-cased), default `INR` | | `next_follow_up_at` | `datetime` | No | | | `last_contacted_at` | `datetime` | No | | | `lost_reason` | `string` | No | Up to 255 characters | | `message` | `string` | No | The original enquiry. Up to 10,000 characters | | `attribution` | `object` | No | | | `external_ref` | `string` | No | Up to 64 characters, unique per tenant | | `tags` | `string[]` | No | Up to 20, each at most 40 characters. Trimmed; blanks and repeats dropped | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/leads \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Priya Nair", "email": "priya@acme.example", "company_name": "Acme Traders", "source": "referral", "estimated_value_minor": 24000000, "currency": "INR" }' ``` Returns `201` with the lead object. A reused `external_ref` returns `409`. An `owner_user_id` outside your tenant returns `404`. ## GET / PATCH / DELETE `/api/v1/crm/leads/{lead_id}` `PATCH` accepts every create field except `external_ref`, all optional. Explicit `null` is `422` for `name`, `source`, `status`, `score` and `currency`. Moving a lead to `contacted` stamps `last_contacted_at` with the current time unless you send it. Changing the status of a `converted` lead is `422`. `tags` replaces the whole list (`null` or `[]` clears it); use [bulk tags](/docs/crm-import-export#post-apiv1crmbulkadd-tags) to add or remove one tag across many leads. `DELETE` permanently deletes the lead and returns `204`; contacts, companies and deals created by a conversion are kept. ## Convert a lead ### POST `/api/v1/crm/leads/{lead_id}/convert` In one transaction: reuses a contact or creates one from the lead (an existing contact in your account with the same phone, then the same email, is reused), reuses a company with the same name (case-insensitive) or creates one when `company_name` is set, and optionally creates a deal. Sets the lead to `converted`, stamps `converted_at` and fills the three `converted_*` ids. The contact's lifecycle stage moves to `opportunity` when a deal is created, otherwise `qualified`, and is never moved backwards. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `create_deal` | `boolean` | No | Default `true` | | `pipeline_id` | `UUID` | No | Pipeline for the new deal. Omit to use the pipeline of `stage_id`, else your default pipeline | | `stage_id` | `UUID` | No | Must belong to that pipeline. Omit to use the pipeline's first stage | | `deal_title` | `string` | No | 1–255 characters, not blank. Defaults to the lead's name | | `deal_value_minor` | `integer` | No | `0` to `10^15`, minor units. Defaults to the lead's `estimated_value_minor` | | `existing_contact_id` | `UUID` | No | Link to a contact you already have instead of creating one | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/leads/7a10…/convert \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "create_deal": true, "deal_title": "Acme — voice agents" }' ``` ```json { "lead": { "id": "7a10…", "status": "converted", "converted_contact_id": "4411…", "converted_at": "2026-10-09T10:00:00Z", "…": "full lead object" }, "contact_id": "4411…", "company_id": "5c6d…", "deal_id": "33cc…" } ``` `lead` is the full lead object. `company_id` and `deal_id` are `null` when no company or deal was created or linked. Converting a lead that is already `converted` returns `409`, as does a new contact that would collide on phone or email. A `pipeline_id`, `stage_id` or `existing_contact_id` outside your tenant returns `404`. With `create_deal: true`, `422` is returned when you have no pipeline (create one or send `create_deal: false`), the pipeline has no stages, the stage is outside the pipeline, or the deal value is too large. --- ## Errors | Status | When | | --- | --- | | `403` | API key is missing `crm_leads:read` / `crm_leads:write` (dashboard logins are not scope-checked) | | `404` | Lead, owner, pipeline, stage or contact not in your tenant | | `409` | Duplicate `external_ref`, converting an already converted lead, or a phone/email collision on the new contact | | `422` | Blank name, unknown `status` or `source`, `converted` as a status, a null on a required field in `PATCH`, a convert problem listed above, more than 20 tags or a tag over 40 characters, or a value outside the bounds above | Nothing on this page consumes credits. --- ### Notes & Tasks URL: /docs/crm-notes-tasks > Attach freeform notes to a contact, company, deal, lead, quote or invoice, and track follow-up work with due dates, assignees and an overdue flag. ## Overview **Notes** are freeform text attached to a contact, company, deal, lead, quote or invoice. **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](/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`, `deal`, `lead`, `quote` or `invoice` | | `entity_id` | `UUID` | Yes | An unknown id returns an empty list | | `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`, `deal`, `lead`, `quote` or `invoice` | | `entity_id` | `UUID` | Yes | Must exist in your tenant | | `body` | `string` | Yes | 1–10,000 characters, not blank | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/notes \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "company", "entity_id": "5c6d…", "body": "Renewal call went well." }' ``` 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. Set when `status` becomes `done`, cleared when it returns to `open` | | `created_by_user_id` | `UUID \| null` | The dashboard user who created it; `null` when created with an API key | | `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`, `deal`, `lead`, `quote` or `invoice` | | `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`, `deal`, `lead`, `quote` or `invoice` | | `entity_id` | `UUID` | No | Must exist in your tenant | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/tasks \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "title": "Send the renewal quote", "due_at": "2026-08-19T10:00:00Z", "entity_type": "company", "entity_id": "5c6d…" }' ``` `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. A linked record outside your tenant returns `422 not found in this tenant`; an assignee outside your tenant returns `422 assignee_user_id is not a user of this tenant`. Returns `201`. ## 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 or task not in your tenant, or (notes only) the record you attach a note to | | `422` | Blank body/title, unknown `entity_type` or `status`, an entity pair sent half-filled, a task's linked record or assignee outside your tenant | Nothing on this page consumes credits. --- ### Products, Tax Rates & Billing Profile URL: /docs/crm-products > Your catalogue of billable items, the named tax rates applied to them, and the billing profile that drives numbering and tax mode on quotes and invoices. ## Overview Three small resources sit behind [quotes](/docs/crm-quotes) and [invoices](/docs/crm-invoices): - **Products** are reusable line items with a price, unit and optional tax rate. - **Tax rates** are named percentages you define, such as "GST 18%" or "VAT 20%". - The **billing profile** is your seller identity (legal name, address, tax ID) plus numbering and default terms. There is exactly one per account. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Read products and tax rates | `crm_products:read` | | Create, update, delete products and tax rates | `crm_products:write` | | Read the billing profile | `crm_billing:read` | | Replace the billing profile | `crm_billing:write` | One scope pair covers both products and tax rates. Dashboard logins are not scope-checked, except that creating, updating or deleting tax rates and replacing the billing profile require an owner or admin role. ## Money and rates - **Money is always an integer in minor units** of the document's currency, never a decimal. INR `1180.50` is `118050`. Currencies with no minor unit (for example `JPY`) use exponent 0; a few (for example `KWD`) use exponent 3; everything else uses 2. - **Tax rates are in basis points** (1 bps = 0.01%). `1800` is 18%. Allowed range `0..10000`. --- # Products ```json { "id": "8b20…", "tenant_id": "a0b1…", "name": "Voice agent setup", "description": "One-time onboarding", "sku": "VA-SETUP", "unit": "unit", "unit_price_minor": 2500000, "currency": "INR", "tax_rate_id": "9c30…", "hsn_sac": "998313", "archived": false, "created_at": "2026-10-07T09:00:00Z", "updated_at": "2026-10-07T09:00:00Z" } ``` | Method | Path | Notes | | --- | --- | --- | | `GET` | `/api/v1/crm/products` | `q` (up to 255 characters; case-insensitive name or SKU substring), results ordered by name, `include_archived` (default `false`), `limit` `1..200` (default `50`), `offset` `0..100000` | | `POST` | `/api/v1/crm/products` | `201` | | `GET` | `/api/v1/crm/products/{product_id}` | | | `PATCH` | `/api/v1/crm/products/{product_id}` | Any create field plus `archived`, all optional. A null `unit`, `unit_price_minor`, `currency` or `archived` is ignored; a blank `name` is `422` | | `DELETE` | `/api/v1/crm/products/{product_id}` | `204`. Archives the product if any quote or invoice line references it; otherwise deletes it | ### POST `/api/v1/crm/products` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank | | `description` | `string` | No | Up to 5000 characters | | `sku` | `string` | No | Up to 64 characters, unique per tenant when set (`409` otherwise) | | `unit` | `string` | No | 1–32 characters, default `unit` | | `unit_price_minor` | `integer` | No | `0` to `10^15`, default `0` | | `currency` | `string` | No | Exactly 3 letters (upper-cased). Defaults to your billing profile's `default_currency` | | `tax_rate_id` | `UUID` | No | Must be your tax rate (`404` otherwise) | | `hsn_sac` | `string` | No | Up to 16 characters. HSN (goods) or SAC (services) code, used on Indian GST documents | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/products \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Voice agent setup", "sku": "VA-SETUP", "unit_price_minor": 2500000, "currency": "INR", "tax_rate_id": "9c30…", "hsn_sac": "998313" }' ``` Create returns `201` with the product object. Archived products stay on existing documents but are hidden from the default list. --- # Tax rates ```json { "id": "9c30…", "tenant_id": "a0b1…", "name": "GST 18%", "rate_bps": 1800, "is_default": true, "archived": false, "created_at": "2026-10-07T09:00:00Z", "updated_at": "2026-10-07T09:00:00Z" } ``` | Method | Path | Notes | | --- | --- | --- | | `GET` | `/api/v1/crm/tax-rates` | `include_archived` (default `false`), `limit` `1..200` (default `50`), `offset` `0..100000`. Ordered by `rate_bps`, then name | | `POST` | `/api/v1/crm/tax-rates` | `201`. `name` 1–64 characters, unique per tenant (`409`). `rate_bps` `0..10000`, required. `is_default` default `false` | | `PATCH` | `/api/v1/crm/tax-rates/{rate_id}` | `name`, `rate_bps`, `is_default`, `archived`, all optional. A null `rate_bps`, `is_default` or `archived` is ignored | | `DELETE` | `/api/v1/crm/tax-rates/{rate_id}` | `204`. Archives the rate (and clears its default flag) rather than removing it, so existing documents keep their history | Only one rate is the default: setting `is_default` on one clears it on the others, and archiving a rate clears its default flag. Writes need an owner or admin role for dashboard logins. Changing a rate does not rewrite existing quotes or invoices. Each line stores the tax name and rate it was created with. --- # Billing profile One profile per account. `GET` creates the profile with defaults on first read if you have not saved one yet. ```json { "id": "1d40…", "tenant_id": "a0b1…", "legal_name": "Acme Traders Pvt Ltd", "address_line1": "12 MG Road", "address_line2": null, "city": "Pune", "state": "Maharashtra", "state_code": "27", "postal_code": "411001", "country": "IN", "tax_id": "27ABCDE1234F1Z5", "tax_regime": "in_gst", "default_currency": "INR", "invoice_prefix": "INV", "quote_prefix": "QT", "numbering_period": "fy_april", "payment_terms_days": 15, "quote_validity_days": 30, "default_notes": null, "default_terms": "Payment due within 15 days.", "bank_details": "Account name, number, IFSC", "reply_to_email": "billing@acme.example", "auto_invoice_on_accept": true, "created_at": "2026-10-07T09:00:00Z", "updated_at": "2026-10-07T09:00:00Z" } ``` | Method | Path | | --- | --- | | `GET` | `/api/v1/crm/billing-profile` | | `PUT` | `/api/v1/crm/billing-profile` | `PUT` replaces the whole profile: every field is optional, but an omitted field is reset to its default (`null` for text fields). With an API key it needs `crm_billing:write`; with a dashboard login only an owner or admin may change it (`403` otherwise). Returns the saved profile. | Field | Type | Constraints | | --- | --- | --- | | `legal_name` | `string` | Up to 255 characters | | `address_line1`, `address_line2` | `string` | Up to 255 characters each | | `city`, `state` | `string` | Up to 120 characters each | | `state_code` | `string` | Up to 4 characters. For India, the 2-digit GST state code | | `postal_code` | `string` | Up to 20 characters | | `country` | `string` | Exactly 2 letters (upper-cased), ISO 3166, default `IN` | | `tax_id` | `string` | Up to 32 characters. GSTIN, VAT number or equivalent | | `tax_regime` | `string` | `in_gst`, `standard` or `none`. Default `in_gst`. See below | | `default_currency` | `string` | Exactly 3 letters (upper-cased), ISO 4217, default `INR` | | `invoice_prefix`, `quote_prefix` | `string` | 1–5 characters: letters, digits and `-` only. Defaults `INV` and `QT` | | `numbering_period` | `string` | `fy_april`, `calendar` or `none`. Default `fy_april` | | `payment_terms_days` | `integer` | `0..365`, default `15`. Sets the invoice due date | | `quote_validity_days` | `integer` | `1..365`, default `30`. Sets the quote `valid_until` | | `default_notes`, `default_terms` | `string` | Up to 5000 characters each. Copied onto new documents | | `bank_details` | `string` | Up to 2000 characters. Copied onto new documents | | `reply_to_email` | `string` | Up to 320 characters, must be a valid-looking email address. Replies to documents you send go here | | `auto_invoice_on_accept` | `boolean` | Default `true`. See [quotes](/docs/crm-quotes#accepting) | ## Tax regimes | `tax_regime` | Effect | | --- | --- | | `in_gst` | Indian GST. If the buyer's state code matches yours the tax is split into **CGST** and **SGST** (half each); otherwise it is a single **IGST** line. A buyer outside India is treated as inter-state | | `standard` | One named tax row per rate, such as "VAT 20%". No country rules are applied | | `none` | All line tax is forced to `0` | The mode is resolved for you from the seller and buyer details and saved on each document as `tax_mode` (`in_intra`, `in_inter`, `standard` or `none`) with a `place_of_supply`. CallMissed does not look up rates for you: tax is calculated from the rates you configure. For tax treatment, such as zero-rating exports, consult your tax advisor. ## Numbering Document numbers look like `INV/26-27/0001`: prefix, period, then a gapless counter that restarts each period. | `numbering_period` | Period segment | | --- | --- | | `fy_april` | Financial year starting 1 April, such as `26-27` | | `calendar` | Calendar year, such as `2026` | | `none` | No period segment, such as `INV/0001` | Quotes are numbered when first sent. Invoices are numbered when issued, so drafts never consume a number. --- ## Errors | Status | When | | --- | --- | | `403` | Key is missing the scope, or a dashboard user who is not an owner or admin creates, updates or deletes tax rates or replaces the billing profile | | `404` | Product, tax rate or referenced tax rate not in your tenant | | `409` | Duplicate SKU or tax rate name | | `422` | Blank name, `rate_bps` outside `0..10000`, unknown `tax_regime` or `numbering_period`, an invalid `country`, currency, prefix or `reply_to_email`, or a value outside the bounds above | Nothing on this page consumes credits. --- ### Quotes URL: /docs/crm-quotes > Draft priced quotes with line items and tax, send them to customers by email or WhatsApp, track acceptance, and convert an accepted quote into an invoice. ## Overview A **quote** is a priced offer to a customer. You build it as a draft, send it, and the customer receives a hosted link where they can view it, accept it or decline it. Accepting can issue an [invoice](/docs/crm-invoices) automatically. The flow end to end: 1. Create a quote (`draft`) with line items. Totals and tax are calculated by the server. 2. **Send** it. It is numbered, your billing details are frozen onto it, and the customer gets a link by email and/or WhatsApp. Status becomes `sent`. 3. The customer opens the link (`viewed`) and accepts or declines. You can also record the decision yourself. 4. On acceptance the quote becomes `accepted`, or `converted` when [`auto_invoice_on_accept`](/docs/crm-products#billing-profile) is on and an invoice is issued for you. 5. The invoice is paid, see [Invoices](/docs/crm-invoices). ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, PDF | `crm_quotes:read` | | Create, edit, send, accept, decline, convert, revise, void, delete | `crm_quotes:write` | Converting a quote creates an invoice, so [convert](#convert-to-an-invoice) needs **both** `crm_quotes:write` and `crm_invoices:write`. A key missing a required scope gets `403`. Attaching a quote to a contact, company, deal or lead, or using a product, also requires that the record is in your tenant. ## Money and tax All money fields end in `_minor` and are **integers in minor units** of the quote `currency` (paise for `INR`, cents for `USD`; `JPY` has none). `1180.50` rupees is `118050`. Rates are in **basis points**: `1800` is 18%. Totals are computed by the server and are authoritative; amounts you send for `subtotal`, `tax` or `total` are ignored. Per line: `subtotal = quantity x unit_price_minor - discount`, `tax = subtotal x tax_rate_bps / 10000`, rounded half up to the minor unit. ## The quote object ```json { "id": "a1b2…", "tenant_id": "a0b1…", "number": "QT/26-27/0001", "status": "sent", "title": "Voice agents for Acme", "contact_id": "4411…", "company_id": "5c6d…", "deal_id": "33cc…", "lead_id": null, "owner_user_id": "b1f2…", "currency": "INR", "issue_date": "2026-10-09", "valid_until": "2026-11-08", "buyer": { "name": "Acme Traders", "email": "accounts@acme.example", "phone": "+919800000000", "address": "44 Park Street", "city": "Mumbai", "state": "Maharashtra", "state_code": "27", "postal_code": "400001", "country": "IN", "tax_id": "27AAAAA0000A1Z5" }, "seller": { "legal_name": "…", "tax_id": "…" }, "tax_mode": "in_intra", "place_of_supply": "27", "subtotal_minor": 2500000, "discount_minor": 0, "tax_minor": 450000, "total_minor": 2950000, "tax_breakdown": [ { "label": "CGST 9%", "rate_bps": 900, "taxable_minor": 2500000, "tax_minor": 225000 }, { "label": "SGST 9%", "rate_bps": 900, "taxable_minor": 2500000, "tax_minor": 225000 } ], "lines": [ { "id": "c3d4…", "position": 0, "product_id": "8b20…", "name": "Voice agent setup", "description": null, "hsn_sac": "998313", "unit": "unit", "quantity": 1, "unit_price_minor": 2500000, "discount_bps": 0, "tax_name": "GST 18%", "tax_rate_bps": 1800, "subtotal_minor": 2500000, "tax_minor": 450000, "total_minor": 2950000 } ], "notes": null, "terms": "Payment due within 15 days.", "public_url": "https://…/quote/…", "sent_at": "2026-10-09T10:00:00Z", "viewed_at": null, "accepted_at": null, "declined_at": null, "accepted_by_name": null, "decline_reason": null, "converted_invoice_id": null, "created_at": "2026-10-09T09:50:00Z", "updated_at": "2026-10-09T10:00:00Z" } ``` | Field | Notes | | --- | --- | | `number` | `null` until the quote is first sent. Format in [Numbering](/docs/crm-products#numbering) | | `lines` | Returned on create, get, update and the action responses. List rows omit it | | `buyer` | A snapshot of the customer's details at the time of the quote, so later edits to the contact do not change the document | | `seller` | Your [billing profile](/docs/crm-products#billing-profile), frozen when the quote is sent. `null` on a draft | | `tax_mode` | `in_intra` (CGST + SGST), `in_inter` (IGST), `standard` or `none`. See [Tax regimes](/docs/crm-products#tax-regimes) | | `public_url` | The hosted link customers use. Treat it as a secret: anyone holding it can view the quote | | `lines[].position` | Zero-based order of the line | | `lines[].quantity` | Number, up to 3 decimal places | ### Status lifecycle | Status | Meaning | Moves to | | --- | --- | --- | | `draft` | Editable. No number yet | `sent`, `void` | | `sent` | Delivered, awaiting a decision | `viewed`, `accepted`, `declined`, `expired`, `void`, or `draft` via revise | | `viewed` | Customer first opened the link | `accepted`, `declined`, `expired`, `void`, or `draft` via revise | | `accepted` | Customer or you accepted | `converted`, `void` | | `declined` | Customer or you declined | `draft` via revise, `void` | | `expired` | Past `valid_until` without a decision. Set automatically | `draft` via revise, `void` | | `converted` | An invoice was created from it | final | | `void` | Cancelled | final | Any move not listed returns `422`. Header fields and lines can be edited **only in `draft`**. Accepting also returns `422` once `valid_until` has passed. ## GET `/api/v1/crm/quotes` Newest first. Each row carries the header fields, `buyer`, `total_minor`, `sent_at`, `valid_until`, `converted_invoice_id` and a computed `buyer_name`. It omits `lines`, `seller`, the tax fields and `public_url`. | Parameter | Type | Constraints | | --- | --- | --- | | `status` | `string` | One of the statuses above, otherwise `422` | | `contact_id` | `UUID` | | | `deal_id` | `UUID` | | | `lead_id` | `UUID` | | | `q` | `string` | Substring on number, title and buyer name. Up to 255 characters | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ## GET `/api/v1/crm/quotes/count` How many quotes the list would return across every page. Takes the same `status`, `contact_id`, `deal_id`, `lead_id` and `q` filters as the list (no `limit` or `offset`). ```json { "total": 48 } ``` ## POST `/api/v1/crm/quotes` Creates a `draft`. Currency, validity, notes and terms default from your billing profile. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `title` | `string` | No | Up to 255 characters | | `contact_id` | `UUID` | No | Must be your contact | | `company_id` | `UUID` | No | Must be your company | | `deal_id` | `UUID` | No | Must be your deal | | `lead_id` | `UUID` | No | Must be your lead | | `owner_user_id` | `UUID` | No | Must be a user in your account | | `currency` | `string` | No | 3-letter ISO 4217 code. Default: profile `default_currency` | | `issue_date` | `date` | No | Default today | | `valid_until` | `date` | No | Default `issue_date` plus `quote_validity_days`. Before `issue_date` returns `422` | | `buyer` | `object` | No | Overrides the details taken from the contact or company. Keys: `name`, `email`, `phone`, `address`, `city`, `state`, `state_code`, `postal_code`, `country` (2 letters), `tax_id` | | `notes`, `terms` | `string` | No | Up to 5000 characters. Default from your billing profile | | `lines` | `object[]` | No | Up to 200. See below | **Line fields** | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `product_id` | `UUID` | No | Must be your product. When `tax_rate_bps` is omitted the line takes the product's tax rate, and omitted `hsn_sac` and `unit` come from the product. `name` and `unit_price_minor` are still required | | `name` | `string` | Yes | 1–255 characters, not blank | | `description` | `string` | No | Up to 5000 characters | | `hsn_sac` | `string` | No | Up to 16 characters | | `unit` | `string` | No | Up to 32 characters | | `quantity` | `number` | Yes | `> 0`, at most `1000000000`. Rounded half up to 3 decimals | | `unit_price_minor` | `integer` | Yes | `0..10^15`, minor units | | `discount_bps` | `integer` | No | `0..10000`, default `0` | | `tax_name` | `string` | No | Up to 64 characters | | `tax_rate_bps` | `integer` | No | `0..10000`, default `0` | ```bash curl -X POST https://api.callmissed.com/api/v1/crm/quotes \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "title": "Voice agents for Acme", "contact_id": "4411…", "deal_id": "33cc…", "lines": [ { "product_id": "8b20…", "name": "Voice agent setup", "quantity": 1, "unit_price_minor": 2500000 }, { "name": "Extra call minutes", "quantity": 500, "unit_price_minor": 600, "tax_name": "GST 18%", "tax_rate_bps": 1800 } ] }' ``` Returns `201` with the full quote, its `lines` and `public_url`. ## GET / PATCH / DELETE `/api/v1/crm/quotes/{quote_id}` `GET` returns the quote with `lines`. `PATCH` accepts the create fields, all optional; when `lines` is present it **replaces** every line and totals are recalculated. An explicit `null` for `currency`, `issue_date` or `valid_until` is ignored. Changing `contact_id`, `company_id` or `buyer` rebuilds the buyer snapshot and recalculates tax. `DELETE` returns `204` with no body. `PATCH` and `DELETE` work only on a `draft`, otherwise `422`. A revised quote is a draft that keeps its number, so it cannot be deleted (`422`): void it instead, which keeps the number sequence complete. ## Sending ### POST `/api/v1/crm/quotes/{quote_id}/send` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `channels` | `string[]` | Yes | One or two of `email`, `whatsapp` | | `to_email` | `string` | No | Valid email, up to 320 characters. Defaults to the buyer email | | `to_phone` | `string` | No | Up to 32 characters. Defaults to the buyer phone | | `message` | `string` | No | Up to 1000 characters. Added to the message body | Sending a `draft` first numbers it, freezes your billing details and moves it to `sent`; a draft with no lines, or whose `valid_until` is in the past, returns `422`. A quote that is already `sent`, `viewed`, `accepted` or `converted` is delivered again without a status change. A `declined`, `expired` or `void` quote returns `422` (revise it first). The status change is saved before delivery starts, so a failed channel does not undo it. The customer receives a hosted link where they can view, accept or decline the quote. With an API key, `to_email` and `to_phone` may only repeat the buyer's own email and phone on the quote; any other recipient returns `403`. Leave them out to use the buyer's details, or change the buyer on the draft first. An API key can email a quote only to an address that belongs to a [contact](/docs/crm-contacts) in your CRM (matched without regard to case); otherwise the send returns `403` and nothing is sent, so add the buyer as a contact first. Signed-in console users can send to any recipient. Each workspace can send up to 200 quotes and invoices per channel per day (UTC), counting every email or WhatsApp delivery attempted. Past that, the call returns `429` before anything is numbered or sent; try again the next day. Channels with no recipient (`skipped`) do not count. The response reports each channel separately: ```json { "email": "sent", "whatsapp": "outside_window" } ``` A channel you did not request is `null`. | Value | Meaning | | --- | --- | | `sent` | Handed off for delivery | | `skipped` | No recipient address for the channel, or no WhatsApp number connected | | `failed` | Delivery was attempted and failed | | `outside_window` | WhatsApp only. The customer has not messaged you in the last 24 hours, so a free-form WhatsApp message cannot be sent. Use email or share the `public_url` yourself | ## Accepting ### POST `/api/v1/crm/quotes/{quote_id}/accept` Records an acceptance on the customer's behalf, for example after a phone approval. Returns the quote. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank. Stored as `accepted_by_name` | Valid from `sent` or `viewed`, and only while `valid_until` has not passed in your workspace timezone. Emits the `quote.accepted` [webhook event](/docs/webhooks) once the request commits. When `auto_invoice_on_accept` is on in your billing profile, an invoice is created and issued from the quote (issue date today, due date from `payment_terms_days`), the quote becomes `converted` and `converted_invoice_id` is set. Otherwise the quote stays `accepted` and you can [convert it](#convert-to-an-invoice) when ready. ### POST `/api/v1/crm/quotes/{quote_id}/decline` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `reason` | `string` | No | Up to 500 characters | Valid from `sent` or `viewed`. Returns the quote. Send `{}` when you have no reason. ## Convert to an invoice ### POST `/api/v1/crm/quotes/{quote_id}/convert` Creates an invoice from an `accepted` quote, copying its lines, and moves the quote to `converted`. Requires both `crm_quotes:write` and `crm_invoices:write`. The invoice is dated today and due after `payment_terms_days` from your billing profile. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `issue` | `boolean` | No | `true` numbers and issues the invoice immediately. `false` leaves it a `draft`. Default `true` | Send `{}` for the default. Returns `201` with the new [invoice](/docs/crm-invoices#the-invoice-object). Any status other than `accepted` returns `422`. ## Revise, void and PDF - `POST /api/v1/crm/quotes/{quote_id}/revise` returns a `sent`, `viewed`, `declined` or `expired` quote to `draft` so it can be edited. It keeps its number and clears the viewed and declined timestamps and the decline reason. Returns the quote. - `POST /api/v1/crm/quotes/{quote_id}/void` cancels a quote and takes no body. Allowed from `draft`, `sent`, `viewed`, `accepted`, `declined` and `expired`; a `converted` or `void` quote returns `422`. Returns the quote. - `GET /api/v1/crm/quotes/{quote_id}/pdf` (scope `crm_quotes:read`) returns the quote as `application/pdf`, rendered on demand. ## Expiry A quote can be accepted until the end of its `valid_until` date in your workspace timezone (the timezone in your workspace settings; India time when none is set and your billing country is India). From the next day, accepting returns `422` and the hosted page shows the quote as no longer acceptable. The stored status moves from `sent` or `viewed` to `expired` automatically once that date has passed in every timezone, so it can lag by up to a day. --- ## Errors | Status | When | | --- | --- | | `403` | Key is missing `crm_quotes:read` / `crm_quotes:write`, or `crm_invoices:write` for convert, or a key sends to a recipient other than the buyer, or emails an address that is not a CRM contact | | `404` | Quote, contact, company, deal, lead, owner or product not in your tenant | | `422` | Invalid status transition, editing or deleting a non-draft quote, deleting a revised (numbered) draft, accepting an expired quote, a line or quote total above 10^15 minor units, sending a draft with no lines or a past `valid_until`, sending a `declined`, `expired` or `void` quote, a line missing `name`, `quantity` or `unit_price_minor`, or a value outside the bounds above | | `429` | The workspace's daily send limit for that channel is used up | Nothing on this page consumes credits. Email and WhatsApp deliveries follow the normal charges of those channels. --- ### CSAT & NPS Surveys URL: /docs/csat > Mint a survey link after a conversation, send the hosted response page to your customer, and read CSAT and NPS statistics. ## Overview Create a survey, send its link to the customer, and read the score back. Every endpoint on this page is called with your `cm_` key. The customer never sees the API: they answer on a hosted response page addressed by the survey's `token`, so no key ever reaches their device. Two scales are supported: `csat_5` (satisfaction, 1–5) and `nps_10` (recommendation, 0–10). ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, stats | `csat:read` | | Create, delete, mark sent | `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` with the survey object. `404 Conversation not found` / `Contact not found` when a linked id is not in your tenant. ## Sending the survey Creating a survey does not deliver it. Build the customer's link from the returned `token`: ``` https://console.callmissed.com/csat/{token} ``` Send that link on your own channel (WhatsApp, email, SMS, your app), then call `POST /api/v1/csat/surveys/{survey_id}/sent` (below) to record when it went out. The hosted page shows the survey's `question`, renders the rating range for its `scale` (`1`–`5` for `csat_5`, `0`–`10` for `nps_10`) and takes an optional comment of up to 2,000 characters. It accepts **one answer per survey**; a second attempt is refused. Once `expires_at` has passed it shows the survey as closed instead of the form. No tenant, contact, conversation or ticket id is shown to the customer. The survey object does not carry the customer's rating or comment. Use the `answered` filter below to see which surveys have a response, and `GET /api/v1/csat/stats` for the aggregates. ## GET `/api/v1/csat/surveys` Returns an array of survey objects, 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` | ```bash curl "https://api.callmissed.com/api/v1/csat/surveys?channel=whatsapp&answered=false" \ -H "Authorization: Bearer cm_your_api_key" ``` An unknown `channel` returns `422 channel must be one of: whatsapp, email, web, voice`. ## GET `/api/v1/csat/stats` | Parameter | Type | Constraints | | --- | --- | --- | | `days` | `integer` | `1 <= days <= 365`, default `30` | The window covers surveys **created** in the last `days` days. ```bash curl "https://api.callmissed.com/api/v1/csat/stats?days=30" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "days": 30, "surveys_sent": 1840, "responses": 611, "response_rate": 0.3321, "average_rating": 6.12, "average_rating_by_scale": { "csat_5": 4.31, "nps_10": 7.86 }, "promoters": 302, "passives": 118, "detractors": 61, "nps": 49.75 } ``` | Field | Notes | | --- | --- | | `surveys_sent` | Surveys created in the window, whether or not you called `/sent` for them | | `response_rate` | `responses / surveys_sent` as a fraction in `0..1`, not a percentage. `0.0` when no survey was created | | `average_rating` | **Deprecated.** Mixes `csat_5` (1–5) and `nps_10` (0–10) ratings into one number; use `average_rating_by_scale`. `null` when there are no responses | | `average_rating_by_scale` | Mean rating per scale: `{"csat_5": …, "nps_10": …}`. A scale with no responses in the window is `null` | | `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`. ```bash curl https://api.callmissed.com/api/v1/csat/surveys/{survey_id} \ -H "Authorization: Bearer cm_your_api_key" curl -X DELETE https://api.callmissed.com/api/v1/csat/surveys/{survey_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/csat/surveys/{survey_id}/sent` Creating a survey does not deliver it: you send the link on your own channel, then call this to record when it went out. Scope: `csat:write`. No body. Returns the survey with `sent_at` set. Idempotent — calling it again keeps the first `sent_at`. `404 Survey not found`. ```bash curl -X POST https://api.callmissed.com/api/v1/csat/surveys/{survey_id}/sent \ -H "Authorization: Bearer cm_your_api_key" ``` --- ## Errors | 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. --- ### Response Cache URL: /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. ## What gets cached - **Only `POST /v1/chat/completions`, non-streaming.** Streaming requests are never cached. - **Only deterministic requests:** `temperature` of `0` (or omitted), or any `temperature` together with a `seed`. A request with `temperature > 0` and no `seed` asks for variety, so it is never served from cache. - **Exact match only.** The whole request body must be identical to an earlier one from your account. - **Never under [zero data retention](/docs/gateway-controls)**, never an error response, and never a response over 1 MB. - Entries expire **one hour** after they were stored. Every non-streaming response carries an `X-CM-Cache` header: `hit` (served from cache), `miss` (looked up, not found, now stored), or `bypass` (the request is not cacheable). A cache hit is billed **zero credits** — no model ran. It still appears in your [usage logs](/docs/usage-api) and counts toward your plan's monthly call cap. ## 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`. The API does not currently return cache keys (there is no endpoint that lists entries), so in practice use the full purge above. ## 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. --- ### Data Retention & End-User Budgets URL: /docs/gateway-controls > Restrict requests to zero-data-retention routes or to India-processed models, and cap what each of your own end users can spend per month. ## Overview Three controls apply to `POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/messages` and `POST /v1/embeddings`: - **Zero data retention (ZDR)** — serve a request only on a route whose model provider does not retain prompts or responses, and store no prompt or response content on our side. - **India-only processing** — serve a request only on a model whose `processing` is `"india"`. - **Per-end-user budgets** — a monthly credit cap per end user of your application, keyed on the `user` field you already send. ## Zero data retention ### Turning it on Send `provider.zdr` in the request body: ```json { "model": "your-model-id", "messages": [{ "role": "user", "content": "Summarise this contract." }], "provider": { "zdr": true } } ``` `provider.zdr` must be a JSON boolean; any other value returns `422`. On `/v1/messages` the same `provider` object is accepted as a CallMissed extension. ZDR can also be enforced without the flag: - **Per API key** — turn on *Zero data retention* on the key in the dashboard. - **Account-wide** — turn on the data-retention policy in the dashboard (owners and admins). When the key or account policy is on, every request is treated as ZDR. A request can switch ZDR on, never off: `"zdr": false` does not override a key or account policy. ### Which models qualify Every entry in `GET /v1/models` carries a boolean `zero_data_retention`. It is `true` only when the route serving that model is backed by the provider's own published zero-retention terms. Today no model in the catalogue is marked `true`. A ZDR request is refused rather than served on a route that retains data. ### When no route qualifies A ZDR request for a model with `zero_data_retention: false` is rejected before any model is called and is not billed: ```json { "error": { "message": "Model 'your-model-id' has no zero-data-retention route. ...", "type": "invalid_request_error", "code": "zdr_unavailable" } } ``` The request is never downgraded to a route that retains data, including through fallbacks: models in your `models` fallback list that have no zero-data-retention route are skipped. On `/v1/chat/completions`, `bot_id` knowledge-base retrieval also returns `zdr_unavailable` under ZDR, because retrieval sends your latest message to an embedding model. The [Batch API](/docs/batch) also returns `zdr_unavailable` under ZDR (at upload and at batch create), because a batch stores its input and output files. ### What we do not store for ZDR requests - **Prompt and response logging** is skipped, even when prompt logging is enabled on the key. - **The response cache** is neither read nor written. - **`Idempotency-Key`** (non-streaming requests): the response body is not stored. A retry with the same key returns `409` instead of a replay, so the call is not run or billed a second time. Usage metering (model, token counts, cost, latency) is still recorded so the call can be billed. ## India-only processing Each model in `GET /v1/models` and `GET /api/v1/models` carries `processing`: | Value | Meaning | | --- | --- | | `"india"` | Processed in India | | `"india_preferred"` | Served from India first, but may be processed outside India, including when a request is moved to another location after a capacity or upstream error on that model | | `"outside_india"` | Processed outside India | India-only processing is opt-in and off by default. No image model has `processing: "india"`, so image generation has no India option. ### Turning it on Send `provider.data_residency` in the request body: ```json { "model": "your-model-id", "messages": [{ "role": "user", "content": "Summarise this contract." }], "provider": { "data_residency": "in" } } ``` `"in"` is the only accepted value; any other value returns `422`. The field is accepted on `/v1/chat/completions`, `/v1/messages`, `/v1/responses` and `/v1/embeddings`, and on each line of a [Batch API](/docs/batch) file. It can also be turned on without the field: - **Per API key** — turn on *India-only processing* on the key in the console (Developer, API keys). - **Workspace-wide** — turn on the setting in the console (Developer, Data controls; owners and admins). When the key or workspace setting is on, every request is treated as India-only. A request can switch the mode on, never off. ### What it covers While the mode is on, only models with `processing: "india"` can be used for chat completions, messages, responses, embeddings, speech-to-text, text-to-speech, image generation and voice sessions. This applies to the requested model and to every model in the request's `models` fallback array: fallback candidates that are not processed in India are skipped. India-processed models are not moved outside India when an upstream is busy. A request for any other model is rejected with `400` before any model is called and is not billed: ```json { "error": { "message": "Model 'gpt-5.5' is not available with India-only processing. ...", "type": "invalid_request_error", "code": "region_unavailable" } } ``` `/v1/messages` answers in its own error shape (`{"type": "error", "error": {"type": "invalid_request_error", "message": "..."}}`) with the same message. The message lists India-processed alternatives in the same category. Endpoints whose models are all processed outside India, such as image generation and audio or text intelligence, return `region_unavailable` while the mode is on. A voice session created with `POST /v1/voice/sessions` using an India-only key, or in an India-only workspace, runs like an agent with `hosting: "india"`. A session that names a model processed outside India is refused with `400` and a `detail` that starts with `region_unavailable:` and names each such model. On the managed voice agent WebSocket the same check runs when the session starts (an `INVALID_SETTINGS` error whose description starts with `region_unavailable:`), and a later switch to a model processed outside India is ignored with a warning. When the mode is off, `"india_preferred"` models may be moved between locations for availability. Read [Data residency](/docs/data-residency) for what else leaves India. ## Per-end-user budgets ### How the cap is applied Pass your end user's id in the request: | Endpoint | Field | | --- | --- | | `/v1/chat/completions`, `/v1/responses` | `user` (a cap can only be set on an id of at most 256 characters; a longer id is accepted and uncapped) | | `/v1/embeddings` | `user` (at most 256 characters) | | `/v1/messages` | `metadata.user_id` (at most 512 characters; a cap can only be set on an id of at most 256) | If that id has a cap and has already spent its monthly limit, the request is rejected before any model is called: ```json { "error": { "message": "Monthly budget for this end user (the `user` field) is exhausted. ...", "type": "insufficient_quota", "code": "end_user_budget_exceeded" } } ``` The status is `402`. On `/v1/messages` the error uses the Anthropic shape with type `billing_error`. - Spend is counted per calendar month (UTC) and resets at the start of each month. - The cap is checked before each call, so requests already in flight when the limit is reached can take the total slightly past it. - A limit of `0` blocks that end user. - Ids without a cap are not limited and not tracked. - Ids are matched exactly and are case-sensitive. ### Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List | `end_user_budgets:read` | | Create or update, delete | `end_user_budgets:write` | ### GET `/api/v1/gateway/end-user-budgets` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `end_user` | `string` | No | Exact id, at most 256 characters | | `limit` | `integer` | No | `1 <= limit <= 200`, default `100` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```json [ { "id": "5c1d…", "end_user": "customer-42", "monthly_limit_credits": 50.0, "period_start": "2026-10-01T00:00:00Z", "period_used_credits": 12.5, "remaining_credits": 37.5, "created_at": "2026-09-14T08:00:00Z", "updated_at": "2026-10-01T09:30:00Z" } ] ``` `period_used_credits` is this month's spend. It reads `0` if the id has not been charged yet this month. ### PUT `/api/v1/gateway/end-user-budgets` Creates the cap, or changes the limit if one exists. Spend already recorded this month is kept. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `end_user` | `string` | Yes | 1–256 printable characters, stored exactly as sent | | `monthly_limit_credits` | `number` | Yes | `0 <= value <= 10000000` | ```bash curl -X PUT https://api.callmissed.com/api/v1/gateway/end-user-budgets \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"end_user": "customer-42", "monthly_limit_credits": 50}' ``` The response is one budget object in the same shape as the list items. ### DELETE `/api/v1/gateway/end-user-budgets/{id}` Removes the cap. The id becomes uncapped and its spend counter is discarded. Returns `204`, or `404` for an id that is not in your account. --- ### Prompt Management URL: /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`](/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. --- ### Handoffs URL: /docs/handoffs > The cross-channel queue of conversations waiting for a human: list and count them, assign an owner, resolve them back to the bot, and call a voice customer back. ## Overview A **handoff** is a [conversation](/docs/conversations) that needs a real person. The queue is every conversation in your tenant whose `status` is `escalated`, across WhatsApp, web chat and voice. A conversation enters the queue when: - your agent hands off to a human during a conversation (`raised_by: "ai"`, with a `reason`, and on voice a `summary` brief); - a teammate takes a thread over by turning the bot off with [`POST /api/v1/conversations/{conversation_id}/autoreply`](/docs/conversations) and `enabled: false` (`raised_by: "human"`); - you set the conversation's status to `escalated` with `PUT /api/v1/conversations/{conversation_id}/status` (no `reason` or `raised_by` is recorded). When your agent hands off or a teammate takes over, the bot stops auto-replying on that conversation (`ai_autoreply_enabled: false` on the conversation). Setting the status to `escalated` with `PUT …/status` only puts the conversation in the queue; it does not turn the bot off. Resolving the handoff takes it off the queue and, by default, hands the thread back to the bot. Handoffs are conversations, so there is no create or delete endpoint here, and the path id is the `conversation_id`. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, count | `conversations:read` | | Assign, resolve, callback | `conversations:write` | ## Enumerations | Field | Values | | --- | --- | | `channel` | `whatsapp`, `voice`, `web` | | `status` | `active`, `completed`, `escalated`, `failed` — an open handoff is `escalated` | | `raised_by` | `ai`, `human`, or `null` | ## The handoff object ```json { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "channel": "voice", "status": "escalated", "external_id": "+919812345678", "contact_name": "Asha Rao", "reason": "Customer wants to dispute a charge", "summary": "Caller was double-charged on order A-4419 and wants a refund. Identity verified.", "voice_session_id": "5e6f…", "can_callback": true, "raised_by": "ai", "escalated_at": "2026-08-17T06:10:00Z", "assignee": { "id": "b1f2…", "name": "Ravi Kumar" }, "last_message": "Can I talk to a person please?", "last_message_at": "2026-08-17T06:09:41Z", "bot_id": "7a8b…" } ``` | Field | Type | Notes | | --- | --- | --- | | `conversation_id` | `UUID` | The conversation this handoff is | | `channel` | `string` | See enumerations | | `status` | `string` | The conversation's current status | | `external_id` | `string` | The customer's id on the channel (WhatsApp id, phone number or web visitor id). Personal data — handle it as such | | `contact_name` | `string \| null` | Name of the linked CRM contact, if any | | `reason` | `string \| null` | Why the conversation was handed off | | `summary` | `string \| null` | The agent's short brief for the human taking over (voice handoffs) | | `voice_session_id` | `string \| null` | Voice handoffs only: the session to read the transcript from with the [Voice Session API](/docs/voice-sessions-api) | | `can_callback` | `boolean` | `true` only when the `/callback` endpoint below can go through: a voice handoff with a captured customer number, an operator phone set in the console, and an active number of yours to dial from. The customer's number itself is never returned here | | `raised_by` | `string \| null` | `ai`, `human`, or `null` when no handoff record was written | | `escalated_at` | `datetime \| null` | When it entered the queue; falls back to the conversation's last update when no handoff time was recorded | | `assignee` | `object \| null` | `{ "id", "name" }` of the current owner. `null` means unclaimed | | `last_message` / `last_message_at` | `string \| null` / `datetime \| null` | Preview of the newest message in the thread | | `bot_id` | `UUID` | The bot that owns the conversation | ## GET `/api/v1/handoffs` Returns an array of handoff objects, ordered by the conversation's last update, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `scope` | `string` | No | `open` (default) = conversations currently `escalated`. `all` also includes conversations whose handoff was already resolved | | `limit` | `integer` | No | `1 <= limit <= 500`, default `100` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/handoffs?scope=open&limit=100" \ -H "Authorization: Bearer cm_your_api_key" ``` Any `scope` other than `open` or `all` returns `422`. ## GET `/api/v1/handoffs/count` The number of open handoffs (conversations currently `escalated`). ```bash curl https://api.callmissed.com/api/v1/handoffs/count \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "count": 3 } ``` ## POST `/api/v1/handoffs/{conversation_id}/assign` Give a handoff an owner, or send it back to the unclaimed pool. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `assignee_id` | `UUID \| null` | No | An active user in your tenant. `null` (or omitted) unassigns | A JSON body is required; send `{}` to unassign. ```bash curl -X POST https://api.callmissed.com/api/v1/handoffs/c0ffee00-1111-2222-3333-444455556666/assign \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "assignee_id": "b1f2c3d4-1111-2222-3333-444455556666" }' ``` Returns the updated handoff object. | Status | Detail | | --- | --- | | `404` | `Conversation not found` | | `404` | `Assignee not found in this workspace` — not a user in your tenant, or deactivated | | `409` | `This conversation is not an open handoff.` — the conversation's status is not `escalated` | | `409` | `Already claimed by {name}.` — a signed-in teammate tried to claim (assign to themselves) a handoff someone else holds | The claim conflict applies only to a signed-in teammate assigning a handoff to themselves. A request made with an API key is treated as a deliberate reassignment and replaces any current assignee. ## POST `/api/v1/handoffs/{conversation_id}/resolve` Take the conversation off the queue. Its status becomes `active` (the conversation can carry on), the assignee is cleared, and the handoff record is kept and marked resolved, so it still shows under `scope=all`. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `resume_ai` | `boolean` | No | Default `true`: the bot resumes auto-replying. `false` keeps the bot silent so a human stays in control | A JSON body is required; send `{}` to accept the default. ```bash curl -X POST https://api.callmissed.com/api/v1/handoffs/c0ffee00-1111-2222-3333-444455556666/resolve \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "resume_ai": true }' ``` Returns the updated handoff object. `404 Conversation not found`. This endpoint does not check that the conversation is currently `escalated`: calling it on any conversation in your tenant sets that conversation to `active`. ## POST `/api/v1/handoffs/{conversation_id}/callback` One-click call-back for a **voice** handoff. CallMissed dials your operator phone first, then the customer, and connects the two directly — no AI on the call. Both legs are placed from one of your own active numbers. No request body. Before calling it, check `can_callback` on the handoff. The preconditions are: - the handoff is on the `voice` channel and a customer phone number was captured for it; - an operator phone number is set for your workspace in the console (**Settings**); - your workspace has at least one active [CallMissed number](/docs/telephony-api) to call from. ```bash curl -X POST https://api.callmissed.com/api/v1/handoffs/c0ffee00-1111-2222-3333-444455556666/callback \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "status": "calling", "room_name": "bridge-…", "operator_call_id": "0d1e…", "caller_call_id": "2f3a…" } ``` | Field | Notes | | --- | --- | | `status` | Always `calling` on success | | `room_name` | Opaque identifier of the bridged call | | `operator_call_id` / `caller_call_id` | The two outbound calls. Read them with `GET /api/v1/telephony/calls/{call_id}` ([Telephony API](/docs/telephony-api), scope `telephony:read`) | **This endpoint spends credits.** Each leg is an outbound call billed like any other [telephony call](/docs/telephony-api): credits are reserved for both legs before dialling and settled to the real cost after the calls end. If either leg fails to dial, both reservations are refunded. | Status | Detail | | --- | --- | | `400` | `Callback is only available for voice handoffs` | | `402` | `insufficient credits` — not enough balance to reserve both legs | | `403` | `This customer's number is on your do-not-call list.` or `Calls to this destination are not permitted.` | | `404` | `Conversation not found` | | `409` | `No customer phone number was captured for this handoff.` | | `409` | `Set an operator phone number in Settings to call customers back.` | | `409` | `Your workspace has no active phone number to call back from.` | | `422` | `A phone number for the callback is invalid.` | | `429` | `Too many calls are in progress right now. Try again shortly.` | A failure while dialling returns an error status with a message that is safe to show to your operator. ## Errors | Status | When | | --- | --- | | `403` | Key is missing `conversations:read` / `conversations:write` | | `404` | Conversation or assignee is not in your tenant | | `409` | Assigning a conversation that is not an open handoff, a claim conflict, or a callback precondition is missing | | `422` | Invalid `scope`, `limit` or `offset`, a malformed id, or a missing JSON body on assign/resolve | Only `/callback` consumes credits; nothing else on this page does. --- ### Integrations URL: /docs/integrations > Connect external services your agents use (stores, calendars, CRMs, spreadsheets, payment gateways), and run Google Sheets order automations. ## Overview An **integration** is one connected account at an external service: a Shopify store, a Cal.com account, a Google Sheets login, your own Razorpay account. Once connected, its tools become available to your agents (see [Voice agent tools](/docs/voice-agent-tools)). Credentials you send are validated against the provider **before** they are stored, are stored encrypted, and are **never returned** by any endpoint. Responses only tell you whether a credential is stored (`has_credentials`). A **sheet automation** watches one tab of a spreadsheet on a Google Sheets connection and acts on each new order row: it sends an approved WhatsApp template and/or has an agent call the customer, then writes the result back into the sheet. The agent-side behaviour (call variables, write-back columns) is described in [Voice agent tools → Order automation from Google Sheets](/docs/voice-agent-tools). ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, catalog, Shopify OAuth status, Shopify pixel snippet, list sheet automations, automation orders, sheet columns | `integrations:read` | | Create, update, delete, set spreadsheets, create/update/delete sheet automations | `integrations:write` | Connecting a **sign-in (OAuth) provider** (Google Calendar & Gmail, Google Sheets, Shopify OAuth) is done in the console (**Developer → Integrations**), because it needs a person to approve it. Once connected, everything on this page works with a key. ## Providers and credentials `POST /api/v1/integrations` takes a `provider` and a `credentials` object whose shape depends on the provider. Send only the keys listed for the provider: `calcom`, `hubspot`, `woocommerce`, `razorpay` and `manual` reject any other key with `422`. | `provider` | How to connect | `credentials` | `external_account_id` set to | | --- | --- | --- | --- | | `calcom` | API key | `{ "api_key" }` | Your Cal.com username | | `hubspot` | API key | `{ "api_key" }`: a HubSpot private-app token | (none) | | `woocommerce` | API key | `{ "site_url", "consumer_key", "consumer_secret" }`: a read-only WooCommerce REST API key | Normalised store URL | | `razorpay` | API key | `{ "key_id", "key_secret", "webhook_secret" }`. `key_id` must look like `rzp_live_…` or `rzp_test_…`; `webhook_secret` at least 8 characters. See [Payment requests](/docs/payment-requests) | The `key_id` | | `shopify` | Access token, or OAuth in the console | `{ "shop_domain", "access_token" }` (custom-app Admin API token); optional `api_secret_key` | The shop's `myshopify.com` domain | | `manual` | API key | Any of `{ "bearer_token", "api_key", "headers" }`, at least one, plus `allowed_hosts` (list of up to 20 bare hostnames, e.g. `["api.example.com"]`) naming the only hosts the credential may be sent to. Used by the `http_request` tool's `connection_id`; a connection without `allowed_hosts` never has its credential attached | (none) | | `google_calendar` | OAuth in the console only | — | The Google account email | | `google_sheets` | OAuth in the console only | — | The Google account email | The provider makes a real authenticated call to check the credentials. If it refuses, you get `422` with the reason (e.g. `api_key is required`, `key id should look like rzp_live_… or rzp_test_…`). Use [`GET /api/v1/integrations/catalog`](#get-apiv1integrationscatalog) for the live list of what can be connected. ## The integration object ```json { "id": "6a1e…", "tenant_id": "a0b1…", "provider": "shopify", "name": "Main store", "status": "connected", "scopes": null, "external_account_id": "acme.myshopify.com", "has_credentials": true, "metadata": null, "created_at": "2026-09-20T06:10:00Z", "updated_at": "2026-09-20T06:10:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `provider` | `string` | One of the providers above | | `name` | `string` | Your label, 1–255 characters | | `status` | `string` | `connected`, `disconnected` or `error` | | `scopes` | `object \| null` | Free-form object you may store with the connection | | `external_account_id` | `string \| null` | The connected account (see table above). One connection per provider + account: connecting the same account again returns `409` | | `has_credentials` | `boolean` | `true` when a credential is stored. The credential itself is never returned | | `metadata` | `object \| null` | Provider-specific settings. Google Sheets: `spreadsheets` (the allowlist, see below). Razorpay: `max_amount_inr` (see [Payment requests](/docs/payment-requests)). Treat other keys as opaque | ## GET `/api/v1/integrations` Returns an array of integration objects, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/integrations?limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/integrations` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `provider` | `string` | Yes | 1–64 characters, a provider from the table above | | `name` | `string` | Yes | 1–255 characters | | `credentials` | `object` | Yes | Provider-specific, see above | | `external_account_id` | `string` | No | At most 255 characters. Ignored when the provider derives its own account id | | `scopes` | `object` | No | Stored as given | ```bash curl -X POST https://api.callmissed.com/api/v1/integrations \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "provider": "calcom", "name": "Clinic bookings", "credentials": { "api_key": "cal_live_xxxxxxxx" } }' ``` Returns `201` with the integration object, `status: "connected"`. | Status | When | | --- | --- | | `400 Unknown integration provider: ` | `provider` is not one this API can connect | | `409 This account is already connected for this provider` | Same provider and account already connected. Update the existing integration instead | | `422` | The credentials are missing fields, have unknown fields, or the provider rejected them | ## GET `/api/v1/integrations/catalog` The gallery of connectable services, annotated with what this account already has connected. No credentials. ```bash curl https://api.callmissed.com/api/v1/integrations/catalog \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "items": [ { "provider": "calcom", "label": "Cal.com", "category": "scheduling", "auth_kind": "apikey", "logo_key": "calcom", "tool_names": ["calcom_list_slots", "calcom_book"], "enabled": true, "aggregator": false, "description": "Check availability and book appointments on the call.", "connected": true, "account": "dr-mehta" } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `category` | `string` | `scheduling`, `crm`, `messaging`, `productivity`, `commerce`, `payments` or `mcp` | | `auth_kind` | `string` | `apikey` (one key), `apikey_multi` (several credential fields), `oauth` (console sign-in) or `mcp` | | `tool_names` | `string[]` | Agent tools the connection unlocks | | `enabled` | `boolean` | `false` means "coming soon": it cannot be connected yet | | `connected` | `boolean` | This account has a `connected` integration for the provider | | `account` | `string \| null` | Its `external_account_id`, when connected | ## GET `/api/v1/integrations/{integration_id}` One integration. `404 Integration not found`. ```bash curl https://api.callmissed.com/api/v1/integrations/{integration_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## PATCH `/api/v1/integrations/{integration_id}` Every field is optional; only the fields you send are changed. | Field | Type | Constraints | | --- | --- | --- | | `name` | `string` | 1–255 characters | | `status` | `string` | `connected`, `disconnected` or `error` | | `credentials` | `object` | Rotates the credential. Validated with the provider exactly as on create; `external_account_id` is re-derived | | `scopes` | `object` | Replaces the stored object | ```bash curl -X PATCH https://api.callmissed.com/api/v1/integrations/{integration_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "credentials": { "api_key": "cal_live_newkey" } }' ``` Returns the updated integration. `404 Integration not found`; `422` if the new credentials are rejected or `status` is not one of the three values. ## PUT `/api/v1/integrations/{integration_id}/spreadsheets` Google Sheets connections only. Sets which spreadsheets agents may use through the connection. The list **replaces** the existing one. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `spreadsheets` | `string[]` | No | At most 20. Each is a Google Sheets URL (`docs.google.com/spreadsheets/d/…`) or a bare spreadsheet id. Duplicates are dropped. `[]` clears the list | ```bash curl -X PUT https://api.callmissed.com/api/v1/integrations/{integration_id}/spreadsheets \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "spreadsheets": ["https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/edit"] }' ``` Each spreadsheet is opened with the connection's own Google access before it is accepted, so it must be one the connected Google account can reach **and** has granted to CallMissed. The connection only reaches files picked in the Google file picker (console → Tools → Connected apps → Google Sheets → Choose spreadsheets). Returns the updated integration with `metadata.spreadsheets`: ```json { "metadata": { "spreadsheets": [ { "id": "1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "title": "Orders 2026", "sheets": ["Orders", "Returns"] } ] } } ``` | Status | When | | --- | --- | | `400 Only a Google Sheets connection has a spreadsheet list` | The integration is another provider | | `404 Integration not found` | | | `409 Reconnect Google Sheets and try again` | The stored Google credential can no longer be used | | `422 Paste a Google Sheets link (docs.google.com/spreadsheets/d/…)` | An entry is neither a Sheets URL nor a spreadsheet id | | `422 Couldn't open spreadsheet . …` | The connected Google account cannot open it, or it was not picked in the file picker | ## DELETE `/api/v1/integrations/{integration_id}` Disconnects and deletes the integration and its stored credential. Its sheet automations are deleted with it. Returns `204`. `404 Integration not found`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/integrations/{integration_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## Shopify A Shopify store connects either with an Admin API access token (`POST /api/v1/integrations`, `provider: "shopify"`) or with Shopify sign-in from the console. ### GET `/api/v1/integrations/shopify/oauth/status` Whether Shopify sign-in is available, so a UI can choose between the sign-in button and the access-token form. Scope `integrations:read`. ```bash curl https://api.callmissed.com/api/v1/integrations/shopify/oauth/status \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "configured": true } ``` ### GET `/api/v1/integrations/shopify/pixel-snippet` Returns the JavaScript to paste into Shopify admin → Settings → Customer events → Add custom pixel, so storefront events (product viewed, added to cart, checkout started and completed) reach CallMissed for that store. Scope `integrations:read`. | Parameter | Type | Required | Notes | | --- | --- | --- | --- | | `integration_id` | `UUID` | Yes | A Shopify integration in your account | ```bash curl "https://api.callmissed.com/api/v1/integrations/shopify/pixel-snippet?integration_id={integration_id}" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "snippet": "// CallMissed storefront tracking — paste into Shopify admin →\n// Settings → Customer events → Add custom pixel.\nvar CM_URL=…" } ``` The first call creates the store's pixel token and saves it on the integration; later calls return a snippet with the same token. Paste the `snippet` value as returned. `404 Shopify integration not found`; `400 Integration has no shop domain`. ## Sheet automations One automation covers one tab of one spreadsheet. The spreadsheet must already be on the Google Sheets connection's list ([`PUT …/spreadsheets`](#put-apiv1integrationsintegration_idspreadsheets)). Each tab can have one automation. How it behaves: - **Columns are matched by header name**, case-insensitively and ignoring extra spaces. The header row is the first of the top 5 rows that has at least two filled cells, so a title row above the header is skipped. - **Orders already in the tab when the automation starts are left alone.** Only rows that appear afterwards are acted on, each order key at most once. - **COD vs prepaid.** A row is `cod` when its `payment_column` cell contains one of `cod_values` (case-insensitive); otherwise, or with no `payment_column`, it is `prepaid`. Each segment has its own WhatsApp and call switches. - **Calls** are placed only inside `call_window_start`–`call_window_end` in `timezone_name`, and unanswered calls are retried up to `max_attempts` times. - **Write-back.** The result is written to `status_column` and `note_column` (default `CallMissed Status` / `CallMissed Note`). ### The automation object ```json { "id": "9b2c…", "integration_id": "6a1e…", "spreadsheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "sheet": "Orders", "enabled": true, "key_column": "Order ID", "phone_column": "Phone", "name_column": "Customer", "payment_column": "Payment", "cod_values": ["cod", "cash on delivery"], "status_column": "CallMissed Status", "note_column": "CallMissed Note", "cod_whatsapp": true, "cod_call": true, "prepaid_whatsapp": true, "prepaid_call": false, "whatsapp_template_id": "77aa…", "whatsapp_params": { "1": "Customer", "2": "Order ID" }, "bot_id": "c3d4…", "from_number_id": "e5f6…", "timezone_name": "Asia/Kolkata", "call_window_start": 9, "call_window_end": 21, "max_attempts": 3, "default_country_code": "91", "baseline_done": true, "last_polled_at": "2026-09-25T08:01:00Z", "last_error": null, "created_at": "2026-09-25T07:55:00Z", "updated_at": "2026-09-25T08:01:00Z", "agent_prompt_ready": true } ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `key_column` | `string` | required | Header of the column holding the order id (the de-duplication key) | | `phone_column` | `string` | required | Header of the customer phone column | | `name_column` | `string \| null` | `null` | Customer name column | | `payment_column` | `string \| null` | `null` | Payment method column. Without it every order is `prepaid` | | `cod_values` | `string[]` | `["cod", "cash on delivery"]` | 1–10 values, each trimmed to 40 characters | | `status_column` / `note_column` | `string` | `CallMissed Status` / `CallMissed Note` | Write-back columns | | `cod_whatsapp` / `cod_call` | `boolean` | `true` / `true` | What to do for COD orders | | `prepaid_whatsapp` / `prepaid_call` | `boolean` | `true` / `false` | What to do for prepaid orders | | `whatsapp_template_id` | `UUID \| null` | `null` | An **approved** WhatsApp template in your account | | `whatsapp_params` | `object` | `{}` | Template placeholder → column header. At most 20. Every placeholder in the template must be mapped | | `bot_id` | `UUID \| null` | `null` | The agent that places confirmation calls. Required when any `*_call` is on | | `from_number_id` | `UUID \| null` | `null` | An **active** phone number in your account to call from. Required when any `*_call` is on | | `timezone_name` | `string` | `Asia/Kolkata` | IANA zone, at most 64 characters | | `call_window_start` | `integer` | `9` | `0`–`23`, hour of day. Must be before `call_window_end` | | `call_window_end` | `integer` | `21` | `1`–`24` | | `max_attempts` | `integer` | `3` | `1`–`5` call attempts per order | | `default_country_code` | `string` | `91` | 1–4 digits, prepended to a 10-digit local number | | `enabled` | `boolean` | `true` | | | `baseline_done` | `boolean` | read-only | `true` once the rows present at start have been recorded | | `last_polled_at` | `datetime \| null` | read-only | Last time the tab was read | | `last_error` | `string \| null` | read-only | The most recent problem reading or writing the tab | | `agent_prompt_ready` | `boolean` | read-only | `false` when calls are on but the agent's prompt does not use `{{order_details}}` or `{{order_id}}`, so the agent would not know which order the call is about | ### GET `/api/v1/sheet-automations` Returns an array of automation objects, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl https://api.callmissed.com/api/v1/sheet-automations \ -H "Authorization: Bearer cm_your_api_key" ``` ### GET `/api/v1/sheet-automations/columns` The detected header row of a tab, to pick column names before creating an automation. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `integration_id` | `UUID` | Yes | A Google Sheets integration in your account | | `spreadsheet_id` | `string` | Yes | 10–128 characters. Must be on the connection's spreadsheet list | | `sheet` | `string` | Yes | Tab name, 1–255 characters | ```bash curl "https://api.callmissed.com/api/v1/sheet-automations/columns?integration_id={integration_id}&spreadsheet_id=1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789&sheet=Orders" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "header_row": 2, "columns": ["Order ID", "Customer", "Phone", "Payment", "Total"] } ``` `404 Google Sheets connection not found`; `422 Choose this spreadsheet in the Google Sheets connection first`; `422 Couldn't read the tab ''`; `409 Reconnect Google Sheets and try again`. ### POST `/api/v1/sheet-automations` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `integration_id` | `UUID` | Yes | A Google Sheets integration in your account | | `spreadsheet_id` | `string` | Yes | 10–128 characters, on the connection's spreadsheet list | | `sheet` | `string` | Yes | Tab name, 1–255 characters | | `key_column` | `string` | Yes | 1–255 characters, must be in the header | | `phone_column` | `string` | Yes | 1–255 characters, must be in the header | | Any other field from the object table | | No | Defaults as listed | ```bash curl -X POST https://api.callmissed.com/api/v1/sheet-automations \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "integration_id": "6a1e0000-1111-2222-3333-444455556666", "spreadsheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "sheet": "Orders", "key_column": "Order ID", "phone_column": "Phone", "name_column": "Customer", "payment_column": "Payment", "whatsapp_template_id": "77aa0000-1111-2222-3333-444455556666", "whatsapp_params": { "1": "Customer", "2": "Order ID" }, "bot_id": "c3d40000-1111-2222-3333-444455556666", "from_number_id": "e5f60000-1111-2222-3333-444455556666" }' ``` Returns `201`. The whole automation is checked against the **live** sheet and your account before it is saved: | Status | When | | --- | --- | | `409 This tab already has an automation` | Update the existing one instead | | `409 Reconnect Google Sheets and try again` | The stored Google credential can no longer be used | | `422 Google Sheets connection not found` | `integration_id` is not a Google Sheets integration in your account | | `422 Choose this spreadsheet in the Google Sheets connection first` | `spreadsheet_id` is not on the connection's list | | `422 Couldn't read the tab ''. Check the tab name.` | | | `422 column '' is not in the header (row N): …` | A mapped column does not exist. The message lists the header | | `422 WhatsApp template not found` / `That WhatsApp template is not approved by Meta yet` | | | `422 Choose a column for template variable(s): …` | A template placeholder has no column in `whatsapp_params` | | `422 Template variable

: column '' not in the header` | | | `422 Choose the agent and the phone number to call from` | A `*_call` switch is on without `bot_id` and `from_number_id` | | `422 Agent not found` / `Phone number not found or not active` | | | `422 The calling window must start before it ends` | | | `422 timezone_name must be an IANA zone like Asia/Kolkata` | | ### PATCH `/api/v1/sheet-automations/{automation_id}` Accepts any field from the object table except `integration_id`, `spreadsheet_id` and `sheet` (the tab an automation watches cannot be changed; delete it and create a new one). Send only the fields you want to change. An empty string clears an optional column such as `payment_column`. When the automation is (or stays) `enabled`, the result is validated exactly as on create and the same `422`/`409` errors apply. Sending `{"enabled": false}` pauses it without re-checking the sheet. ```bash curl -X PATCH https://api.callmissed.com/api/v1/sheet-automations/{automation_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "prepaid_call": true, "call_window_end": 20 }' ``` Returns the updated automation. `404 Automation not found`. ### GET `/api/v1/sheet-automations/{automation_id}/orders` The orders this automation has acted on, newest first. Rows that were already in the tab when it started are not listed. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/sheet-automations/{automation_id}/orders?limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "total": 1, "items": [ { "id": "0d1e…", "order_key": "#1042", "segment": "cod", "phone_e164": "+919812345678", "whatsapp_status": "sent", "whatsapp_error": null, "call_status": "done", "call_attempts": 1, "call_error": null, "outcome": "confirmed", "note": "Customer confirmed delivery address.", "sheet_status": "Confirmed", "sheet_error": null, "created_at": "2026-09-25T08:03:00Z", "updated_at": "2026-09-25T08:09:00Z" } ] } ``` | Field | Values | | --- | --- | | `segment` | `cod` or `prepaid` | | `whatsapp_status` | `null` (not wanted), `pending`, `sending`, `sent`, `failed`, `skipped` | | `call_status` | `null` (not wanted), `pending`, `calling`, `answered`, `done`, `failed` | | `outcome` | `confirmed`, `cancelled`, `reschedule`, `unclear`, `no_answer` or `null` | | `sheet_status` | The text last written to the status column, e.g. `Confirmed`, `No answer`, `Call scheduled` | | `sheet_error` | Why the last write-back failed, if it did | `404 Automation not found`. ### DELETE `/api/v1/sheet-automations/{automation_id}` Stops the automation and deletes it with its order history. Returns `204`. `404 Automation not found`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/sheet-automations/{automation_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## Errors | Status | When | | --- | --- | | `400` | Unknown provider, or a Google Sheets-only endpoint called on another provider | | `403` | Key is missing `integrations:read` / `integrations:write` | | `404` | Integration, automation or Shopify integration is not in your account | | `409` | Account already connected, tab already automated, or the stored Google credential needs reconnecting | | `422` | Invalid or rejected credentials, an unreadable spreadsheet or tab, or an automation setting that does not match the sheet or your account | Managing integrations and automations consumes no credits. The WhatsApp messages and calls an automation makes are billed like any other message or call. --- ### API Keys URL: /docs/keys > What an API key controls: service permissions, resource scopes, budget caps and logging. > Keys are created, rotated and revoked in the **console** (Developer → API keys) — you cannot manage keys with a key, so there is no API for it. This page explains what the settings on a key actually do. A key is shown in plaintext **once**, at creation. Store it immediately; if you lose it, the console can re-reveal it after an emailed one-time code, or you can rotate it. ## Service permissions Permissions decide which AI services the key may call. A key without the matching permission gets `403 permission_denied`. | Permission | Gates | |------------|-------| | `llm` | `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (+ `/anthropic/v1/messages`), `/v1/embeddings`, `/v1/batches` and `/v1/files` | | `stt` | `/v1/audio/transcriptions`, `/v1/audio/translations` | | `tts` | `/v1/audio/speech` | | `search` | `/v1/search` | | `image` | `/v1/images/generations`, `/v1/images/history` | | `email` | The [Email API](/docs/email) | | `*` | All services (the default for a new key) | Voice sessions and the Managed Voice Agent sockets need **all three** of `stt`, `tts` and `llm`. ## Resource scopes Scopes gate the platform resources the key may touch. They are independent of permissions and default to **empty = no resource access** — you opt in explicitly. | Scope | Gates | |-------|-------| | `bots:read` / `bots:write` | View vs. create/update/delete agents | | `conversations:read` / `conversations:write` | View vs. update conversations and handoffs | | `knowledge:read` / `knowledge:write` | View vs. add/remove knowledge sources | | `webhooks:write` | Manage webhook subscriptions and read their delivery log | | `whatsapp:read` / `whatsapp:write` / `whatsapp:send` | Read vs. manage vs. send WhatsApp messaging | | `*` | All resource scopes | CRM, support desk, campaigns, telephony, integrations, gateway and voice-agent tooling each have their own pairs. The full list, with the page each one unlocks, is on [Authentication](/docs/authentication#resource-scopes). A key created for plain inference — the common case — needs only service permissions. Leave scopes empty. ## Limits on a key Each key carries its own guardrails, so a key handed to one service or environment cannot spend or reach beyond what you intended. | Setting | What it does | |---------|--------------| | **Budget** | A credit cap for the key. Once spent, calls on that key fail with `402` until you raise it. Other keys keep working. | | **RPM limit** | A per-key requests-per-minute ceiling, independent of your plan's overall quota. | | **Allowed models** | Restricts the key to a named set of model ids, or `*` for everything your plan allows. | | **Allowed search providers** | Restricts which providers `/v1/search` may use on this key. | | **Domain allowlist** | Restricts which origins may send the key, for browser-side use. `*` allows any. | ## Logging Logging is **off** by default on a new key and is opt-in per key: - **Request logging** records metadata for each call — model, timing, token counts, credits spent. - **Prompt logging** additionally stores message content. Enable it only when you need to inspect payloads. ## Expiry A key can be given an expiry (1-365 days) when it is created. After that it gets `401`, and an `api_key.expired` [webhook](/docs/webhooks) fires. A key with no expiry set never expires. ## Rotating and revoking Rotate a key in the console whenever it may have been exposed. Rotation issues a new secret for the **same** key: its id, name, permissions, scopes and budget carry over, and the old secret stops working within about a minute. There is no overlap window, so deploy the new secret straight away. Revocation likewise takes effect within about a minute; in-flight calls already authenticated are not retroactively cancelled. Because budgets, scopes and limits are per key, issuing one key per service or environment keeps a leak contained and makes usage attributable. --- ### Payment Requests URL: /docs/payment-requests > Track the payment links your agents send to customers through your own Razorpay account, and cap how much an agent may request. ## Overview A **payment request** is one payment link an agent sent to one of your customers during a call or chat: an EMI instalment, an order payment, an invoice. The link is created with **your own Razorpay account**, so the money is paid into your account. It is never CallMissed credits and never passes through CallMissed. Agents create payment requests with the `create_payment_link` tool once Razorpay is connected. On a WhatsApp chat the link is sent in the chat; elsewhere (for example on a phone call) Razorpay texts it to the customer's phone and emails it when an email is known. This API is read-only for the requests themselves: you list them, read one, and set the per-link limit. Status changes arrive from Razorpay and are applied to the request, then delivered to you as `payment_request.*` [webhook events](/docs/webhooks). ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, read settings | `integrations:read` | | Update settings | `integrations:write` | ## Setup: connect Razorpay 1. In Razorpay Dashboard → Account & Settings → API Keys, create a key. Choose a webhook secret (at least 8 characters) and keep it for step 3. 2. Connect it with [`POST /api/v1/integrations`](/docs/integrations): ```bash curl -X POST https://api.callmissed.com/api/v1/integrations \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "provider": "razorpay", "name": "Collections", "credentials": { "key_id": "rzp_live_XXXXXXXXXXXXXX", "key_secret": "your_key_secret", "webhook_secret": "the_secret_you_chose" } }' ``` The key is checked with Razorpay before it is saved. A `rzp_test_…` key connects in test mode. 3. Call [`GET /api/v1/payment-requests/settings`](#get-apiv1payment-requestssettings). In Razorpay Dashboard → Webhooks, add a webhook whose URL is the returned `webhook_url`, whose secret is the one from step 1, and whose events are the returned `webhook_events`. Without this webhook, requests stay at `created` and no `payment_request.*` events are sent. If more than one Razorpay integration is connected, the oldest one is used. ## Statuses | `status` | Meaning | | --- | --- | | `creating` | Saved, link being created with Razorpay | | `created` | Link created and sent; nothing paid yet | | `partially_paid` | Part of the amount paid (`amount_paid_minor` is less than `amount_minor`) | | `paid` | Paid in full. `paid_at` is set | | `expired` | The link expired unpaid | | `cancelled` | The link was cancelled | | `failed` | Razorpay refused the link, or the result could not be confirmed. See `error` | Statuses only move forward: a late or repeated event from Razorpay never moves a request back. `paid`, `expired` and `cancelled` are final. A `failed` request can still become `partially_paid` or `paid` if Razorpay reports a payment on its link. ## The payment request object ```json { "id": "5c7d…", "provider": "razorpay", "status": "paid", "amount_minor": 149900, "amount_paid_minor": 149900, "currency": "INR", "description": "EMI 3 of 12", "external_reference": "LOAN-2291-03", "short_url": "https://rzp.io/i/AbC123", "customer_name": "Priya Sharma", "customer_phone": "+919812345678", "customer_email": null, "contact_id": "4411…", "bot_id": "c3d4…", "channel": "voice", "conversation_id": null, "voice_session_id": "8e9f…", "delivered_via": "sms", "error": null, "paid_at": "2026-09-26T10:42:00Z", "created_at": "2026-09-26T10:31:00Z", "updated_at": "2026-09-26T10:42:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `provider` | `string` | `razorpay` | | `amount_minor` | `integer` | Requested amount in the smallest currency unit (paise): `149900` = ₹1,499.00 | | `amount_paid_minor` | `integer` | Paid so far, in paise | | `currency` | `string` | `INR` | | `description` | `string` | What the payment is for, as shown to the customer | | `external_reference` | `string \| null` | Your own reference (invoice, EMI or order number), if the agent was given one | | `short_url` | `string \| null` | The payment link. `null` until the link is created | | `customer_name` / `customer_phone` / `customer_email` | `string \| null` | Who the link was sent to. Phone in E.164 | | `contact_id` | `UUID \| null` | The CRM contact, when known. Status changes are also added as a note on this contact | | `bot_id` | `UUID \| null` | The agent that created the request | | `channel` | `string \| null` | Where the agent was, e.g. `voice` or `whatsapp` | | `conversation_id` / `voice_session_id` | `UUID \| null` | The chat or call it came from | | `delivered_via` | `string \| null` | Comma-separated channels the link was sent through: `whatsapp`, `sms`, `email` | | `error` | `string \| null` | Why the request `failed`, safe to show | | `paid_at` | `datetime \| null` | When it became `paid` | ## GET `/api/v1/payment-requests` Returns an array of payment request objects, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | No | One of the seven statuses | | `contact_id` | `UUID` | No | One contact's requests | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/payment-requests?status=paid&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` An unknown `status` returns `422 status must be one of: creating, created, partially_paid, paid, expired, cancelled, failed`. ## GET `/api/v1/payment-requests/{request_id}` One payment request. `404 Payment request not found`. ```bash curl https://api.callmissed.com/api/v1/payment-requests/{request_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## GET `/api/v1/payment-requests/settings` The connected Razorpay account, the per-link limit and the webhook to configure in Razorpay. ```bash curl https://api.callmissed.com/api/v1/payment-requests/settings \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "connected": true, "integration_id": "6a1e…", "account": "rzp_live_XXXXXXXXXXXXXX", "mode": "live", "max_amount_inr": 10000, "platform_max_amount_inr": 500000, "webhook_url": "https://api.callmissed.com/api/v1/webhooks/razorpay/6a1e…", "webhook_events": [ "payment_link.cancelled", "payment_link.expired", "payment_link.paid", "payment_link.partially_paid" ] } ``` | Field | Type | Notes | | --- | --- | --- | | `connected` | `boolean` | `false` when no Razorpay integration is connected. `integration_id`, `account`, `mode` and `webhook_url` are then `null` | | `account` | `string \| null` | The connected Razorpay key id | | `mode` | `string \| null` | `live` for a `rzp_live_…` key, otherwise `test` | | `max_amount_inr` | `integer` | The largest amount, in whole rupees, an agent may request in one link. Default `10000` | | `platform_max_amount_inr` | `integer` | The highest value `max_amount_inr` can be set to: `500000` | | `webhook_url` | `string \| null` | Paste this exactly as returned into Razorpay's webhook settings | | `webhook_events` | `string[]` | The Razorpay events to enable on that webhook | ## PUT `/api/v1/payment-requests/settings` Sets the largest amount an agent may request in one payment link. Larger collections are left to a person. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `max_amount_inr` | `integer` | Yes | Whole rupees, `1 <= max_amount_inr <= 500000` | ```bash curl -X PUT https://api.callmissed.com/api/v1/payment-requests/settings \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "max_amount_inr": 25000 }' ``` Returns the settings object. `404 Connect Razorpay first` when no Razorpay integration is connected; `422` when the value is out of range. ## Webhook events Subscribe a [webhook](/docs/webhooks) to these events to hear about payments as they happen: | Event | When | | --- | --- | | `payment_request.partially_paid` | A part payment was made | | `payment_request.paid` | Paid in full | | `payment_request.expired` | The link expired | | `payment_request.cancelled` | The link was cancelled | The event's `data` object: ```json { "payment_request_id": "5c7d…", "status": "paid", "provider": "razorpay", "amount": 1499.0, "amount_paid": 1499.0, "currency": "INR", "description": "EMI 3 of 12", "reference": "LOAN-2291-03", "short_url": "https://rzp.io/i/AbC123", "contact_id": "4411…", "customer_phone": "+919812345678", "customer_email": null, "bot_id": "c3d4…", "paid_at": "2026-09-26T10:42:00.481203+00:00" } ``` Note the units: in event payloads `amount` and `amount_paid` are in **rupees**, while the API's `amount_minor` and `amount_paid_minor` are in **paise**. `reference` is the API's `external_reference`. ## Errors | Status | When | | --- | --- | | `403` | Key is missing `integrations:read` / `integrations:write` | | `404` | Payment request not in your account, or settings updated before Razorpay is connected | | `422` | Unknown `status` filter, or `max_amount_inr` outside `1`–`500000` | Nothing on this page consumes credits. --- ### Bring Your Own Key URL: /docs/provider-keys > Store your own model-provider credentials, verify them, rotate them, and deactivate them — the secret is never returned. ## Overview Bring Your Own Key (BYOK) lets you store your own credential for a model provider so inference runs on your account with that provider instead of ours. The stored secret is **write-only**. No endpoint on this page returns it, and there is no reveal route. You can see the provider, an optional label, the last four characters, whether it is active, and when it was last verified — nothing more. If you lose the original secret, delete the record and add a new one. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List | `provider_keys:read` | | Create, update, delete, verify | `provider_keys:write` | ## Supported providers Provider ids are lowercase. The accepted set is intentionally small — only providers whose credentials the gateway can actually inject into inference — for example `google` and `sarvam`. An unsupported value returns `422` and lists the supported provider ids. ## Which requests use your key, and billing A stored key is used for `POST /v1/chat/completions` (and `/v1/responses`, `/v1/messages` and Batch lines) when the model you call is served through that provider — for example a `google` key for the `gemini-*` chat models, and a `sarvam` key for the `sarvam-*` chat models. - A completion served on your own key costs **0 credits**: you pay the provider directly. It still appears in your [usage logs](/docs/usage-api). - With no active key for that provider, or if the stored key cannot be read, the request runs on CallMissed's credential and is billed normally. - Your key is only ever sent to the provider it belongs to. If a [`models` fallback](/docs/chat-completion#model-substitution) is served by a different provider, that fallback runs on our credential and is billed normally. ## The uniqueness rule A credential occupies one slot per `(provider, label)`. A record with no label is that provider's **default** slot. Adding a second key to an occupied slot returns `409`: ```json { "detail": "A default google key already exists. Delete it first to replace the secret." } ``` To hold several keys for one provider, give each a distinct `label` (for example `eu`, `us`, `batch`). **Rotation is delete-then-create.** `PATCH` deliberately refuses a `key` field, so a secret can never be silently swapped under a record that other systems believe they know. ## GET `/api/v1/gateway/provider-keys` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `provider` | `string` | No | At most 32 characters, case-insensitive, must be a supported provider | | `limit` | `integer` | No | `1 <= limit <= 200`, default `100` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```json [ { "id": "1a2b…", "provider": "google", "label": "eu", "key_last4": "9f3a", "is_active": true, "last_verified_at": "2026-08-17T09:12:00Z", "created_at": "2026-08-02T11:00:00Z", "updated_at": "2026-08-17T09:12:00Z" } ] ``` Newest first. ## POST `/api/v1/gateway/provider-keys` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `provider` | `string` | Yes | 1–32 characters, lowercased, must be supported | | `key` | `string` | Yes | 8–512 characters, not blank. Write-only — stored encrypted, never returned | | `label` | `string` | No | At most 128 characters. Blank is stored as no label (the default slot) | Unknown fields are rejected with `422` rather than ignored, so a typo in a field name fails loudly instead of quietly dropping your secret. ```bash curl -X POST https://api.callmissed.com/api/v1/gateway/provider-keys \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "provider": "google", "key": "AIza…", "label": "eu" }' ``` Returns `201` with the record — note the response has no `key` field. ## PATCH `/api/v1/gateway/provider-keys/{key_id}` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `label` | `string` | No | At most 128 characters | | `is_active` | `boolean` | No | `false` takes the credential out of use without deleting it | Sending `key` returns `422`. Moving a label into an occupied slot returns `409`. Use `is_active: false` when you suspect a credential and want traffic to fall back immediately while you investigate. ## DELETE `/api/v1/gateway/provider-keys/{key_id}` Returns `204`. `404 Provider key not found` for an unknown or another tenant's id. ## POST `/api/v1/gateway/provider-keys/{key_id}/verify` Performs a live check against the provider. No request body. ```json { "id": "1a2b…", "provider": "google", "ok": true, "detail": "Credential accepted by the provider.", "last_verified_at": "2026-08-17T09:12:00Z" } ``` `detail` is one of exactly two strings — `Credential accepted by the provider.` or `The provider rejected this credential or could not be reached.` The upstream status code and body are never passed through, so a verify call cannot be used to probe a provider. A successful check stamps `last_verified_at`. | Status | Detail | Cause | | --- | --- | --- | | `400` | `Liveness verification is not available for {provider} keys.` | That provider has no cheap check | | `404` | `Provider key not found` | Unknown or another tenant's id | | `409` | `This stored credential can no longer be read. Delete and re-add it.` | The stored secret is unreadable — re-add it | ## Errors | Status | When | | --- | --- | | `403` | Key is missing `provider_keys:read` / `provider_keys:write` | | `404` | Record not found in your tenant | | `409` | Slot already occupied, or an unreadable stored credential | | `422` | Unsupported provider, blank/short secret, an unknown field, or a `key` sent to `PATCH` | Storing, verifying and deleting provider keys never consumes credits. --- ### Status API URL: /docs/status-api > Public service-health endpoints — current status, historical uptime, and the incident feed that power status.callmissed.com. > These endpoints are **public** (no auth) and back [status.callmissed.com](https://status.callmissed.com). Use them to embed live status in your own dashboards or to gate automated jobs on platform health. ## Credential class: none Send no `Authorization` header. A dashboard JWT or a `cm_` API key is accepted but ignored: these routes have no auth dependency and return identical data either way. They are **not** tenant-scoped, so nothing here reveals your account. Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`; out-of-range query parameters are `422`. The JSON endpoints share the same status vocabulary: | `status` value | Meaning | | --- | --- | | `operational` | Healthy | | `degraded` | Working with reduced quality | | `down` | Not serving | | `not_configured` | Not enabled on this deployment | | `unknown` | No data recorded for that day (uptime only) | --- ## GET /api/v1/status Live health snapshot. No parameters. ```bash curl https://api.callmissed.com/api/v1/status ``` ```json { "overall": "operational", "checked_at": "2026-08-04T10:31:07.512004+00:00", "services": [ { "name": "CallMissed API", "group": "infrastructure", "status": "operational", "description": "REST API, dashboard, webhooks, and API keys", "latency_ms": 4 }, { "name": "AI, speech & language", "group": "ai", "status": "operational", "description": "Chat, speech-to-text, text-to-speech, and embeddings-compatible routes" }, { "name": "Billing & checkout", "group": "payments", "status": "operational", "description": "Subscriptions and one-time payments" }, { "name": "WhatsApp Business", "group": "channels", "status": "operational", "description": "Meta WhatsApp Cloud API" }, { "name": "Voice & SMS", "group": "channels", "status": "operational", "description": "PSTN voice and SMS" }, { "name": "Transactional email", "group": "notifications", "status": "operational", "description": "Account notifications and receipts" } ], "self_heal": [], "interval_seconds": 30 } ``` | Field | Type | Notes | | --- | --- | --- | | `overall` | `string` | `down` if any monitored row is down, else `degraded` if any is degraded, else `operational`. Rows with `not_configured` are ignored | | `checked_at` | `string` | ISO-8601 UTC timestamp of this check | | `services[].name` | `string` | Human label | | `services[].group` | `string` | One of `infrastructure`, `ai`, `payments`, `channels`, `notifications` | | `services[].status` | `string` | See the vocabulary above | | `services[].description` | `string` | What the row covers | | `services[].latency_ms` | `integer` | **Optional.** Present only where a live latency was measured | | `services[].checked_at` | `string` | **Optional.** ISO-8601 UTC time this row was last checked | | `services[].ci` | `object \| null` | **Optional.** Latest automated API check for the row: `status` (`operational` or `degraded`) and `checked_at` | | `self_heal[]` | `array` | Automatic recoveries in the last 7 days, each with `at`, `component` (a `services[].name`), and `result` (`recovered` or `still_failing`) | | `interval_seconds` | `integer` | How often the snapshot is refreshed | To gate a job on platform health, poll this and require `overall == "operational"`. Other fields may appear in the response; treat only the ones above as stable. ## GET /api/v1/status/stream The same payload as `GET /api/v1/status`, pushed as Server-Sent Events: one `status` event on connect and another on every change, with a `: ping` comment every 25 seconds to keep the connection open. ```bash curl -N https://api.callmissed.com/api/v1/status/stream ``` ``` event: status data: {"overall":"operational","checked_at":"…","services":[…]} ``` Connections are capped: too many from one client returns `429`, and `503` means the stream is at capacity — fall back to polling `GET /api/v1/status`. ## GET /api/v1/status/uptime Per-service daily uptime for a rolling window ending today. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `days` | `integer` | No | `1 <= days <= 90`, default `90` | ```bash curl "https://api.callmissed.com/api/v1/status/uptime?days=30" ``` ```json { "days": 30, "services": [ { "service_name": "CallMissed API", "uptime_percent": 99.94, "daily": [ { "date": "2026-07-06", "total_checks": 288, "operational_checks": 288, "degraded_checks": 0, "down_checks": 0, "worst_status": "operational" }, { "date": "2026-07-07", "total_checks": 0, "operational_checks": 0, "degraded_checks": 0, "down_checks": 0, "worst_status": "unknown" } ] } ] } ``` `daily` always contains exactly `days` entries, oldest first. Days with no recorded checks are returned as zero-filled placeholders with `worst_status: "unknown"` so a chart axis stays continuous. `uptime_percent` counts a degraded check as half an operational one: ``` uptime_percent = round(((operational + 0.5 * degraded) / total) * 100, 2) ``` It is `0.0` when the window contains no checks at all. Services are sorted by name. `422` when `days` is outside `1 .. 90`. ## GET /api/v1/status/incidents Incidents overlapping the requested window, newest first. An incident is included when it started before now and either has not ended or ended inside the window. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `days` | `integer` | No | `1 <= days <= 90`, default `7` | ```bash curl "https://api.callmissed.com/api/v1/status/incidents?days=7" ``` ```json { "days": 7, "incidents": [ { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "service_name": "CallMissed API", "severity": "degraded", "title": "Elevated latency on the CallMissed API", "summary": "Requests were slower than normal for roughly 20 minutes.", "started_at": "2026-08-01T04:12:00+00:00", "ended_at": "2026-08-01T04:33:00+00:00", "resolved": true } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | UUID | | `service_name` | `string` | Matches a `services[].name` from `/status` | | `severity` | `string` | `degraded` or `down` | | `title` | `string` | Generated from service and severity when no custom title was written | | `summary` | `string \| null` | Optional detail | | `started_at` | `string` | ISO-8601 UTC | | `ended_at` | `string \| null` | `null` while the incident is ongoing | | `resolved` | `boolean` | Whether the incident is closed | `incidents` is an empty array when the window is clean. `422` when `days` is outside `1 .. 90`. --- ### Macros, Tags & Routing URL: /docs/support-ops > Canned replies with placeholders and side-effect actions, a tag vocabulary, and a first-match-wins routing engine with a dry-run evaluator. ## Overview Three operational primitives sit behind the support desk: - **Macros** — canned replies. A macro carries a body with `{{placeholder}}` variables and, optionally, `actions` that are applied *besides* sending the text: set a status, add tags, assign a user. - **Tags** — your tenant's tag vocabulary for tickets: a name, a display colour and a description. - **Routing rules** — an ordered, first-match-wins triage list. Each rule ANDs a set of conditions over a fixed six-field allowlist and names what should happen when it matches (assignee, priority, tags). Rules are applied when a ticket is created (see [creating a ticket](/docs/support-tickets)), and the dry run tells you which one would match without writing anything. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | Every list/read, plus `POST /routing-rules/evaluate` | `support_ops:read` | | Every create, update, delete, plus `POST /macros/{id}/use` | `support_ops:write` | `POST /routing-rules/evaluate` is a **dry run** — it writes nothing, so it needs only the read scope. `POST /macros/{id}/use` increments a counter, so it needs write. --- # Macros ## The macro object ```json { "id": "9b8a…", "tenant_id": "a0b1…", "name": "Refund acknowledged", "body": "Hi {{customer_name}}, your refund for order {{order_id}} is on its way.", "actions": { "set_status": "pending", "add_tags": ["refund"], "assign_to": null }, "category": "billing", "is_active": true, "usage_count": 41, "created_at": "2026-08-01T10:00:00Z", "updated_at": "2026-08-16T14:00:00Z" } ``` ### `actions` | Field | Type | Constraints | | --- | --- | --- | | `set_status` | `string \| null` | At most 32 characters, lowercase identifier | | `add_tags` | `string[] \| null` | At most 20, each at most 64 characters, non-blank | | `assign_to` | `UUID \| null` | Must be a user in your tenant | `set_status` must match `^[a-z][a-z0-9_-]{0,31}$`; it is stored as written, not checked against the ticket status list, so use one of the [ticket statuses](/docs/support-tickets) if you intend to apply it to a ticket. Unknown keys inside `actions` are **rejected** with `422` rather than ignored, so a typo cannot silently do nothing. ## GET `/api/v1/support/ops/macros` Ordered by `usage_count` descending, then name — so the picker shows what your team actually uses. | Parameter | Type | Constraints | | --- | --- | --- | | `category` | `string` | At most 64 characters, exact match | | `is_active` | `boolean` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/support/ops/macros?category=billing&is_active=true" \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/support/ops/macros` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank, unique per tenant | | `body` | `string` | Yes | 1–20,000 characters, not blank | | `actions` | `object` | No | See above | | `category` | `string` | No | At most 64 characters | | `is_active` | `boolean` | No | Default `true` | ```bash curl -X POST https://api.callmissed.com/api/v1/support/ops/macros \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Refund acknowledged", "body": "Hi {{customer_name}}, your refund for order {{order_id}} is on its way.", "actions": { "set_status": "pending", "add_tags": ["refund"] }, "category": "billing" }' ``` Returns `201` with the macro object. `409 A macro named '…' already exists` on a duplicate name; `404 Assignee not found in this tenant` when `actions.assign_to` is not one of your users. ## PATCH / DELETE `/api/v1/support/ops/macros/{macro_id}` `PATCH` takes the same fields, all optional, and returns the updated macro; sending `actions: null` clears the actions. `DELETE` returns `204`. Both return `404 Macro not found` for an id outside your tenant. ```bash curl -X PATCH https://api.callmissed.com/api/v1/support/ops/macros/{macro_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "is_active": false }' curl -X DELETE https://api.callmissed.com/api/v1/support/ops/macros/{macro_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/support/ops/macros/{macro_id}/use` Renders the macro against a context and bumps `usage_count`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `context` | `object` | No | Default `{}`. At most 50 keys; at most 16,000 characters serialised | ```bash curl -X POST https://api.callmissed.com/api/v1/support/ops/macros/9b8a…/use \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "context": { "customer_name": "Asha", "order_id": "A-4419" } }' ``` ```json { "id": "9b8a…", "name": "Refund acknowledged", "body": "Hi Asha, your refund for order A-4419 is on its way.", "actions": { "set_status": "pending", "add_tags": ["refund"] }, "usage_count": 42 } ``` Placeholder syntax is `{{name}}` (surrounding spaces allowed), where the name starts with a letter or underscore and may contain letters, digits, `_` and `.`, up to 64 characters. Substituted values are truncated to 500 characters and are not re-scanned for placeholders. > **Unknown or null-valued placeholders are left verbatim** — `{{order_id}}` stays in the text rather than becoming an empty gap. Check the rendered body before sending it to a customer, or supply every variable the macro declares. Applying the returned `actions` is your job: this endpoint renders and counts, it does not mutate a ticket. --- # Tags ```json { "id": "7f6e…", "tenant_id": "a0b1…", "name": "refund", "color": "#c2410c", "description": "Money going back to the customer", "created_at": "2026-08-01T10:00:00Z", "updated_at": "2026-08-01T10:00:00Z" } ``` | Method | Path | Scope | | --- | --- | --- | | `GET` | `/api/v1/support/ops/tags` | `support_ops:read` | | `POST` | `/api/v1/support/ops/tags` | `support_ops:write` | | `PATCH` | `/api/v1/support/ops/tags/{tag_id}` | `support_ops:write` | | `DELETE` | `/api/v1/support/ops/tags/{tag_id}` | `support_ops:write` | `GET` is ordered by name and takes `limit` (`1..500`, default `100`) and `offset` (`0..100000`). There are no filters. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–64 characters, not blank, unique per tenant | | `color` | `string` | No | A hex triplet such as `#c2410c` or `#c40`. Stored lowercase | | `description` | `string` | No | At most 255 characters | Anything other than a hex triplet returns `422 color must be a hex triplet like '#aabbcc'`. `PATCH` takes the same fields, all optional, and returns the updated tag; `DELETE` returns `204`. `404 Tag not found` for an id outside your tenant, `409 A tag named '…' already exists` on a duplicate name. ```bash curl "https://api.callmissed.com/api/v1/support/ops/tags?limit=100" \ -H "Authorization: Bearer cm_your_api_key" curl -X POST https://api.callmissed.com/api/v1/support/ops/tags \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "refund", "color": "#c2410c", "description": "Money going back to the customer" }' curl -X PATCH https://api.callmissed.com/api/v1/support/ops/tags/{tag_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "color": "#15803d" }' curl -X DELETE https://api.callmissed.com/api/v1/support/ops/tags/{tag_id} \ -H "Authorization: Bearer cm_your_api_key" ``` --- # Routing rules ## Vocabulary | Concept | Allowed values | | --- | --- | | Condition `field` | `channel`, `priority`, `status`, `subject_contains`, `contact_email_domain`, `tag` | | Condition `operator` | `eq`, `neq`, `contains`, `in`, `is_set`, `is_not_set` | | `assign_strategy` | `direct`, `round_robin` | The field list is a **closed allowlist** — an unknown field is `422`, not a silently-false condition. | Condition `field` | Read from (in a dry run) | | --- | --- | | `channel` | `channel` | | `priority` | `priority` | | `status` | `status` | | `subject_contains` | `subject` | | `contact_email_domain` | The part of `contact_email` after `@`, lowercased | | `tag` | `tags` — `eq` means "carries this tag", `neq` "does not carry it", `contains` "some tag contains the text" | All comparisons are case-insensitive and ignore surrounding whitespace. ### Condition value rules | Operator | `value` | | --- | --- | | `in` | A non-empty array, at most 100 entries | | `is_set` / `is_not_set` | Omitted | | Everything else | A non-blank scalar (string at most 512 characters, number or boolean) | ## The rule object ```json { "id": "2c3d…", "tenant_id": "a0b1…", "name": "Enterprise urgent → Ravi", "position": 0, "conditions": [ { "field": "priority", "operator": "eq", "value": "urgent" }, { "field": "contact_email_domain", "operator": "in", "value": ["acme.com", "globex.com"] } ], "assign_to_user_id": "b1f2…", "assign_strategy": "direct", "set_priority": "urgent", "add_tags": ["enterprise"], "is_active": true, "created_at": "2026-08-05T09:00:00Z", "updated_at": "2026-08-05T09:00:00Z" } ``` All conditions on a rule are **ANDed**. Rules are evaluated in `position` order and the **first match wins** — `position` is the policy, so the list is never re-sorted for you. ## GET `/api/v1/support/ops/routing-rules` Returns an array of rule objects in evaluation order (`position`, then creation time). Takes `is_active`, `limit` (`1..500`, default `100`) and `offset` (`0..100000`, default `0`). ```bash curl "https://api.callmissed.com/api/v1/support/ops/routing-rules?is_active=true" \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/support/ops/routing-rules` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, unique per tenant | | `conditions` | `object[]` | No | Default `[]`. **At most 20 per rule** | | `position` | `integer` | No | `0 <= position <= 100000`, default `0` | | `assign_to_user_id` | `UUID` | No | Must be a user in your tenant | | `assign_strategy` | `string` | No | `direct` (default) or `round_robin` | | `set_priority` | `string` | No | At most 16 characters, lowercase identifier | | `add_tags` | `string[]` | No | At most 20, each at most 64 characters. De-duplicated | | `is_active` | `boolean` | No | Default `true` | A rule with an empty `conditions` array matches everything — use it deliberately as a final catch-all at the highest `position`. `round_robin` is stored on the rule for your own assignment logic; the dry run below has no team roster to rotate through, so it returns the rule's `assign_to_user_id` for both strategies. ```bash curl -X POST https://api.callmissed.com/api/v1/support/ops/routing-rules \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Enterprise urgent → Ravi", "position": 0, "conditions": [ { "field": "priority", "operator": "eq", "value": "urgent" }, { "field": "contact_email_domain", "operator": "in", "value": ["acme.com", "globex.com"] } ], "assign_to_user_id": "b1f2c3d4-1111-2222-3333-444455556666", "add_tags": ["enterprise"] }' ``` Returns `201` with the rule object. `409 A routing rule named '…' already exists` on a duplicate name; `404 Assignee not found in this tenant` when `assign_to_user_id` is not one of your users. ## PATCH `/api/v1/support/ops/routing-rules/reorder` Rewrites the whole evaluation order. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `rule_ids` | `UUID[]` | Yes | 1–500 entries. Must be **every** routing rule in your tenant, exactly once | ```bash curl -X PATCH https://api.callmissed.com/api/v1/support/ops/routing-rules/reorder \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "rule_ids": ["2c3d…", "8e9f…", "4a5b…"] }' ``` Returns the rules in their new order. New `position` is the array index. Duplicate ids return `422 rule_ids must not contain duplicates`. A partial list returns `422 rule_ids must list every routing rule in this tenant exactly once` — reordering is all-or-nothing, so two concurrent partial reorders cannot interleave into a nonsense order. ## POST `/api/v1/support/ops/routing-rules/evaluate` A **dry run**. Nothing is written; you get back what would happen. | Field | Type | Constraints | | --- | --- | --- | | `channel` | `string` | At most 64 characters | | `priority` | `string` | At most 32 characters | | `status` | `string` | At most 32 characters | | `subject` | `string` | At most 1,000 characters | | `contact_email` | `string` | At most 320 characters | | `tags` | `string[]` | At most 100 entries | Every field is optional. **An absent fact fails any `eq`, `contains`, `in` or `is_set` condition that asks about it** (it satisfies `neq` and `is_not_set`) — so send the whole picture when testing. ```bash curl -X POST https://api.callmissed.com/api/v1/support/ops/routing-rules/evaluate \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "channel": "whatsapp", "priority": "urgent", "subject": "Cannot check out", "contact_email": "asha@acme.com", "tags": ["vip"] }' ``` ```json { "matched": true, "rule_id": "2c3d…", "rule_name": "Enterprise urgent → Ravi", "assign_to_user_id": "b1f2…", "assign_strategy": "direct", "set_priority": "urgent", "add_tags": ["enterprise"] } ``` Only `is_active: true` rules are considered. When nothing matches you get `{"matched": false, …, "add_tags": []}` — a miss, not an error. The same decision is applied automatically when a ticket is created with `POST /api/v1/support/tickets`: the rule fills in the assignee only when the request names none, sets the priority only when the request sends none, and adds its tags to the request's. Tickets that already exist are not re-routed — change them with [`PATCH /api/v1/support/tickets/{ticket_id}`](/docs/support-tickets). ## PATCH / DELETE `/api/v1/support/ops/routing-rules/{rule_id}` Same fields as create, all optional; sending `conditions` replaces the whole list. Returns the updated rule. `DELETE` returns `204`. `404 Routing rule not found` for an id outside your tenant. ```bash curl -X PATCH https://api.callmissed.com/api/v1/support/ops/routing-rules/{rule_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "is_active": false }' curl -X DELETE https://api.callmissed.com/api/v1/support/ops/routing-rules/{rule_id} \ -H "Authorization: Bearer cm_your_api_key" ``` --- ## Errors | Status | When | | --- | --- | | `403` | Key is missing `support_ops:read` / `support_ops:write` | | `404` | Macro, tag, rule or assignee not in your tenant | | `409` | Duplicate macro, tag or rule name | | `422` | Blank name or body, over 20 conditions, an unknown condition field or operator, a value that does not fit its operator, a bad colour, an unknown key in `actions`, an incomplete or duplicated `reorder` list | Nothing on this page consumes credits. --- ### SLA Policies URL: /docs/support-sla > Define response and resolution targets with business hours, read a ticket's live SLA clock, and list what is breaching. ## Overview An **SLA policy** promises two things about a ticket: how fast someone will respond, and how fast it will be resolved. A policy may be scoped to one priority, or left un-scoped as the catch-all. Deadlines are computed against the ticket's server-managed [lifecycle stamps](/docs/support-tickets), so a policy cannot be gamed by editing a timestamp. Breach is **computed on read, never stored**. That means the numbers are always current, and it has one consequence for pagination — see the note on `/breaches`. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List policies, ticket status, breaches | `sla:read` | | Create, update, delete a policy | `sla:write` | ## Business hours A policy with `business_hours` only burns its clock during those hours. Omit it and the clock runs continuously. | Field | Type | Default | Constraints | | --- | --- | --- | --- | | `tz` | `string \| null` | Your tenant's timezone | At most 64 characters | | `days` | `integer[]` | `[1,2,3,4,5]` | 1–7 entries, values `1`–`7` where Monday is `1` and Sunday is `7`. Stored sorted and de-duplicated | | `start` | `string` | `"09:00"` | `HH:MM`, 24-hour | | `end` | `string` | `"18:00"` | `HH:MM`, must be later than `start` | ```json { "tz": "Asia/Kolkata", "days": [1,2,3,4,5,6], "start": "10:00", "end": "19:00" } ``` ## The policy object ```json { "id": "d1c2…", "tenant_id": "a0b1…", "name": "Urgent — 15 min first response", "priority": "urgent", "first_response_minutes": 15, "resolution_minutes": 240, "business_hours": null, "is_active": true, "created_at": "2026-08-10T08:00:00Z", "updated_at": "2026-08-10T08:00:00Z" } ``` ## GET `/api/v1/support/sla/policies` Returns an array of policy objects, newest first. | Parameter | Type | Constraints | | --- | --- | --- | | `priority` | `string` | At most 16 characters, matched lowercase | | `is_active` | `boolean` | | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/support/sla/policies?is_active=true" \ -H "Authorization: Bearer cm_your_api_key" ``` ## POST `/api/v1/support/sla/policies` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `name` | `string` | Yes | 1–255 characters, not blank, unique per tenant | | `priority` | `string` | No | At most 16 characters, must start with a letter. Omit for a catch-all policy | | `first_response_minutes` | `integer` | No | `1 <= n <= 100000` | | `resolution_minutes` | `integer` | No | `1 <= n <= 100000` | | `business_hours` | `object` | No | See above | | `is_active` | `boolean` | No | Default `true` | **At least one of `first_response_minutes` / `resolution_minutes` must be set** — a policy that promises nothing is rejected with `422 set at least one of first_response_minutes / resolution_minutes`. ```bash curl -X POST https://api.callmissed.com/api/v1/support/sla/policies \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Urgent — 15 min first response", "priority": "urgent", "first_response_minutes": 15, "resolution_minutes": 240 }' ``` A duplicate name returns `409 An SLA policy named '…' already exists`. ## PATCH `/api/v1/support/sla/policies/{policy_id}` All fields optional; only the fields you send are changed. Sending `business_hours: null` removes business hours (the clock then runs continuously); sending `priority: null` turns the policy into a catch-all. The "must promise something" rule is re-checked against the **merged** result, so you cannot clear both minute fields in two steps. Returns the updated policy. ```bash curl -X PATCH https://api.callmissed.com/api/v1/support/sla/policies/{policy_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "first_response_minutes": 30, "business_hours": { "tz": "Asia/Kolkata", "days": [1,2,3,4,5], "start": "09:00", "end": "18:00" } }' ``` `404 SLA policy not found`; `409` on a name another policy already uses. ## DELETE `/api/v1/support/sla/policies/{policy_id}` Returns `204`. `404 SLA policy not found`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/support/sla/policies/{policy_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## GET `/api/v1/support/sla/status/{ticket_id}` The live clock for one ticket. ```bash curl https://api.callmissed.com/api/v1/support/sla/status/{ticket_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "ticket_id": "e5d4…", "policy_id": "d1c2…", "policy_name": "Urgent — 15 min first response", "first_response_due_at": "2026-08-17T06:25:00Z", "first_response_breached": true, "resolution_due_at": "2026-08-17T10:10:00Z", "resolution_breached": false, "minutes_remaining": 84 } ``` | Field | Type | Notes | | --- | --- | --- | | `policy_id` | `UUID \| null` | `null` when no policy matched this ticket | | `minutes_remaining` | `integer \| null` | Wall-clock minutes to the nearest still-running deadline. **Negative when overdue.** `null` when both clocks have stopped or no policy matched | | `first_response_breached` / `resolution_breached` | `boolean` | A stopped clock is judged against its stamp: a ticket first answered after the deadline stays breached for good, and one answered in time never becomes breached later | Policy selection: only active policies (`is_active: true`) are considered. A policy whose `priority` matches the ticket's priority (case-insensitive) wins; otherwise the oldest catch-all policy applies; otherwise nothing does and every due date is `null`. `404 Ticket not found`. ## GET `/api/v1/support/sla/status` The same answer as `/status/{ticket_id}`, for up to 200 tickets in one call — what a ticket list uses to show every row's clock. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `ticket_id` | `UUID` (repeatable) | Yes | 1–200 ids; duplicates are ignored | ```bash curl "https://api.callmissed.com/api/v1/support/sla/status?ticket_id={id_1}&ticket_id={id_2}" \ -H "Authorization: Bearer cm_your_api_key" ``` Returns an array of status objects. Ids that are not tickets in your tenant are left out of the answer rather than reported as errors. ## GET `/api/v1/support/sla/breaches` Tickets currently past a deadline, oldest first. | Parameter | Type | Constraints | | --- | --- | --- | | `priority` | `string` | At most 16 characters, matched lowercase | | `include_resolved` | `boolean` | Default `false`, which skips tickets that have a `resolved_at` stamp | | `limit` | `integer` | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/support/sla/breaches?priority=urgent&limit=200" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "ticket_id": "e5d4…", "subject": "Refund not received", "status": "open", "priority": "urgent", "created_at": "2026-08-17T06:10:00Z", "sla": { "ticket_id": "e5d4…", "policy_id": "d1c2…", "policy_name": "Urgent — 15 min first response", "first_response_due_at": "2026-08-17T06:25:00Z", "first_response_breached": true, "resolution_due_at": "2026-08-17T10:10:00Z", "resolution_breached": false, "minutes_remaining": -32 } } ] ``` `sla` has the same shape as the `/status/{ticket_id}` response. > **Pagination behaves differently here.** Because breach is computed rather than stored, `limit` and `offset` page over the **tickets scanned** (oldest first), not the breaches returned. A page can come back shorter than `limit`, or empty, and still have more behind it — the response does not tell you how many tickets were scanned. To sweep everything, advance `offset` by `limit` each call until `offset` passes your ticket count, rather than stopping at the first short or empty page. ## Errors | Status | When | | --- | --- | | `403` | Key is missing `sla:read` / `sla:write` | | `404` | Policy or ticket not in your tenant | | `409` | Duplicate policy name | | `422` | Policy promises nothing, blank name, minutes outside `1..100000`, a `priority` that is not a short identifier, or `end` not later than `start` | Nothing on this page consumes credits. --- ### Support Tickets URL: /docs/support-tickets > Create, filter, assign and move support tickets through their lifecycle, with server-managed response and resolution stamps. ## Overview A **ticket** is one unit of support work. It can stand alone, or hang off a conversation and a contact so an agent sees the thread that produced it. The lifecycle timestamps — `first_responded_at`, `resolved_at`, `closed_at`, `reopened_count` — are **server-managed**. You never send them; you change `status` and the API stamps the rest. That is what makes the [SLA endpoints](/docs/support-sla) and CSAT reporting trustworthy. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` | Operation | Scope | | --- | --- | | List, get, list comments | `support_tickets:read` | | Create, update, assign, status, delete, add comment | `support_tickets:write` | ## Enumerations | Field | Values | | --- | --- | | `status` | `open`, `pending`, `waiting_on_customer`, `resolved`, `closed` | | `priority` | `low`, `normal`, `high`, `urgent` | `resolved` and `closed` are the **terminal** statuses; the other three are **active**. ## The ticket object ```json { "id": "e5d4…", "tenant_id": "a0b1…", "conversation_id": "c0ff…", "contact_id": "4411…", "subject": "Refund not received", "description": "Customer says the refund has not landed after 7 days.", "status": "open", "priority": "high", "assignee_user_id": null, "tags": ["billing", "refund"], "first_responded_at": null, "resolved_at": null, "closed_at": null, "reopened_count": 0, "created_at": "2026-08-17T06:10:00Z", "updated_at": "2026-08-17T06:10:00Z" } ``` | Field | Type | Notes | | --- | --- | --- | | `assignee_user_id` | `UUID \| null` | `null` means the ticket is in the unassigned queue | | `tags` | `string[] \| null` | Trimmed, blanks dropped, de-duplicated, order preserved | | `first_responded_at` | `datetime \| null` | Stamped **once**, the first time the ticket leaves `open`. Never re-stamped | | `resolved_at` | `datetime \| null` | Stamped on the move to `resolved` | | `closed_at` | `datetime \| null` | Stamped on the move to `closed` | | `reopened_count` | `integer` | Incremented each time a terminal ticket returns to an active status | ## GET `/api/v1/support/tickets` Returns an array of ticket objects, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | No | One of the five statuses | | `priority` | `string` | No | One of the four priorities | | `assignee_user_id` | `UUID` | No | One agent's queue | | `contact_id` | `UUID` | No | | | `conversation_id` | `UUID` | No | | | `unassigned` | `boolean` | No | `true` = no assignee, `false` = has one. Cannot be combined with `assignee_user_id` | | `q` | `string` | No | At most 255 characters. Case-insensitive substring on `subject` | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/api/v1/support/tickets?status=open&unassigned=true&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` Sending both `unassigned=true` and `assignee_user_id` returns `422 unassigned=true cannot be combined with assignee_user_id` — the two contradict each other, so the API refuses rather than silently picking one. ## POST `/api/v1/support/tickets` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `subject` | `string` | Yes | 1–255 characters, not blank | | `description` | `string` | No | At most 20,000 characters | | `conversation_id` | `UUID` | No | Must exist in your tenant | | `contact_id` | `UUID` | No | Must exist in your tenant | | `assignee_user_id` | `UUID` | No | Must be a user in your tenant | | `status` | `string` | No | Default `open` | | `priority` | `string` | No | Default `normal` | | `tags` | `string[]` | No | At most 20 tags, each at most 64 characters | ```bash curl -X POST https://api.callmissed.com/api/v1/support/tickets \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "subject": "Refund not received", "description": "Customer says the refund has not landed after 7 days.", "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "priority": "high", "tags": ["billing", "refund"] }' ``` Returns `201`. Creating a ticket directly as `resolved` or `closed` stamps the matching timestamp immediately. Your active [routing rules](/docs/support-ops) run on create, first match wins: a matching rule fills in `assignee_user_id` only when you sent none, sets `priority` only when you sent none, and adds its tags to yours. Values you send always win. `404 Conversation not found` / `Contact not found` / `Assignee not found` when a linked id is not in your tenant. ## GET `/api/v1/support/tickets/{ticket_id}` One ticket. `404 Ticket not found`. ```bash curl https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## PATCH `/api/v1/support/tickets/{ticket_id}` Accepts the same editable fields as create. The lifecycle timestamps and `reopened_count` are **not** accepted — a status change here runs the same transition rules as the dedicated status endpoint. Every field is optional; only the fields you send are changed. Linked ids (`conversation_id`, `contact_id`, `assignee_user_id`) are checked against your tenant exactly as on create. Returns the updated ticket. ```bash curl -X PATCH https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "priority": "urgent", "tags": ["billing", "escalated"] }' ``` `subject` sent as blank or `null` returns `422 subject must not be blank`. An explicit `null` for `status` or `priority` is ignored rather than written. ## POST `/api/v1/support/tickets/{ticket_id}/assign` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `assignee_user_id` | `UUID \| null` | No | `null` (or omitted) unassigns and returns the ticket to the queue | ```bash curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/assign \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "assignee_user_id": "b1f2c3d4-1111-2222-3333-444455556666" }' ``` Returns the updated ticket. `404 Assignee not found` when the user is not in your tenant. ## POST `/api/v1/support/tickets/{ticket_id}/status` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `string` | Yes | One of the five statuses | ```bash curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/status \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "status": "resolved" }' ``` Returns the updated ticket. **Idempotent.** Sending the status the ticket already has returns it untouched — no re-stamp, no `reopened_count` increment. Safe to retry. ### Transition rules | Move | Effect | | --- | --- | | Same status | No-op | | → `resolved` | Stamps `resolved_at` if unset, clears `closed_at` | | → `closed` | Stamps `closed_at`, leaves `resolved_at` alone | | Terminal → active | Clears both stamps, `reopened_count += 1` | | Leaving `open` for the first time | Stamps `first_responded_at` once | A logged reply (see [comments](#post-apiv1supportticketsticket_idcomments)) also stamps `first_responded_at` if nothing has yet. ## GET `/api/v1/support/tickets/{ticket_id}/comments` The ticket's thread — internal notes and logged replies — oldest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | 1–200, default 100 | | `offset` | `integer` | No | 0–100000, default 0 | ```bash curl https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/comments \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "9a1c…", "ticket_id": "e5d4…", "author_user_id": "b1f2…", "body": "Refund re-issued, reference RF-2210.", "is_internal": false, "created_at": "2026-08-17T07:02:00Z" } ] ``` `author_user_id` is `null` for entries added with an API key. `404 Ticket not found`. ## POST `/api/v1/support/tickets/{ticket_id}/comments` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `body` | `string` | Yes | 1–20000 characters, not blank | | `is_internal` | `boolean` | No | Default `true`. `true` is a team-only note; `false` records a reply the customer was given | A comment is **recorded on the ticket only** — nothing is sent to the customer. The first entry with `is_internal: false` stamps `first_responded_at` if it is still unset; internal notes never do. ```bash curl -X POST https://api.callmissed.com/api/v1/support/tickets/{ticket_id}/comments \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "body": "Refund re-issued, reference RF-2210.", "is_internal": false }' ``` Returns `201` with the comment. `404 Ticket not found`, `422` for a blank body. ## DELETE `/api/v1/support/tickets/{ticket_id}` Returns `204`. `404 Ticket not found`. ```bash curl -X DELETE https://api.callmissed.com/api/v1/support/tickets/{ticket_id} \ -H "Authorization: Bearer cm_your_api_key" ``` ## Errors | Status | When | | --- | --- | | `403` | Key is missing `support_tickets:read` / `support_tickets:write` | | `404` | Ticket, conversation, contact or assignee is not in your tenant | | `422` | Unknown status/priority, blank subject, more than 20 tags or a tag over 64 characters, or `unassigned` combined with `assignee_user_id` | Nothing on this page consumes credits. --- ### Usage API URL: /docs/usage-api > Read your own metering data — spend summaries, per-request logs, and a CSV export you can drop into a warehouse. ## Overview The Usage API returns the same metering rows that back the dashboard's usage charts: one record per billable API call, with the service, model, token counts, latency and the credits it cost you. Use it to build an internal cost dashboard, attribute spend to a customer via `session_id`, or reconcile an invoice. | Endpoint | Returns | | --- | --- | | `GET /v1/usage/summary` | Rolled-up totals, per-service and per-model breakdowns, a daily series | | `GET /v1/usage/logs` | Individual request records, newest first | | `GET /v1/usage/logs.csv` | The same records as a CSV download | | `GET /v1/credits/balance` | Your current credit balance | ## Authentication ``` Authorization: Bearer cm_your_api_key ``` All three endpoints require the `usage:read` scope. A dashboard session also works. ```json { "detail": "API key missing required scope: usage:read. Add it under the key's 'Permissions' section in your dashboard." } ``` ## Retention window Usage history is queryable for the **last 90 days**. Any request for a wider or older window returns `422` — export to CSV on a schedule if you need to keep more. ## GET `/v1/usage/summary` | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `days` | `integer` | No | `1 <= days <= 90`, default `30` | ```bash curl "https://api.callmissed.com/v1/usage/summary?days=7" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "period_days": 7, "totals": { "total_requests": 18422, "success": 18310, "errors": 112, "success_rate": 0.9939, "total_cost_usd": 41.87, "total_input_tokens": 9120334, "total_output_tokens": 1844920, "total_cache_read_tokens": 4210000, "total_cache_creation_tokens": 120500, "total_audio_seconds": 6120.5, "avg_latency_ms": 812.4 }, "by_service": [ { "service": "llm", "requests": 15980, "cost_usd": 38.11, "input_tokens": 9120334, "output_tokens": 1844920, "cache_read_tokens": 4210000, "cache_creation_tokens": 120500 }, { "service": "tts", "requests": 1422, "cost_usd": 2.44, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0 } ], "by_model": [ { "model": "kimi-k2.6", "requests": 9120, "cost_usd": 12.30 } ], "series": [ { "date": "2026-08-11", "requests": 2610, "cost_usd": 5.98 } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `totals.success_rate` | `number` | Fraction in `0..1`, not a percentage | | `totals.total_cost_usd` | `number` | What **you** were charged, in US$ at the published rate (1 credit = ₹1, US$1 = ₹96, so credits = US$ × 96). Every `cost_usd` in this response uses the same unit | | `totals.total_input_tokens` | `integer` | LLM prompt tokens that were **not** served from the prompt cache | | `totals.total_cache_read_tokens` | `integer` | Prompt tokens served from the prompt cache, billed at the cache-read rate | | `totals.total_cache_creation_tokens` | `integer` | Prompt tokens written to the prompt cache, billed at the cache-write rate | | `by_service[].cache_read_tokens`, `by_service[].cache_creation_tokens` | `integer` | The same cache split for one service | | `by_model` | `array` | Top 10 models by request volume | | `series[].date` | `string` | `YYYY-MM-DD`, one row per day in the window | A tenant with no traffic gets zeroed totals and empty arrays — never an error. ## GET `/v1/usage/logs` Individual request records, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `days` | `integer` | No | `1 <= days <= 90`, default `7`. Ignored when `start` or `end` is present | | `start` | `datetime` | No | ISO-8601 UTC | | `end` | `datetime` | No | ISO-8601 UTC | | `service` | `string` | No | One of `llm`, `stt`, `tts`, `image`, `search`, `embedding`, `bot`, `whatsapp_message`, `whatsapp_call`, `telephony_call`, `voice_tier_call` (the flat per-minute charge for a call on a priced voice tier, billed instead of the per-component `stt`/`llm`/`tts` rows), `voice_platform_fee` (the ₹1 per-minute platform fee on a metered call, billed beside its `stt`/`llm`/`tts` rows) | | `model` | `string` | No | At most 255 characters. Exact model id | | `api_key_id` | `UUID` | No | Restrict to one key | | `status` | `string` | No | `ok` or `error` (`error` means a status code of 400 or above) | | `session_id` | `string` | No | At most 64 characters | | `trace_id` | `string` | No | At most 64 characters | | `limit` | `integer` | No | `1 <= limit <= 500`, default `100` | | `offset` | `integer` | No | `0 <= offset <= 100000`, default `0` | ```bash curl "https://api.callmissed.com/v1/usage/logs?days=1&service=llm&status=error&limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "period": { "start": "2026-08-16T00:00:00Z", "end": "2026-08-17T00:00:00Z" }, "limit": 50, "offset": 0, "count": 2, "logs": [ { "id": "9a1f…", "created_at": "2026-08-16T18:04:11Z", "service": "llm", "endpoint": "/v1/chat/completions", "method": "POST", "model": "kimi-k2.6", "status_code": 429, "latency_ms": 41, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0, "audio_seconds": 0.0, "cost_usd": 0.0, "request_id": "req_01J…", "api_key_id": "b1f2…", "error_message": "rate limit exceeded", "trace_id": null, "session_id": "checkout-flow" } ] } ``` For LLM rows, `input_tokens` is the part of the prompt that was not cached; `cache_read_tokens` (served from the prompt cache) and `cache_creation_tokens` (written to it) are counted separately, so the whole prompt is the sum of the three. Rows recorded before cache tracking was added show `0` for both cache fields. `cost_usd` is your price for the call, in US$ at the published rate (1 credit = ₹1, US$1 = ₹96), the same unit as `/v1/usage/summary`. Failed requests are recorded with `cost_usd: 0.0` — an error is never billed. ### Attributing spend On `POST /v1/messages`, put `trace_id` and/or `session_id` (each at most 64 characters) inside the `metadata` object, then filter here by `session_id` or `trace_id` to attribute spend to one of your own customers, tenants or workflows. Other `metadata` keys (up to 16, string/number/boolean values) are stored with the row and exported in the CSV's `metadata_json` column. **This needs request logging on the key that makes the call.** `trace_id`, `session_id` and the rest of `metadata` are stored only when **Request logging** is turned on for that API key, and logging is off by default on a new key. On a key with logging off the request is still metered and billed, but the row keeps `trace_id`, `session_id` and `metadata_json` empty (as it does `model`, `latency_ms`, `request_id` and `error_message`), so filtering by `session_id` or `trace_id` will not find it. Request logging is a console setting: turn it on when you create the key, or later from the key's edit dialog under **Developer → API keys** in the [console](https://console.callmissed.com/developer/keys). It cannot be changed with an API key, and it applies only to requests made after you turn it on. See [Logging](/docs/keys#logging). ```json { "model": "kimi-k2.6", "max_tokens": 512, "metadata": { "session_id": "checkout-flow", "trace_id": "tr_8812", "feature": "summary" }, "messages": [{ "role": "user", "content": "Summarise this order history." }] } ``` On `/v1/chat/completions` and `/v1/responses` the `metadata`, `trace_id` and `session_id` fields are accepted and validated but are **not yet recorded** on the usage row, so those rows show `null` for both ids. Attribute that traffic with a separate API key per customer and filter by `api_key_id`. Text-to-speech rows (`POST /v1/audio/speech`) record the audio settings in `metadata` (the CSV's `metadata_json` column): `response_format` as you sent it, `speech_sample_rate` as you sent it (`null` when omitted), and `sample_rate_sent`, the rate passed to the model (`null` when the model's own default applied or the model takes no rate). Like the rest of `metadata`, they are stored only when request logging is on for the key. ## GET `/v1/usage/logs.csv` Same filters as `/logs` **minus `limit` and `offset`**, capped at **5,000 rows** per download. Narrow the window or add filters if you need more. ```bash curl "https://api.callmissed.com/v1/usage/logs.csv?days=30&service=llm" \ -H "Authorization: Bearer cm_your_api_key" \ -o usage.csv ``` Returns `text/csv` with `Content-Disposition: attachment; filename="usage-YYYYMMDD.csv"`. Header row: ``` id,created_at,service,endpoint,method,model,status_code,latency_ms,input_tokens,output_tokens,cache_read_tokens,cache_creation_tokens,audio_seconds,cost_usd,request_id,api_key_id,error_message[,trace_id][,session_id][,metadata_json] ``` `cost_usd` is in US$, as on `/logs`. `error_message` is truncated to 500 characters. Text cells are escaped so a spreadsheet cannot interpret a value as a formula. ## GET `/v1/credits/balance` Your account's current credit balance. Any valid `cm_` key can call it — no scope or service permission is needed. ```bash curl https://api.callmissed.com/v1/credits/balance \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "balance": 1843.25 } ``` `balance` is in credits (1 credit = ₹1). Errors use the OpenAI envelope, e.g. `401 invalid_api_key`. ## Errors | Status | Detail | Cause | | --- | --- | --- | | `403` | `API key missing required scope: usage:read…` | Key lacks the scope | | `422` | `` `start` must be earlier than `end`. `` | Inverted range | | `422` | `Requested range exceeds the 90-day maximum.` | `start`/`end` span too wide | | `422` | `Usage history is available for the last 90 days only.` | `start` older than the window | | `422` | `Unknown service '…'. Valid: …` | Bad `service` value | Reading usage never consumes credits. --- ### Webhooks URL: /docs/webhooks > Create outbound webhook subscriptions, fire test deliveries, and inspect or replay the delivery log. > Subscribe an HTTPS endpoint to platform events. Every payload is signed with HMAC-SHA256 using the subscription's secret. Deliveries are logged, inspectable, and replayable. ## Credential class: dashboard JWT **or** a `cm_` key with `webhooks:write` These routes accept either credential. ``` Authorization: Bearer # or Authorization: Bearer cm_your_api_key ``` | Caller | Requirement | | --- | --- | | `cm_` API key | Must carry the `webhooks:write` scope. Every endpoint on this page checks it, **including the read-only list and delivery endpoints** - there is no `webhooks:read` scope | | Dashboard JWT | Every endpoint, **including the reads**, requires **owner or admin** (`403 Only owners/admins can manage webhooks` otherwise). Delivery payloads carry customer data, so the log is not open to every member | Base URL for every example: `https://api.callmissed.com`. Errors are `{"detail": "..."}`. ## Event types Pass these in the `events` array. An unrecognised value returns `422` listing the valid set. Duplicates are dropped, and the array may hold at most as many entries as the catalogue below (31). | Category | Events | | --- | --- | | Conversations & messages | `conversation.started`, `conversation.ended`, `message.received`, `message.sent`, `message.status` (a WhatsApp message you sent reached `sent`, `delivered`, `read` or `failed`, see [delivery status](/docs/whatsapp-api#delivery-status-and-proof-of-service)) | | Voice sessions | `voice_session.started`, `voice_session.ended`, `voice_session.failed` | | Telephony call lifecycle | `call.started`, `call.completed`, `call.failed`, `call.amd_detected` (answering-machine detection resolved on an outbound call), `call.handoff` (an [agent squad](/docs/voice-squads) member handed a live call to another member), `call.moderation_flagged` (caller or agent speech matched a [moderation](/docs/voice-moderation) category) | | Post-call analysis | `voice_analysis.completed` | | Outbound campaigns | `campaign.started`, `campaign.completed`, `campaign.failed` | | Metric alerts | `voice_alert.triggered` | | Billing | `budget.alert`, `budget.exceeded`, `credits.low` | | Keys | `api_key.expired` | | Invoices & payments | `invoice.created`, `payment.succeeded`, `payment.failed` | | Customer payment links | `payment_request.partially_paid`, `payment_request.paid`, `payment_request.expired`, `payment_request.cancelled` — a payment link an agent sent to your customer changed status in your own connected Razorpay account | | CRM quotes & invoices | `quote.accepted` (a customer accepted a [quote](/docs/crm-quotes) on its public page, or your team recorded the acceptance), `invoice.paid` (a [CRM invoice](/docs/crm-invoices) was fully paid, online through its payment link or by a recorded manual payment) | ### CRM quote and invoice events Both are sent once the change is saved. Money is in minor units of the document's `currency`; ids are your own records. | Event | `data` fields | | --- | --- | | `quote.accepted` | `quote_id`, `number`, `status` (`converted` when the quote was turned straight into an issued invoice, else `accepted`), `currency`, `total_minor`, `accepted_by_name`, `accepted_at`, `contact_id`, `deal_id`, `invoice_id` (the issued invoice, or `null`) | | `invoice.paid` | `invoice_id`, `number`, `status` (`paid`), `currency`, `total_minor`, `amount_paid_minor`, `contact_id`, `deal_id`, `paid_at` | ### Squad handoff event `call.handoff` is sent once per handoff, after it is saved, on phone and web calls alike. A subscription scoped to one agent receives it when that agent is the one the call was handed **to**. | `data` field | Meaning | | --- | --- | | `session_id` | The voice session | | `call_id` | The phone call, or `null` for a web or API session | | `squad_id` | The squad the call runs in | | `from_bot_id`, `from_role` | The member that handed the call over | | `to_bot_id`, `to_role` | The member that has the call now | | `reason` | The handing agent's one-line summary of what the caller needs. Empty when the agent or your workspace keeps no voice content (zero data retention) | | `handoff_number` | `1` for the call's first handoff, then `2`, … | | `at` | When the handoff happened (ISO 8601) | ### Voice session end reasons A voice session fires exactly one of `voice_session.ended` or `voice_session.failed`, once, whichever way it ends: the agent or caller hanging up, a `DELETE`, the managed voice agent socket closing, or the session timing out. The event is sent only after the end has been saved. If two end signals arrive at the same moment, the first one decides `end_reason` and `duration_seconds`, and the later one changes nothing. | `end_reason` | Session `status` | Event | | --- | --- | --- | | `agent_error`, `start_failed`, and the managed voice agent's start-up errors (`invalid_settings`, `concurrency_limit`, `model_unavailable`, `non_settings_message_before_settings`) | `failed` | `voice_session.failed` | | `timeout` (ran past its maximum duration and nothing reported the end), `token_expired` (never connected) | `timeout` | `voice_session.failed` | | Any other reason, for example `caller_hangup`, `participant_disconnect`, `client_disconnect`, `agent_closed`, `max_duration`, `no_input`, `handoff`, `transferred`, `budget_exhausted`, `api_delete` | `completed` | `voice_session.ended` | ## Delivery format Every event is a `POST` with this JSON body: ```json { "event": "message.received", "data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" }, "timestamp": "2026-08-04T09:12:00.512301+00:00" } ``` and these headers: ``` Content-Type: application/json X-CallMissed-Event: message.received X-CallMissed-Delivery: X-CallMissed-Signature: sha256= X-CallMissed-Signature-Previous: sha256= (only for 24 hours after a secret rotation) ``` `X-CallMissed-Delivery` is the `id` you see in the delivery log; use it to de-duplicate, because a retry or a replay can deliver the same event more than once. A response with a status below `300` counts as delivered. Anything else, or no response within 10 seconds, is retried: up to **5 attempts** in total, waiting 2, 4, 8 and 16 seconds between them. The delivery log records the attempt count, the last status code and the last error. A Slack Incoming Webhook URL (`https://hooks.slack.com/services/...`) receives the event formatted as Slack message blocks instead of the JSON body above. ## Signing and verification Every delivery, including the test delivery, carries the `X-CallMissed-Signature` header above. The digest is `HMAC-SHA256(secret, raw_request_body)`. Compute it over the **raw bytes** you received, before any JSON parsing, and compare with a constant-time function. ```python import hashlib, hmac def verify(raw_body: bytes, headers: dict, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return any( hmac.compare_digest(expected, headers.get(name, "")) for name in ("X-CallMissed-Signature", "X-CallMissed-Signature-Previous") ) ``` The full `secret` is returned **only once**, in the `POST` create response (and, for a new secret, in the [rotate-secret](#rotating-the-secret) response). Every later read masks it as `first4 + "****" + last4`. Store it when you create the subscription. ### Rotating the secret `POST /api/v1/webhooks/{webhook_id}/rotate-secret` replaces the secret. From then on `X-CallMissed-Signature` is signed with the **new** secret, and for **24 hours** every delivery also carries `X-CallMissed-Signature-Previous`, signed with the **old** one. A receiver that accepts either header (as in the snippet above) keeps verifying while you deploy the new secret. After 24 hours only the new secret signs. Rotating again during the window replaces the old secret with the one just retired. ## URL rules The `url` is validated on create, on update, before a test delivery, and on every real delivery attempt. Endpoints resolving to private, loopback, link-local, or shared address space are rejected. Use a public HTTPS URL. --- ## GET /api/v1/webhooks Lists your tenant's subscriptions, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | ```bash curl https://api.callmissed.com/api/v1/webhooks \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "url": "https://hooks.acme.com/callmissed", "secret": "Xk4p****9dQz", "events": ["conversation.started", "message.received"], "is_active": true, "bot_id": null, "created_at": "2026-07-19T08:00:00Z" } ] ``` `403` when a `cm_` key lacks `webhooks:write`, or a JWT caller is not owner/admin; `422` when `limit` is outside `1 .. 200`. ## POST /api/v1/webhooks Creates a subscription and generates its secret. Returns `201`. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `url` | `string` | Yes | 1-2048 chars. Must pass the URL rules above | | `events` | `string[]` | Yes | 1-30 entries, each from the event table | | `bot_id` | `UUID \| null` | No | Scope the subscription to one agent. Omit for a workspace-wide subscription | ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.acme.com/callmissed", "events": ["conversation.started", "message.received", "payment.succeeded"], "bot_id": null }' ``` ```json { "id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "url": "https://hooks.acme.com/callmissed", "secret": "Xk4pR7tYb2Lm8sNcVhJ3wQeZ1aFgD6uO9dQz", "events": ["conversation.started", "message.received", "payment.succeeded"], "is_active": true, "bot_id": null, "created_at": "2026-08-04T10:40:00Z" } ``` This response is the **only** place the full `secret` appears. | Status | Cause | | --- | --- | | `400` | The URL failed validation | | `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin | | `404` | `Agent not found` when `bot_id` is not an agent in your tenant | | `422` | Unknown event type, empty `events`, or `url` over 2048 chars | ## PUT `/api/v1/webhooks/{webhook_id}` Partial update. Every field is optional; omitted fields are unchanged. Returns the subscription with a **masked** secret. | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `url` | `string \| null` | No | 1-2048 chars. Re-validated when present | | `events` | `string[] \| null` | No | Replaces the whole list. An empty array `[]` subscribes to **every** event | | `is_active` | `boolean \| null` | No | Pause or resume deliveries | | `bot_id` | `UUID \| null` | No | Re-scope to an agent in your tenant | | `clear_bot_id` | `boolean` | No | Default `false`. Send `true` to widen a scoped subscription back to the whole workspace. Takes precedence over `bot_id` | ```bash curl -X PUT https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"events": ["message.received"], "is_active": false}' ``` `404` `Webhook not found`; `404` `Agent not found` for a foreign `bot_id`; `403`, `400`, and `422` as for create. ## DELETE `/api/v1/webhooks/{webhook_id}` Returns `204` with an empty body. Writes a `webhook.delete` audit event. ```bash curl -X DELETE https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \ -H "Authorization: Bearer cm_your_api_key" ``` `403` for insufficient permission; `404` when the subscription is not in your tenant. ## POST `/api/v1/webhooks/{webhook_id}/rotate-secret` Generates a new signing secret and returns it **once**. No request body. The previous secret keeps signing `X-CallMissed-Signature-Previous` until `previous_secret_expires_at` (24 hours), see [Rotating the secret](#rotating-the-secret). Writes a `webhook.secret.rotate` audit event. ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/rotate-secret \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff", "url": "https://hooks.acme.com/callmissed", "secret": "Qm7tZ2pLw9XcV4bN1sRkY8eH3uJ6dF0aGq5o", "events": ["conversation.started", "message.received"], "is_active": true, "bot_id": null, "created_at": "2026-07-19T08:00:00Z", "previous_secret_expires_at": "2026-10-18T09:30:00+00:00" } ``` `403` when a `cm_` key lacks `webhooks:write`, or a JWT caller is not owner/admin; `404` when the subscription is not in your tenant. ## POST `/api/v1/webhooks/{webhook_id}/test` Sends a real signed POST to the configured URL and reports the result. No request body. The stored URL is re-validated first, so a hostname repointed at a private address after creation is rejected. The delivered payload: ```json { "event": "test", "timestamp": "2026-08-04T10:45:12.004921+00:00", "data": { "message": "This is a test webhook delivery from CallMissed" } } ``` ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/test \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "success": true, "status_code": 200, "latency_ms": 214, "error": null } ``` `success` is true only for a 2xx from your endpoint. On a network failure the response is still `200` with `success: false`, `status_code: null`, and a short `error` such as `Connection failed` or `Request timed out`. The 10-second request timeout applies. The test is not retried and is not written to the delivery log. `403` for insufficient permission; `404` when the subscription is not in your tenant. ## GET `/api/v1/webhooks/{webhook_id}/deliveries` Delivery log for one subscription, newest first. | Parameter | Type | Required | Constraints | | --- | --- | --- | --- | | `limit` | `integer` | No | `1 <= limit <= 200`, default `50` | ```bash curl "https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries?limit=50" \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3", "webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "event": "message.received", "status_code": 500, "attempts": 3, "success": false, "error": "HTTP 500", "created_at": "2026-08-04T09:12:00Z", "delivered_at": null } ] ``` `status_code` and `error` are `null` when not applicable; `delivered_at` is `null` until a delivery succeeds. The payload body is **not** in this list response - fetch one delivery for that. `403` when a `cm_` key lacks `webhooks:write`; `404` when the subscription is not in your tenant; `422` when `limit` is outside `1 .. 200`. ## GET `/api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}` Full inspector view of one delivery, including the stored payload. ```bash curl https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3 \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3", "webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "event": "message.received", "status_code": 500, "attempts": 3, "success": false, "error": "HTTP 500", "payload": { "event": "message.received", "data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" } }, "created_at": "2026-08-04T09:12:00+00:00", "delivered_at": null } ``` | Status | Cause | | --- | --- | | `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin | | `404` | `Webhook not found` or `Delivery not found` | | `422` | Either path id is not a valid UUID | ## POST `/api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay` Re-sends a past delivery's payload against the subscription's **current** URL and secret. No request body. A new delivery row is created; the original is preserved. Returns `202`. ```bash curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3/replay \ -H "Authorization: Bearer cm_your_api_key" ``` ```json { "delivery_id": "a7b8c9d0-e1f2-4304-8516-27a8b9c0d1e2", "status": "queued" } ``` `status: "queued"` means the replay was accepted, not that it succeeded. Poll the deliveries list for the new row's outcome. Writes a `webhook.delivery.replay` audit event. | Status | Cause | | --- | --- | | `400` | `Webhook is inactive` - re-enable it with `PUT` first | | `403` | Key missing `webhooks:write`, or JWT caller is not owner/admin | | `404` | `Webhook not found` or `Delivery not found` | --- ### Domains & Senders URL: /docs/email-domains > Register a sending domain, publish its DNS records, verify ownership, SPF and both DKIM keys, and understand the donotreply sender rule. ## Overview Before you can send anything you register a domain you own, publish the DNS records we generate for it, and verify. Verification also registers the one sender username you may send from. Everything on this page uses the base path `https://api.callmissed.com/api/v1/email` and a `cm_` key with the **email** permission. ## Add & Verify a Domain Register a domain, publish the returned DNS records at your DNS provider, then verify. **`POST /api/v1/email/domains`** registers the domain and returns its records. **`POST /api/v1/email/domains/{domain_id}/verify`** runs the checks. | Create field | Type | Required | Notes | |--------------|------|----------|-------| | `domain` | string | Yes | The domain you own, 3–255 chars, for example `acme.com` | :::tabs ```bash [cURL] # 1. Add the domain - returns the DNS records to publish curl -X POST https://api.callmissed.com/api/v1/email/domains \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"domain": "acme.com"}' # 2. After publishing the records, verify curl -X POST https://api.callmissed.com/api/v1/email/domains/{domain_id}/verify \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} created = httpx.post(f"{BASE}/domains", headers=h, json={"domain": "acme.com"}).json() for rec in created["dns_records"]: print(rec["type"], rec["host"], rec["value"]) # publish these at your DNS host # once published: httpx.post(f"{BASE}/domains/{created['domain']['id']}/verify", headers=h) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const created = await fetch(`${BASE}/domains`, { method: "POST", headers, body: JSON.stringify({ domain: "acme.com" }), }).then((r) => r.json()); for (const rec of created.dns_records) { console.log(rec.type, rec.host, rec.value); // publish these at your DNS host } // once published: await fetch(`${BASE}/domains/${created.domain.id}/verify`, { method: "POST", headers: { Authorization: "Bearer cm_your_key" }, }); ``` ::: The add response returns `domain` (a `DomainOut`), `dns_records` (the exact set to publish) and a `message` reading `"Publish these DNS records, then call verify."`. Use each record's `host` (already formatted the way DNS panels want it: `@` for the apex, a bare label for a subdomain) and its `value` verbatim. | Record | Required | Purpose | |--------|----------|---------| | `TXT` (account ownership) | Yes, until the domain is verified | Published at `_callmissed-verify.your-domain` with a value starting `callmissed-domain-verification=`. Proves the domain belongs to **your** account | | `TXT` (ownership) | Yes | A one-off token proving you control the domain | | `TXT` (SPF) | Yes | Authorises our sending infrastructure to send for the domain | | `CNAME` (DKIM) | Yes | Delegates the DKIM signing key for the domain to us | | `CNAME` (DKIM2) | Yes | A second delegated DKIM key, used for key rotation | | `MX` | No | Routes inbound mail to us (only needed to **receive**) | DKIM is **delegated by `CNAME`**, not published as a `TXT` key. The exact host and value of every record are generated per domain, so read them from `dns_records` rather than hard-coding them. If your DNS panel refuses the `CNAME`, you almost certainly have a conflicting record at that name already. **No DMARC record is generated.** DMARC is worth publishing and we recommend it, but you author `_dmarc.your-domain` yourself. It is not in the returned set and not part of verification. Verification covers **five** checks (account ownership, domain ownership, SPF, DKIM and DKIM2) and a domain may send only once **all five** report verified. The account-ownership check appears in `checks` as `ownership`; a domain verified before this check existed keeps working without it. `POST /domains/{id}/verify` returns the refreshed `domain`, `verified` (a single boolean over all checks) and a `checks` array with one entry per check, so a partial pass tells you exactly which record has not propagated yet. Each check is a `CheckOut` carrying `name`, `status`, `detail`, and `found` (the values actually seen in DNS, empty when the check is served by the mail service rather than a DNS scan). Propagation is not instant; call verify again until `verified` is `true`. A newly verified domain starts on a warm-up quota that rises automatically as it sends clean volume. See [Limits & Quotas](/docs/email-limits). ### Managing domains | Endpoint | Purpose | |----------|---------| | `POST /api/v1/email/domains` | Register a domain (`201`) and get its DNS records. A domain already on your account → `400` | | `GET /api/v1/email/domains` | List your domains, newest first, as `DomainOut` | | `GET /api/v1/email/domains/{id}/records` | Re-fetch a domain's DNS records at any time, the same set the create call returned | | `GET /api/v1/email/domains/{id}/provider` | Detect the domain's DNS host from its nameservers and return a deep link to the right DNS-management page | | `POST /api/v1/email/domains/{id}/verify` | Run verification and return the per-check states | | `PATCH /api/v1/email/domains/{id}` | Toggle [open/click tracking and sending](#tracking-and-sending-toggles) | | `DELETE /api/v1/email/domains/{id}` | Remove a domain (`204`). Sending from it stops immediately | ```bash # List your domains, re-fetch records, look up the DNS host, then remove a domain curl https://api.callmissed.com/api/v1/email/domains \ -H "Authorization: Bearer cm_your_key" curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/records \ -H "Authorization: Bearer cm_your_key" curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/provider \ -H "Authorization: Bearer cm_your_key" curl -X DELETE https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" ``` A domain id that isn't yours returns `404` with `{"detail": "Domain not found"}`. ### Tracking and sending toggles **`PATCH /api/v1/email/domains/{id}`** sets per-domain behaviour. Needs a write key. | Field | Type | Notes | |-------|------|-------| | `open_tracking` | boolean | Track opens on HTML mail from this domain. Also accepted as `track_opens` | | `click_tracking` | boolean | Track link clicks on HTML mail from this domain. Also accepted as `track_clicks` | | `sending` | boolean | `false` pauses sending from this domain; `true` resumes it | Only the fields you send are applied, so an omitted toggle is left untouched. An unsupported field is a `422` rather than a silent no-op, so you always know whether a setting took effect. ```bash # Turn on open + click tracking curl -X PATCH https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"open_tracking": true, "click_tracking": true}' # Pause sending from this domain curl -X PATCH https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"sending": false}' ``` The response is `DomainSettingsOut`, the full domain including `track_opens` and `track_clicks`, so you can see what the call just set. **Tracking.** Both toggles are off by default and independent. Once on, HTML sends from the domain get an open pixel and/or signed click-redirect links. Details of what gets rewritten are on [Send Email](/docs/email-send#open-and-click-tracking); read the numbers back from [engagement metrics](/docs/email-logs#engagement-metrics). **Sending.** `sending: false` takes effect immediately: sends from the domain stop, and its `status` becomes `paused`. `sending: true` resumes it. One important limit: **`sending: true` only resumes a domain you paused yourself.** A domain paused automatically for deliverability reasons (too many bounces or complaints for its volume) stays paused and returns `409`. That pause is a circuit breaker, and a breaker a caller can clear is only advice. Fix the underlying list quality, then contact support. `pause_reason` on `DomainOut` tells the two cases apart. ### Response objects `DomainOut` returns `id`, `domain`, `status` (`pending` / `verified` / `failed` / `paused`), `dkim_selector`, `daily_quota`, `verified_at`, `last_checked_at`, `paused_at`, `pause_reason`, `sent_count`, `bounce_count`, `complaint_count`, `track_opens`, `track_clicks`, `created_at`. `DomainSettingsOut`, returned by `PATCH /domains/{id}`, carries the same fields as `DomainOut`. `DnsRecordOut` returns `type`, `name` (the full FQDN), `host` (the panel-ready name), `value`, `purpose`, `required`, and `priority` (MX only; `null` otherwise). `DnsProviderOut` returns `detected`, `provider_id`, `provider_name`, `manage_url`, `domain_specific`, and `nameservers`. ### Errors on the domain routes | Status | Body | Meaning | |--------|------|---------| | 400 | string `detail` | The domain is not a valid domain name, or is already registered on your account | | 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header | | 403 | string `detail` | The API key is read-only and this route writes | | 404 | `{"detail": "Domain not found"}` | The domain id is not yours | | 409 | string `detail` | `PATCH sending=true` on a domain paused automatically for deliverability reasons or never verified, or `sending=false` on a domain that is not verified | | 409 | string `detail` | Add or verify a domain that is already verified on another account: `"This domain is verified on another account. Remove it there first, or contact support if you own it."` | | 422 | schema array `detail` | An unsupported field in a `PATCH` body | | 503 | `reason` nested under `detail` | `provider_unavailable` (older deployments may return `acs_unavailable`; match both): domain provisioning or verification is temporarily unavailable, retry the domain call | | 503 | string `detail` | `"Domain onboarding is not configured"` | Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes). ## Sender Addresses A message may only be sent from an address whose local part is a **registered sender** on the verified domain. Verification registers exactly one, `donotreply`, so `donotreply@your-domain` works the moment the domain reports verified: ```json { "from": "Acme " } ``` The local part is matched case-insensitively, so `DoNotReply@acme.com` works too. The **display name is entirely yours**: `"Acme Billing "` is what a recipient's mail client shows first, so the mailbox name is rarely the part they read. Any other local part on the domain has to be registered, and there are two ways to do it. **1. Register it up front** with the senders endpoints below. This is the explicit route and the one to use when you know your sending addresses. **2. Let the send path register it.** When a send is refused because the `from` local part is not a registered sender, the service registers that local part and retries the send once. Registration is eventually consistent across the mail service, so if the retries still land before it is live you get `503 sender_propagating`, which means the address is now registered and the send should simply be retried in a minute or two. Nothing else is required. ### Sender endpoints | Endpoint | Purpose | |----------|---------| | `GET /api/v1/email/domains/{domain_id}/senders` | List the addresses this domain may send from. Read from the mail service, which is the source of truth | | `POST /api/v1/email/domains/{domain_id}/senders` | Register an address as a permitted sender (`201`) | | `DELETE /api/v1/email/domains/{domain_id}/senders/{username}` | Remove a sender (`204`) | The domain must be **verified** before any of the three work; on a `pending`, `failed` or `paused` domain they return `400` with `"Verify the domain before managing its senders"`. | Create field | Type | Required | Notes | |--------------|------|----------|-------| | `username` | string | Yes | A bare local part, 1–64 chars, for example `hello`. It must not contain `@` or `/`, else `422`. Lower-cased before registration | `SenderOut` returns `username` and `address` (the full `username@domain`). :::tabs ```bash [cURL] # What can this domain send from today? curl https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders \ -H "Authorization: Bearer cm_your_key" # Register hello@acme.com as a sender curl -X POST https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"username": "hello"}' # Remove it again curl -X DELETE https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/senders/hello \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} domain_id = "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e" httpx.get(f"{BASE}/domains/{domain_id}/senders", headers=h).json() httpx.post(f"{BASE}/domains/{domain_id}/senders", headers=h, json={"username": "hello"}).json() httpx.delete(f"{BASE}/domains/{domain_id}/senders/hello", headers=h) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const domainId = "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; await fetch(`${BASE}/domains/${domainId}/senders`, { headers }).then((r) => r.json()); await fetch(`${BASE}/domains/${domainId}/senders`, { method: "POST", headers, body: JSON.stringify({ username: "hello" }), }).then((r) => r.json()); await fetch(`${BASE}/domains/${domainId}/senders/hello`, { method: "DELETE", headers: { Authorization: "Bearer cm_your_key" }, }); ``` ::: A registered sender responds: ```json { "username": "hello", "address": "hello@acme.com" } ``` Registering is **idempotent**: it is a create-or-update, so re-registering an existing username succeeds rather than returning `409`. It is also eventually consistent, so a send from a just-registered address can still be refused for up to a couple of minutes. | Status | Body | Meaning | |--------|------|---------| | 400 | string `detail` | The domain is not verified yet | | 422 | string `detail` | `username` is not a bare local part (it contains `@` or `/`, or is empty) | | 503 | string `detail` | The sender list could not be read, or the sender could not be registered or removed. Note this `503` is a plain string, not the `{error, reason}` shape the domain create/verify routes use | ### Reply-To **To give people a real address to answer, set `reply_to`.** It is an ordinary header with no sender registration behind it, so it can be any address at all, including a mailbox on another provider: ```json { "from": "Acme Support ", "reply_to": "support@acme.com" } ``` If you want those replies to come back through the API, claim the same address as a [receiving address](/docs/email-inbound). The sending **domain** is entirely yours (any verified domain on your account), and `to`, `cc`, `bcc` and `reply_to` are unrestricted. ## Next - [Send Email](/docs/email-send) once a domain reports verified. - [Receive Email](/docs/email-inbound) if you also published the `MX` record. --- ### Receive Email URL: /docs/email-inbound > Publish the MX record, claim receiving addresses on a verified domain, and read or forward inbound mail. ## Receive Email Receiving is optional. Publish the domain's **MX** record (returned with its [DNS records](/docs/email-domains#add--verify-a-domain)), then claim receiving addresses. Mail to an unknown address is refused; there is no catch-all. :::tabs ```bash [cURL] # Claim support@acme.com on a verified domain curl -X POST https://api.callmissed.com/api/v1/email/inbound/addresses \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"local_part": "support", "domain_id": "{domain_id}", "forward_url": "https://your-app.com/inbound"}' # List received messages curl https://api.callmissed.com/api/v1/email/inbound/messages \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} httpx.post(f"{BASE}/inbound/addresses", headers=h, json={ "local_part": "support", "domain_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "forward_url": "https://your-app.com/inbound", }) messages = httpx.get(f"{BASE}/inbound/messages", headers=h).json() ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; await fetch(`${BASE}/inbound/addresses`, { method: "POST", headers, body: JSON.stringify({ local_part: "support", domain_id: "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", forward_url: "https://your-app.com/inbound", }), }); const messages = await fetch(`${BASE}/inbound/messages`, { headers: { Authorization: "Bearer cm_your_key" }, }).then((r) => r.json()); ``` ::: ## Endpoints | Endpoint | Purpose | |----------|---------| | `POST /api/v1/email/inbound/addresses` | Claim an address on a verified domain | | `GET /api/v1/email/inbound/addresses` | List your receiving addresses | | `DELETE /api/v1/email/inbound/addresses/{id}` | Stop receiving at an address | | `GET /api/v1/email/inbound/messages` | List received messages, newest first (`limit` / `offset`) | | `GET /api/v1/email/inbound/messages/{id}` | Read one message (parsed text/HTML) | Mail arriving for an address you have not claimed is refused at the door, so nothing is stored for it. ### Claim an address **`POST /api/v1/email/inbound/addresses`** | Field | Type | Required | Notes | |-------|------|----------|-------| | `local_part` | string | Yes | The mailbox name to claim, 1–64 chars, for example `support` for `support@acme.com`. A bare name only: `@` or `/` in it is a `422`. Lower-cased before the address is built | | `domain_id` | string (UUID) | Yes | The id of one of your **verified** domains. An unverified domain is a `400` | | `forward_url` | string | No | Up to 2048 chars, and it must be a public `http`/`https` URL: an internal or private target is refused at write time with a `400`. Every message that arrives at this address is [POSTed to it](#forwarding-to-your-app) | An inbound address is **globally unique**: one mailbox per address across the whole platform, so a `local_part` already claimed on that domain returns `409`. `InboundAddressOut` returns `id`, `address` (the full `local_part@domain`), `domain_id`, `forward_url`, `is_active` and `created_at`. ### Forwarding to your app Set `forward_url` on an address and every message that arrives there is POSTed to it as JSON, so you do not have to poll the messages endpoints. ```json { "type": "email.received", "data": { "id": "3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d", "message_id": "", "from": "customer@example.com", "to": "support@acme.com", "subject": "Where is my order?", "text": "Hi, checking on order 1043.", "html": "

Hi, checking on order 1043.

", "auth_results": "not_evaluated", "size_bytes": 4821, "received_at": "2026-08-13T09:41:02.118431+00:00" } } ``` The `data` object is the message as we stored it. Raw MIME is not included; it is not retained after parsing. `auth_results` carries the sender-authentication verdicts computed when the message was received, and is the string `"not_evaluated"` when none were recorded. The request carries an `X-CallMissed-Event: email.received` header. The forward payload uses shorter key names than the messages API, so a handler written against one does not read the other unchanged: | Forward payload | `GET /inbound/messages/{id}` | |-----------------|------------------------------| | `data.id` | `id` | | `data.from` | `from_address` | | `data.to` | `to_address` | | `data.text` | `text_body` | | `data.html` | `html_body` | | `data.auth_results` | not returned | `message_id`, `subject`, `size_bytes` and `received_at` are spelled the same on both. How it behaves: - **The message is stored first, then forwarded.** It is always readable through the API even if your endpoint is down, and the `id` in the payload is the one you can fetch. - **The message's `status` records the outcome:** `forwarded` when your endpoint answered 2xx, `failed` when it did not. Poll `GET /inbound/messages` filtered by nothing and check `status` to find what your endpoint missed. - **A failed forward never loses the message.** Delivery is best-effort on top of a message we have already persisted. - **Redirects are not followed**, and the URL is re-validated at delivery time: one that resolves to a private or internal address is refused even though it passed validation when you claimed the address. - **Forwards are not signed.** Unlike [email webhooks](/docs/email-webhooks), a `forward_url` carries no HMAC signature, because there is no per-subscription secret behind it. Treat the payload as unauthenticated: use an unguessable URL, and confirm anything you act on by re-reading the message with `GET /inbound/messages/{id}` using your API key. If you want signed, retried, per-event delivery instead, subscribe to `email.received` on [Email Webhooks](/docs/email-webhooks) — note that event is accepted but not emitted yet, so `forward_url` is the live path for inbound mail today. ### Read what arrived ```bash # The addresses you currently receive at curl https://api.callmissed.com/api/v1/email/inbound/addresses \ -H "Authorization: Bearer cm_your_key" # One message, parsed to text/HTML curl https://api.callmissed.com/api/v1/email/inbound/messages/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \ -H "Authorization: Bearer cm_your_key" # Stop receiving at an address curl -X DELETE https://api.callmissed.com/api/v1/email/inbound/addresses/7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d \ -H "Authorization: Bearer cm_your_key" ``` `GET /inbound/messages` takes `limit` (1–200, default 50) and `offset` (≥0, default 0) and returns the newest first. Each `InboundMessageOut` carries `id`, `address_id`, `message_id`, `from_address`, `to_address`, `subject`, `text_body`, `html_body`, `size_bytes`, `status` (`received`, `forwarded` or `failed`) and `received_at`. ## Common inbound failures | Status | Body | Meaning | |--------|------|---------| | 400 | string `detail` | The domain is not verified yet, or the `forward_url` is not a permitted public URL | | 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header | | 403 | string `detail` | The API key is read-only and this route writes | | 404 | string `detail` | The inbound message, or the receiving address, is not yours | | 409 | string `detail` | The address is already claimed | | 422 | string `detail` | `local_part` is not a bare mailbox name | These use the plain-string `detail` shape with no `reason` key. See [Limits, Quotas & Errors](/docs/email-limits#response-shapes). ## Related - [Domains & Senders](/docs/email-domains) for the `MX` record and verification. - [Sender Addresses](/docs/email-domains#sender-addresses): claim the same address you put in `reply_to` if you want replies to come back through the API. --- ### Limits, Quotas & Errors URL: /docs/email-limits > The three sending ceilings, domain warm-up and reputation pauses, the four error body shapes, and every error reason. ## Overview Everything that can stop a send lives here: the three ceilings that govern volume, and the four error shapes plus the full reason table a client has to branch on. Per-message limits (recipients, size, tags, attachments) are under [Send Email](/docs/email-send); batch limits under [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions). ## Limits & Quotas Three separate ceilings govern sending, and they count three **different** things. A rejection always names which one you hit. | Plan | Send rate | Recipients per calendar month | Daily-quota ceiling | |------|-----------|-------------------------------|---------------------| | Free | 10 requests / min | 2,000 | 200 | | Starter | 60 requests / min | 50,000 | 5,000 | | Pro | 300 requests / min | 1,000,000 | 50,000 | | Enterprise, Scale, Business, Corporate | 1,000 requests / min | Unlimited | 500,000 | - **Send rate** counts **requests** accepted in the trailing 60 seconds, across your whole account. One call is one request whether it carries 1 recipient or 50, and a `messageVersions` batch is still one request. Exceeding it is `429 rate_limited`. - **Recipients per calendar month** counts **delivered recipients** in the current calendar month. It resets on the 1st, not on a rolling 30 days. A send is refused up front if it *would* push you past the cap. Exceeding it is `429 monthly_cap_exceeded`. - **Daily quota** counts **sends**, meaning messages and not recipients, for **one domain** over a rolling 24 hours. Exceeding it is `429 quota_exceeded`. **Warm-up.** The daily quota is a property of each domain, not of your plan. Every newly verified domain starts at **200 sends / day** and climbs the ladder **200 → 1,000 → 5,000 → 20,000 → 50,000**, at most one step per day, and only after a day that carried real volume with a healthy bounce and complaint rate. Past the top of the ladder the quota keeps **doubling** on each clean day rather than jumping straight to the plan ceiling, so a plan whose ceiling is higher than 50,000 is reached over several more clean days. The plan column above is the ceiling the plan permits; the figure actually enforced is the domain's current `daily_quota`, which `GET /api/v1/email/domains` returns. **Reputation pause.** A domain whose bounce or complaint rate degrades is paused automatically, whatever the plan or remaining quota. Sends from it then return `403 domain_paused` carrying the reason, and `DomainOut` shows `paused_at` and `pause_reason`. ```bash # Read the quota actually enforced for each domain curl https://api.callmissed.com/api/v1/email/domains \ -H "Authorization: Bearer cm_your_key" ``` ## Errors ### Response shapes Error bodies come in **four** shapes. They are not interchangeable, and a client that always reads `reason` from the same place breaks, most obviously on `relay_failed`, where `reason` sits at the top level rather than under `detail`. **1. Send rejection: `reason` nested under `detail`.** Everything the send pipeline refuses (every row in the table below except `relay_failed`), plus `403 email_not_enabled`, `422 scheduled_batch_unsupported`, `503 sending_unavailable`, and the `503` from the domain routes: ```json { "detail": { "error": "acme.com has not completed DNS verification", "reason": "domain_not_verified" } } ``` **2. `503 relay_failed`: flat, and it carries an `id`.** This one is **not** wrapped in `detail`. The send row already exists, so the `id` comes back for you to look up with `GET /api/v1/email/sends`: ```json { "error": "The message could not be accepted for delivery", "reason": "relay_failed", "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e" } ``` **3. Auth, not-found and conflict: `detail` is a plain string with no `reason` at all.** Covers `401`, the read-only-key `403`, every `404` (domain, template, inbound message, suppression, scheduled send), `409` (duplicate template name, address already claimed), the `400` on a duplicate domain, the `400`s and `422`s on the sender and inbound-address routes, the `503`s from the sender routes, and the `503`s raised when email is not configured: ```json { "detail": "A valid API key is required" } ``` **4. Schema-validation `422`: `detail` is an array, with no `reason` key.** Anything rejected before the send pipeline runs: a malformed or control-character-bearing address, a subject or header carrying control characters, an attachment that is not exactly one of `url`/`content` (or `content` without `name`), over-limit `params`, a `scheduledAt` in the past or beyond the 72-hour horizon, and the batch cross-version rules: ```json { "detail": [ { "type": "value_error", "loc": ["body"], "msg": "Value error, invalid email address: 'not-an-email'", "input": { "...": "..." } } ] } ``` When handling errors, branch on the HTTP status first, then check whether `detail` is an object, a string or an array before reaching for `reason`. Handling all four shapes: :::tabs ```python [Python] import httpx r = httpx.post( "https://api.callmissed.com/api/v1/email/send", headers={"Authorization": "Bearer cm_your_key"}, json={ "from": "Acme Ops ", "to": ["Ada "], "subject": "Your receipt", "text": "Thanks for your order.", }, ) if r.status_code != 202: body = r.json() if "reason" in body: # shape 2 - relay_failed, flat, has id reason, send_id = body["reason"], body.get("id") else: detail = body.get("detail") if isinstance(detail, dict): # shape 1 - send rejection reason, send_id = detail.get("reason"), None elif isinstance(detail, list): # shape 4 - schema validation reason, send_id = "validation_error", None else: # shape 3 - plain string detail reason, send_id = None, None print(r.status_code, reason, send_id) ``` ```javascript [JavaScript] const r = await fetch("https://api.callmissed.com/api/v1/email/send", { method: "POST", headers: { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ from: "Acme Ops ", to: ["Ada "], subject: "Your receipt", text: "Thanks for your order.", }), }); if (r.status !== 202) { const body = await r.json(); let reason = null; let sendId = null; if (body.reason) { reason = body.reason; // shape 2 - relay_failed, flat, has id sendId = body.id ?? null; } else if (body.detail && typeof body.detail === "object" && !Array.isArray(body.detail)) { reason = body.detail.reason; // shape 1 - send rejection } else if (Array.isArray(body.detail)) { reason = "validation_error"; // shape 4 - schema validation } // else shape 3 - plain string detail console.log(r.status, reason, sendId); } ``` ::: ### Reasons | Status | Reason | Meaning | |--------|--------|---------| | 401 | *(none, string `detail`)* | Missing, malformed or unrecognised `Authorization` header | | 402 | `payment_required` | Not enough credit balance to cover the send | | 403 | *(none, string `detail`)* | The API key is read-only and this route writes | | 403 | `email_not_enabled` | The API key lacks the email permission | | 403 | `domain_not_found` | The From domain is not registered on your account at all | | 403 | `domain_not_verified` | The From domain is registered but hasn't passed verification | | 403 | `domain_paused` | Sending from the domain is paused (reputation) | | 403 | `all_recipients_suppressed` | Every recipient is on your suppression list, nothing to send | | 404 | `template_not_found` | The `templateId` doesn't exist or isn't yours | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used by a different kind of send (a single send's key reused on a `messageVersions` batch, or the reverse). Use a fresh key | | 422 | `no_sender` | Neither `from`/`sender` nor a template `default_sender` supplied one | | 422 | `invalid_from` | The resolved sender is not a usable email address | | 422 | `no_recipients` | The resolved recipient list came out empty | | 422 | `empty_body` | Neither `text` nor `html` (nor a template body) was present | | 422 | `invalid_headers` | A rendered header value contains invalid characters, usually a template `param` with a newline in it | | 422 | `unresolvable_template_vars` | The subject or body references `{{ contact.something }}`, a namespace nothing can populate, so it would render empty. Pass the value in `params` instead | | 422 | `message_too_large` | The message exceeds 10 MB, attachments included after base64 encoding | | 422 | `template_inactive` | The template exists but is not active (`is_active: false`) | | 422 | `too_many_recipients` | Over 50 recipients on a single send, or over the union limit on a batch | | 422 | `scheduled_batch_unsupported` | A send set both `scheduledAt` and `messageVersions`, pick one | | 422 | `invalid_attachment` | An attachment's `content` is not valid base64 | | 422 | `attachment_fetch_failed` | A `url` attachment could not be fetched, or exceeded the size cap | | 422 | `attachment_url_forbidden` | A `url` attachment points at a blocked (internal/private) address, or uses a scheme other than http/https | | 429 | `rate_limited` | Per-minute request rate for your plan exceeded | | 429 | `monthly_cap_exceeded` | Monthly recipient volume for your plan exceeded | | 429 | `quota_exceeded` | The domain's daily send quota is exhausted | | 503 | `relay_failed` | The message could not be accepted for delivery. Uses the flat shape above | | 503 | `provider_unavailable` (older deployments: `acs_unavailable`) | Domain provisioning or verification is temporarily unavailable, retry the domain call. Both strings mean the same thing; match both | | 503 | `sender_propagating` | The `from` address has now been registered as a sender for the domain, but the mail service has not finished propagating it. Retry the send shortly; no further setup is needed. See [Sender Addresses](/docs/email-domains#sender-addresses) | | 503 | `sending_unavailable` | Sending is temporarily unavailable | A **malformed recipient address is not `invalid_attachment`**. It is a schema `422` (shape 4), rejected before the send pipeline runs and before any charge. --- ### Delivery, Suppressions & Usage URL: /docs/email-logs > Read the send log, manage the suppression list, and check email spend and pricing. ## Overview Four read-mostly surfaces cover what happened after a send: the send log (`/sends`), the suppression list (`/suppressions`), engagement metrics (`/emails/metrics`), and spend (`/usage`). Everything except the suppression writes is a read, so a read-only key works. To be told about a bounce or a complaint as it happens instead of polling, subscribe to [Email Webhooks](/docs/email-webhooks). ## Suppressions A suppression list per account prevents sending to addresses that hard-bounced or complained. Entries are added automatically from delivery feedback, and you can manage them: | Endpoint | Purpose | |----------|---------| | `GET /api/v1/email/suppressions` | List suppressed addresses, newest first. `limit` (1–500, default 100) and `offset` (≥0, default 0) | | `POST /api/v1/email/suppressions` | Suppress an address manually (`201`) | | `POST /api/v1/email/suppressions/batch` | Suppress up to 100 addresses in one call (`201`) | | `GET /api/v1/email/suppressions/{id}` | Retrieve one suppression by id | | `DELETE /api/v1/email/suppressions/{id}` | Remove a suppression (`204`) | | Create field | Type | Required | Notes | |--------------|------|----------|-------| | `address` | string | Yes | A valid email address. Stored lower-cased | | `reason` | string | No | One of `hard_bounce`, `complaint`, `manual`, `unsubscribe`. Defaults to `manual`; anything else is a `422` | | `detail` | string | No | Your own note about why | Adding an address that is already suppressed is safe: the existing entry is returned unchanged rather than duplicated or rejected. `SuppressionOut` returns `id`, `address`, `reason`, `detail`, and `created_at`. `GET /suppressions/{id}` returns the same object for a single entry; an id that is not yours is a `404`. ### Suppress in bulk **`POST /api/v1/email/suppressions/batch`** applies one `reason` and `detail` to many addresses: | Field | Type | Required | Notes | |-------|------|----------|-------| | `addresses` | array | Yes | 1–100 valid email addresses. Stored lower-cased | | `reason` | string | No | Same set as the single-add route. Defaults to `manual`; anything else is a `422` | | `detail` | string | No | Your own note, applied to every address in the batch | ```bash curl -X POST https://api.callmissed.com/api/v1/email/suppressions/batch \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "addresses": ["one@example.com", "two@example.com", "three@example.com"], "reason": "unsubscribe", "detail": "Imported from legacy list" }' ``` The `201` response is an array of `SuppressionOut` in the order you sent, so it lines up with your input. It is idempotent per address: one already on the list is returned unchanged rather than erroring, and duplicates within a single payload are collapsed. That means a partially-applied batch can simply be retried. :::tabs ```bash [cURL] # List curl https://api.callmissed.com/api/v1/email/suppressions \ -H "Authorization: Bearer cm_your_key" # Suppress an address manually curl -X POST https://api.callmissed.com/api/v1/email/suppressions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"address": "blocked@example.com", "reason": "manual"}' # Remove a suppression curl -X DELETE https://api.callmissed.com/api/v1/email/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} httpx.get(f"{BASE}/suppressions", headers=h).json() httpx.post(f"{BASE}/suppressions", headers=h, json={ "address": "blocked@example.com", "reason": "manual", }) httpx.delete(f"{BASE}/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d", headers=h) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; await fetch(`${BASE}/suppressions`, { headers }).then((r) => r.json()); await fetch(`${BASE}/suppressions`, { method: "POST", headers, body: JSON.stringify({ address: "blocked@example.com", reason: "manual" }), }); await fetch(`${BASE}/suppressions/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d`, { method: "DELETE", headers: { Authorization: "Bearer cm_your_key" }, }); ``` ::: Suppressed recipients are dropped from `to`, `cc`, and `bcc` before sending and returned in the send response's `suppressed` array. If every recipient is suppressed the send is refused with `403 all_recipients_suppressed`. A `404` on a suppression id uses the plain-string `detail` shape. ## Delivery Log & Usage **`GET /api/v1/email/sends`** is your send log, newest first. Returns an array of `SendOut`: | Field | Type | Notes | |-------|------|-------| | `id` | string (UUID) | The send id returned by `POST /send` | | `message_id` | string \| null | RFC 5322 `Message-ID`; null if the message was never built | | `from_address` | string | The sender the message went out with | | `subject` | string | The rendered subject | | `status` | string | `queued`, `sent` (accepted for delivery), `delivered`, `bounced`, `complained`, `rejected` (we refused it), or `failed` (delivery error) | | `size_bytes` | integer | Assembled message size | | `sent_at` | string \| null | When it was accepted for delivery | | `smtp_response` | string \| null | `accepted for delivery` once the message was handed off. It shows the hand-off, not that the message reached the inbox: see `delivered_at` and [per-recipient delivery events](#per-recipient-delivery-events). `null` for sends made before this field existed and for messages that were never accepted | | `delivered_at` | string \| null | When the first recipient's mail server accepted the message. The outcome for every recipient is in [per-recipient delivery events](#per-recipient-delivery-events) | | `bounced_at` | string \| null | Set from bounce feedback | | `complained_at` | string \| null | Set from a spam complaint | | `created_at` | string \| null | When the row was written | | `failure_reason` | string \| null | Why a `bounced`, `complained`, `rejected` or `failed` send didn't land, in plain language (e.g. `Address doesn't exist`, `Mailbox full`, `Recipient marked it as spam`, `Not sent: credit balance too low`). When only some recipients of a send failed, a summary such as `1 of 3 recipients bounced: Address doesn't exist` or `1 of 3 recipients not delivered: Blocked as spam by the recipient's server`; this can appear on `bounced`, `sent` or `delivered` sends. Otherwise `null` | | `failure_code` | string \| null | Short code for the same outcome: the receiving server's SMTP status code when it gave one (e.g. `5.1.1`, `550`), otherwise one of `bounced`, `suppressed`, `complaint`, `insufficient_credit`, `billing_unavailable`, `rejected`, `recipient_refused`, `send_refused`, `temporary_failure`, `send_failed`, `filtered_spam`, `not_delivered`. For a partial failure, the first failed recipient's code. Otherwise `null` | One row per **message**, so a `messageVersions` batch writes one row per version. This is how you find out which versions of a batch failed. ### Per-recipient delivery events **`GET /api/v1/email/sends/{id}/events`** returns, for one send, each recipient's delivery outcome reported by the receiving side, ordered by attempt time. This is the audit trail behind `email.delivered` and `email.bounced`, and a read-only key can call it. ```bash curl https://api.callmissed.com/api/v1/email/sends/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/events \ -H "Authorization: Bearer cm_your_api_key" ``` ```json [ { "id": "5c2a9e41-7b3d-4f0a-9e8c-1d2b3c4d5e6f", "send_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "recipient": "customer@example.com", "status": "delivered", "reason": null, "code": null, "detail": null, "occurred_at": "2026-08-13T09:41:03.201000+00:00", "received_at": "2026-08-13T09:41:05.770114+00:00" } ] ``` | Field | Notes | |-------|-------| | `status` | `delivered`, `bounced`, `suppressed`, `failed`, `filtered_spam`, `quarantined` or `expanded` (a distribution list was expanded into its members) | | `reason` | Why this recipient didn't get the message, in plain language (e.g. `Address doesn't exist`, `Blocked as spam by the recipient's server`). `null` when delivered | | `code` | The receiving server's SMTP status code when it gave one (e.g. `5.1.1`), otherwise `bounced`, `suppressed`, `filtered_spam`, `not_delivered` or `quarantined`. `null` when delivered | | `detail` | Deprecated. Same text as `reason` | | `occurred_at` | When delivery to this recipient was attempted | | `received_at` | When CallMissed recorded the outcome | The list is empty until outcomes arrive, usually within seconds to a few minutes of sending. It is always empty for sends made before 2026-10-03. A send id that is not yours returns `404`. Outcomes are kept for the life of your account. ### Filtering the send log | Query param | Type | Notes | |-------------|------|-------| | `limit` | integer | 1–200, default 50 | | `offset` | integer | ≥0, default 0 | | `status` | string | Exact send status, e.g. `delivered`, `bounced`, `queued`. An unknown value is a `422` listing the valid set | | `message_id` | string | Exact RFC 5322 `Message-ID` match, up to 255 chars | | `recipient` | string | Match a `To:` address on the send, up to 320 chars. Case-insensitive and exact per address, so `bob@ex.com` will not match `notbob@ex.com`. A display form like `Alice ` matches on the bare address. `cc` and `bcc` are deliberately not searched | | `since` | string | ISO 8601. Only sends created at or after this timestamp | | `until` | string | ISO 8601. Only sends created at or before this timestamp | Filters combine. When you pass both `since` and `until`, the window may not exceed **90 days**, and `until` must not precede `since`; either violation is a `422`. ```bash # Everything that bounced in a date window curl -G https://api.callmissed.com/api/v1/email/sends \ -H "Authorization: Bearer cm_your_key" \ --data-urlencode "status=bounced" \ --data-urlencode "since=2026-07-01T00:00:00Z" \ --data-urlencode "until=2026-07-31T23:59:59Z" # Every send to one recipient curl -G https://api.callmissed.com/api/v1/email/sends \ -H "Authorization: Bearer cm_your_key" \ --data-urlencode "recipient=customer@example.com" ``` ### Retrieve one send **`GET /api/v1/email/sends/{send_id}`** returns a single `SendOut` for the `id` you got back from `POST /send`: ```bash curl https://api.callmissed.com/api/v1/email/sends/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" ``` The fields are identical to a row from the list, so the two surfaces cannot drift. An id that is not yours is a `404`, not a `403`. Message bodies are **not** returned: we do not retain the rendered `html`/`text` after the message is handed off for delivery. Keep your own copy if you need to display what was sent. **`GET /api/v1/email/usage`** is spend for the account. Cost is summed from the price stamped on each send at send time, so a later price change never rewrites history. Returns `UsageOut`: | Field | Type | Notes | |-------|------|-------| | `currency` | string | `INR` | | `price_per_1000` | number | Current price per 1,000 emails | | `billed_sends` | integer | Sends that were actually charged, all time | | `total_cost` | number | All-time spend in whole currency units | | `sends_30d` | integer | Billed sends in the trailing 30 days | | `cost_30d` | number | Spend in the trailing 30 days | Both endpoints are reads, so a read-only key works. :::tabs ```bash [cURL] # Newest 50 sends curl "https://api.callmissed.com/api/v1/email/sends?limit=50&offset=0" \ -H "Authorization: Bearer cm_your_key" # Spend curl https://api.callmissed.com/api/v1/email/usage \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} sends = httpx.get(f"{BASE}/sends", headers=h, params={"limit": 50, "offset": 0}).json() usage = httpx.get(f"{BASE}/usage", headers=h).json() for row in sends: print(row["id"], row["status"], row["subject"]) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key" }; const sends = await fetch(`${BASE}/sends?limit=50&offset=0`, { headers }).then((r) => r.json(), ); const usage = await fetch(`${BASE}/usage`, { headers }).then((r) => r.json()); for (const row of sends) console.log(row.id, row.status, row.subject); ``` ::: ## Engagement metrics **`GET /api/v1/email/emails/metrics`** is one aggregate row for a time window: volume, engagement and the derived rates. A read, so a read-only key works. The repeated `email/emails` in that path is correct, not a typo: the metrics route is named `/emails/metrics` and sits under the `/api/v1/email` prefix like every other endpoint here. | Query param | Notes | |-------------|-------| | `start_date` | ISO 8601. Defaults to 6 days before `end_date`. A value with no offset is treated as UTC | | `end_date` | ISO 8601. Defaults to now; a future value is clamped to now | The window may not exceed **90 days**, and `start_date` must be on or before `end_date`; either violation is a `422`. ```bash # Default window (the last 6 days) curl https://api.callmissed.com/api/v1/email/emails/metrics \ -H "Authorization: Bearer cm_your_key" # An explicit window curl -G https://api.callmissed.com/api/v1/email/emails/metrics \ -H "Authorization: Bearer cm_your_key" \ --data-urlencode "start_date=2026-07-01T00:00:00Z" \ --data-urlencode "end_date=2026-07-31T23:59:59Z" ``` ```json { "start_date": "2026-07-01T00:00:00Z", "end_date": "2026-07-31T23:59:59Z", "sent": 4820, "delivered": 4731, "bounced": 61, "complained": 3, "opened": 5904, "unique_opened": 2140, "clicked": 812, "unique_clicked": 655, "tracked_opens": 4820, "tracked_clicks": 4820, "delivery_rate": 0.9815, "bounce_rate": 0.0127, "complaint_rate": 0.0006, "open_rate": 0.4439, "click_rate": 0.1359 } ``` | Field | Type | Notes | |-------|------|-------| | `start_date` | string | The window actually used, after defaults and clamping | | `end_date` | string | As above | | `sent` | integer | Sends accepted for delivery in the window. This is the denominator for the delivery, bounce and complaint rates | | `delivered` | integer | Confirmed delivered | | `bounced` | integer | Bounced | | `complained` | integer | Marked as spam | | `opened` | integer | **Total** open hits. A mail client refetching the pixel increments this | | `unique_opened` | integer | Distinct sends that were opened at least once | | `clicked` | integer | Total click hits | | `unique_clicked` | integer | Distinct sends that were clicked at least once | | `tracked_opens` | integer | Sends in the window that actually carried an open pixel | | `tracked_clicks` | integer | Sends in the window that actually carried rewritten links | | `delivery_rate` | number | `delivered / sent` | | `bounce_rate` | number | `bounced / sent` | | `complaint_rate` | number | `complained / sent` | | `open_rate` | number | `unique_opened / tracked_opens` | | `click_rate` | number | `unique_clicked / tracked_clicks` | Every rate is a fraction in `[0, 1]` rounded to 4 decimal places. An empty window returns zeros rather than nulls or an error, so a graph always has a number to plot. **Open and click rates divide by the tracked subset, not by `sent`.** A message that carried no pixel cannot be opened, so counting it in the denominator would understate your real open rate. Whether a send carried tracking is recorded at send time, which is what keeps a rate meaningful across a window where you flipped a domain toggle. If `tracked_opens` is `0`, tracking is off for the domains you sent from: see [Open and click tracking](/docs/email-send#open-and-click-tracking). Only sends accepted for delivery are counted. A rejected or still-queued send never reached a mailbox, so it is not in any denominator. This is the aggregate total for one window. There is no per-day or per-dimension breakdown; call it once per window you want to chart. ## Pricing **30 credits (₹30) per 1,000 emails**, charged per recipient to your credit balance, the same credits as every other API (your signup bonus counts). Only accepted sends are billed; rejected or failed sends cost nothing. See [Credits & Pricing](/docs/credits-rate-limits). ## Common failures on these routes | Status | Body | Meaning | |--------|------|---------| | 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header | | 403 | string `detail` | The API key is read-only and this route writes (suppression writes only) | | 404 | string `detail` | The suppression id, or the send id, is not yours | | 422 | string `detail` | An unknown `status` on `GET /sends`, a date window over 90 days, `until` before `since`, or an unknown `reason` on `POST /suppressions/batch` | | 422 | schema array `detail` | An unknown `reason` on `POST /suppressions`, or a malformed address in a batch | Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes). --- ### Scheduled & Batch Sending URL: /docs/email-scheduled > Send later with scheduledAt, cancel before it fires, or deliver many recipient sets in one call with messageVersions. ## Overview Two variants of `POST /api/v1/email/send` live on this page: a send that fires later (`scheduledAt`) and a send that carries many recipient sets at once (`messageVersions`). A message cannot be both. Every other field behaves exactly as it does on [Send Email](/docs/email-send). ## Scheduled Sending Send a message later by adding `scheduledAt` to `POST /api/v1/email/send`. The send is accepted and enqueued: **no charge and no delivery happen at enqueue time**; billing and delivery occur when it fires at `scheduledAt`. | Field | Type | Required | Notes | |-------|------|----------|-------| | `scheduledAt` | string | Yes (to schedule) | ISO-8601 UTC timestamp. Must be in the **future** and **within 72 hours**, else `422`. A timestamp with no offset is treated as UTC rather than rejected | | `batchId` | string (UUID) | No | A UUID you supply to group related scheduled sends; auto-generated if omitted | A message cannot be both scheduled **and** a `messageVersions` batch; sending both returns `422`. A scheduled send returns `202`: ```json { "id": "3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d", "message_id": "", "messageId": "", "messageIds": [], "status": "scheduled", "suppressed": [], "batchId": "7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d", "scheduledAt": "2026-07-28T09:00:00Z" } ``` `message_id` is empty and `suppressed` is `[]` because nothing has been built or filtered yet: suppression, quota, billing and delivery all run when the send fires. `scheduledAt` is set only on a scheduled enqueue, and `batchId` only on a scheduled enqueue or a batch; both are `null` on an immediate single send. ### Managing scheduled sends Manage scheduled sends by id **or** batchId: | Endpoint | Purpose | |----------|---------| | `GET /api/v1/email/scheduled/{identifier}` | List the scheduled rows for a send `id` or a `batchId`, ordered by `scheduledAt`. A batch lists all its rows, including ones that already fired or were cancelled. A UUID you don't own returns an empty list | | `DELETE /api/v1/email/scheduled/{identifier}` | Cancel the **pending** scheduled send(s) by id or batchId (`204`). `404` if there's nothing pending to cancel | An `identifier` that is not a UUID at all is a `404` on both routes rather than a `422`. Each `ScheduledSendOut` returns `id`, `batch_id` / `batchId`, `scheduled_at` / `scheduledAt`, `status` (`pending` / `sent` / `failed` / `cancelled`), `send_id` (the delivered send once it fires), and `created_at` / `createdAt`. The camelCase keys are Brevo-compatible aliases of the snake_case ones and carry the same values. :::tabs ```bash [cURL] # Schedule a send for later (within 72h) curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Ops ", "to": ["Ada "], "subject": "Reminder", "text": "Your appointment is tomorrow.", "scheduledAt": "2026-07-28T09:00:00Z" }' # Inspect the scheduled rows by send id or batchId curl https://api.callmissed.com/api/v1/email/scheduled/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \ -H "Authorization: Bearer cm_your_key" # Cancel it before it fires - never charged curl -X DELETE https://api.callmissed.com/api/v1/email/scheduled/3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d \ -H "Authorization: Bearer cm_your_key" ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} scheduled = httpx.post(f"{BASE}/send", headers=h, json={ "from": "Acme Ops ", "to": ["Ada "], "subject": "Reminder", "text": "Your appointment is tomorrow.", "scheduledAt": "2026-07-28T09:00:00Z", }).json() httpx.get(f"{BASE}/scheduled/{scheduled['id']}", headers=h).json() httpx.delete(f"{BASE}/scheduled/{scheduled['id']}", headers=h) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const scheduled = await fetch(`${BASE}/send`, { method: "POST", headers, body: JSON.stringify({ from: "Acme Ops ", to: ["Ada "], subject: "Reminder", text: "Your appointment is tomorrow.", scheduledAt: "2026-07-28T09:00:00Z", }), }).then((r) => r.json()); await fetch(`${BASE}/scheduled/${scheduled.id}`, { headers: { Authorization: "Bearer cm_your_key" }, }); await fetch(`${BASE}/scheduled/${scheduled.id}`, { method: "DELETE", headers: { Authorization: "Bearer cm_your_key" }, }); ``` ::: A cancelled send never fires and is never charged. ## Batch (messageVersions) Send to many recipient sets in one call. Add `messageVersions`, an array where each version is its own recipient set that may override the global subject, body, params, or template. **Per-version fields:** | Field | Type | Required | Notes | |-------|------|----------|-------| | `to` | array | Yes | Recipient addresses (same forms as the top-level send) | | `cc` | array | No | Carbon-copy recipients | | `bcc` | array | No | Blind-copy recipients | | `subject` | string | No | Overrides the global subject | | `htmlContent` | string | No | Overrides the global HTML body | | `textContent` | string | No | Overrides the global text body | | `replyTo` | string \| object | No | Overrides the global reply-to | | `params` | object | No | Substitution values for this version, shallow-merged over the global `params` | | `templateId` | string (UUID) | No | Overrides the global template | - **Base / override.** The top-level `subject` / `html` / `text` / `templateId` / `params` / `from` are the **base** each version overrides. A per-version body override requires a global body to be present; a per-version `templateId` requires a global `templateId`. Global attachments and tags apply to all versions; there are no per-version attachments. - **Limits** (exceeding any → `422`): ≤99 recipients per version, ≤2000 recipients across the batch (deduped), ≤1000 versions, ≤100 KB per-version `params`, ≤1000 KB `params` across the batch. The 50-recipient single-send cap does **not** apply here; the batch union cap replaces it. - **A recipient is delivered by exactly one version, the first.** Versions are processed in array order, and each version delivers only the recipients no earlier version already claimed. If `ada@example.com` appears in version 1 **and** version 2, she receives **version 1's** subject, body and params, and version 2 simply does not send to her at all. This is a delivery outcome, not only a billing rule: repeating an address across versions silently drops the later content. Keep each version's recipient set disjoint. - **Billing.** The **deduped union** of recipients across all versions is billed **once** at 30 credits (₹30) per 1,000; a recipient in two versions is billed once, matching the delivery rule above. Suppression, quota, rate and monthly-cap checks are likewise evaluated once, over the union. - **A partial failure still returns `202`.** The response `status` is `sent` when **any** version was accepted for delivery; only an all-versions-failed batch returns `503 relay_failed`. `messageIds` carries one id per version in array order **whether or not that version was accepted**, so the response alone cannot tell you which versions failed. To find out, list the sends and read each row's `status`. A batch returns `202` with `messageIds` (one per version, in order) and a `batchId` grouping the batch's sends; `id` / `messageId` are the first version, and `suppressed` lists the union's suppressed addresses. Look up each send with `GET /api/v1/email/sends`. See [Delivery Log & Usage](/docs/email-logs). :::tabs ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Ops ", "templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "subject": "Your receipt, {{ params.name }}", "messageVersions": [ { "to": ["Ada "], "params": { "name": "Ada", "order_id": "1043" } }, { "to": ["Bo "], "params": { "name": "Bo", "order_id": "1044" } } ] }' ``` ```python [Python] import httpx httpx.post( "https://api.callmissed.com/api/v1/email/send", headers={"Authorization": "Bearer cm_your_key"}, json={ "from": "Acme Ops ", "templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "subject": "Your receipt, {{ params.name }}", "messageVersions": [ {"to": ["Ada "], "params": {"name": "Ada", "order_id": "1043"}}, {"to": ["Bo "], "params": {"name": "Bo", "order_id": "1044"}}, ], }, ) ``` ```javascript [JavaScript] await fetch("https://api.callmissed.com/api/v1/email/send", { method: "POST", headers: { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ from: "Acme Ops ", templateId: "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", subject: "Your receipt, {{ params.name }}", messageVersions: [ { to: ["Ada "], params: { name: "Ada", order_id: "1043" } }, { to: ["Bo "], params: { name: "Bo", order_id: "1044" } }, ], }), }); ``` ::: ```json { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "messageId": "<1a2b3c4d@acme.com>", "messageIds": ["<1a2b3c4d@acme.com>", "<5e6f7g8h@acme.com>"], "batchId": "7a2b1c0d-9e8f-4a5b-8c7d-6e5f4a3b2c1d", "status": "sent", "suppressed": [] } ``` ## Common failures | Status | Reason | Meaning | |--------|--------|---------| | 422 | `scheduled_batch_unsupported` | A send set both `scheduledAt` and `messageVersions`, pick one | | 422 | *(schema array `detail`)* | A `scheduledAt` in the past or beyond the 72-hour horizon, over-limit `params`, or a broken batch cross-version rule | | 422 | `too_many_recipients` | Over the union limit on a batch | | 404 | *(string `detail`)* | `DELETE /scheduled/{identifier}` found nothing pending to cancel | | 503 | `relay_failed` | Every version of a batch failed. Flat shape, carries `id` | Shapes and the full reason table: [Limits, Quotas & Errors](/docs/email-limits). --- ### Send Email URL: /docs/email-send > POST /api/v1/email/send: every field, attachment rule, header, semantic and response, plus the drop-in Brevo migration. ## Send Email **Endpoint:** `POST /api/v1/email/send` The send body is a **superset**: it accepts both the original CallMissed shapes (string `from`, string-list `to`, `text`/`html`) and Brevo-style shapes (`sender` object, recipient objects, `textContent`/`htmlContent`). A Brevo `sendTransacEmail` integration works here by changing only the base URL and the auth header. See [Switching from Brevo](#switching-from-brevo) below. Note the `from` in every example below. `donotreply@your-verified-domain` is registered as a sender by verification, so it always works, and the address you want humans to answer goes in `reply_to`. Any other local part must be a registered sender on the domain; the send path registers it on first use and retries, which can surface as `503 sender_propagating` until the registration is live. See [Sender Addresses](/docs/email-domains#sender-addresses). The example below uses `cc`, one URL attachment and one base64 attachment, `tags`, and the `Idempotency-Key` header: :::tabs ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1043-receipt" \ -d '{ "from": "Acme Ops ", "to": ["Ada "], "cc": ["accounts@example.com"], "subject": "Your receipt", "text": "Thanks for your order. Your receipt is attached.", "html": "

Thanks for your order. Your receipt is attached.

", "reply_to": "support@acme.com", "tags": ["receipt", "order-1043"], "attachment": [ { "url": "https://acme.com/receipts/1043.pdf" }, { "name": "terms.txt", "content": "VGhhbmsgeW91IGZvciB5b3VyIG9yZGVyLg==" } ] }' ``` ```python [Python] import base64, httpx pdf_b64 = base64.b64encode(b"...raw bytes...").decode() httpx.post( "https://api.callmissed.com/api/v1/email/send", headers={ "Authorization": "Bearer cm_your_key", "Idempotency-Key": "order-1043-receipt", }, json={ "from": "Acme Ops ", "to": ["Ada "], "cc": ["accounts@example.com"], "subject": "Your receipt", "text": "Thanks for your order. Your receipt is attached.", "html": "

Thanks for your order. Your receipt is attached.

", "reply_to": "support@acme.com", "tags": ["receipt", "order-1043"], "attachment": [ {"url": "https://acme.com/receipts/1043.pdf"}, {"name": "terms.txt", "content": pdf_b64}, ], }, ) ``` ```javascript [JavaScript] const termsB64 = Buffer.from("Thank you for your order.").toString("base64"); await fetch("https://api.callmissed.com/api/v1/email/send", { method: "POST", headers: { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", "Idempotency-Key": "order-1043-receipt", }, body: JSON.stringify({ from: "Acme Ops ", to: ["Ada "], cc: ["accounts@example.com"], subject: "Your receipt", text: "Thanks for your order. Your receipt is attached.", html: "

Thanks for your order. Your receipt is attached.

", reply_to: "support@acme.com", tags: ["receipt", "order-1043"], attachment: [ { url: "https://acme.com/receipts/1043.pdf" }, { name: "terms.txt", content: termsB64 }, ], }), }); ``` ::: ## Fields An **address** may be written three ways, and you can mix them within one array: a bare `"a@b.com"`, a display form `"Name "`, or an object `{"email": "a@b.com", "name": "Name"}`. | Field | Type | Required | Notes | |-------|------|----------|-------| | `from` | string | one of `from` / `sender`, unless a template supplies `default_sender` | Sender as `"Name "` or bare address. The domain must be one of your verified domains and the local part must be a registered sender on it. See [Sender Addresses](/docs/email-domains#sender-addresses). The display name is stored on the sender address, not on each message, so recipients see the name most recently set for that address. After you change it, the first sends can show the old name for a couple of minutes. Use one display name per sender address; if you need different names, register a separate sender for each | | `sender` | object | one of `from` / `sender`, unless a template supplies `default_sender` | Brevo-style sender `{"email", "name"}`, an alternative to `from`. Same registered-sender rule applies | | `to` | array | Yes | One or more recipient addresses (min 1). Each delivered recipient is billed | | `cc` | array | No | Carbon-copy recipients. Appear in the `Cc` header **and** are delivered | | `bcc` | array | No | Blind-copy recipients. Delivered but **never** written to any header | | `subject` | string | No | Up to 998 characters | | `text` | string | body required | Plain-text body. Omit it on an HTML send and one is [generated for you](#automatic-plain-text); send `""` to opt out and ship HTML only | | `html` | string | body required | HTML body | | `textContent` | string | body required | Brevo alias for `text` | | `htmlContent` | string | body required | Brevo alias for `html` | | `reply_to` | string | No | Reply-To address as a string | | `replyTo` | string \| object | No | Brevo alias for `reply_to`, string or `{"email", "name"}` | | `headers` | object | No | Extra headers as string→string. Reserved headers (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `DKIM-Signature`, `Received`) are ignored | | `attachment` | array | No | Attachments, and [inline images](#inline-images); also accepted as `attachments`. See below | | `tags` | array | No | Up to 10 tags for your own categorisation. Each is either a plain string or a [`{name, value}` object](#tags); trimmed, empties dropped | | `templateId` | string (UUID) | No | Send from a saved template: its subject/body are the base; explicit send fields override. See [Templates](/docs/email-templates) | | `params` | object | No | Substitution values for `{{ params.KEY }}` placeholders. They are applied to the template's subject and bodies **and** to any inline `subject` / `text` / `html` you send, so `params` works with no `templateId` at all. Capped at 100 KB of JSON | | `scheduledAt` | string | No | ISO-8601 UTC timestamp to send later (future, within 72h). See [Scheduled Sending](/docs/email-scheduled) | | `batchId` | string (UUID) | No | Groups related scheduled sends; auto-generated if omitted | | `messageVersions` | array | No | One call, many recipient sets, each overrides the base. See [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions) | Provide **at least one** of `text` / `html` (or their Brevo aliases), a `templateId`, or `messageVersions`. Provide **exactly one** of `from` / `sender`. A top-level `to` is not required when `messageVersions` is present. **Attachments.** Each item in `attachment` is **either** a URL reference **or** inline base64, exactly one of the two: | Attachment field | Type | Required | Notes | |------------------|------|----------|-------| | `url` | string | one of `url` / `content` | `http`/`https` URL fetched at send time. Internal/private URLs are refused; the fetched file is size-capped | | `content` | string | one of `url` / `content` | Base64-encoded file bytes | | `name` | string | with `content` | Filename (≤255 chars). Required when `content` is set; optional with `url` | | `content_id` | string | No | Makes the part an [inline image](#inline-images) your HTML references as ``. Also accepted as `contentId`. Up to 255 chars, and only letters, numbers, `.`, `-`, `_` and `@`; anything else is a `422` | | `disposition` | string | No | `inline` or `attachment`, to be explicit. Omitted, a part with a `content_id` is inline and everything else is an attachment | ### Inline images Give an attachment a `content_id` and reference that id from the HTML body as `cid:`. The part is embedded where you placed it instead of arriving as a download: ```bash curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Ops ", "to": ["customer@example.com"], "subject": "Your receipt", "html": "

Thanks for your order.

\"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 10 MB message ceiling like any other attachment, and `cid:` references in your HTML are never rewritten by [click tracking](#open-and-click-tracking). ### Tags A tag is either a plain string, as before, or a `{name, value}` object when you need to carry a value: ```json { "tags": [ "receipt", { "name": "order_id", "value": "1043" }, { "name": "campaign", "value": "spring-sale" } ] } ``` Both shapes can be mixed in the same array. A structured tag's `name` and `value` are required, may contain only ASCII letters, numbers, underscores and dashes, and are capped at 256 characters each; anything else is a `422`. Up to 10 tags per send. On the wire, tag **names** are joined into an `X-Tags` header exactly as before, and each structured tag additionally gets its own `X-Tag-: ` header so the value survives onto the message. The authoritative copy is the one stored against the send. Plain-string tags behave exactly as they did, so existing calls need no change. ### Automatic plain text Send `html` with no `text` and a plain-text alternative is generated from your HTML. A message with no text part reads badly in text-only clients and scores worse with spam filters, so this is the default. Link destinations are kept alongside their label as `label (https://url)`, and block-level markup becomes line breaks so the text keeps the shape of the document. Scripts and styles are dropped entirely. It is a best-effort reading of your HTML, never an exact rendering. Two ways to take control: - **Supply `text` yourself** for exact copy. Anything you send is used as-is. - **Send `"text": ""`** to opt out and ship an HTML-only message. An empty string is treated as a deliberate choice, not an omission. ### Open and click tracking Tracking is **per sending domain and off by default**. Turn it on with [`PATCH /api/v1/email/domains/{id}`](/docs/email-domains#tracking-and-sending-toggles): ```bash curl -X PATCH https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"open_tracking": true, "click_tracking": true}' ``` Once a domain opts in, every HTML send from it is rewritten as it goes out: - **Opens** append a 1x1 pixel at the end of the HTML body. It is marked `aria-hidden` with an empty `alt`, so a screen reader does not announce it, and it never displaces visible content. - **Clicks** rewrite `http`/`https` links to a signed redirect that forwards the recipient to the original destination. `mailto:`, `tel:` and `cid:` links are left alone, as are unquoted `href` attributes, so nothing in your markup is mangled. Only the HTML part is tracked; the plain-text part always keeps the real destinations. Both toggles are independent, and whether a send carried tracking is recorded at send time, so [metrics](/docs/email-logs#engagement-metrics) stay meaningful across a window where you flipped a toggle. Read the results from [engagement metrics](/docs/email-logs#engagement-metrics). **Headers.** | Header | Notes | |--------|-------| | `Authorization` | `Bearer cm_...`, the key needs the **email** permission (required) | | `Idempotency-Key` | Optional, 1–128 characters. A repeat with the same key returns the first send's result without sending or charging again. Reusing a single send's key on a batch (or the reverse) is `409 idempotency_conflict` | ## Semantics - **cc vs bcc.** `cc` recipients are written to the `Cc` header and delivered; `bcc` recipients are delivered but never appear in any header. - **De-duplication.** Each of `to`, `cc` and `bcc` is de-duplicated case-insensitively, then the three are merged into one recipient set. An address listed in both `to` and `cc` is dropped from the `Cc` header and delivered, and billed, once; a `bcc` address already covered by `to` or `cc` is likewise dropped. - **Suppression.** Recipients on your [suppression list](/docs/email-logs#suppressions) are dropped from `to`, `cc`, and `bcc` before sending, and returned in `suppressed`. - **Recipient limit: 50 per message.** A single (non-batch) send accepts at most **50** recipients, counted across `to` + `cc` + `bcc` **after** de-duplication and suppression filtering, so 60 addresses of which 12 are suppressed and 3 are duplicates does pass. Over the limit is `422 too_many_recipients`. Batches have their own, larger limits; see [Batch (messageVersions)](/docs/email-scheduled#batch-messageversions). - **Size limit: 10 MB per message.** The whole message (subject, both bodies, and every attachment **after base64 encoding**) must stay within 10 MB, otherwise you get `422 message_too_large` before anything is charged or sent. Base64 makes attachments about 1.37 times larger, so in practice you have about **7 MB** of raw attachments, minus whatever the bodies take. While a `url` attachment is being fetched, it is capped at 25 MB, but the finished message still has to fit in 10 MB. - **Validation.** Recipient addresses are validated first: a malformed address, or one containing control characters, is rejected with `422` before any charge. That rejection is a **schema** error, so its body is the validation-array shape, not `{"reason": …}`. See [Errors](/docs/email-limits#response-shapes). - **Idempotency.** Send the same `Idempotency-Key` on a retry to guarantee the message is sent and charged at most once; the original response is replayed. The message is DKIM-signed with the From domain's key and handed to delivery. ## Response A successful call returns `202 Accepted`: ```json { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "messageId": "<1a2b3c4d@acme.com>", "messageIds": ["<1a2b3c4d@acme.com>"], "status": "sent", "suppressed": ["blocked@example.com"] } ``` | Field | Type | Notes | |-------|------|-------| | `id` | string | CallMissed send id, use it with `GET /api/v1/email/sends` | | `message_id` | string | RFC 5322 `Message-ID` of the sent message | | `messageId` | string | Brevo-compatible; equal to `message_id` | | `messageIds` | string[] | Brevo-compatible; `[message_id]` | | `status` | string | `sent` when accepted for delivery | | `suppressed` | string[] | Recipients dropped by your suppression list | | `batchId` | string \| null | `null` on an immediate single send; set on a [scheduled](/docs/email-scheduled) or [batch](/docs/email-scheduled#batch-messageversions) send | | `scheduledAt` | string \| null | `null` unless the send was scheduled | View delivery history and spend: see [Delivery Log & Usage](/docs/email-logs). ## Common send failures `relay_failed` is flat (no `detail` wrapper) and carries the `id` of the send row; every other reason here is nested under `detail`; schema rejections are an array under `detail`. Full shapes and the complete table: [Limits, Quotas & Errors](/docs/email-limits). | Status | Reason | Meaning | |--------|--------|---------| | 402 | `payment_required` | Not enough credit balance to cover the send | | 403 | `email_not_enabled` | The API key lacks the email permission | | 403 | `domain_not_verified` | The From domain is registered but hasn't passed verification | | 403 | `all_recipients_suppressed` | Every recipient is on your suppression list | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used by a different kind of send | | 422 | `empty_body` | Neither `text` nor `html` (nor a template body) was present | | 422 | `too_many_recipients` | Over 50 recipients on a single send | | 422 | `message_too_large` | The message exceeds 10 MB, attachments included after base64 encoding | | 422 | `unresolvable_template_vars` | The subject or body references `{{ contact.something }}`, which nothing can populate. Pass the value in `params` instead | | 429 | `rate_limited` / `monthly_cap_exceeded` / `quota_exceeded` | A plan or domain ceiling was hit | | 503 | `relay_failed` | The message could not be accepted for delivery | | 503 | `sender_propagating` | The `from` address was just registered as a sender and is not live yet. Retry shortly; no further setup is needed | ## Switching from Brevo The endpoint accepts Brevo `sendTransacEmail` payloads unchanged (`sender`, recipient objects, `replyTo`, `htmlContent`/`textContent`, `attachment` with `url` or `content`, `tags`, and the `Idempotency-Key` header) and returns `messageId` / `messageIds` alongside our native fields. To migrate, point your client at `https://api.callmissed.com/api/v1/email/send` and send `Authorization: Bearer cm_...`. ```bash curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "sender": { "email": "donotreply@acme.com", "name": "Acme Ops" }, "to": [{ "email": "customer@example.com", "name": "Ada" }], "subject": "Your receipt", "htmlContent": "

Thanks for your order.

", "textContent": "Thanks for your order.", "replyTo": { "email": "support@acme.com", "name": "Acme Support" } }' ``` Two things do **not** carry over unchanged. Your Brevo `sender` must be an address on a verified domain of yours, and its local part must be a registered sender (see [Sender Addresses](/docs/email-domains#sender-addresses)). And a single send here is capped at 50 recipients rather than Brevo's higher per-message limit, so split a larger list across calls or use [messageVersions](/docs/email-scheduled#batch-messageversions). --- ### Email Templates URL: /docs/email-templates > Save a reusable subject and body once, then send it with per-recipient substitution values. ## Templates Save a reusable subject + body once, then send it with per-recipient values. Templates are tenant-scoped and managed with the same `cm_` key (email permission). | Endpoint | Purpose | |----------|---------| | `POST /api/v1/email/templates` | Create a template (`201`). Duplicate `name` for the same account → `409` | | `GET /api/v1/email/templates` | List your templates, newest first. `limit` (1–200, default 50) and `offset` (≥0, default 0) | | `GET /api/v1/email/templates/{id}` | Fetch one (`404` if not yours) | | `PUT /api/v1/email/templates/{id}` | Partial update, only supplied fields change | | `DELETE /api/v1/email/templates/{id}` | Delete a template (`204`) | ## Create a template **`POST /api/v1/email/templates`** | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string | Yes | 1–255 chars; unique per account | | `subject` | string | Yes | Base subject (overridable per send) | | `html` | string | Yes | Base HTML body | | `text` | string | No | Base plain-text body | | `default_sender` | string | No | Used when the send omits `from` / `sender` | | `default_reply_to` | string | No | Used when the send omits `reply_to` | | `tags` | array | No | String tags for your own categorisation | | `is_active` | boolean | No | Defaults to `true`; an inactive template can't be sent | The template object returns `id`, `name`, `subject`, `html`, `text`, `default_sender`, `default_reply_to`, `tags`, `is_active`, `created_at`, `updated_at`. The `id` is a UUID. `PUT /templates/{id}` takes the same fields, all optional, and applies only the ones you actually send. Two write-time rejections apply to both create and update, and both come back as a `422`: - A control character in `subject`, `default_sender` or `default_reply_to`. Those values are rendered into raw headers at send time. This is a schema rejection, so `detail` is the validation array. - A body or subject that references `{{ contact.anything }}`, returned with a plain string `detail`. Nothing can populate that namespace, so the reference would render as an empty string and ship a broken message. Pass the value in `params` instead. The same reference on a send is rejected as `422 unresolvable_template_vars`. ## Using a template on send Add `templateId` and `params` to `POST /api/v1/email/send`: :::tabs ```bash [cURL] # Create a template curl -X POST https://api.callmissed.com/api/v1/email/templates \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "receipt", "subject": "Your receipt, {{ params.name }}", "html": "

Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.

", "default_sender": "Acme Ops " }' # Send from it - send-call fields override the template curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "templateId": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "to": ["Ada "], "params": { "name": "Ada", "order_id": "1043" } }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} tpl = httpx.post(f"{BASE}/templates", headers=h, json={ "name": "receipt", "subject": "Your receipt, {{ params.name }}", "html": "

Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.

", "default_sender": "Acme Ops ", }).json() httpx.post(f"{BASE}/send", headers=h, json={ "templateId": tpl["id"], "to": ["Ada "], "params": {"name": "Ada", "order_id": "1043"}, }) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const tpl = await fetch(`${BASE}/templates`, { method: "POST", headers, body: JSON.stringify({ name: "receipt", subject: "Your receipt, {{ params.name }}", html: "

Hi {{ params.name }}, your order {{ params.order_id }} is confirmed.

", default_sender: "Acme Ops ", }), }).then((r) => r.json()); await fetch(`${BASE}/send`, { method: "POST", headers, body: JSON.stringify({ templateId: tpl.id, to: ["Ada "], params: { name: "Ada", order_id: "1043" }, }), }); ``` ::: The send returns the same `202 Accepted` body as any other send. See [Send Email](/docs/email-send#response). - **Override rule.** The template's `subject` / `html` / `text` are the base; an explicit `subject` / `html` / `text` / `from` / `reply_to` on the send **wins**. `default_sender` / `default_reply_to` fill in only when the send omits them. - **Substitution.** `{{ params.KEY }}` (and nested `{{ params.a.b }}`) are replaced from `params`; a missing key renders empty. Values placed into the HTML body are HTML-escaped. This is plain variable substitution and **not** a programming language: no logic, loops, or expressions, and it can only read the `params` you pass. A substituted value is inserted once and never re-scanned, so a param whose value itself contains `{{ ... }}` is not expanded again. - **Inline substitution.** `params` also renders placeholders in a `subject` / `text` / `html` you pass directly on the send, so you can use `{{ params.KEY }}` with no `templateId` at all. Whichever value is actually used, yours or the template's, is rendered exactly once. > **Note:** unlike Brevo's integer template id, a CallMissed `templateId` is a UUID. ## Manage templates ```bash # List (limit / offset supported) curl "https://api.callmissed.com/api/v1/email/templates?limit=50&offset=0" \ -H "Authorization: Bearer cm_your_key" # Fetch one curl https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" # Partial update - only the fields you send change curl -X PUT https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"subject": "Your updated receipt, {{ params.name }}", "is_active": true}' # Delete (204) curl -X DELETE https://api.callmissed.com/api/v1/email/templates/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" ``` ## Common template failures | Status | Reason | Meaning | |--------|--------|---------| | 404 | `template_not_found` | The `templateId` doesn't exist or isn't yours | | 422 | `template_inactive` | The template exists but is not active (`is_active: false`) | | 422 | `invalid_headers` | A rendered header value contains invalid characters, usually a template `param` with a newline in it | | 422 | `unresolvable_template_vars` | The rendered subject or body references `{{ contact.something }}` | | 409 | *(string `detail`)* | Duplicate template name for the same account, on create **or** on a rename | `409` and `404` on the template routes use a plain string `detail` with no `reason`; `template_not_found` and `template_inactive` on a send are nested under `detail`. See [Limits, Quotas & Errors](/docs/email-limits#response-shapes). --- ### Email Webhooks URL: /docs/email-webhooks > Subscribe your own endpoint to email events: bounces, complaints, delivery and engagement, signed with HMAC-SHA256 and logged per attempt. ## Overview Register an HTTPS endpoint and we POST each email event to it as it happens, signed with a per-subscription secret. This is how you learn about a bounce or a spam complaint without polling the [send log](/docs/email-logs). These webhooks are scoped to **your own email events** and are managed with the same `cm_` key you send with. Up to **20 subscriptions** per account. | Endpoint | Purpose | |----------|---------| | `POST /api/v1/email/webhooks` | Create a subscription (`201`). The only response that carries the signing secret | | `GET /api/v1/email/webhooks` | List your subscriptions, newest first | | `GET /api/v1/email/webhooks/deliveries` | The delivery log: every attempt, its result and its error | | `PATCH /api/v1/email/webhooks/{id}` | Enable or disable without losing the URL or the secret | | `DELETE /api/v1/email/webhooks/{id}` | Remove the subscription (`204`) | Creating, updating and deleting need a write key. Listing and the delivery log are reads, so a read-only key works. ## Create a subscription **`POST /api/v1/email/webhooks`** | Field | Type | Required | Notes | |-------|------|----------|-------| | `url` | string | Yes | Your endpoint, 1–2048 chars. Must be a public `http`/`https` URL; an internal or private target is refused at creation with `422 webhook_url_forbidden` | | `description` | string | No | Your own label, up to 255 chars | | `events` | array | No | Which events to receive. Omit it (or send an empty list) to receive **every** event. An unknown name is a `422` listing the supported set | :::tabs ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/email/webhooks \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"] }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} created = httpx.post(f"{BASE}/webhooks", headers=h, json={ "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"], }).json() secret = created["secret"] # store this now: it is never returned again webhook_id = created["webhook"]["id"] ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const created = await fetch(`${BASE}/webhooks`, { method: "POST", headers, body: JSON.stringify({ url: "https://your-app.com/hooks/email", description: "Bounce + complaint handler", events: ["email.bounced", "email.complained"], }), }).then((r) => r.json()); const secret = created.secret; // store this now: it is never returned again ``` ::: The `201` response wraps the subscription and the secret: ```json { "webhook": { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "url": "https://your-app.com/hooks/email", "description": "Bounce + complaint handler", "events": ["email.bounced", "email.complained"], "secret_prefix": "whsec_A1b2C3", "is_active": true, "created_at": "2026-08-13T09:41:02.118Z" }, "secret": "whsec_A1b2C3d4E5f6..." } ``` **The `secret` is returned once, here, and never again.** Every later read exposes only `secret_prefix`. Store it when you create the subscription; if you lose it, delete the subscription and create a new one. | `WebhookOut` field | Type | Notes | |--------------------|------|-------| | `id` | string (UUID) | Use it with `PATCH` / `DELETE` and as the `webhook_id` filter on the delivery log | | `url` | string | Where we POST | | `description` | string \| null | Your label | | `events` | array \| null | The subscribed events. `null` means every event | | `secret_prefix` | string | The first characters of the secret, for identifying which secret a subscription holds | | `is_active` | boolean | `false` stops deliveries; the URL and secret are kept | | `created_at` | string | When it was registered | ## Events | Event | Fires when | Status | |-------|-----------|--------| | `email.sent` | The message was accepted for delivery. Fires once per message | **Live** | | `email.opened` | A message with open tracking was opened for the first time. Fires once per message | **Live** | | `email.bounced` | A recipient's mail server rejected the message. The address is also added to your [suppression list](/docs/email-logs#suppressions) | **Live** | | `email.complained` | A recipient marked the message as spam. The address is suppressed too | **Live** | | `email.delivered` | The recipient's mail server accepted the message. Fires once per recipient | **Live** | | `email.received` | Inbound mail arrived at one of your receiving addresses | Subscribable; not emitted yet | You can subscribe to all six now. `email.received` is accepted so you won't have to rewrite your subscription when it starts firing; until then it delivers nothing. For inbound mail right now, use the per-address `forward_url` on [Receive Email](/docs/email-inbound), which is live. **Proof of delivery.** Two events track each message: - `email.sent`: we accepted the message for delivery. It fires once per message. - `email.delivered`: a recipient's mail server accepted the message. It fires once for each recipient, with the time of the delivery attempt. If a recipient's server refuses the message, you get `email.bounced` for that recipient instead. [`GET /sends/{id}/events`](/docs/email-logs#per-recipient-delivery-events) returns the same per-recipient outcomes, so you can look up delivery evidence for any send at any time. `email.delivered` means the receiving server took the message. It does not tell you whether the message went to the inbox or a spam folder, or whether anyone read it. Sends made before 2026-10-03 have no per-recipient delivery events. **About opens.** Opens are counted with a tracking pixel, so they are approximate. Some mail apps and privacy proxies load images automatically, which can register an open nobody saw. Other apps block images, so a real open is never seen. Treat `email.opened` as a sign of engagement, not as proof that someone read the message. ### Payload Every delivery is a POST with this envelope: ```json { "type": "email.bounced", "created_at": "2026-08-13T09:41:02.118431+00:00", "data": { "email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "recipient": "customer@example.com", "detail": "Bounced: Address doesn't exist", "reason": "Address doesn't exist", "code": "5.1.1", "domain": "acme.com" } } ``` | Field | Notes | |-------|-------| | `type` | The event name | | `created_at` | When we generated the event, ISO 8601 UTC. This value **is** covered by the signature, so it is the timestamp to trust for an age check | | `data.email_id` | The send id from `POST /send`; `null` if the event could not be matched to a send | | `data.message_id` | The RFC 5322 `Message-ID` | | `data.recipient` | The address that bounced or complained | | `data.reason` | Why it didn't land, in plain language (e.g. `Address doesn't exist`, `Recipient marked it as spam`) | | `data.code` | The receiving server's SMTP status code when it gave one (e.g. `5.1.1`), otherwise `bounced`, `suppressed`, `not_delivered`, `filtered_spam` or `complaint` | | `data.detail` | Deprecated: `reason`, prefixed with the outcome for delivery-report failures | | `data.domain` | Your sending domain the message went out on | `email.bounced` and `email.complained` carry the shape above. #### `email.sent` ```json { "type": "email.sent", "created_at": "2026-08-13T09:40:58.402113+00:00", "data": { "email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "recipients": ["customer@example.com", "accounts@example.com"], "domain": "acme.com", "smtp_response": "accepted for delivery", "accepted_at": "2026-08-13T09:40:58.398000+00:00" } } ``` #### `email.opened` ```json { "type": "email.opened", "created_at": "2026-08-13T10:02:11.031552+00:00", "data": { "email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "recipients": ["customer@example.com", "accounts@example.com"], "domain": "acme.com", "opened_at": "2026-08-13T10:02:10.991000+00:00" } } ``` | Field | Notes | |-------|-------| | `data.recipients` | Every address the message was sent to (`to`, `cc` and `bcc`, minus suppressed addresses). Each message fires one event, not one per recipient. A message is accepted in one hand-off and has a single tracking pixel, so these events cannot be tied to an individual recipient | | `data.smtp_response` | `accepted for delivery` once the message was handed off for delivery | | `data.accepted_at` | When the message was accepted, the same value as `sent_at` on the send | | `data.opened_at` | When the first open was recorded | In a `messageVersions` batch, each version that was handed to the mail server fires its own `email.sent`. #### `email.delivered` ```json { "type": "email.delivered", "created_at": "2026-08-13T09:41:05.774201+00:00", "data": { "email_id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "recipient": "customer@example.com", "delivered_at": "2026-08-13T09:41:03.201000+00:00", "detail": null, "reason": null, "code": null } } ``` | Field | Notes | |-------|-------| | `data.recipient` | The one recipient this event is about. A message with three recipients can fire three `email.delivered` events | | `data.delivered_at` | When the recipient's mail server accepted the message | | `data.reason` | Always `null` on this event | | `data.code` | Always `null` on this event | | `data.detail` | Deprecated, always `null` on this event | #### Which outcome fires which event | Outcome reported for a recipient | Event | Address suppressed | |---|---|---| | Delivered | `email.delivered` | No | | Bounced (the address or domain does not exist) | `email.bounced` | Yes | | Suppressed (the address hard-bounced before) | `email.bounced` | Yes | | Failed (the message could not be delivered) | `email.bounced` | No | | Rejected as spam by the receiving side | `email.bounced` | No | | Quarantined by the receiving side | none, recorded in [delivery events](/docs/email-logs#per-recipient-delivery-events) | No | For these failures, `data.detail` on `email.bounced` starts with the outcome, for example `FilteredSpam: Blocked as spam by the recipient's server`, and `data.code` is `not_delivered`, `filtered_spam` or the receiving server's SMTP code. ### Headers ``` Content-Type: application/json X-CallMissed-Signature: sha256= X-CallMissed-Event: email.bounced X-CallMissed-Delivery: 3f1c9a2d-4b5e-6a7f-8c9d-0e1f2a3b4c5d ``` `X-CallMissed-Delivery` is the delivery id, so a row in the [delivery log](#delivery-log) can be matched to the request your handler saw. Use it to make your handler idempotent: a retried delivery reuses the same id. ## Verifying the signature The digest is `HMAC-SHA256(secret, raw_request_body)`. Compute it over the **raw bytes** you received, before any JSON parsing, and compare with a constant-time function. Re-serialising the parsed JSON will not reproduce the signed bytes. :::tabs ```python [Python] import hashlib, hmac def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(f"sha256={expected}", header) ``` ```javascript [JavaScript] import crypto from "node:crypto"; function verify(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(header ?? ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ::: There is deliberately **no timestamp header**: a timestamp the signature does not cover could be rewritten, so an age check based on it would not be trustworthy. Read `created_at` from the signed body instead. ## Delivery behaviour - **Retries.** A non-2xx response or a transport error is retried up to **3 attempts** with exponential backoff. Anything in the 2xx range counts as success, so answer `200` as soon as you have accepted the event and do your work afterwards. - **Timeout.** Each attempt allows 10 seconds for a response. - **Redirects are not followed.** Point the subscription at its final URL. - **The URL is re-checked on every attempt.** A URL that resolves to a private or internal address at send time is refused and the delivery is marked `blocked` rather than retried, even if it passed validation when you created the subscription. - **Events are not delayed by your endpoint.** Delivery runs outside the request that produced the event, so a slow handler never slows a send or the processing of a bounce. - **Order is not guaranteed.** Use `created_at` from the payload if you need to sequence events. ## Delivery log **`GET /api/v1/email/webhooks/deliveries`** returns every attempt chain, newest first, so you can tell "we never sent it" from "my handler returned 500". | Query param | Notes | |-------------|-------| | `webhook_id` | Optional UUID; restrict the log to one subscription | | `limit` | 1–200, default 50 | | `offset` | ≥0, default 0 | | `WebhookDeliveryOut` field | Type | Notes | |----------------------------|------|-------| | `id` | string (UUID) | Matches the `X-CallMissed-Delivery` header your handler received | | `webhook_id` | string (UUID) | Which subscription this went to | | `event` | string | The event name | | `send_id` | string (UUID) \| null | The send the event was about, when it could be matched | | `status` | string | `pending`, `delivered`, `failed` or `blocked` — see below | | `attempt_count` | integer | How many POSTs were made. `0` on a `blocked` row, because no connection was opened | | `response_code` | integer \| null | The last HTTP status your endpoint returned; `null` on a transport error | | `error_detail` | string \| null | Why the last attempt failed | | `created_at` | string | When the event was generated | | `last_attempt_at` | string \| null | When we last tried | | `delivered_at` | string \| null | When your endpoint accepted it | | `status` | Meaning | |----------|---------| | `pending` | Created, not yet attempted | | `delivered` | Your endpoint answered 2xx | | `failed` | Non-2xx or a transport error, and the retries are exhausted | | `blocked` | The URL resolved somewhere we refuse to POST to, so no request was sent | ```bash # Everything, newest first curl "https://api.callmissed.com/api/v1/email/webhooks/deliveries?limit=50&offset=0" \ -H "Authorization: Bearer cm_your_key" # Just one subscription curl "https://api.callmissed.com/api/v1/email/webhooks/deliveries?webhook_id=9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e" \ -H "Authorization: Bearer cm_your_key" ``` ## Pause, resume and delete **`PATCH /api/v1/email/webhooks/{id}`** takes one field, `is_active` (boolean), and returns the updated `WebhookOut`. This is how you stop a noisy endpoint without re-registering and redeploying a new secret. ```bash # Stop deliveries, keep the URL, secret and history curl -X PATCH https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"is_active": false}' # Resume curl -X PATCH https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"is_active": true}' # Remove it entirely curl -X DELETE https://api.callmissed.com/api/v1/email/webhooks/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e \ -H "Authorization: Bearer cm_your_key" ``` `DELETE` is a hard delete and **also removes that subscription's delivery history**, so a deleted subscription leaves none of your payloads behind. To stop deliveries while keeping the audit trail, `PATCH is_active=false` instead. ## Common failures on these routes | Status | Body | Meaning | |--------|------|---------| | 401 | string `detail` | Missing, malformed or unrecognised `Authorization` header | | 403 | string `detail` | The API key is read-only and this route writes | | 404 | string `detail` | The webhook id is not yours | | 422 | `reason: webhook_url_forbidden` | The `url` is not a permitted public URL | | 422 | `reason: too_many_webhooks` | You already have 20 subscriptions | | 422 | schema array `detail` | An unknown event name in `events` | The two `reason` bodies are nested under `detail` alongside an `error` string. Every shape is spelled out on [Limits, Quotas & Errors](/docs/email-limits#response-shapes). ## Related - [Delivery, Suppressions & Usage](/docs/email-logs) for the send log, the suppression list and engagement metrics. - [Receive Email](/docs/email-inbound) for inbound mail, which is forwarded per address rather than through this subsystem. - [Domains & Senders](/docs/email-domains#tracking-and-sending-toggles) to turn open and click tracking on. --- ### Email API URL: /docs/email > Send and receive email from your own domain over the API: verified-domain onboarding, DKIM signing, delivery and suppression tracking. ## Overview The Email API sends and receives email from a domain you own. You verify the domain once (we generate its DKIM key and the DNS records to publish), then send over the API and, optionally, receive mail at addresses on that domain. **Base path:** `https://api.callmissed.com/api/v1/email` Authentication uses your existing CallMissed API key, the same `cm_` key you use for every other API. The key needs the **email** permission enabled (toggle it on the [API keys](https://console.callmissed.com/developer/keys) page). No separate email key. > **Read this before you write your first send.** Verification registers exactly one sender username on the domain, `donotreply`, so `donotreply@your-domain` always works. Any other local part on a verified domain has to be registered as a sender first: either up front with `POST /api/v1/email/domains/{domain_id}/senders`, or implicitly, because the send path registers the `from` local part on its first refusal and retries. Registration is eventually consistent, so a send from a brand-new sender can still come back as `503 sender_propagating`, meaning retry shortly and nothing else is needed. See [Sender Addresses](/docs/email-domains#sender-addresses). :::flow icon:app | Your app | Add a domain, publish the DNS records we generate icon:gateway | CallMissed | Verify ownership, SPF and both DKIM records, then accept sends from that domain icon:done | Recipients | Receive DKIM-signed mail from your own domain ::: > **Billing:** Email is fully credit-based. Every send is charged to your credit balance at **30 credits (₹30) per 1,000 emails** (per recipient), the same wallet as every other API. There is no separate email invoice. Full detail: [Pricing](/docs/email-logs#pricing). ## The pages in this section :::cards /docs/email-domains | Domains & Senders | globe | Add a domain, publish DNS, verify, and the donotreply sender rule /docs/email-send | Send Email | send | POST /api/v1/email/send with every field, header, response and the Brevo migration /docs/email-templates | Templates | file-text | Reusable subject and body with per-send substitution values /docs/email-scheduled | Scheduled & Batch Sending | calendar-clock | Send later with scheduledAt, or many recipient sets in one call /docs/email-inbound | Receive Email | inbox | Claim addresses on a verified domain, read inbound mail, or have it forwarded to your app /docs/email-logs | Delivery, Suppressions & Usage | chart-column | Send log, suppression list, engagement metrics, spend, and pricing /docs/email-webhooks | Email Webhooks | webhook | Subscribe your endpoint to bounces and complaints, signed and logged per attempt /docs/email-limits | Limits, Quotas & Errors | gauge | Send rate, monthly cap, daily quota, and every error shape and reason ::: ## End to end in three calls Every request below uses the real base URL and the real auth header. Replace `cm_your_key` with your key and `acme.com` with your domain. :::tabs ```bash [cURL] # 1. Register the domain - the response carries the DNS records to publish curl -X POST https://api.callmissed.com/api/v1/email/domains \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"domain": "acme.com"}' # 2. After publishing every required record, verify it (repeat until verified is true) curl -X POST https://api.callmissed.com/api/v1/email/domains/9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e/verify \ -H "Authorization: Bearer cm_your_key" # 3. Send curl -X POST https://api.callmissed.com/api/v1/email/send \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Ops ", "to": ["Ada "], "subject": "Your receipt", "text": "Thanks for your order.", "html": "

Thanks for your order.

", "reply_to": "support@acme.com" }' ``` ```python [Python] import httpx BASE = "https://api.callmissed.com/api/v1/email" h = {"Authorization": "Bearer cm_your_key"} created = httpx.post(f"{BASE}/domains", headers=h, json={"domain": "acme.com"}).json() for rec in created["dns_records"]: print(rec["type"], rec["host"], rec["value"]) # publish these at your DNS host # once published (repeat until verified is true): httpx.post(f"{BASE}/domains/{created['domain']['id']}/verify", headers=h) httpx.post(f"{BASE}/send", headers=h, json={ "from": "Acme Ops ", "to": ["Ada "], "subject": "Your receipt", "text": "Thanks for your order.", "html": "

Thanks for your order.

", "reply_to": "support@acme.com", }) ``` ```javascript [JavaScript] const BASE = "https://api.callmissed.com/api/v1/email"; const headers = { Authorization: "Bearer cm_your_key", "Content-Type": "application/json", }; const created = await fetch(`${BASE}/domains`, { method: "POST", headers, body: JSON.stringify({ domain: "acme.com" }), }).then((r) => r.json()); // publish created.dns_records at your DNS host, then (repeat until verified is true): await fetch(`${BASE}/domains/${created.domain.id}/verify`, { method: "POST", headers: { Authorization: "Bearer cm_your_key" }, }); await fetch(`${BASE}/send`, { method: "POST", headers, body: JSON.stringify({ from: "Acme Ops ", to: ["Ada "], subject: "Your receipt", text: "Thanks for your order.", html: "

Thanks for your order.

", reply_to: "support@acme.com", }), }); ``` ::: A successful send returns `202 Accepted`: ```json { "id": "9d0f8b3a-1c2e-4a5b-8f7d-6e2a1b0c9d4e", "message_id": "<1a2b3c4d@acme.com>", "messageId": "<1a2b3c4d@acme.com>", "messageIds": ["<1a2b3c4d@acme.com>"], "status": "sent", "suppressed": ["blocked@example.com"] } ``` Field-by-field detail for that body is on [Send Email](/docs/email-send#response). ## When a call fails Error bodies come in four shapes and they are not interchangeable, so branch on the HTTP status first, then check whether `detail` is an object, a string or an array before reaching for `reason`. The shapes, the full reason table, and the three sending ceilings are on [Limits, Quotas & Errors](/docs/email-limits). Two failures dominate the first send. `403 domain_not_verified` means the domain has not passed all four DNS checks yet. `503 sender_propagating` means the `from` address was just registered as a sender and the mail service has not finished propagating it, so retry shortly. Both are covered on [Sender Addresses](/docs/email-domains#sender-addresses). --- ### Account MCP Server URL: /docs/agent-tools-mcp > Give an AI agent 342 tools that act on your CallMissed account: draft, build and configure voice agents, set up call flows, menus and queues, place and review calls, run outbound calling campaigns end to end, work the inbox and the support desk, search and update the CRM with its pipelines, custom fields and lead scoring, send WhatsApp and email, sell from a WhatsApp catalog, generate images, connect and disconnect WhatsApp, Facebook, Instagram, integrations and knowledge sources, manage webhooks and managed prompts, version and roll back agents, run evals and A/B experiments, and check credits, over the Model Context Protocol. Connect by signing in with your CallMissed account, or with an API key. :::cards /docs/mcp-server | Docs MCP Server | book | Searchable CallMissed docs inside your coding agent /docs/quickstart | Quickstart | rocket | Make your first API call in under a minute ::: ## Overview The **Account MCP server** lets an AI agent take actions in your CallMissed account through the [Model Context Protocol](https://modelcontextprotocol.io). Point any MCP client at one URL, sign in with your CallMissed account or pass an API key, and the agent gets 342 tools: draft, build and configure voice agents end to end, set up how inbound calls are answered with flows, menus and queues, place and inspect phone calls, read transcripts, build and run outbound calling campaigns, work the shared inbox and the support desk, search and update the CRM and set up its pipelines, custom fields and lead scoring, summarise and score calls, send WhatsApp messages, catalog products and Flow forms, send email, generate images, connect and disconnect WhatsApp, Facebook, Instagram, integrations and knowledge sources, wire up webhooks and managed prompts, version and roll back an agent, run evaluation suites and A/B experiments, and check what it all cost. It is hosted, so there is nothing to install and nothing to run locally. This is a **different server** from the [Docs MCP Server](/docs/mcp-server). That one is a local npm package that makes this documentation searchable inside your editor and needs no credentials. This one is hosted, needs your account, and acts on your real data and credits. ## Endpoint ``` https://api.callmissed.com/api/v1/mcp ``` The transport is **Streamable HTTP**: every call is a single `POST` that returns one JSON response. There is no session to open or close, so the server works behind any load balancer and needs no sticky routing. `GET` and `DELETE` return `405` by design, because this server does not offer a server-initiated event stream. The server answers `initialize`, `tools/list`, `tools/call`, `resources/list` and `resources/read`, and declares the `tools` and `resources` capabilities. ## Connect Two ways in. Both reach the same tools; they differ in how the connection is authorised and how much you have to set up. ### Option 1: sign in with your CallMissed account The easy path, and the recommended one. Paste the server URL into your client, click **Connect**, sign in to CallMissed, and tick what the connection is allowed to do. There is no key to create and nothing to paste into a header. ``` https://api.callmissed.com/api/v1/mcp ``` Paste that URL exactly as written, path included. Discovery is bound to the URL you type, so a shortened or altered one is not recognised as this server and sign-in never starts. If your client cannot complete the sign-in, use an API key instead (option 2 below). #### Choose what to allow The consent screen offers five permission groups. | Group | What it allows | On by default | Can spend credits | | --- | --- | --- | --- | | **Read your data** | See your calls, contacts, companies, deals, leads, quotes, invoices, products, tasks, notes, conversations, agents and usage, plus how your CRM and support desk are set up: pipelines, custom fields, saved views, lead scores, tickets, SLA policies, macros, routing rules and satisfaction scores. Cannot change anything. Searching your knowledge base costs a small amount to read the question. | Yes | Yes | | **Read call transcripts** | Read what was said on your voice calls, and what each one cost. This also lets the connection use AI models on your account, which spends credits. | No | Yes | | **Take actions** | Create and update records — including leads, products, quotes and invoices — send WhatsApp messages and email — including catalog products, order updates and Flow forms — place and end calls, rent phone numbers, and generate images. It can also build outbound calling campaigns and start them, which dials everyone on the list, change how your CRM and support desk are set up — pipelines, custom fields, lead scoring, tickets, SLA policies, macros and routing — and open satisfaction surveys. Spends credits, and a rented number is a recurring charge. | No | Yes | | **Set up and change your agents** | Create agents and change their instructions, voice, tools and knowledge, draft a new agent from a description, give them custom tools that call your own URLs, change how a team of agents hands callers between them, and run test suites and A/B experiments against them. Cannot send messages or place calls. Indexing a knowledge source, drafting an agent and running a test suite spend credits. | No | Yes | | **Configure your workspace** | Set up where your events are delivered and when you are alerted, connect an external service or a WhatsApp, Facebook or Instagram account by handing you a link to approve, choose which agent answers each connected number or account, disconnect them, create, edit or delete WhatsApp message templates, and manage the prompts the API resolves by name. Cannot read or write the credentials of a connected service. Publishing a prompt or pinning a model to one changes what your own API calls do and what they cost. | No | Yes | **Read your data** is the only group ticked when the screen opens. The other four start off and are granted only if you tick them yourself, so a single click cannot hand an agent the ability to message a customer or spend credits. Building an agent is its own tick rather than part of **Take actions**: letting an assistant send a WhatsApp message should not also let it rewrite the instructions, voice and tools of the agent answering your phone. Running an eval suite or an A/B experiment sits on that same tick, because it is work done on the agents it already governs. At least one group has to be ticked. Approving nothing would create a connection that fails every call, so the consent screen asks you to choose something or press **Deny**. Against the tool tables further down: **Read your data** covers every `:read` scope, including the campaign, CRM-setup, support-desk, commerce, prompt and eval reads; **Read call transcripts** grants `telephony:read` (so it can read every call, not only those the connection placed) and the `stt`, `tts` and `llm` permissions the three voice-session tools need; **Take actions** covers the record, CRM-setup, support-desk, messaging, commerce, telephony and campaign `:write` scopes plus `whatsapp:send` and the `image` and `email` permissions; **Set up and change your agents** covers `bots:write`, `knowledge:write`, `squads:write`, `evals:write` and `experiments:write`; and **Configure your workspace** covers `webhooks:write`, `integrations:write`, `prompts:write` and `whatsapp:write`. #### The connection appears as an API key Approving creates an API key on your account named `MCP connection: {host}`, where the host is the one shown on the consent screen as the client that asked. You will find it at **Developer → API keys** in the [console](https://console.callmissed.com/developer/keys) next to your other keys, carrying exactly the scopes you ticked, with the same budget, rate limit and credit accounting as any key you make by hand. Request logging is off for it. Its value is never shown and cannot be revealed or copied out, so it can only ever be used by the connection it was made for. **To disconnect, deactivate that key.** That is the whole revocation story: once the key is inactive, every token issued to that connection stops working immediately. #### Staying connected The access token your client receives is short-lived and your client renews it in the background using the refresh token issued alongside it. You are not asked to sign in again each time you use the tools. You will be asked again if you deny, if the key is deactivated, if you want to change what is allowed, or after the connection has gone unused for a long stretch. #### Discovery endpoints A client finds the flow from the URL you paste, by fetching these: | Endpoint | What it is for | | --- | --- | | `GET /.well-known/oauth-protected-resource` | Protected-resource metadata (RFC 9728): the MCP resource URL, the authorization server, and the scopes this server understands. | | `GET /.well-known/oauth-protected-resource/api/v1/mcp` | The same document at the path-suffixed address, which is what a client derives from the full server URL. Both forms are published so discovery works either way. | | `GET /.well-known/oauth-authorization-server` | Authorization-server metadata (RFC 8414): the authorization and token endpoints, the supported grant types, and the PKCE methods. | The flow itself is a standard OAuth 2.1 authorization code exchange: | Endpoint | Method | | --- | --- | | `https://api.callmissed.com/api/v1/mcp/oauth/authorize` | `GET` | | `https://api.callmissed.com/api/v1/mcp/oauth/token` | `POST`, form-encoded | Two things to know if you are writing the client yourself: * **PKCE with `S256` is required.** There is no client secret, so a request without `code_challenge_method=S256` is refused. * **`client_credentials` is not supported.** The only grant types are `authorization_code` and `refresh_token`, because every connection needs a person to approve it on the consent screen. There is no registration step. `client_id` is an `https://` URL that identifies your client, and the consent screen shows its host as plain text so the person approving can see who is asking. Redirect addresses are limited to ones CallMissed has approved, plus loopback addresses for native apps. Any other `redirect_uri` is refused with a `400` before the consent screen is ever shown, so nothing is redirected to an address we have not vetted. ### Option 2: connect with an API key For clients that do not do the sign-in flow, and for calling the endpoint yourself, pass an API key from your dashboard exactly as you would for any other CallMissed endpoint: ``` Authorization: Bearer cm_your_api_key ``` **A dashboard login token is refused with `401`**, even though it works elsewhere in the API. Scope checks are what keep an agent inside its lane, and those apply to keys, so this endpoint takes an API key or a token from the sign-in flow above and nothing else. Everything attached to the key still applies: its scopes, its spend budget, its rate limit, and its domain allowlist. A key that runs out of budget gets `402`; one over its rate limit gets `429`. ## What a connection needs Two different gates decide whether a tool works, whether the connection came from signing in or from a key you made by hand: * **Resource scopes** such as `contacts:read` or `whatsapp:send`. Most tools use these, and the table below names the one each tool needs. A tool whose scope is missing returns a readable refusal naming the scope to add, so the agent can tell you what to fix. * **Service permissions** on the key. The three voice-session tools need a key with the `stt`, `tts` and `llm` permissions; the two image tools need the `image` permission. `get_credit_balance` needs nothing beyond a valid key. Give each key only what its agent needs. A read-only key is a perfectly good way to let an agent look at the account without letting it spend anything or reach a real person. Twenty-eight tools spend credits and several of them reach real people. `start_campaign` reaches the most: it dials every person on the campaign's list. They are marked in the tables below. For an agent that should look but not act, tick only **Read your data** when you sign in, or use a key holding only the read scopes. ## Tools 342 tools in sixteen groups. `Access` is the tool's own `readOnlyHint`. `Credits` marks the tools that draw down your balance. Every tool's full JSON Schema, including argument names, types and bounds, comes back from `tools/list`. ### Voice and calls | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `place_call` | Place an outbound call from one of your numbers, answered by one of your voice agents | Write | `telephony:write` | Yes | | `list_calls` | List calls, newest first, with status, duration and cost | Read | `telephony:read` | No | | `get_call` | Fetch one call by id, with hangup cause and cost | Read | `telephony:read` | No | | `end_call` | Hang up a call that is still in progress. Immediate and not undoable | Write, destructive | `telephony:write` | No | | `get_call_recording` | Get a short-lived download link for a call's recording | Read | `telephony:read` | No | | `click_to_call` | Ring one of your people, dial the contact, and bridge the two. No AI agent involved | Write | `telephony:write` | Yes | | `list_phone_numbers` | List your numbers and their status, to find the `from_number_id` `place_call` takes | Read | `telephony:read` | No | | `search_available_numbers` | Search the numbers you could rent in a country, with monthly price, whether each takes calls and whether it needs documents. Buys nothing | Read | `telephony:read` | No | | `list_number_countries` | List the countries you can rent numbers in, India first, with dialling codes and whether each is open yet | Read | `telephony:read` | No | | `get_number_requirements` | Get the documents and fields a country's numbers need before activation; every country differs | Read | `telephony:read` | No | | `provision_phone_number` | Rent a number. It is bought immediately and renews monthly until released; Indian numbers and numbers that need documents require an approved compliance application for that country | Write | `telephony:write` | Yes | | `attach_number_to_agent` | Point one of your numbers at an agent, so calls to it are answered by that agent. Pass `bot_id: null` to detach | Write | `telephony:write` | No | | `list_voice_sessions` | List voice agent sessions, newest first | Read | `stt` + `tts` + `llm` permissions | No | | `get_voice_session_transcript` | Get a session's full turn-by-turn transcript | Read | `stt` + `tts` + `llm` permissions | No | | `get_voice_session_cost` | Break down what one voice session cost in credits | Read | `stt` + `tts` + `llm` permissions | No | #### Call handling How an inbound call is answered: the flow it walks, the menu it hears, the queue it waits in, the message left on a machine, and whether the number has been flagged as spam. This is the whole console setup journey, available to an agent. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_call_flows` | List call flows with each one's shape and whether it is live. Returns no graphs | Read | `telephony:read` | No | | `get_call_flow` | One flow with its full draft graph; the live snapshot is summarised, not repeated | Read | `telephony:read` | No | | `create_call_flow` | Create a flow. It answers nothing until published and attached to a number | Write | `telephony:write` | No | | `update_call_flow` | Change the draft. Sending `graph` replaces the whole graph | Write | `telephony:write` | No | | `publish_call_flow` | Make the draft the version that answers **real** calls, from the next call on | Write, destructive | `telephony:write` | No | | `attach_number_to_flow` | Answer one of your numbers with this flow. `detach: true` undoes it. A flow answers once it is published | Write | `telephony:write` | No | | `simulate_call_flow` | Dry-run one step and see the instruction a real call would get. Webhook steps are not called | Read | `telephony:read` | No | | `list_call_menus` | List press-1-for-sales menus with their prompt and key map | Read | `telephony:read` | No | | `get_call_menu` | One menu with its prompt, key map and retry settings | Read | `telephony:read` | No | | `create_call_menu` | Create a menu: what the caller hears and where each key sends them | Write | `telephony:write` | No | | `update_call_menu` | Change a menu. Sending `options` replaces the whole key map | Write | `telephony:write` | No | | `attach_number_to_menu` | Answer one of your numbers with this menu. `detach: true` undoes it. | Write | `telephony:write` | No | | `list_call_queues` | List queues: how callers are held and in what order they are served | Read | `telephony:read` | No | | `get_call_queue` | One queue with its serving order, hold settings and overflow behaviour | Read | `telephony:read` | No | | `create_call_queue` | Create a queue that holds callers until a roster member is free | Write | `telephony:write` | No | | `update_call_queue` | Change serving order, hold audio, waiting limit or overflow behaviour | Write | `telephony:write` | No | | `list_queue_members` | Who answers for a queue, with priority, capacity and how busy each is | Read | `telephony:read` | No | | `add_queue_member` | Put an agent (`bot_id`) or a person (`user_id`) on a queue's roster | Write | `telephony:write` | No | | `remove_queue_member` | Take an answerer off the roster. They stop receiving calls immediately | Write, destructive | `telephony:write` | No | | `attach_number_to_queue` | Send one of your numbers through this queue. `detach: true` undoes it | Write | `telephony:write` | No | | `get_queue_live` | Who is holding right now, how long, and how much of the roster is free. Callers show as last four digits only | Read | `telephony:read` | No | | `list_voicemail_templates` | The messages your calls leave on a machine, and whether each is ready to play | Read | `telephony:read` | No | | `get_voicemail_template` | One voicemail message with its words, voice and state | Read | `telephony:read` | No | | `create_voicemail_template` | Write the message a call should leave when a machine answers | Write | `telephony:write` | No | | `update_voicemail_template` | Change a message. New words throw away the recording made from the old ones | Write | `telephony:write` | No | | `get_number_reputation` | Whether one of your numbers is flagged as spam, and the complaints still open against it | Read | `telephony:read` | No | Deleting a flow, menu, queue or voicemail message is not an MCP tool: an assistant should not be able to remove a routing object that live numbers still point at. Take one out of service reversibly instead — `enabled: false` on a menu or queue, or detach the number. Clearing a spam complaint needs a proof document and stays in the dashboard. #### More voice and calls tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_voice_session` | Get one voice agent session's status, timing and post-call analysis (summary, outcome, sentiment) once it exists | Read | `stt` + `tts` + `llm` permissions | No | | `update_number_call_settings` | Set a number's call settings | Write | `telephony:write` | No | | `declare_number_for_ai_calls` | Record that a number has been declared to your telecom provider for automated/AI calls (India TRAI). Records a declaration already made; does not file one | Write | `telephony:write` | No | | `list_telephony_compliance` | List this account's phone-number KYC applications and whether each is approved, pending or rejected (with the reason) | Read | `telephony:read` | No | | `unpublish_call_flow` | Stop answering live calls with this flow; numbers on it answer normally again | Write, destructive | `telephony:write` | No | | `update_queue_member` | Change a queue member's priority, concurrent-call limit, or enabled flag | Write | `telephony:write` | No | ### Calling campaigns An outbound campaign is a list of people, a voice agent and the rules for when and how fast to dial. Build it as a draft, load the list, then start it. The account-wide do-not-call list lives here too. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_campaigns` | List campaigns, newest first, with status and contact counts | Read | `campaigns:read` | No | | `get_campaign` | Fetch one campaign: status, schedule, calling hours, dial mode | Read | `campaigns:read` | No | | `create_campaign` | Create a campaign. It starts as a draft and calls nobody | Write | `campaigns:write` | No | | `update_campaign` | Change name, schedule, calling number, calling hours, dial mode or DLT registration fields | Write | `campaigns:write` | No | | `add_campaign_contacts` | Add up to 1000 people to the calling list. Adding calls nobody | Write | `campaigns:write` | No | | `list_campaign_contacts` | The per-person result: waiting, calling, done or failed, with attempts and last error | Read | `campaigns:read` | No | | `get_campaign_live_state` | What the campaign is doing right now, and in preview mode who is awaiting a go-ahead | Read | `campaigns:read` | No | | `get_campaign_trai_readiness` | What stops the campaign meeting India's TRAI rules for AI calls, and whether the account enforces them | Read | `campaigns:read` | No | | `start_campaign` | **Starts calling.** Real calls to every person on the list | Write, destructive | `campaigns:write` | Yes | | `pause_campaign` | Stop placing new calls. Can be started again later | Write | `campaigns:write` | No | | `stop_campaign` | Cancel the campaign for good. Cannot be started again | Write, destructive | `campaigns:write` | No | | `list_do_not_call` | List the numbers on this account's do-not-contact list, with the channel each one blocks | Read | `campaigns:read` | No | | `add_do_not_call` | Suppress numbers from calls, WhatsApp, or both (`channel`, default `all`) | Write | `campaigns:write` | No | Taking a number back **off** the do-not-call list is deliberately not a tool. Adding one is safe in the direction that matters — the worst case is a call that does not happen — but removing one un-does somebody's opt-out, and the next thing that happens is a call to a person who asked not to be called. That stays with a person in the [console](https://console.callmissed.com). API keys cannot remove an entry either. Uploading a CSV to a campaign is not a tool either: it is a file upload, and MCP arguments are JSON. Use `add_campaign_contacts` for a list the agent already holds, or the [Campaigns API](/docs/voice-campaigns) for a file. ### Conversations | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_conversations` | List conversations across channels with a preview and unread count | Read | `conversations:read` | No | | `get_conversation_messages` | Read the messages in one conversation, oldest first | Read | `conversations:read` | No | | `set_conversation_status` | Change a conversation's status, for example to close or escalate it | Write | `conversations:write` | No | | `assign_conversation` | Give a conversation to a teammate by id or email, or unassign it | Write | `conversations:write` | No | | `set_conversation_labels` | Replace a conversation's labels (short tags such as refund or vip) | Write | `conversations:write` | No | | `add_conversation_note` | Write an internal note on the contact behind a conversation. Never sent to the customer | Write | `crm_notes:write` | No | | `list_handoffs` | List conversations escalated to a person and still waiting | Read | `conversations:read` | No | | `resolve_handoff` | Mark an escalated conversation handled, optionally keeping the AI quiet | Write | `conversations:write` | No | #### More conversations tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_conversation` | Get one conversation's details: channel, status, contact, agent and unread count | Read | `conversations:read` | No | | `mark_conversation_read` | Clear a conversation's unread count, as opening it in the inbox does | Write | `conversations:write` | No | | `assign_handoff` | Assign a waiting handoff to a teammate, or pass assignee_id null to unassign it | Write | `conversations:write` | No | | `callback_handoff` | For a voice handoff, phone your operator number and then the customer and bridge them (two real, billed calls) | Write | `conversations:write` | Yes | ### CRM | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `crm_search` | Search contacts, companies, deals, notes and tasks in one call | Read | `crm_search:read` | No | | `crm_timeline` | One contact's or company's full history: conversations, calls, notes, tasks, deals | Read | `crm_timeline:read` | No | | `list_contacts` | List contacts with their per-channel opt-in state | Read | `contacts:read` | No | | `create_contact` | Add a person to the CRM. Needs at least a phone or an email | Write | `contacts:write` | No | | `update_contact` | Change fields on an existing contact | Write | `contacts:write` | No | | `get_contact_memory` | Get the facts your agents have learned about one contact | Read | `contacts:read` | No | | `create_deal` | Open a deal in a pipeline | Write | `crm_deals:write` | No | | `move_deal` | Move a deal to another stage of its own pipeline | Write | `crm_deals:write` | No | | `create_task` | Add a follow-up task, optionally attached to a record | Write | `crm_tasks:write` | No | | `complete_task` | Mark a task done | Write | `crm_tasks:write` | No | | `create_crm_note` | Attach a note to a contact, company or deal | Write | `crm_notes:write` | No | | `list_companies` | List companies, optionally matching a name or domain | Read | `companies:read` | No | | `create_company` | Add a company. A domain has to be unique on the account | Write | `companies:write` | No | | `update_company` | Change fields on an existing company | Write | `companies:write` | No | #### Pipelines and stages The board a deal moves across. Pipelines and stages share the deal scopes, because a stage is only meaningful as part of the pipeline it belongs to. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_pipelines` | List the sales pipelines, the default one first | Read | `crm_deals:read` | No | | `create_pipeline` | Add a pipeline. It starts with no stages | Write | `crm_deals:write` | No | | `update_pipeline` | Rename a pipeline, or make it the default | Write | `crm_deals:write` | No | | `list_pipeline_stages` | List one pipeline's stages in board order | Read | `crm_deals:read` | No | | `create_pipeline_stage` | Add a column, with its win probability and won/lost meaning | Write | `crm_deals:write` | No | | `update_pipeline_stage` | Rename a stage, move it, or change what it means | Write | `crm_deals:write` | No | | `reorder_pipeline_stages` | Rewrite the whole column order. Must name every stage exactly once | Write | `crm_deals:write` | No | #### Custom fields | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_custom_fields` | List the custom fields defined on contacts, companies and deals, with each one's type | Read | `crm_custom_fields:read` | No | | `create_custom_field` | Define a typed field: text, number, date, boolean or select | Write | `crm_custom_fields:write` | No | | `update_custom_field` | Rename, reorder, re-option or (un)require a field. Its key and type never change | Write | `crm_custom_fields:write` | No | | `list_custom_field_values` | Read every custom value on one record | Read | `crm_custom_fields:read` | No | | `set_custom_field_value` | Set one field on one record. The value has to match the field's type | Write | `crm_custom_fields:write` | No | | `clear_custom_field_value` | Unset one field on one record. The stored value is gone | Write, destructive | `crm_custom_fields:write` | No | Deleting a custom field **definition** is not on this surface: the delete cascades to every value stored against it, on every record. Do that in the console. #### Saved views | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_saved_views` | List the saved filtered lists you can see: shared ones plus your own | Read | `crm_views:read` | No | | `create_saved_view` | Save a named filter, sort and column preset over a CRM list | Write | `crm_views:write` | No | #### Lead scoring | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_lead_score_rules` | List the rules that award points, oldest first | Read | `crm_scores:read` | No | | `create_lead_score_rule` | Add a rule over one of the allowed signals | Write | `crm_scores:write` | No | | `update_lead_score_rule` | Change a rule's signal, comparison, points or enabled state | Write | `crm_scores:write` | No | | `recompute_lead_scores` | Rescore up to 200 contacts or companies. All or nothing | Write | `crm_scores:write` | No | | `list_lead_scores` | The leaderboard: scored records, highest first | Read | `crm_scores:read` | No | | `get_lead_score` | One record's score with the breakdown of which rules fired | Read | `crm_scores:read` | No | A new or changed rule does not move any score until `recompute_lead_scores` runs. The signals a rule may test are a closed list, documented on [Lead scoring](/docs/crm-lead-scores). #### More cRM tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_contact` | Get one contact's full record by id | Read | `contacts:read` | No | | `list_deals` | List deals, newest first, filtered by pipeline, stage, status, owner, contact or company | Read | `crm_deals:read` | No | | `get_deal` | Get one deal by id | Read | `crm_deals:read` | No | | `update_deal` | Change a deal's title, value, links, owner, close date, stage or open/won/lost status | Write | `crm_deals:write` | No | | `list_tasks` | List CRM tasks, filtered by status, assignee, attached record, overdue or due window | Read | `crm_tasks:read` | No | | `update_task` | Edit a task's title, description, due date, assignee, attached record or status | Write | `crm_tasks:write` | No | | `list_crm_notes` | List the notes on one contact, company or deal, newest first | Read | `crm_notes:read` | No | | `update_crm_note` | Replace the text of a note | Write | `crm_notes:write` | No | | `list_crm_duplicates` | Find groups of contacts or companies that look like the same record | Read | `crm_search:read` | No | | `merge_crm_duplicates` | Irreversibly fold duplicates into a primary record: their linked records move to it and the duplicates are deleted | Write, destructive | `crm_search:write` | No | | `update_saved_view` | Rename a saved view or change its filters, sort, columns, layout or sharing; the list it belongs to cannot change | Write | `crm_views:write` | No | | `get_company` | Get one company's record by id | Read | `companies:read` | No | ### Support desk | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_tickets` | List tickets by status, priority, assignee, contact, conversation or subject | Read | `support_tickets:read` | No | | `get_ticket` | Fetch one ticket with its lifecycle timestamps | Read | `support_tickets:read` | No | | `create_ticket` | Open a ticket, optionally linked to a conversation or contact | Write | `support_tickets:write` | No | | `update_ticket` | Change subject, description, priority, links or tags | Write | `support_tickets:write` | No | | `set_ticket_status` | Move a ticket through its lifecycle, keeping the SLA clocks honest | Write | `support_tickets:write` | No | | `assign_ticket` | Hand a ticket to a teammate, or send it back to the queue | Write | `support_tickets:write` | No | Deleting a ticket is not on this surface — it would destroy the record of a customer interaction and every SLA number computed from it. Close it instead. #### SLA | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_sla_policies` | List the first-response and resolution targets the desk promises | Read | `sla:read` | No | | `create_sla_policy` | Promise a first-response time, a resolution time, or both, with business hours | Write | `sla:write` | No | | `update_sla_policy` | Change a policy's targets, priority, hours or enabled state | Write | `sla:write` | No | | `get_ticket_sla_status` | Where one ticket stands: due times, what is missed, minutes left | Read | `sla:read` | No | | `list_sla_breaches` | Tickets already past a deadline, oldest first | Read | `sla:read` | No | #### Macros, tags and routing | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_support_macros` | List canned replies, most-used first | Read | `support_ops:read` | No | | `create_support_macro` | Save a canned reply, with `{{placeholders}}` and optional actions | Write | `support_ops:write` | No | | `update_support_macro` | Change a macro's text, actions, category or enabled state | Write | `support_ops:write` | No | | `use_support_macro` | Fill a macro's placeholders and get the finished text. Sends nothing | Write | `support_ops:write` | No | | `list_support_tags` | List the tag vocabulary with colours and descriptions | Read | `support_ops:read` | No | | `create_support_tag` | Add a tag to the vocabulary | Write | `support_ops:write` | No | | `list_ticket_routing_rules` | List the triage rules in evaluation order | Read | `support_ops:read` | No | | `create_ticket_routing_rule` | Add a triage rule that assigns, prioritises and tags a match | Write | `support_ops:write` | No | | `update_ticket_routing_rule` | Change a rule's conditions, actions, position or enabled state | Write | `support_ops:write` | No | | `reorder_ticket_routing_rules` | Rewrite the evaluation order. Must name every rule exactly once | Write | `support_ops:write` | No | | `preview_ticket_routing` | Dry run: which rule would match a ticket, and what it would do. Writes nothing | Read | `support_ops:read` | No | Macros and rules are retired with `is_active: false` rather than deleted, so nothing that a past ticket refers to disappears. #### Satisfaction | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `create_csat_survey` | Mint a survey and its public response link. **Does not send it** | Write | `csat:write` | No | | `list_csat_surveys` | List surveys, optionally only answered or only still-open ones | Read | `csat:read` | No | | `get_csat_stats` | Response rate, average rating and NPS over a window of up to a year | Read | `csat:read` | No | `create_csat_survey` returns a `token`; the link you send is that token on the public response page. Delivering it over WhatsApp or email is a separate call, and that send is what costs credits. #### More support desk tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `update_support_tag` | Rename a support tag or change its color or description | Write | `support_ops:write` | No | ### Call intelligence | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_call_notes` | Get the notes already generated for a call: summary, action items, outcome | Read | `conversations:read` | No | | `generate_call_notes` | Read a call's transcript and write structured notes from it | Write | `conversations:write` | Yes | | `score_call` | Grade a call against one of your scorecards and store the result | Write | `conversations:write` | Yes | ### Messaging | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `send_whatsapp_message` | Send a free-form WhatsApp text, inside the 24-hour service window only | Write | `whatsapp:send` | Yes | | `send_whatsapp_template` | Send an approved template, the only way to reach someone outside that window | Write | `whatsapp:send` | Yes | | `send_whatsapp_buttons` | Send a message with up to three tap-to-reply buttons, inside the 24-hour window | Write | `whatsapp:send` | Yes | | `send_whatsapp_media` | Send an image, video, audio clip, document or sticker from a public https link or an uploaded media id, inside the 24-hour window. Audio can go as a voice note | Write | `whatsapp:send` | Yes | | `send_whatsapp_call_button` | Send a button that starts a WhatsApp call to your business, inside the 24-hour window | Write | `whatsapp:send` | Yes | | `request_whatsapp_location` | Ask the customer to share their location with a "Send location" button, inside the 24-hour window | Write | `whatsapp:send` | Yes | | `request_whatsapp_address` | Ask an Indian customer for a delivery address in a native WhatsApp form, inside the 24-hour window | Write | `whatsapp:send` | Yes | | `list_whatsapp_templates` | Your templates with review status in plain words, rejection reason and quality | Read | `whatsapp:read` | No | | `review_whatsapp_template` | WhatsApp's template rules, the practices that keep your account from being restricted (opt-in, opt-outs, the 24-hour window, quality rating), and a scored review of a draft: errors, warnings and tips. Call it before creating or editing a template | Read | `whatsapp:read` | No | | `create_whatsapp_template` | Create a template (or copy a library template by name) and submit it for review. A draft that breaks WhatsApp's template rules is refused with the list of problems; remaining warnings come back as `policy_warnings` | Write | `whatsapp:write` | No | | `edit_whatsapp_template` | Replace an approved, rejected or paused template's content and resubmit it, with the same rules check as create | Write | `whatsapp:write` | No | | `delete_whatsapp_template` | **Permanently** delete a template in every language | Write, destructive | `whatsapp:write` | No | | `get_whatsapp_business_verification` | Whether the business behind your WhatsApp account is verified | Read | `whatsapp:read` | No | | `create_whatsapp_template_group` | Group templates from one business account so their metrics can be read together | Write | `whatsapp:write` | No | | `get_whatsapp_template_group` | Read one template group and its member templates, live from WhatsApp | Read | `whatsapp:read` | No | | `update_whatsapp_template_group` | Rename a template group or add and remove templates | Write | `whatsapp:write` | No | | `delete_whatsapp_template_group` | **Permanently** delete a template group. Its templates and their metrics are kept | Write, destructive | `whatsapp:write` | No | | `list_whatsapp_campaigns` | List broadcast campaigns with status and recipient counts | Read | `whatsapp:read` | No | | `send_email` | Send an email from a domain this account has verified | Write | `email` permission | Yes | | `send_whatsapp_product` | Send one product from a WhatsApp catalog so the recipient can add it to a cart in the chat | Write | `wa_commerce:write` | Yes | | `send_whatsapp_product_list` | Send up to 30 products grouped into titled sections, as one browsable message | Write | `wa_commerce:write` | Yes | | `send_whatsapp_catalog` | Send the whole catalog attached to a business number, fronted by one product as the cover | Write | `wa_commerce:write` | Yes | | `list_whatsapp_orders` | List the carts customers sent from your catalog, with status, currency and subtotal | Read | `wa_commerce:read` | No | | `get_whatsapp_order` | One cart order with every line item: SKU, quantity and unit price | Read | `wa_commerce:read` | No | | `update_whatsapp_order_status` | Advance a stored order and tell the customer in the same step. `completed` and `canceled` are final | Write, destructive | `wa_commerce:write` | Yes | | `list_whatsapp_flows` | List your WhatsApp Flows with status and categories. The design document is left out | Read | `wa_flows:read` | No | | `send_whatsapp_flow` | Send a Flow — a form filled in without leaving the chat — and get the token that identifies the reply | Write | `wa_flows:write` | Yes | | `list_whatsapp_flow_responses` | The Flow forms customers submitted, with the answers they gave | Read | `wa_flows:read` | No | | `publish_whatsapp_flow` | Publish a draft Flow. One-way: the design is then frozen and changing it means a new Flow | Write, destructive | `wa_flows:write` | No | #### More messaging tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_whatsapp_numbers` | List your connected WhatsApp Business numbers with their status, quality rating and linked agent | Read | `whatsapp:read` | No | | `refresh_whatsapp_number` | Re-read a number's status, name and quality rating from WhatsApp | Write | `whatsapp:write` | No | | `link_whatsapp_number_to_agent` | Choose which agent auto-replies on a WhatsApp number; null unlinks it | Write | `whatsapp:write` | No | | `set_whatsapp_autoreply` | Turn the linked agent's automatic replies on or off for one number | Write | `whatsapp:write` | No | | `get_whatsapp_business_profile` | Read the public business profile customers see on a number | Read | `whatsapp:read` | No | | `update_whatsapp_business_profile` | Change the text fields of a number's public business profile | Write | `whatsapp:write` | No | | `search_whatsapp_template_library` | Search WhatsApp's pre-written utility templates, which can be created by name with create_whatsapp_template | Read | `whatsapp:read` | No | | `sync_whatsapp_templates` | Pull the latest templates and review statuses from WhatsApp | Write | `whatsapp:write` | No | | `draft_whatsapp_template` | Write a template draft from a plain-language intent, with notes on review-rejection risks | Write | `whatsapp:write` | Yes | | `create_whatsapp_campaign` | Create a broadcast campaign draft for an approved template | Write | `whatsapp:write` | No | | `get_whatsapp_campaign` | Read one campaign with its status and delivery counts | Read | `whatsapp:read` | No | | `list_whatsapp_campaign_recipients` | Page through every recipient of a campaign with its delivery status and WhatsApp's timestamp for each status, for auditing who got it | Read | `whatsapp:read` | No | | `add_whatsapp_campaign_recipients` | Add recipients, each with optional template variables, to a campaign that has not launched | Write | `whatsapp:write` | No | | `launch_whatsapp_campaign` | Start sending a campaign to all its recipients | Write | `whatsapp:write` | Yes | | `cancel_whatsapp_campaign` | Stop a campaign; messages already sent are not recalled | Write, destructive | `whatsapp:write` | No | | `get_whatsapp_analytics` | Read WhatsApp messaging analytics: the delivery funnel, a daily time series, or credits spent | Read | `whatsapp:read` | No | | `list_whatsapp_webhook_events` | List the most recent events WhatsApp delivered to your numbers, for debugging | Read | `whatsapp:read` | No | | `get_whatsapp_message_status` | Look up one message by its `wamid` to prove whether it was delivered: its furthest status, WhatsApp's timestamp for each status, the failure error and every status recorded | Read | `whatsapp:read` | No | | `list_whatsapp_calls` | List WhatsApp voice calls, newest first | Read | `whatsapp:read` | No | | `get_whatsapp_call_settings` | Read whether calling is on for a number, its call hours and callback settings | Read | `whatsapp:read` | No | | `update_whatsapp_call_settings` | Turn calling on or off for a number and set its call hours, call icon and callback permission | Write | `whatsapp:write` | No | | `request_whatsapp_call_permission` | Send a customer WhatsApp's call-permission prompt; you can only call them after they accept | Write | `whatsapp:send` | No | | `place_whatsapp_call` | Call a customer on WhatsApp; the number's linked agent does the talking | Write | `whatsapp:send` | Yes | | `end_whatsapp_call` | Hang up a live WhatsApp call | Write, destructive | `whatsapp:send` | No | ### Images | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `generate_image` | Generate an image from a prompt. `model` picks which image model draws it — its enum lists each model's strength. Returns the image itself, so the model can see it, plus a link to the full-size version. No image model is processed in India, so with India-only processing on it fails with `region_unavailable` | Write | `image` permission | Yes | | `list_generated_images` | List images made with this key, with fresh short-lived links | Read | `image` permission | No | ### Agents and knowledge | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_agents` | List the voice and chat agents on the account, to find a `bot_id` | Read | `bots:read` | No | | `get_agent` | Fetch one agent's configuration by id | Read | `bots:read` | No | | `craft_agent_prompt` | Everything needed to write a strong voice-agent prompt — the four-section structure and limits, the rules that make a prompt work on a phone call, the agent's current prompt, enabled tools and declared `{{variables}}` — plus a scored review of your draft. Free, writes nothing | Read | `bots:read` | No | | `get_agent_prompt` | An agent's instructions split into the four boxes its owner edits: objective, response guidelines, conversation script and first message | Read | `bots:read` | No | | `list_voice_tiers` | The voice tiers a calling agent can run on: the `voice_tier` id, what it sounds like, what a minute costs in credits, and the voices you may pick from. Read before creating or reconfiguring a calling agent | Read | `bots:read` | No | | `get_agent_config_schema` | Every configuration key an agent accepts, with allowed values, defaults, what happens when a key is omitted, and a minimal working example | Read | `bots:read` | No | | `validate_agent_config` | Check a config you assembled and get one finding per problem. Writes nothing | Read | `bots:read` | No | | `list_agent_tool_catalog` | The agent-tool names that go in the config's `tools` list, with what each does and the channels it cannot run on | Read | `bots:read` | No | | `list_agent_skills` | The skill names that go in the config's `skills` list, with the tools each bundles and a preview of the instructions it injects. Chat channels only | Read | `bots:read` | No | | `create_agent` | Create a voice or WhatsApp agent. Active immediately | Write | `bots:write` | No | | `update_agent` | Rename an agent, or replace its instructions with one flat block | Write | `bots:write` | No | | `update_agent_prompt` | Change the instructions section by section, so the objective / guidelines / script structure survives the write | Write | `bots:write` | No | | `update_agent_config` | Change configuration keys in place — set some, remove others, leave the rest alone | Write | `bots:write` | No | | `toggle_agent` | Flip an agent between active and paused | Write | `bots:write` | No | | `add_agent_knowledge` | Add a document to one agent's knowledge base | Write | `knowledge:write` | No | | `knowledge_search` | Search the knowledge base and get the passages that match | Read | `knowledge:read` | No | | `add_agent_memory` | Store a durable fact for one agent, applied on every future conversation | Write | `bots:write` | No | | `list_agent_custom_tools` | List the custom HTTP tools this account has defined. Stored credentials are never returned | Read | `bots:read` | No | | `create_agent_custom_tool` | Define an HTTP endpoint an agent may call mid-conversation | Write | `bots:write` | No | | `update_agent_custom_tool` | Change a custom HTTP tool. Omitted fields, including secrets, are kept | Write | `bots:write` | No | | `test_agent_custom_tool` | Run a saved custom tool once. This **really calls** the endpoint with its stored credentials | Write | `bots:write` | No | | `delete_agent_custom_tool` | **Permanently** delete a custom tool and its stored credentials, for every agent that uses it | Write, destructive | `bots:write` | No | | `set_agent_voicemail_drop` | Make a calling agent leave a saved voicemail message when a machine answers | Write | `bots:write` | No | | `draft_agent_from_description` | Describe the agent you want and get a complete, reviewable draft back. Creates nothing | Write | `squads:write` | Yes | #### Building an agent with these tools `create_agent` is not the first call. An agent's `config` is a free-form object whose keys are mostly not checked when written — a wrong value is stored and then fails on the call, usually without an error. So the order is: 1. `get_agent_config_schema` — the keys this agent type accepts, and a `minimal_example` that works. `draft_agent_from_description` is the shortcut past the blank page: describe the agent and review what comes back. It creates nothing, so whatever it proposes still goes through the steps below. 2. `validate_agent_config` — fix every `error` finding before writing anything. 3. `create_agent`, then `add_agent_knowledge`. 4. For a voice agent: `search_available_numbers` → `provision_phone_number` → `attach_number_to_agent`, then `place_call` and `get_voice_session_transcript` to hear the result. 5. `update_agent_config` to change one key later; it merges, so it cannot wipe the rest of the configuration. 6. `craft_agent_prompt` → `update_agent_prompt` to write or improve the instructions. `craft_agent_prompt` returns the structure, the phone-call rules and the agent's real tools and variables; write the four sections, pass them back as `draft` until the review has no `error` findings, then save. An agent's prompt is edited in four boxes — objective, response guidelines, conversation script and first message — and `update_agent`'s flat `system_prompt` collapses them into one, silently. The pair above is what keeps them. The same flow over plain HTTP, with curl for every step and the five ways a config silently misbehaves, is on [Build an Agent](/docs/build-an-agent). #### More agents and knowledge tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_knowledge_sources` | List the documents, URLs and facts in this account's knowledge base, optionally for one agent, with their ingest status | Read | `knowledge:read` | No | | `add_agent_knowledge_url` | Fetch a public web page and add its text to one agent's knowledge base | Write | `knowledge:write` | Yes | | `list_agent_memories` | List the durable facts one agent has been taught, newest first | Read | `bots:read` | No | | `list_agent_squads` | List squads: groups of agents that hand a caller to one another by role | Read | `squads:read` | No | | `get_agent_squad` | Fetch a squad with its handoff policy and full member roster | Read | `squads:read` | No | | `create_agent_squad` | Create a squad whose entry agent answers the call; the entry agent is enrolled as its first member | Write | `squads:write` | No | | `update_agent_squad` | Change a squad's name, description, entry agent (must already be a member), handoff policy, or active flag | Write | `squads:write` | No | | `add_squad_member` | Put an agent on a squad's roster under a role; its description is what routing matches callers against | Write | `squads:write` | No | | `update_squad_member` | Change a squad member's role, description or position | Write | `squads:write` | No | | `remove_squad_member` | Take a member off a squad's roster; the entry agent cannot be removed | Write, destructive | `squads:write` | No | | `simulate_squad_handoff` | Dry-run a squad's routing: given what a caller said, which member would take the call and why | Read | `squads:read` | No | ### Usage and credits | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_credit_balance` | Check how many credits are left on the account | Read | Any valid key | No | | `get_usage_summary` | Summarise recent spend in US$, broken down by service, model and day, with prompt-cache reads and writes counted separately | Read | `usage:read` | No | ### Platform configuration Webhooks, integrations, managed prompts and agent versions. Every webhook tool — including the two that only read — needs `webhooks:write`, because a delivery row carries customer conversation data and there is no `webhooks:read` scope to grant. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_webhooks` | List webhook subscriptions with the events each receives. Signing secrets come back masked | Read | `webhooks:write` | No | | `create_webhook` | Subscribe an HTTPS endpoint to account events. Returns the signing secret in full, once | Write | `webhooks:write` | No | | `update_webhook` | Change a subscription's URL, events, agent scope or active flag. `is_active: false` pauses it | Write | `webhooks:write` | No | | `test_webhook` | Send a real signed POST to the configured endpoint and report its status code and latency | Write | `webhooks:write` | No | | `list_webhook_deliveries` | Delivery attempts for one webhook, with status code, attempts and error | Read | `webhooks:write` | No | | `get_webhook_delivery` | One attempt in full, including the JSON payload that was sent | Read | `webhooks:write` | No | | `replay_webhook_delivery` | Re-send a past payload to the current endpoint. Your server really receives it again | Write, destructive | `webhooks:write` | No | | `list_integrations` | The external services connected to the account. Stored credentials are never returned | Read | `integrations:read` | No | | `list_integration_catalog` | What can be connected, annotated with what already is | Read | `integrations:read` | No | | `start_integration_oauth` | Return a console link that connects a sign-in (OAuth) service to the workspace of the person who opens it | Write | `integrations:write` | No | | `list_prompts` | The managed prompts on the account, with each one's current version | Read | `prompts:read` | No | | `create_prompt` | Create a managed prompt. Starts empty — add the text as a version | Write | `prompts:write` | No | | `list_prompt_versions` | The revision log: version, label, pinned model, declared variables. Template text omitted | Read | `prompts:read` | No | | `get_prompt_version` | One version in full, with its template, variables and pinned model and parameters | Read | `prompts:read` | No | | `create_prompt_version` | Append a revision. Nothing live changes until a label points at it | Write | `prompts:write` | No | | `set_prompt_label` | Point a label such as `production` at a version. This is how a prompt is published | Write, destructive | `prompts:write` | No | | `render_prompt` | Substitute variables and get the text back, plus any you did not supply. No model call | Read | `prompts:read` | No | | `list_prompt_presets` | Saved presets: a named model plus pinned parameters | Read | `prompts:read` | No | | `create_prompt_preset` | Save a model and parameters under a name, optionally bound to a prompt version | Write | `prompts:write` | No | | `list_agent_versions` | An agent's version history with label, status and commit message. Bodies omitted | Read | `bots:read` | No | | `get_agent_version` | One version in full. Stored channel credentials are redacted | Read | `bots:read` | No | | `create_agent_version` | Stage a draft from what is live now. Callers are unaffected until it is published | Write | `bots:write` | No | | `update_agent_version` | Edit the staged draft. Published versions are immutable | Write | `bots:write` | No | | `publish_agent_version` | Publish the draft onto the live agent. The next caller hears the new behaviour | Write, destructive | `bots:write` | No | | `rollback_agent_version` | Put an older version back on the live agent. Appends rather than rewriting history | Write, destructive | `bots:write` | No | `test_webhook` and `replay_webhook_delivery` both make a real outbound HTTP request to **your own** endpoint, so whatever your server does on that event happens for real — and a replay makes it happen a second time. Neither takes a URL: they use the one already stored on the subscription, which is re-checked against private and internal address ranges on every attempt. `start_integration_oauth` hands back a console link for the account owner to open. They sign in to CallMissed and approve access there, and the account is connected to their own workspace; nothing is connected until they do. Confirm it with `get_integration_status`, and disconnect with `disconnect_integration` (see **Connected platforms**). There is no tool that writes a provider credential. #### More platform configuration tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `update_prompt` | Change a prompt's name or description | Write | `prompts:write` | No | | `update_prompt_preset` | Change a preset's name, model, default params or pinned prompt version | Write | `prompts:write` | No | | `set_integration_spreadsheets` | Replace the list of spreadsheets agents may use through a Google Sheets connection | Write | `integrations:write` | No | | `list_usage_logs` | Row-level log of this account's API calls, newest first: service, model, status, latency, token counts (prompt-cache reads and writes separate) and cost in US$ per call | Read | `usage:read` | No | | `list_models` | The public model catalogue with pricing, context window, feature flags and `processing` (where each model is processed: `india`, `india_preferred` or `outside_india`) | Read | None | No | | `get_model` | Pricing, context window, capabilities and `processing` of one model id from list_models | Read | None | No | | `get_platform_status` | Current health of each CallMissed API service, as shown on the public status page | Read | None | No | | `get_task` | Fetch one task in full | Read | `crm_tasks:read` | No | ### Testing and quality | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_eval_suites` | Evaluation suites on the account, with the agent each tests and its scorecard | Read | `evals:read` | No | | `create_eval_suite` | Create a suite of synthetic conversations for one agent | Write | `evals:write` | No | | `list_eval_cases` | The cases in a suite: persona, opening line, turn budget, success criteria | Read | `evals:read` | No | | `create_eval_case` | Add one synthetic conversation to a suite | Write | `evals:write` | No | | `create_eval_case_from_session` | Turn a real voice session into a test case: its opening, the caller's goal, and suggested outcome checks. The key also needs the stt, tts and llm permissions | Write | `evals:write` | No | | `run_eval_suite` | Run every case against the agent and report what passed. Takes minutes on a big suite | Write | `evals:write` | Yes | | `list_eval_runs` | Run history with pass counts, model and credits spent | Read | `evals:read` | No | | `get_eval_run` | One run in full, including every synthetic transcript | Read | `evals:read` | No | | `list_experiments` | A/B experiments with status, deciding metric and traffic split | Read | `experiments:read` | No | | `create_experiment` | Create an experiment on one agent. Starts as a draft taking no traffic | Write | `experiments:write` | No | | `create_experiment_arm` | Add a configuration to compare: an agent version plus optional overrides | Write | `experiments:write` | No | | `start_experiment` | Put it live. Real callers are split across the arms from this moment | Write, destructive | `experiments:write` | No | | `stop_experiment` | Stop splitting traffic. Everything already collected is kept | Write | `experiments:write` | No | | `conclude_experiment` | Record the winner and close it. Final — it cannot be restarted or changed | Write, destructive | `experiments:write` | No | | `get_experiment_results` | Per-arm sample sizes and the deciding metric, with a plain-language verdict | Read | `experiments:read` | No | | `list_voice_alerts` | Metric alerts with their thresholds, windows and whether each is firing | Read | `webhooks:write` | No | | `create_voice_alert` | Be notified when a call metric crosses a threshold over a rolling window | Write | `webhooks:write` | No | | `update_voice_alert` | Change a threshold, window, channel or cooldown, or silence an alert | Write | `webhooks:write` | No | `run_eval_suite` is the one tool here that spends: each case is a real model conversation of up to its turn budget, and a suite with a scorecard adds a grading call per case. No real caller is ever contacted — the caller is synthetic. The balance is checked before anything runs, so a refusal costs nothing. Metric alerts use `webhooks:write` rather than a scope of their own. An alert is a notification subscription that sends mail and fires webhooks, which is exactly what that scope already governs. #### More testing and quality tools | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `update_eval_suite` | Rename a suite, change its description or grading scorecard, or turn it on or off | Write | `evals:write` | No | | `update_eval_case` | Change one case's name, persona, opening line, turn budget, success criteria or position | Write | `evals:write` | No | | `get_experiment` | Fetch an experiment with its status, metric, traffic split and arms | Read | `experiments:read` | No | | `update_experiment` | Change an experiment's name, hypothesis, metric (only while a draft) or traffic split | Write | `experiments:write` | No | | `update_experiment_arm` | Change an arm's name, agent version, overrides or control flag | Write | `experiments:write` | No | | `list_scorecards` | List the rubrics used to grade calls and eval runs | Read | `conversations:read` | No | | `create_scorecard` | Create a grading rubric: up to 20 criteria, each a unique label, a relative weight (0-100] and optional guidance | Write | `conversations:write` | No | | `update_scorecard` | Replace a rubric's name and full criteria list; scores already taken keep their old breakdown | Write | `conversations:write` | No | ### Connected platforms Check, connect and disconnect the channels and services the account runs on, without opening the console. Listing and re-pointing WhatsApp numbers, Facebook Pages and Instagram accounts is in the Messaging and Facebook and Instagram groups; adding knowledge is in Agents and knowledge. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_integration_status` | Whether an integration is connected, and to which account. Call it after the person approves the `start_integration_oauth` link | Read | `integrations:read` | No | | `disconnect_integration` | Disconnect an integration and discard its stored credentials. Reconnect with `start_integration_oauth` | Write, destructive | `integrations:write` | No | | `start_channel_connection` | The link that connects a WhatsApp Business number, Facebook Page, Instagram account or personal WhatsApp number, with the steps to follow | Read | `whatsapp:write` | No | | `list_whatsapp_accounts` | Connected WhatsApp Business accounts | Read | `whatsapp:read` | No | | `get_whatsapp_number` | One WhatsApp number's live status | Read | `whatsapp:read` | No | | `disconnect_whatsapp_number` | Deregister a WhatsApp number from this workspace. History is kept | Write, destructive | `whatsapp:write` | No | | `get_knowledge_source` | One knowledge source's status, including the error if indexing failed | Read | `knowledge:read` | No | | `remove_knowledge_source` | Remove a source from an agent's knowledge | Write, destructive | `knowledge:write` | No | | `list_payment_requests` | Payment links your agents sent to customers during calls and chats, with amount, amount paid and status. The money goes to your own connected Razorpay account | Read | `integrations:read` | No | Two steps cannot happen inside a chat: Meta's own sign-in window for WhatsApp Business, Facebook and Instagram, and the QR scan that links a personal WhatsApp number. `start_channel_connection` returns the one page that runs that step, and `list_whatsapp_numbers`, `list_facebook_pages` or `list_instagram_accounts` confirms it worked. No tool accepts an access token or provider key. The disconnect and remove tools are marked destructive, so your client asks before running them. None of them deletes conversation history. ### Facebook and Instagram Facebook Pages and Instagram accounts: publish and schedule posts, answer comments and DMs, read analytics. Posting, comments and DMs are not billed. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_facebook_pages` | List your connected Facebook Pages with their ids and linked agent | Read | `whatsapp:read` | No | | `list_instagram_accounts` | List your connected Instagram professional accounts with their ids and linked agent | Read | `whatsapp:read` | No | | `set_social_account_agent` | Choose which agent auto-replies to DMs on a Facebook Page or Instagram account; null unlinks it | Write | `whatsapp:write` | No | | `publish_facebook_post` | Publish to a Facebook Page: text, a link, photos by URL, or a Reel from a video URL | Write | `whatsapp:send` | No | | `publish_instagram_post` | Publish an image, video, Reel, story or carousel from public media URLs | Write | `whatsapp:send` | No | | `get_instagram_container_status` | Check whether Instagram has finished processing a post's media | Read | `whatsapp:send` | No | | `publish_instagram_container` | Publish a processed media container once its status is FINISHED | Write | `whatsapp:send` | No | | `get_instagram_publishing_limit` | How many posts the account has published in the last 24 hours against Instagram's cap | Read | `whatsapp:send` | No | | `generate_social_post` | Draft a ready-to-publish post: a caption, hashtags and, unless generate_image is false, an image link. Not available with India-only processing (`region_unavailable`) | Write | None | Yes | | `list_social_comments` | List comments on a Facebook post or Instagram media | Read | `whatsapp:read` | No | | `list_social_comment_replies` | List the replies under one comment | Read | `whatsapp:read` | No | | `reply_to_social_comment` | Post a public reply to a Facebook or Instagram comment as the Page or account | Write | `whatsapp:send` | No | | `hide_social_comment` | Hide a comment from everyone but its author, or unhide it | Write | `whatsapp:send` | No | | `list_social_conversations` | List Facebook Messenger or Instagram DM threads, most recent first | Read | `whatsapp:read` | No | | `get_social_conversation_messages` | Read the messages in one Facebook or Instagram DM thread | Read | `whatsapp:read` | No | | `send_social_message` | Send a text reply into an existing Facebook Messenger or Instagram DM thread; Meta only allows it within its reply window | Write | `whatsapp:send` | No | | `get_social_analytics` | Read Facebook or Instagram DM analytics: the conversation funnel or a daily time series | Read | `whatsapp:read` | No | ### Email Domains, templates, suppressions, inbound addresses and send history for the Email API. Every tool needs a key with the `email` permission. | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `list_email_domains` | List your sending domains and whether each is verified | Read | `email` permission | No | | `add_email_domain` | Add a sending domain | Write | `email` permission | No | | `get_email_domain_records` | List the DNS records a domain needs and whether each is found | Read | `email` permission | No | | `verify_email_domain` | Check a domain's DNS records now and mark it verified if they pass | Write | `email` permission | No | | `list_email_sends` | List recent sends with their delivery status | Read | `email` permission | No | | `get_email_usage` | Read email send volume and spend for the last 30 days | Read | `email` permission | No | | `list_email_suppressions` | List addresses that will not be emailed (bounces, complaints, manual) | Read | `email` permission | No | | `add_email_suppression` | Stop all future email to an address | Write | `email` permission | No | | `list_inbound_email_addresses` | List the addresses that receive email on your verified domains | Read | `email` permission | No | | `create_inbound_email_address` | Start receiving email at an address on a verified domain, optionally forwarding each message to a webhook URL | Write | `email` permission | No | | `list_inbound_emails` | List recently received emails, newest first | Read | `email` permission | No | | `list_email_templates` | List your saved email templates | Read | `email` permission | No | | `create_email_template` | Save a reusable email template | Write | `email` permission | No | | `update_email_template` | Change an email template; only the fields given change | Write | `email` permission | No | | `get_scheduled_email` | Look up scheduled sends by the send id or batchId a send returned | Read | `email` permission | No | | `cancel_scheduled_email` | Cancel every still-pending send for a send id or batchId | Write, destructive | `email` permission | No | ### Guides and playbooks Step-by-step playbooks an assistant follows before it builds anything: it interviews you about the use case first. The same playbooks are served as MCP prompts (`build_voice_agent`, `build_whatsapp_agent`, `launch_campaign`, `setup_support_desk`, `setup_crm`). | Tool | What it does | Access | Scope or permission | Credits | | --- | --- | --- | --- | --- | | `get_playbook` | Get a step-by-step playbook | Read | None | No | ### Calling tools are deployment-gated 35 of the 342 tools are gated on whether calling is switched on for your account, in two independent groups. **22 telephony tools:** `place_call`, `list_calls`, `get_call`, `end_call`, `get_call_recording`, `click_to_call`, `list_phone_numbers`, `search_available_numbers`, `list_number_countries`, `get_number_requirements`, `provision_phone_number`, `attach_number_to_agent`, the four voicemail-message tools, `set_agent_voicemail_drop`, `get_number_reputation`, `update_number_call_settings`, `declare_number_for_ai_calls`, `list_telephony_compliance` and `callback_handoff`. With telephony off, `tools/list` returns the other 320. **13 campaign tools:** the whole **Calling campaigns** table above. With campaigns off, `tools/list` returns the other 327. The flow, menu and queue tools are **not** in either group. Call routing is authored before calling is switched on, so those routes are always mounted and those tools are always served. Each group appears only where that feature is switched on. Where neither is, `tools/list` returns the other 307 and those 33 are simply absent, because the REST routes behind them are not mounted there. Calling one by name anyway returns the same `Unknown tool` error as a typo. If you do not see them and you expect to, talk to us and we will get calling turned on for you. ## Public catalog ``` GET https://api.callmissed.com/api/v1/mcp/catalog ``` Unauthenticated, no key needed. It returns the live tool list this deployment serves, which is what the tables above are built from, so you can check what is available before you wire anything up. ```bash curl https://api.callmissed.com/api/v1/mcp/catalog ``` ```json { "serverUrl": "https://api.callmissed.com/api/v1/mcp", "protocolVersion": "2025-06-18", "count": 342, "categories": { "voice": "Voice and calls", "crm": "CRM" }, "tools": [ { "name": "list_contacts", "title": "List contacts", "description": "List contacts in your address book, newest first...", "category": "crm", "categoryLabel": "CRM", "readOnly": true, "destructive": false, "costsCredits": false, "scope": "contacts:read" } ] } ``` It carries declarations only: no argument schemas, nothing about the caller, nothing about tools this deployment has gated off. For the JSON Schema of a tool's arguments, call `tools/list` with your key. ## Interactive views Some results render as an interactive view instead of a wall of JSON, using the official MCP Apps extension (`io.modelcontextprotocol/ui`). Each view is one self-contained HTML document served over `resources/read`; a tool points at it through `_meta.ui.resourceUri` in its declaration. | View | Shows | Tools that use it | | --- | --- | --- | | `ui://callmissed/call-log` | Calls with status, duration and cost | `list_calls` | | `ui://callmissed/transcript` | A call's turns, caller and agent side by side | `get_voice_session_transcript` | | `ui://callmissed/inbox` | Conversations with previews and unread counts | `list_conversations`, `list_handoffs` | | `ui://callmissed/timeline` | A contact's history in one stream | `crm_timeline` | | `ui://callmissed/image-gallery` | Generated images | `generate_image`, `list_generated_images` | So five views across seven tools, or six tools where telephony is off. **Where they render.** claude.ai, Claude Desktop, Claude mobile and Claude Cowork render MCP Apps, as do ChatGPT, VS Code Copilot and Cursor. The **Claude Code terminal does not**: it shows the text result instead. That is not a degraded mode. Every tool returns its full data as text and as `structuredContent` whether or not a view exists, so a client that ignores the extension loses nothing but the pictures. ## Connect a client If your client supports signing in, add the server by URL alone and click **Connect**: no header, no key. See [option 1](#option-1-sign-in-with-your-callmissed-account) above. Otherwise, most MCP clients accept a remote server as a URL plus a header. In Claude Code: ```bash claude mcp add --transport http callmissed \ https://api.callmissed.com/api/v1/mcp \ --header "Authorization: Bearer cm_your_api_key" ``` For clients configured by file, the shape is usually: ```json { "mcpServers": { "callmissed": { "type": "http", "url": "https://api.callmissed.com/api/v1/mcp", "headers": { "Authorization": "Bearer cm_your_api_key" } } } } ``` Keep the key out of version control. Reference an environment variable if your client supports it. ## Call it directly The endpoint is plain JSON-RPC, so you can drive it with `curl`. List the tools your key can reach: ```bash curl https://api.callmissed.com/api/v1/mcp \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' ``` Call one: ```bash curl https://api.callmissed.com/api/v1/mcp \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list_contacts", "arguments": { "q": "jane", "limit": 10 } } }' ``` A successful call returns the data twice, once as `structuredContent` and once serialized into a text block, so clients that only read one shape still work: ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "[{\"id\":\"...\",\"name\":\"Jane\"}]" }], "structuredContent": { "result": [{ "id": "...", "name": "Jane" }] }, "isError": false } } ``` ## Errors Two different shapes, matching the protocol. **A malformed request**, meaning an unknown tool, a missing argument, or a bad method, comes back as a JSON-RPC `error`: ```json { "jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "Unknown tool: send_sms" } } ``` **A refused action**, meaning a missing scope, no credits, or a closed messaging window, is a successful result carrying `isError`, so the agent can read the reason and adapt instead of treating it as a crash: ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "API key missing required scope: whatsapp:send." }], "isError": true } } ``` When a tool calls the API and the API answers with an error code, the code follows the message in brackets, for example `Model 'flux-2-pro' is not available with India-only processing. … (code: region_unavailable)`, so the agent can tell a policy refusal from a temporary failure. Authentication failures happen before any tool runs and use normal HTTP status codes: `401` for a missing or malformed credential, a dashboard login token, or a connection that has been revoked; `402` when the key is out of budget; `429` when it is over its rate limit. Every `401` from this endpoint carries a `WWW-Authenticate` header pointing at the protected-resource document, including the very first call a client makes with no credential at all. That is what lets a client offer "Connect and sign in" instead of asking you to paste a key. ## Protocol version The server implements MCP revision `2025-11-25` and also accepts `2025-06-18` and `2025-03-26`. Send the version you speak on every call after initializing: ``` MCP-Protocol-Version: 2025-06-18 ``` Omit the header and the server assumes `2025-03-26`, per the specification. Send a version it does not support and the call returns `400`. Batched requests are not accepted, because the `2025-06-18` revision removed them, so send one request object per POST. --- ### API Speed: Best Practices URL: /docs/api-speed > How to make CallMissed API calls feel as fast as the playground — streaming, model choice, connection reuse, and Kimi instant mode. > **About the numbers on this page.** [Inference] Latency and throughput figures here (token rates, time-to-first-byte, per-call timings) are representative measurements taken under specific conditions — small prompts, warm connections, a given region and provider. They illustrate *relative* differences between settings; they are not SLAs or guarantees and will vary with prompt size, model, upstream load, and your network. AI behavior is not guaranteed and may vary. ## Why the playground feels faster A single playground call and a typical API call hit the **exact same endpoint** at `https://api.callmissed.com/v1/chat/completions`. When the API feels slower, three compounding factors are usually at work: | Setting | Playground default | Common API default | Effect | | --- | --- | --- | --- | | Streaming | `stream: true` | `stream: false` | Non-streaming waits for the *whole* generation before any byte returns | | Model | `gpt-oss-120b` (fast free model) | `gpt-6-sol` | Bigger model → 2-3× wall-clock for the same prompt | | Connection | One persistent HTTP/2 connection | New TCP+TLS per call | Adds ~150-300ms handshake to every request | Same endpoint, very different perceived speed. Below: how to close the gap. ## 1. Use stream: true With `stream: false` your client waits for the full generation. With `stream: true` the first byte typically arrives in well under a second — often around 100ms on fast models — and tokens flow as the model produces them instead of all at the end. ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1", ) stream = client.chat.completions.create( model="gpt-oss-120b", messages=[{"role": "user", "content": "Explain quicksort in one paragraph."}], stream=True, ) for chunk in stream: delta = chunk.choices[0].delta.content or "" print(delta, end="", flush=True) ``` ```ts [TypeScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Explain quicksort in one paragraph." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` ## 2. Pick the right model Same prompt, three different routes. The first-token and total times below are **indicative only**, not a dated benchmark: they were carried over unchanged when the model list was last revised, so they have not been measured against the models now named. Use them for rough shape and measure your own prompt size and region before designing around them. | Model | Route | TTFB | Total | | --- | --- | --- | --- | | `gpt-oss-120b` | Direct-routed | ~50ms | ~1.5s | | `kimi-k2.6` | Direct-routed | ~90ms | ~2.0s | | `gpt-5.6-sol` | First-party flagship | ~70ms | ~4.8s | | `gpt-5.6-luna` | First-party fast | ~80ms | ~2.0s | For latency-sensitive integrations (autocomplete, agent tool loops), prefer **`gpt-oss-120b`** or **`kimi-k2.6`** — both direct-routed, both OpenAI-compatible, both sub-2s on small prompts. ## 3. Reasoning effort by model Reasoning models can spend 100+ tokens "thinking" before producing visible content. On short answers, that's both a wall-clock and a credit cost the user never sees. Use `reasoning_effort` to dial it down — or off, where supported. ```json { "model": "kimi-k2.6", "messages": [{ "role": "user", "content": "What is 2+2?" }], "reasoning_effort": "none" } ``` Behaviour per model, as the gateway applies it. Each model's accepted values come from its provider's documentation and, where the documentation is silent, live probes: | Model | `"none"` | `"low"` | `"medium"` | `"high"` | `"xhigh"` | `"minimal"` | | --- | --- | --- | --- | --- | --- | --- | | `gpt-6-sol`, `gpt-6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-6.1-sol` | ↓ `"low"` | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` | ↓ `"low"` | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5.5` | ✅ off | ✅ | ✅ | ✅ | ✅ | ↓ `"low"` | | `gpt-5-mini` | ↓ `"minimal"` | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ | | `gpt-4o`, `gpt-4.1`, `grok-4.3` | ⊘ | ⊘ | ⊘ | ⊘ | ⊘ | ⊘ | | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.1-pro-preview` | ↓ `"low"` | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"low"` | | `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-flash-lite` | ↓ `"minimal"` | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ | | `kimi-k2.5`, `kimi-k2.6` | ✅ off | ✅ | ✅ | ✅ | ↓ `"max"` | ↓ `"none"` | | `kimi-k2.7-code` | ⊘ | ✅ | ✅ | ✅ | ↓ `"max"` | ⊘ | | `kimi-k3.1-pro` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` | | `gpt-oss-120b` | ↓ `"low"` | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"low"` | | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | ✅ off | ↓ on | ↓ on | ↓ on | ↓ on | ↓ on | | `sarvam-105b`, `sarvam-105b-conversations` | ✅ off | ✅ | ⊘ default | ✅ | ↓ `"max"` | ✅ off | | `glm-5.3` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` | | `deepseek-v4.1-flash` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` | | `glm-5.3-flash` | ↓ `"low"` | ✅ | ↓ `"high"` | ✅ | ↓ `"max"` | ↓ `"low"` | | `deepseek-v4.1-flash-1017` | ✅ off | ✅ | ✅ | ✅ | ✅ | ✅ | | `gemma-4-31b` | ✅ off | ✅ | ✅ | ✅ | ↓ `"high"` | ↓ `"none"` | | `deepgram-think-fast` | ✅ off | ✅ | ✅ | ✅ | ↓ `"high"` | ✅ | | `deepgram-phonellm-mini` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | ↓ `"none"` | Legend: ✅ = sent to the model as-is · ✅ off = thinking is switched off · ↓ = mapped to the listed value before forwarding · ⊘ = dropped from the request; the model runs at its default thinking behaviour and the request still succeeds. Notes worth reading before you rely on a value: - `kimi-k2.5`, `kimi-k2.6` and `kimi-k2.7-code` accept `"none"`, `"low"`, `"medium"`, `"high"` and `"max"`; `"xhigh"` runs as `"max"`. `"minimal"` turns thinking off on K2.5 and K2.6. `kimi-k2.7-code` always thinks, so `"none"` and `"minimal"` are dropped. - `kimi-k3.1-pro` always thinks; thinking cannot be switched off. `"low"`, `"high"` and `"max"` are sent as-is. `"none"` and `"minimal"` run as `"low"`, `"medium"` as `"high"` and `"xhigh"` as `"max"`. Send `"low"` for the fastest replies. - `DeepSeek-V4-Pro` and `DeepSeek-V4-Flash` have an on/off switch, not levels: `"none"` turns thinking off and any other value turns it on. Without `reasoning_effort` they think and return the trace in `reasoning_content`. - `gpt-oss-120b` has three levels, `"low"`, `"medium"` and `"high"`. `"none"` and `"minimal"` run as `"low"` (it has no full off switch); `"xhigh"` and `"max"` run as `"high"`. - The retired `glm-4.7-flash`, `glm-5.2`, `nemotron-3-super`, `gemma-4-26b-a4b-it` and `mistral-small-3.1` IDs follow the row of the model that answers them (`gpt-oss-120b`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2.5`). - `sarvam-105b` and `sarvam-105b-conversations` switch thinking fully off for `"none"` and `"minimal"`. Their levels are `"low"`, `"high"` and `"max"`; `"medium"` is not one of them, so it is dropped and the model runs at its own default. When you omit `max_tokens` on a Sarvam model we send 4,096 (16,384 for `glm-5.3`), because thinking tokens count toward the limit and the upstream default of 2,048 cut answers short. - `deepseek-v4.1-flash` always reasons and defaults to its highest level, `"max"`. Its levels are `"low"`, `"high"` and `"max"`, mapped as in the table. Reasoning tokens share the `max_tokens` budget, so raise `max_tokens` or lower the effort if answers come back short. - `glm-5.3-flash` always reasons and cannot be switched off. Its levels are `"low"`, `"high"` and `"max"`, mapped as in the table, and it runs at `"high"` when you omit `reasoning_effort`. In our tests with `"low"`, the first token arrived in about 0.4 to 0.5s. - `deepseek-v4.1-flash-1017` reasons at `"high"` by default, and `"none"` switches thinking off (first token in about 0.25 to 0.6s in our tests). Every value is sent as-is: upstream runs `"minimal"` as `"low"`, `"medium"` and `"xhigh"` as `"high"`, and `"max"` as `"max"`. - `glm-5.3` always reasons and defaults to its highest level, `"max"`. Send `"low"` when latency matters: in our tests it cut time to first word from about 3.5s to about 0.9s. `gemma-4-31b` does not think unless asked. Any of `"low"`, `"medium"` or `"high"` switches thinking on (in our tests it did not track the level), and `"none"` or `"minimal"` keep it off at about 0.25s to first word. - `deepgram-think-fast` accepts `"none"` (the default, thinking off), `"minimal"`, `"low"`, `"medium"` and `"high"`; `"xhigh"` and `"max"` run as `"high"`. The trace comes back as `reasoning_content`. In our tests every level above `"none"` took 2 to 5 seconds to answer, against about 0.3 seconds with `"none"`, so keep it off for voice. `deepgram-phonellm-mini` does not reason: every value is sent as `"none"`, because otherwise it would put its reasoning in the spoken answer. - `gpt-4o`, `gpt-4.1` and `grok-4.3` take no reasoning control through this API. Every value is dropped and the request still returns 200. - The `gemini-*` models map `reasoning_effort` onto their own thinking levels. They have no full off switch: `"none"` gives the lowest level the model accepts. - The GPT-5.5 / GPT-5.6 / GPT-6 family accepts the full `none`/`low`/`medium`/`high`/`xhigh` ladder. `"minimal"` is rejected upstream, so we map it to `"low"`. Send `xhigh`, not `max` or `ultra`. `gpt-5-mini` is an original GPT-5 model: its ladder is `minimal`/`low`/`medium`/`high`, so `"none"` is sent as `"minimal"` and `"xhigh"` as `"high"`. - On `gpt-6-sol` and `gpt-6-luna`, `temperature` and `top_p` are applied only when `reasoning_effort` is `"none"`; with any other effort they are dropped, because the model accepts only the default sampling while it reasons. - On the GPT-5.6 and GPT-6 models, a request that includes `tools` always runs with `reasoning_effort: "none"`, whatever you send — those models cannot combine function tools with reasoning on Chat Completions. - `gpt-6.1-sol` always reasons: it accepts `low`/`medium`/`high`/`xhigh` (default `medium`) and cannot switch thinking off, so `"none"` and `"minimal"` are sent as `"low"` and `"max"` is sent as `"xhigh"`. - `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5` (Pro plan and up) accept `low`, `medium`, `high`, `xhigh` and `max`; `"none"` and `"minimal"` are accepted and run as `"low"`. Defaults when you omit it: `medium` on `claude-opus-5-5` and `claude-haiku-5-5`, `high` on `claude-sonnet-5-5`. `temperature`, `top_p` and `top_k` are ignored on these models. Tools can be combined with reasoning (see [function calling](/docs/chat-function-calling)). Concrete numbers on `kimi-k2.6` answering "What is 2+2?": | Mode | Answer | Completion tokens | | --- | --- | --- | | Default (thinking on) | `2 + 2 = 4` | 101 | | `reasoning_effort: "none"` | `2 + 2 = 4` | **9** | Same answer, **11× fewer tokens** and a proportionally shorter wall-clock. Pass per-request based on whether you need the trace. ## 4. Reuse the HTTP connection CallMissed serves HTTP/2. A persistent connection multiplexes many requests with no extra TLS handshake per call. Most modern HTTP clients do this *if you reuse the same client instance*: ```python [Python] # Bad — new connection per call (~150-300ms TLS overhead each time) def ask(prompt): return OpenAI(api_key=K, base_url=URL).chat.completions.create(...) # Good — reuse one client (and its underlying connection pool) client = OpenAI(api_key=K, base_url=URL) def ask(prompt): return client.chat.completions.create(...) ``` ```ts [TypeScript] // Bad async function ask(prompt: string) { const c = new OpenAI({ apiKey: K, baseURL: URL }); return c.chat.completions.create(...); } // Good — module-scoped client const client = new OpenAI({ apiKey: K, baseURL: URL }); async function ask(prompt: string) { return client.chat.completions.create(...); } ``` Three back-to-back streaming calls on a reused HTTP/2 connection land in the region of **400-460ms each** for a small prompt, again indicative rather than a dated measurement; the first call without reuse pays an extra TLS handshake on top. ## Checklist Before reporting "the API feels slow," verify: - [ ] `stream: true` is set on every chat completion request - [ ] Model is one of `gpt-oss-120b`, `kimi-k2.6`, `gpt-6-luna`, or `DeepSeek-V4-Flash` (avoid the 1M-context flagships for latency-bound paths) - [ ] For Kimi: `reasoning_effort: "none"` is passed when you don't need the reasoning trace - [ ] One `OpenAI()` / `Anthropic()` client instance is shared across requests - [ ] HTTP client supports HTTP/2 (the official OpenAI / Anthropic SDKs do) If all four are checked and you still see latency that doesn't match the playground, share the request and the upstream model — there are very few request shapes that beat playground default for the same model. --- ### Connect Your Store URL: /docs/connect-your-store > Let a WhatsApp or voice agent answer from your real catalogue and order data instead of guessing. Connect Shopify or WooCommerce in a click, or point the agent at your own backend with no code. :::cards /docs/agent-tools-mcp | Account MCP Server | wrench | Give an external AI agent tools that act on your account /docs/whatsapp-setup | WhatsApp Setup | message-circle | Connect a number and go live ::: ## Overview An agent with no access to your store can only talk in generalities. It will invent a price, promise stock it cannot see, and guess at where an order is. Connecting your store fixes that: the agent looks the answer up at the moment it replies. There are three routes, and they all end in the same place. Pick the one that matches your site: | Your site | Route | What it takes | | --- | --- | --- | | Shopify | Built-in connector | Sign in, grant access | | WooCommerce / WordPress | Built-in connector | Paste a read-only REST key | | Anything else | Custom tools | Expose a few read-only endpoints, then point the agent at them | All three work for **WhatsApp and voice agents alike**, with one exception noted under [sending product photos](#send-product-photos). ## Shopify 1. Open **Integrations** on your agent. 2. Choose **Shopify**, then **Connect**. You are taken to Shopify to sign in and grant access, then brought back. 3. The Shopify tools are switched on for that agent automatically. The agent can then look up an order's payment and fulfilment status with tracking, search products for price and stock, find a customer, and start a draft order. ## WooCommerce WooCommerce uses a read-only API key you create yourself, so there is no app to install on your site. 1. In WordPress, go to **WooCommerce → Settings → Advanced → REST API** and **Add key**. Set **Permissions** to **Read**. Copy the consumer key and consumer secret. WooCommerce shows the secret once. 2. In CallMissed, open **Integrations** on your agent, choose **WooCommerce**, and paste three values: your store URL, the consumer key, and the consumer secret. 3. Press **Connect**. We make one authenticated read to confirm the key works before saving it, and the WooCommerce tools switch on for that agent. Your store URL must be **https**. WooCommerce only accepts key-and-secret authentication over TLS; over plain `http` the credentials would be readable in transit, so we refuse the connection rather than send them in the clear. The key is stored encrypted and used only on your account's conversations. Reconnecting replaces it. ## Any other site If you run your own backend (custom, headless, Magento, Django, Rails, a spreadsheet behind an API), expose a few read-only endpoints and register them as tools. Nothing runs on your side but the endpoints themselves: the rest is configuration, in the console or through the [Agent Tools API](/docs/agent-tools). ### Design the endpoints The agent is a language model, not a browser, so shape these for a model rather than for a web page. - **Keep responses small.** A tool response is truncated past **32 KB**. Return flat objects with the handful of fields a customer actually asks about, and cap list results at about 5 to 10. Never return a full catalogue document. - **Return the price the customer will actually pay.** If your storefront displays a tax-inclusive price, return the tax-inclusive number. An agent quoting your pre-tax figure will under-quote every customer. - **Return absolute URLs.** Both the product page link and the image URL must be complete `https://` addresses, because the agent sends them to the customer as-is. - **Only return what is published.** Filter out drafts, hidden and inactive items. A public catalogue endpoint often does not, and an agent will happily recommend an unreleased product. - **Say "not found" in words.** Return something like `{"found": false, "message": "No orders for that number."}` rather than an empty list or a 404. The model relays the message. - **Authenticate with a header.** A single static key in a header such as `X-API-Key`, compared in constant time, is enough. Restrict the key to letters and digits. A product search that returns this is doing its job: ```json { "count": 1, "products": [ { "id": "kit-104", "title": "Line Follower Robot Kit", "price": 1180, "currency": "INR", "inStock": true, "url": "https://yourstore.com/product/line-follower", "image": "https://yourstore.com/media/line-follower.jpg" } ] } ``` ### Register the tool 1. Open your agent, go to **Tools**, and choose **New tool**. 2. Give it a **name** and a **description**. The description is the only thing telling the model when to reach for it, so write it as an instruction: "Search the catalogue by keyword. Returns price, stock, the product link and an image URL." 3. Set the **method** and **URL**, for example `GET https://api.yourstore.com/agent-tools/products/search`. 4. Add the parameters: - Your API key: a **Header** parameter named `X-API-Key`, source **Secret**. It is encrypted and never shown again. - The search term: a **Query** parameter named `q`, source **Agent decides**, described as "what the customer is looking for". 5. **Test** it, then **Save**. Repeat for each endpoint: product search, product detail, order status. Your endpoint must be reachable on the public internet over HTTPS. Requests to private, loopback or cloud-metadata addresses are refused, and every redirect is re-checked, so an internal URL cannot be reached through a redirect either. ## Identify the customer safely Order history is the case where a small mistake matters. If a tool takes the phone number as a value the **agent** supplies, then whatever the customer types becomes the lookup: someone can ask for orders belonging to a number that is not theirs, and the agent will fetch them. Bind the identity to the conversation instead: 1. On the order-lookup tool, add the parameter that carries the customer's number, for example a **Query** parameter named `phone`. 2. Set its source to **From the conversation**, and pick **Their phone number**. That value is now filled in by the server from the person actually in the chat. The model never sees the parameter and cannot set it, so a customer asking about someone else's number changes nothing. The same control can bind their name, their email, or the conversation ID. A voice call or a web chat may have no known contact. An unbound value is simply left out, so decide what your endpoint should do: either mark the parameter **required** so the tool fails cleanly rather than running an unscoped query, or have the endpoint return "not found" when the identity is missing. **Test** runs outside any conversation, so there is no real customer to bind to. It sends obvious placeholders instead (`+10000000000`, `test@example.com`), which is enough to prove your endpoint is reachable and your key works. Expect it to answer "not found". Check the real behaviour from a live chat. If you would rather let a customer ask about an order they have the number for, take the order ID **and** a detail only the real customer knows, such as the last four digits of the phone on the order. Return the same "not found" response for a wrong ID and a failed check, so the endpoint cannot be used to discover which order IDs exist. ## Send product photos A customer asking to see something wants a picture, not a URL. Switch on the **`send_image`** tool on your agent. When the model has an image URL from your catalogue tool, it sends the photo with a caption. Put the product name, the price and the link in the caption so one message carries everything. Two requirements: the image URL must be a public `https://` link that needs no login, and it must be a **JPEG or PNG**. WhatsApp fetches the image from your server directly, so a URL behind an auth wall silently fails to send. `send_image` is WhatsApp-only. On a voice call the agent will explain the product and can text the link instead. ## Tell the agent how to use it Connecting tools is half the job. The agent's instructions decide whether it uses them well. Add something like this to your agent's prompt: ```text You answer for {store name}. Never invent a price, stock level or delivery date: look it up with your tools, and if a tool returns nothing, say so plainly. When a customer asks about a product, search the catalogue, then send the photo with send_image and put the name, price and link in the caption. When a customer asks about an order, look it up. If you cannot identify them, ask for their order number, then for the last four digits of the phone on the order before sharing any detail. ``` ## Check it works 1. Open the tool and press **Test** to confirm the endpoint answers and your key is accepted. 2. Message your agent on WhatsApp with a real product name and confirm the price it quotes matches your website exactly. 3. Ask it to show you the product and confirm the photo arrives. 4. Ask about an order from a number that has one, then from a number that does not, and confirm the second says it found nothing. 5. Ask it for orders belonging to a number that is not yours. It should not return them. --- ### Credits & Rate Limits URL: /docs/credits-rate-limits > How CallMissed credits are priced and spent, the per-plan call caps and request rates, and how to handle 402 and 429. ## Credits One currency for every service. **1 credit = ₹1 ≈ US$0.0104 (US$1 = ₹96).** Every call deducts credits: LLM tokens, STT audio, TTS characters, an image, a web search. Credits do not expire. | Grant | Amount | |-------|--------| | Signup bonus (once per account) | 1,000 credits | | Free plan, monthly | 100 credits | | Starter, monthly | 550 credits your first month, then 500 a month | | Pro, monthly | 6,000 credits your first month, then 5,000 a month | | Enterprise, monthly | 26,000 credits your first month, then 20,000 a month | | Scale, monthly | 65,000 credits your first month, then 52,500 a month | | Business, monthly | 1,30,000 credits your first month, then 1,10,000 a month | | Corporate, monthly | 2,60,000 credits your first month, then 2,30,000 a month | | Top-up | Any amount from 10 to 50,000 credits, at ₹1 per credit | Usage is always metered. There is no unlimited tier — Pro and above remove the monthly call caps, not the per-call credit cost. ## How each service is metered | Service | Unit | Worked example | |---------|------|----------------| | LLM | per 1M input + 1M output tokens | `kimi-k2.5` at $0.8438 in / $4.219 out: 500 in + 200 output tokens = $0.001266 = **0.1215 credits** | | Speech to text | per audio hour | `saaras:v4` at $0.3125/hr: a 4-minute call = **2 credits** | | Text to speech | per 10,000 characters | `bulbul:v3` at $0.3125/10K (`bulbul:v4-flash` is $0.2083/10K): a 400-character reply = **1.2 credits** | | Image generation | per image | `flux-2-pro` at $0.1042: one image = **10 credits** | | Web search | flat | **1 credit** per search, whichever provider serves it | Per-model rates are in the [model catalog](/docs/models#pricing) and live at `GET /api/v1/models`. ## Plan call caps Monthly caps counted per service, reset on the 1st. Only Free and Starter have caps; Pro and above are uncapped and limited by request rate instead. | Plan | LLM | STT | TTS | Image | Storage | Team | |------|-----|-----|-----|-------|---------|------| | Free | 100 | 50 | 50 | 50 | 100 MB | 2 | | Starter | 5,000 | 2,500 | 2,500 | 500 | 1 GB | 5 | | Pro | uncapped | uncapped | uncapped | uncapped | 10 GB | 20 | | Enterprise, Scale, Business, Corporate | uncapped | uncapped | uncapped | uncapped | uncapped | uncapped | Caps are separate from credits. Exceeding a cap returns `429` even with credits in the balance; running out of credits returns `402` even under the cap. ## Request rate Per API key, requests per minute: | Plan | Default RPM | |------|-------------| | Free | 60 | | Starter | 500 | | Pro | 3,000 | | Enterprise | 10,000 | | Scale | 20,000 | | Business | 40,000 | | Corporate | 80,000 | Override a single key with `rate_limit_rpm` in the dashboard. An explicit override can only lower the plan default; a higher value is capped at the plan's limit. ## Response headers Every response to a metered endpoint carries the current cap state. | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | Monthly call cap for that service | | `X-RateLimit-Remaining` | Calls left this month | | `X-RateLimit-Reset` | ISO-8601 timestamp when the cap resets (the 1st) | | `X-Usage-Warning` | Present at 80% (`warning:`) and 95% (`critical:`) of the cap | | `X-Credits-Balance` | Credits remaining (sent on 402 responses and on search) | ## 402 — out of credits ```json { "error": { "message": "Insufficient credits (balance: 0.0). Purchase more at https://console.callmissed.com/org/billing", "type": "insufficient_quota", "code": "insufficient_credits" } } ``` Do not retry. Top up first. ## 429 — monthly cap reached ```json { "error": { "message": "Plan limit exceeded: 100/100 llm calls this month. Upgrade your plan at console.callmissed.com/org/billing", "type": "insufficient_quota", "code": "quota_exceeded" } } ``` This 429 carries `Retry-After` in seconds until the 1st of next month. Do not retry inside that window — upgrade the plan instead. ## 429 — too many concurrent requests ```json { "error": { "message": "Too many concurrent requests for this API key. Retry shortly.", "type": "rate_limit_error", "code": "too_many_concurrent_requests" } } ``` This one clears in seconds. Retry with jittered backoff. ```python import time from openai import OpenAI, RateLimitError client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") try: resp = client.chat.completions.create( model="kimi-k2.5", messages=[{"role": "user", "content": "Hello"}], ) except RateLimitError as e: retry_after = int(e.response.headers.get("Retry-After", 0)) if 0 < retry_after < 120: time.sleep(retry_after) resp = client.chat.completions.create( model="kimi-k2.5", messages=[{"role": "user", "content": "Hello"}], ) else: raise # monthly cap — upgrade rather than wait ``` See [Errors](/docs/errors) for the full status and code tables. --- ### How CallMissed Works URL: /docs/how-it-works > The architecture behind CallMissed — one OpenAI-compatible gateway that routes to the best provider for each model, billed in a single credit currency. ## The Gateway CallMissed is a single, OpenAI-compatible gateway in front of many AI providers. You point the official OpenAI (or Anthropic) SDK at `https://api.callmissed.com/v1`, authenticate with a `cm_` key, and call chat, speech, image, and search — without integrating each provider yourself. The same endpoint powers the dashboard playground and your production code, so behavior is identical everywhere. :::flow icon:app | Your app | One SDK, one base URL, one `cm_` key for every capability icon:gateway | CallMissed gateway | Authenticate, enforce tenant isolation + rate limits, route by model id icon:provider | Best provider | the best-fit backend for each model — chosen automatically icon:db | Credits & logs | Deduct from one credit balance and record usage for every request ::: ## Model Routing The model id picks the backend. You never manage multiple SDKs or keys. | Model id | Routed to | | --- | --- | | `sarvam-*` (e.g. `sarvam-105b`) | Indic LLM | | `saaras:*`, `whisper`, `gpt-4o-*transcribe*`, `deepgram-*`, `gnani-prisma-*`, `ink-*`, `assemblyai-*`, `inworld-stt-*`, `microsoft-speech-*` | Speech to text | | `bulbul:*`, `deepgram-aura-*`, `gpt-4o-mini-tts`, `gnani-timbre-*`, `sonic-*`, `rime-*`, `inworld-tts-*`, `fish-audio-*`, `gradium-*`, `microsoft-neural-*` | Text to speech | | `flux-*`, `gpt-image-*`, `nano-banana-*`, … | Image generation | | everything else (e.g. `kimi-k2.5`, `gpt-5.6-terra`, `gpt-6-sol`) | Chat completions | Every id is a plain CallMissed id. One API surface, one key, one bill. Need a model that isn't in the catalog? We deploy 300+ more on demand — see [Models on demand](/docs/models#models-on-demand). ## One Credit Currency Every call — LLM tokens, STT minutes, TTS characters, an image, a web search — deducts **credits**. **1 credit = ₹1 ≈ US$0.0104 (US$1 = ₹96).** This removes per-provider pricing math: top up once, spend across every capability. See [Credits & Rate Limits](/docs/credits-rate-limits) for the per-service rates. ## Tenancy & Isolation Your organization is a **tenant**. Every user, bot, API key, conversation, and log belongs to exactly one tenant, and every database query is scoped to it — data is never shared across tenants. Roles (owner / admin / agent) gate sensitive actions, and you manage members and roles from the dashboard. ## Channels vs APIs There are two ways to use CallMissed: - **Direct APIs** — call `/v1/*` from your own app (chat, speech, image, search, voice sessions). - **Channels** — let CallMissed run a bot end-to-end on [WhatsApp](/docs/whatsapp) or [voice calls](/docs/voice), handling the inbound webhook, the AI turn, and the reply. --- ### Idempotency URL: /docs/idempotency > Make POST requests to the inference API safely retryable with an Idempotency-Key, so a network retry never runs or bills a call twice. ## Why Idempotency If a request times out or the connection drops, you often can't tell whether the server processed it. Retrying blindly risks doing the work twice and paying for it twice. An **idempotency key** lets you retry safely: the server runs the first request and returns the same stored result for any replay with the same key. ## Where it applies Idempotency keys are honoured on every `POST` under `/v1/` and `/anthropic/v1/` (chat completions, messages, responses, embeddings, speech, transcription, image generation, search, batches, voice sessions), authenticated with a `cm_` API key in either `Authorization: Bearer` or `x-api-key`. On other paths, other methods, or without an API key the header is ignored and the request runs normally. The one exception is the Email API: `POST /api/v1/email/send` (including `messageVersions` batches) accepts its own `Idempotency-Key` of up to 128 characters — see [Send Email](/docs/email-send). ## Using the Header Send an `Idempotency-Key` header with a unique value of up to 255 characters (a UUID works well): ```bash curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Idempotency-Key: 3f9c1d2e-0b9a-4c7d-8e1f-2a3b4c5d6e7f" \ -H "Content-Type: application/json" \ -d '{"model":"kimi-k2.5","messages":[{"role":"user","content":"Hello"}]}' ``` Reuse the **same** key when retrying the same logical request. Use a **new** key for a genuinely new action. A key longer than 255 characters is ignored. ## Behavior - Keys are scoped to your account and kept for **24 hours**. - A replay with the same key, endpoint **and** body returns the original status and body with an `Idempotent-Replay: true` header. The call is not run or billed again. - Only a successful (`2xx`) response is stored. After an error the key is released, so retrying with the same key runs the request again. - Streaming (`text/event-stream`) and binary responses (audio, images), and responses over 1 MiB, are not stored. The key is released after them, so a retry runs again. | Status | `detail` | When | | --- | --- | --- | | `409` | `Idempotency-Key reused with a different request body` | Same key, different body | | `409` | `Idempotency-Key reused for a different request` | Same key sent to a different endpoint | | `409` | `A request with this Idempotency-Key is already in progress` | The first request has not finished yet. Wait and retry | | `409` | `... Its response was not retained (zero data retention), so it cannot be replayed.` | The original ran under zero data retention, so there is nothing to replay | --- ### Welcome to CallMissed API URL: /docs/introduction > CallMissed provides AI-powered communication APIs to deploy WhatsApp chatbots and voice call agents for your business. :::cards /docs/quickstart | Quickstart | play | Make your first API call in under a minute /docs/models | Models | boxes | 139 models — Indic STT/TTS, direct-routed LLMs, realtime voice, image gen, embeddings /docs/managed-voice-agent | Managed Voice Agent | audio-lines | Speech-to-speech over one WebSocket — Deepgram-compatible or native /docs/voice-agent | Voice Agent | phone | Real-time WebRTC agents with Indic speech pipeline ::: ## Overview CallMissed is an AI Communication Infrastructure platform. Use our APIs to: - Deploy **WhatsApp chatbots** with custom knowledge bases - Build **AI voice call agents** for inbound calls - Create **Smart IVR** flows with AI escalation - Call **139 models** — LLM, STT, TTS, realtime voice, image and embeddings — from one endpoint, plus **300+ more we deploy on demand** - Use **OpenAI-compatible APIs** — same SDK, just change the base URL - Manage **multi-tenant** deployments for your customers ## Base URL All API requests go to: ``` https://api.callmissed.com ``` ## Key Features - **Indic Models** — STT, TTS, and LLM purpose-built for 22 Indic languages - **Multi-tenant** — full data isolation between tenants - **Real-time** — WebSocket voice streaming with ultra-low-latency STT→LLM→TTS pipeline - **Webhooks** — WhatsApp Business API and Twilio integration - **OpenAI-compatible** — use the OpenAI SDK with your `cm_` API key - **Request Logging** — per-key request logs with latency, model, cost, and error tracking --- ### Docs MCP Server URL: /docs/mcp-server > Connect the CallMissed documentation to your AI coding agent with the official callmissed-docs MCP server — searchable docs inside Claude, Cursor, VS Code, Windsurf, and OpenCode. :::cards /docs/quickstart | Quickstart | rocket | Make your first API call in under a minute /docs/sdks | SDKs & Libraries | package | Use the OpenAI & Anthropic SDKs ::: ## Overview The **CallMissed Docs MCP server** brings this entire documentation site into your AI coding agent through the [Model Context Protocol](https://modelcontextprotocol.io). Instead of copy-pasting docs, your agent can search, grep, and read CallMissed docs directly while it writes your integration. It is published on npm as [`callmissed-docs-mcp`](https://www.npmjs.com/package/callmissed-docs-mcp), runs locally over stdio via `npx`, and stays current by fetching the live docs export from the CallMissed API with an offline cache fallback. - **Always up to date** — pulls the latest docs on startup, caches for 1 hour - **Works offline** — falls back to the local cache when the API is unreachable - **Fast search** — fuzzy search plus regex grep over every doc page - **Zero install** — `npx` runs the latest version on demand > **Tip:** Use the **Copy page** dropdown at the top of any docs page to copy the MCP config or one-click connect to Cursor and VS Code. ## Install The server runs through `npx`, so no global install is required. To install it globally anyway: ```bash npm install -g callmissed-docs-mcp ``` ## Install via CLI (one command) The fastest way in. Several coding agents can add an MCP server from a single terminal command — no config file to hand-edit. **Any agent (Cursor, Claude Code, Codex, Windsurf, VS Code, and more)** — the cross-agent installer detects the agents on your machine and writes the right config for each: ```bash npx add-mcp "npx -y callmissed-docs-mcp" --name callmissed-docs ``` **Claude Code:** ```bash claude mcp add callmissed-docs -- npx -y callmissed-docs-mcp ``` **Codex CLI:** ```bash codex mcp add callmissed-docs -- npx -y callmissed-docs-mcp ``` After it runs, restart the agent — the CallMissed docs tools appear alongside your other tools. Verify with `claude mcp list` or `codex mcp list`. > Cursor, VS Code, Windsurf, OpenCode and Kilo Code are configured through their MCP config file (no dedicated `mcp add` command). Use the JSON below, or the cross-agent `npx add-mcp` one-liner above. ## Connect Your Client Add the server to your client's MCP configuration. :::tabs ```json [Claude Desktop] { "mcpServers": { "callmissed-docs": { "command": "npx", "args": ["-y", "callmissed-docs-mcp"] } } } ``` ```json [Cursor] { "mcpServers": { "callmissed-docs": { "command": "npx", "args": ["-y", "callmissed-docs-mcp"], "env": {} } } } ``` ```json [VS Code] { "servers": { "callmissed-docs": { "command": "npx", "args": ["-y", "callmissed-docs-mcp"], "type": "stdio" } } } ``` ```json [Windsurf] { "mcpServers": { "callmissed-docs": { "command": "npx", "args": ["-y", "callmissed-docs-mcp"] } } } ``` ```json [OpenCode] { "$schema": "https://opencode.ai/config.json", "mcp": { "callmissed-docs": { "type": "local", "command": ["npx", "-y", "callmissed-docs-mcp"], "enabled": true } } } ``` ::: Config file locations: | Client | Path | |--------|------| | Claude Desktop | `claude_desktop_config.json` | | Cursor | `~/.cursor/mcp.json` | | VS Code | `.vscode/mcp.json` (workspace) or User Configuration | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | | OpenCode | `opencode.json` (project root) | After adding the config, restart your client. The CallMissed docs tools then appear automatically alongside your other tools. ## Use via Context7 [Context7](https://context7.com) aggregates library documentation for AI agents. CallMissed docs are indexed there under the library ID `/callmissed/callmissed-docs` — so if you already run the Context7 MCP server, you can pull CallMissed docs through it without installing a separate server. Reference the library directly in your prompt: ```text Use the CallMissed docs (/callmissed/callmissed-docs) from Context7 to wire up streaming chat completions. ``` > **Tip:** The dedicated `callmissed-docs-mcp` server always serves the live docs export. Context7 is a convenient option when it's already part of your toolchain. ## Available Tools Once connected, your agent gains these tools: | Tool | Description | |------|-------------| | `callmissed_search` | Fuzzy search across all documentation | | `callmissed_grep` | Regex search over doc content | | `callmissed_cat` | Read a documentation chunk by ID | | `callmissed_ls` | List available documentation pages | | `callmissed_find` | Substring search across pages | ## Configuration The server reads one optional environment variable: | Variable | Default | Description | |----------|---------|-------------| | `CALLMISSED_DOCS_API` | `https://api.callmissed.com/api/v1/docs/export` | Docs export endpoint the server fetches on startup | To point the server at a different docs export endpoint, set the variable in your MCP client's `env` block: ```json { "mcpServers": { "callmissed-docs": { "command": "npx", "args": ["-y", "callmissed-docs-mcp"], "env": { "CALLMISSED_DOCS_API": "https://api.callmissed.com/api/v1/docs/export" } } } } ``` **Ready to connect?** Use the **Copy page** menu at the top of any docs page to grab the config or one-click install into Cursor or VS Code. --- ### Developer Quickstart URL: /docs/quickstart > Get started with CallMissed APIs in under a minute using just a few lines of code > **Note:** CallMissed APIs are OpenAI-compatible — use the official OpenAI SDK and change only the base URL and API key prefix (`cm_`). :::tabs ```python [Python] from openai import OpenAI client = OpenAI(api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1") response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Hello in Hindi"}], ) print(response.choices[0].message.content) ``` ```typescript [TypeScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1" }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello in Hindi" }], }); console.log(response.choices[0].message.content); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"model":"sarvam-105b","messages":[{"role":"user","content":"Hello in Hindi"}]}' ``` ::: :::cards /docs/sdks | SDKs & Libraries | package | Install the OpenAI SDK in your language /docs/models | Model Catalog | boxes | 139 models — LLM, STT, TTS, realtime voice, image, embeddings ::: ## Get started ::::steps ## Create an API Key In the [CallMissed console](https://console.callmissed.com/developer/keys), open **Developer → API keys** and create a new key. It is shown once, so copy it now. Keep this key secure — you'll need it to authenticate your requests. ## Set up your environment Export your API key as an environment variable: :::tabs ```bash [macOS / Linux] export CALLMISSED_API_KEY="your_api_key_here" ``` ```powershell [Windows (PowerShell)] $env:CALLMISSED_API_KEY = "your_api_key_here" ``` ```cmd [Windows (CMD)] set CALLMISSED_API_KEY=your_api_key_here ``` ::: ## Install the SDK Choose your preferred language and install the OpenAI SDK: :::tabs ```bash [Python] pip install openai ``` ```bash [JavaScript / TypeScript] npm install openai ``` ```bash [Go] go get github.com/openai/openai-go ``` ```bash [PHP] composer require openai-php/client ``` ```bash [Ruby] gem install ruby-openai ``` ```bash [Java] # Add to pom.xml or build.gradle — use OkHttp or any HTTP client ``` ::: ## Make your first API call Use the same pattern as the hero example above — pass your `cm_` API key and pick any [free-tier model](/docs/model-access). > **Tip:** Store your API key in environment variables. Never commit keys to version control. :::: **Ready to explore?** See [Chat Completion](/docs/chat-completion) for streaming and tool calling, or browse the [Model Catalog](/docs/models). ## CallMissed APIs ### Chat Completion :::tabs ```python [Python] response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Explain quantum computing"}], stream=True ) for chunk in response: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="") ``` ```javascript [JavaScript] const stream = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Explain quantum computing" }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content || ""); } ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "sarvam-105b", "messages": [{"role": "user", "content": "Explain quantum computing"}], "stream": true }' ``` ::: ### Speech to Text :::tabs ```python [Python] with open("audio.wav", "rb") as f: response = client.audio.transcriptions.create( model="saaras:v4", file=f ) print(response.text) ``` ```javascript [JavaScript] import fs from "fs"; const response = await client.audio.transcriptions.create({ model: "saaras:v4", file: fs.createReadStream("audio.wav"), }); console.log(response.text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/transcriptions \ -H "Authorization: Bearer cm_your_key" \ -F file=@audio.wav \ -F model=saaras:v4 ``` ::: ### Text to Speech :::tabs ```python [Python] response = client.audio.speech.create( model="bulbul:v3", voice="shubh", input="Namaste, kaise hain aap?" ) response.stream_to_file("speech.mp3") ``` ```javascript [JavaScript] const response = await client.audio.speech.create({ model: "bulbul:v3", voice: "shubh", input: "Namaste, kaise hain aap?", }); const buffer = Buffer.from(await response.arrayBuffer()); fs.writeFileSync("speech.mp3", buffer); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/audio/speech \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model": "bulbul:v3", "input": "Namaste, kaise hain aap?", "voice": "shubh"}' \ --output speech.mp3 ``` ::: ## Next steps :::cards /docs/models | Models | boxes | LLM, STT, TTS, and image models across every major provider /docs/chat-completion | Chat Completion | message-square | Streaming, tool calling, vision, and the Responses API /docs/managed-voice-agent | Managed Voice Agent | audio-lines | Speech-to-speech over one WebSocket — no SDK, Deepgram-compatible /docs/voice-agent | Voice Agent | phone | Real-time WebRTC voice agents with Indic STT/TTS ::: --- ### Rate Limits & Quotas URL: /docs/rate-limits > How CallMissed limits request rate — per-key RPM, plan caps, budget caps, response headers, and how to handle 429s. ## Limit Layers Requests pass through several limits: | Layer | Limit | Scope | When exceeded | | --- | --- | --- | --- | | Per-key RPM | Plan defaults: Free 60 · Starter 500 · Pro 3,000 · Enterprise 10,000 · Scale 20,000 · Business 40,000 · Corporate 80,000 (override per key) | per API key | `429 rate_limit_exceeded` | | In-flight requests | A cap on simultaneous chat-completion requests per key | per API key | `429 too_many_concurrent_requests` | | Key budget | Optional credit cap on one key | per API key | `402 budget_exceeded` | | Monthly budget cap | Optional credit cap for the whole account | per account | `429 quota_exceeded` until the 1st | | Plan call caps | Monthly caps on LLM/STT/TTS/image calls (Free and Starter only), see [Credits & Rate Limits](/docs/credits-rate-limits#plan-call-caps) | per account | `429 quota_exceeded` until the 1st | Abuse protection also runs in front of the API and may throttle traffic that looks automated or hostile, independently of your plan's per-key RPM. Set a per-key RPM and a [budget cap](/docs/keys) when issuing keys, then track live consumption for each key from the console. ## Response Headers | Header | Sent on | Meaning | | --- | --- | --- | | `X-RateLimit-Limit` | Metered inference responses | The monthly call cap for that service | | `X-RateLimit-Remaining` | Metered inference responses | Calls left this month | | `X-RateLimit-Reset` | Metered inference responses | ISO-8601 time the monthly cap resets | | `X-Usage-Warning` | At 80% and 95% of a monthly cap | `warning: …` or `critical: …` with the count used | | `Retry-After` | `quota_exceeded`, `too_many_concurrent_requests`, and the per-key RPM 429 on `/api/v1/*` | Seconds to wait before retrying | The `X-RateLimit-*` headers describe the **monthly plan cap**, not the per-minute limit. A per-key RPM 429 from an inference endpoint (`/v1/*`) carries no `Retry-After`: the window is a rolling minute, so back off and retry within it. ## Handling 429 Read the error `code` first (on `/api/v1/*` routes, the `detail` message): 1. `rate_limit_exceeded`: wait (`Retry-After` when present, otherwise exponential backoff with jitter, starting at about a second) and retry. Spread bursts over time, or raise the key's RPM. 2. `too_many_concurrent_requests`: wait the `Retry-After` seconds and retry. Lower your client's parallelism. 3. `quota_exceeded`: do not retry. The cap lasts until the 1st of next month. Upgrade the plan or raise the budget cap. See [Error Codes](/docs/errors) for the full status/code reference. --- ### Libraries & SDKs URL: /docs/sdks > Use the OpenAI or Anthropic SDK to integrate CallMissed — point the client at our base URL and use your cm_ API key. ## Supported Libraries CallMissed does not ship its own client library for the inference API. Our endpoints are compatible with **two existing SDK families**, so use whichever you prefer: change the base URL and use your `cm_` API key. The platform APIs under `/api/v1/` (agents, CRM, support, webhooks and the rest) are plain JSON over HTTPS; call them with any HTTP client. ### OpenAI SDK (recommended for most use cases) | Language | Package | Manager | |----------|---------|---------| | Python | `openai` | PyPI | | JavaScript / TypeScript | `openai` | npm | | Go | `github.com/openai/openai-go/v3` | go modules | | PHP | `openai-php/client` | Composer | | Ruby | `ruby-openai` | RubyGems | | Java / Kotlin | HTTP client | Maven / Gradle | ### Anthropic SDK (for /v1/messages endpoint) | Language | Package | Manager | |----------|---------|---------| | Python | `anthropic` | PyPI | | JavaScript / TypeScript | `@anthropic-ai/sdk` | npm | > **Tip:** The Anthropic SDK talks to `/v1/messages`. See the [Anthropic API docs](/docs/anthropic-api) for full details. ## Installation :::tabs ```bash [Python] pip install openai ``` ```bash [JavaScript / TypeScript] npm install openai ``` ```bash [Go] go get github.com/openai/openai-go/v3 ``` ```bash [PHP] composer require openai-php/client ``` ```bash [Ruby] gem install ruby-openai ``` ```bash [Python (Anthropic)] pip install anthropic ``` ```bash [JS/TS (Anthropic)] npm install @anthropic-ai/sdk ``` ::: Upgrade to the latest version: :::tabs ```bash [Python] pip install --upgrade openai ``` ```bash [JavaScript / TypeScript] npm install openai@latest ``` ```bash [Go] go get -u github.com/openai/openai-go/v3 ``` ```bash [PHP] composer update openai-php/client ``` ```bash [Ruby] gem update ruby-openai ``` ::: ## Configuration :::tabs ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1" ) response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Hello"}] ) print(response.choices[0].message.content) ``` ```typescript [TypeScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }], }); console.log(response.choices[0].message.content); ``` ```javascript [JavaScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_api_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }], }); console.log(response.choices[0].message.content); ``` ```go [Go] package main import ( "context" "fmt" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" ) func main() { client := openai.NewClient( option.WithAPIKey("cm_your_api_key"), option.WithBaseURL("https://api.callmissed.com/v1"), ) resp, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{ Model: "sarvam-105b", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage("Hello"), }, }, ) if err != nil { panic(err) } fmt.Println(resp.Choices[0].Message.Content) } ``` ```php [PHP] withApiKey('cm_your_api_key') ->withBaseUri('https://api.callmissed.com/v1') ->make(); $response = $client->chat()->create([ 'model' => 'sarvam-105b', 'messages' => [['role' => 'user', 'content' => 'Hello']], ]); echo $response->choices[0]->message->content; ``` ```ruby [Ruby] require "openai" client = OpenAI::Client.new( access_token: "cm_your_api_key", uri_base: "https://api.callmissed.com/v1" ) response = client.chat( parameters: { model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }] } ) puts response.dig("choices", 0, "message", "content") ``` ```bash [cURL] curl https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{"model": "sarvam-105b", "messages": [{"role": "user", "content": "Hello"}]}' ``` ::: ## Resources | Resource | Link | |----------|------| | API Reference | [docs.callmissed.com](https://docs.callmissed.com) | | Docs in your coding agent | [`callmissed-docs-mcp`](/docs/mcp-server) on npm | | Dashboard | [console.callmissed.com](https://console.callmissed.com) | | LinkedIn | [linkedin.com/company/callmissed](https://www.linkedin.com/company/callmissed) | --- ### Knowledge Base & RAG URL: /docs/knowledge > Store content your bots use to answer questions, plus a vector Knowledge API that chunks, embeds, and semantically retrieves your sources for RAG. ## Overview There are two independent layers of knowledge, and they do not share storage: 1. **Bot knowledge base** — a flat document store scoped to a single bot. Add plain text or upload PDF/DOCX/TXT (max 20 MB); text is extracted on upload. This layer is **not** searched by RAG. 2. **Knowledge API (RAG)** — a vector store. Ingest text, URLs, or PDFs as **sources**; each is chunked and embedded, then retrieved by semantic search and passed as context to the model. Every source you ingest is attached to a bot via a required `bot_id`. Retrieval is always tenant-scoped, and additionally bot-scoped whenever you pass a `bot_id`. > Every endpoint on both layers accepts either a `cm_` API key or a dashboard session (JWT). API keys need the `knowledge:read` / `knowledge:write` [scopes](/docs/keys); scopes do not apply to JWT callers, whose access is role-based. **Base paths:** `https://api.callmissed.com/api/v1/knowledge` for RAG, and `https://api.callmissed.com/api/v1/bots/{bot_id}/knowledge` for the flat store. ### Which layer to use | You want to… | Use | | --- | --- | | Ground an LLM reply in your documents | **Knowledge API (RAG)** | | Run semantic search over your content | **Knowledge API (RAG)** | | Keep a plain list of documents attached to a bot | **Bot knowledge base** | | Upload DOCX, or a file up to 20 MB | **Bot knowledge base** | New integrations should use the Knowledge API. The flat store predates it and is kept for existing bots — it still counts toward the storage total in your usage rollup. ## Bot Knowledge Base A flat, non-vector list of entries on one bot. The bot must belong to your tenant or the request returns `404`. `GET /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:read` Returns every entry for the bot, newest first. `POST /api/v1/bots/{bot_id}/knowledge` · scope `knowledge:write` | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string (1–255) | Yes | Label for the entry | | `content` | string (1–100000) | Yes | The raw text | | `metadata` | object | No | Arbitrary JSON you can read back | ```bash curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"name":"Refund policy","content":"Refunds are issued within 7 days of purchase."}' ``` **Response (201 Created)** — a knowledge entry: ```json { "id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "name": "Refund policy", "content": "Refunds are issued within 7 days of purchase.", "metadata": null, "file_url": null, "file_size_bytes": null, "format": null, "status": "indexed", "error_message": null, "created_at": "2026-07-20T09:15:00Z" } ``` ### Upload a document `POST /api/v1/bots/{bot_id}/knowledge/upload` · scope `knowledge:write` A multipart upload of a single `file`. Accepted extensions are **PDF, DOCX, and TXT**, up to **20 MB**. Text is extracted server-side and stored in `content`; if the extracted text is over **5 MB** of UTF-8 the upload returns `413`. ```bash curl -X POST https://api.callmissed.com/api/v1/bots/$BOT_ID/knowledge/upload \ -H "Authorization: Bearer cm_your_key" \ -F 'file=@handbook.pdf' ``` The entry comes back with `format` set to the extension, `file_size_bytes` set, and `status` either `indexed` (text extracted) or `failed` with an `error_message`. A scanned PDF with no text layer yields `failed` — there is no OCR. ### Delete an entry `DELETE /api/v1/bots/{bot_id}/knowledge/{entry_id}` · scope `knowledge:write` · returns `204 No Content` ## Knowledge API (RAG) ### How ingestion works Ingestion is **synchronous** — the response returns only once the source is fully indexed, so there is no job to poll: :::flow icon:file-text | Extract | Text is taken as-is, fetched from a URL, or pulled out of a PDF icon:scissors | Chunk | Split into ~600-token chunks with a 100-token overlap icon:database | Embed & store | Each chunk is embedded to a 768-dimension vector and indexed for cosine similarity ::: Embedding tokens are billed against your [credits](/docs/credits-rate-limits). The cost is checked before anything is embedded: if your balance (or monthly budget) cannot cover it, nothing is embedded or charged, and the source is saved with `status: "failed"` and an `error_message` saying so — the response's `status` is `"failed"` too. Top up and re-ingest. ### The source object | Field | Type | Notes | |-------|------|-------| | `id` | uuid | Source identifier | | `tenant_id` | uuid | Owning tenant | | `bot_id` | uuid | Bot the source is attached to | | `kind` | string | `text`, `pdf`, or `url` | | `title` | string \| null | Your label; defaults to the URL for URL sources | | `uri` | string \| null | Source URL, or the uploaded filename for PDFs | | `status` | string | `pending` → `ingesting` → `ready`, or `failed` | | `error_message` | string \| null | Set when `status` is `failed` | | `byte_size` | int \| null | Size of the extracted text in bytes | | `token_count` | int \| null | Total tokens embedded | | `chunk_count` | int | Number of retrievable chunks | | `created_at` | datetime | | | `ingested_at` | datetime \| null | Set when indexing completes | | `resync_interval_hours` | int \| null | URL sources only: re-fetch the page every N hours (6–720). `null` = ingest once | | `last_resync_at` | datetime \| null | When the last automatic re-fetch ran | | `resync_error` | string \| null | Why the last automatic re-fetch failed; the previous content keeps serving | All three ingest endpoints return the same envelope — the source plus a flat summary: ```json { "source": { "id": "…", "kind": "text", "status": "ready", "chunk_count": 12, "…": "…" }, "status": "ready", "chunk_count": 12, "token_count": 7043 } ``` ### Ingest text `POST /api/v1/knowledge/sources` · scope `knowledge:write` · returns `201` | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid | Yes | Must be a bot in your tenant | | `title` | string (1–512) | Yes | Label for the source | | `content` | string | Yes | The text to index; max **5 MB** of UTF-8 | :::tabs ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/knowledge/sources \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "title": "Shipping FAQ", "content": "We ship across India in 3-5 business days. Express delivery reaches metro cities next day." }' ``` ```python [Python] import httpx resp = httpx.post( "https://api.callmissed.com/api/v1/knowledge/sources", headers={"Authorization": "Bearer cm_your_key"}, json={ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "title": "Shipping FAQ", "content": "We ship across India in 3-5 business days.", }, ) result = resp.json() print(result["status"], result["chunk_count"]) # "ready" 1 ``` ::: ### Ingest a URL `POST /api/v1/knowledge/sources/url` · scope `knowledge:write` · returns `201` The server fetches the page itself, strips HTML to text, and indexes the result. | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid | Yes | Must be a bot in your tenant | | `url` | string | Yes | A bare domain works — `https://` is added when no scheme is present | | `title` | string (≤512) | No | Defaults to the URL | | `resync_interval_hours` | int (6–720) | No | Re-fetch the page automatically every N hours. Omit to ingest once | ```bash curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/url \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"bot_id":"'$BOT_ID'","url":"acme.in/help/returns","title":"Returns policy"}' ``` > **Only public URLs.** Requests that resolve to private or internal address ranges are rejected with `400 URL rejected`. The fetch caps the download at **2 MB**, times out after **30 seconds**, and follows at most **5 redirects** — each hop is re-validated. A page that yields no extractable text returns `400`, so JavaScript-rendered pages with no server-side HTML will not ingest. ### Auto re-sync a URL `PATCH /api/v1/knowledge/sources/{source_id}` · scope `knowledge:write` Set `{"resync_interval_hours": 24}` (6–720) to have the page re-fetched on that schedule, or `{"resync_interval_hours": null}` to stop. Only URL sources can be re-synced (`400` otherwise). Each re-fetch uses the same public-URL checks as the first ingest. The page text is compared with the last indexed version; chunks are replaced only when it changed, and the replacement is all-or-nothing, so retrieval never sees a half-updated source. Re-embedding a changed page is billed as embedding tokens exactly like the first ingest; an unchanged page costs nothing. If a re-fetch fails (page down, no text, balance too low), the previous content stays in place, the reason appears in `resync_error`, and the next attempt is delayed progressively. ### Ingest a PDF `POST /api/v1/knowledge/sources/pdf` · scope `knowledge:write` · returns `201` A multipart upload. Unlike the text and URL endpoints, the fields are **form fields**, not JSON. | Field | Type | Required | Notes | |-------|------|----------|-------| | `bot_id` | uuid (form) | Yes | Must be a bot in your tenant | | `title` | string (form) | Yes | Label for the source | | `file` | file | Yes | PDF only, max **5 MB**. The extracted text is also capped at 5 MB of UTF-8 (`413` above it) | ```bash curl -X POST https://api.callmissed.com/api/v1/knowledge/sources/pdf \ -H "Authorization: Bearer cm_your_key" \ -F "bot_id=$BOT_ID" \ -F 'title=Product catalogue' \ -F 'file=@catalogue.pdf' ``` > A scanned PDF is an image, not text. With no text layer to extract, the upload returns `400` — there is no OCR in v1. ### List sources `GET /api/v1/knowledge/sources` · scope `knowledge:read` | Query | Type | Default | Notes | |-------|------|---------|-------| | `bot_id` | uuid | — | Filter to one bot; omit to list the whole tenant | | `limit` | int (1–200) | `50` | Page size | | `offset` | int (0–10000) | `0` | Page offset | Returns `{ "items": [...], "total": 42 }`, newest first, where `total` is the count before paging. `GET /api/v1/knowledge/sources/{source_id}` returns a single source, or `404` if it is not in your tenant. ### Delete a source `DELETE /api/v1/knowledge/sources/{source_id}` · scope `knowledge:write` · returns `204 No Content` Deleting a source also deletes all of its chunks, which removes it from retrieval immediately. ## Semantic search `POST /api/v1/knowledge/search` · scope `knowledge:read` Runs the retrieval step on its own. Use it to tune `k` and `min_score`, or to build your own RAG prompt. | Field | Type | Default | Notes | |-------|------|---------|-------| | `query` | string (1–4096) | — | Required. The text to match against | | `bot_id` | uuid | `null` | Restrict to one bot; omit to search every source in the tenant | | `k` | int (1–50) | `6` | How many chunks to return | | `min_score` | float (0–1) | `0.0` | Drop chunks below this score | :::tabs ```bash [cURL] curl -X POST https://api.callmissed.com/api/v1/knowledge/search \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "query": "how long does delivery take?", "k": 4, "min_score": 0.6 }' ``` ```python [Python] import httpx resp = httpx.post( "https://api.callmissed.com/api/v1/knowledge/search", headers={"Authorization": "Bearer cm_your_key"}, json={"query": "how long does delivery take?", "k": 4, "min_score": 0.6}, ) for chunk in resp.json()["chunks"]: print(round(chunk["score"], 3), chunk["content"][:80]) ``` ::: **Response (200 OK)** ```json { "query": "how long does delivery take?", "chunks": [ { "id": "e4a1b2c3-d4e5-6f70-8192-a3b4c5d6e7f8", "source_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "chunk_index": 0, "content": "We ship across India in 3-5 business days...", "token_count": 412, "score": 0.8317 } ] } ``` ### Reading the score `score` is cosine similarity — `1.0` is an exact semantic match and `0.0` is unrelated. Two details matter when tuning: - `min_score` is applied **after** ranking, not before. You can get back fewer than `k` chunks, including zero. - A default `min_score` of `0` returns the nearest chunks whether or not they are relevant. For question-answering, start around **`0.6`** to drop off-topic matches. Searching a bot with no ingested chunks returns an empty list without spending credits. Otherwise each search embeds the query, which is billed as a small number of embedding tokens. ## Grounding a chat completion You do not have to call `/search` and assemble a prompt yourself. Pass `bot_id` to [chat completions](/docs/chat-completion) and the server retrieves for you, prepending the matched chunks to the system message before the model runs. | Field | Type | Default | Notes | |-------|------|---------|-------| | `bot_id` | string | — | Enables retrieval against that bot's sources | | `knowledge_top_k` | int (1–50) | `6` | Chunks to retrieve | | `knowledge_min_score` | float (0–1) | `0.0` | Minimum score to include | ```bash curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "bot_id": "b1f2c3d4-5678-90ab-cdef-1234567890ab", "knowledge_top_k": 4, "knowledge_min_score": 0.6, "messages": [{"role": "user", "content": "Do you deliver to Pune?"}] }' ``` Behaviour worth knowing: - Retrieval runs against the **latest user message** in `messages`, not the system prompt or conversation history. - Retrieved context is merged into your existing system message, or inserted as one if you did not send any. - If retrieval fails, the completion still runs — just without context, rather than returning an error. - A `bot_id` belonging to another tenant is treated as "no knowledge" instead of returning `404`. Cost is one embedding call for the query plus the added context tokens, billed at your model's normal input rate. ## Limits | | Bot knowledge base | Knowledge API (RAG) | | --- | --- | --- | | Storage | Raw documents | Chunks + vectors | | Formats | PDF, DOCX, TXT, plain text | Plain text, PDF, URL | | Max upload | 20 MB | 5 MB (2 MB for a URL fetch) | | Max text per entry | 100,000 characters typed; 5 MB of UTF-8 extracted from an upload | 5 MB of UTF-8 | | Chunking | None | ~600 tokens, 100-token overlap | | Semantic search | No | Yes | | Scoping | One bot | Ingest per bot; search per bot or tenant-wide | ## Status codes | Code | Meaning | |------|---------| | `201` | Source ingested | | `204` | Source or entry deleted | | `400` | No extractable text, a non-PDF upload, an unsupported format, or a rejected URL | | `403` | Key is missing the `knowledge:read` / `knowledge:write` scope | | `404` | Bot or source not found in your tenant | | `413` | Content or upload exceeds the size cap | | `503` | Indexing failed after the text was extracted — safe to retry | A `503` means extraction succeeded but embedding did not, so nothing was stored. Retry the same request. See [Errors](/docs/errors) for the standard error shape. --- ### Agent Assist URL: /docs/agent-assist > Suggested replies for a human agent working a live conversation, grounded in the conversation's knowledge base. ## Overview `POST /api/v1/assist/suggest` returns up to three short candidate replies for the next message in one of your [conversations](/docs/conversations), plus the knowledge-base snippets they were grounded in. It is built for a human agent who has taken over a thread: show the suggestions, let the agent pick or edit one, then send it yourself. Nothing is sent to the customer by this endpoint — it only reads the thread and returns text. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` The key needs the `conversations:write` scope. A dashboard session also works. ## POST `/api/v1/assist/suggest` | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `conversation_id` | `UUID` | Yes | A conversation in your account | | `turns` | `array` | No | Up to 12 recent `{role, content}` turns (`role` is `user`, `assistant` or `system`; `content` 1–4,000 characters). Omit it to use the conversation's stored messages | | `instruction` | `string` | No | Extra steering for these suggestions only, at most 500 characters (e.g. "offer a callback") | | `log_suggestions` | `boolean` | No | Default `false`. When `true`, the returned suggestions are saved on the conversation for audit | Send `turns` when the agent can see a message that may not be stored yet; the server otherwise reads the last 12 messages of the conversation. ```bash curl -X POST https://api.callmissed.com/api/v1/assist/suggest \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "0f5c2a8e-6b1d-4c3e-9a7f-2d4e6b8c0a12", "instruction": "keep it under two sentences" }' ``` ```json { "suggestions": [ "Sorry about the delay! Could you share your order number so I can check it right away?", "Your order left our warehouse yesterday and should arrive within 2 business days.", "I'm escalating this to our delivery team now and will update you within the hour." ], "kb_snippets": [ { "source_id": "b3e1…", "chunk_index": 4, "score": 0.81, "content": "Standard delivery takes 3–5 business days after dispatch…" } ], "model": "gpt-oss-120b", "remaining_in_window": 29 } ``` | Field | Type | Notes | | --- | --- | --- | | `suggestions` | `string[]` | Up to 3 replies, each at most 320 characters, in the customer's language | | `kb_snippets` | `array` | Up to 4 knowledge chunks (score at least 0.55) from the conversation's bot, each `content` at most 600 characters. Empty when nothing relevant was found | | `model` | `string` | The model that wrote the suggestions — the conversation's bot model, or `gpt-oss-120b` when the bot sets none | | `remaining_in_window` | `integer` | Calls left for this conversation in the current 5-minute window | ## Limits Each conversation allows **30 calls per rolling 5-minute window**. The 31st call returns `429` with the number of seconds until the window resets in the message. ## Billing Each call is billed as one small completion at the model's normal [per-token rate](/docs/models), plus the knowledge-base search. The call fails with `402` before any model runs when your balance is empty or your monthly budget cap is reached. ## Errors Errors use the `{"detail": "..."}` shape. | Status | When | | --- | --- | | `400` | The conversation has no messages yet | | `402` | `Insufficient credits` or `Monthly budget cap reached` | | `403` | Key is missing the `conversations:write` scope | | `404` | `Conversation not found` (unknown id, or another account's) | | `422` | Body failed validation (bad `role`, `turns` over 12, `instruction` over 500 characters) | | `429` | The conversation's 30-per-5-minutes limit is spent | | `503` | The model call failed — safe to retry | --- ### Anthropic-Compatible API URL: /docs/anthropic-api > Use the Anthropic SDK with CallMissed — just change the base URL. Full Messages API compatibility. ## Overview CallMissed provides an **Anthropic Messages API-compatible endpoint** alongside the OpenAI-compatible API. If you're already using the Anthropic SDK, you can switch to CallMissed by changing only the `base_url`. **Endpoints:** - `POST /v1/messages` — chat completions (streaming + non-streaming) - `POST /v1/messages/count_tokens` — token count estimation (real BPE, not char-based); also at `/anthropic/v1/messages/count_tokens` - `GET /anthropic/v1/models` — list models in Anthropic shape with capability metadata - `GET /anthropic/v1/models/{model_id}` — single model detail - `POST /anthropic/v1/messages` — alternate path for the chat endpoint **Authentication:** Use either header style: - `x-api-key: cm_your_key` (Anthropic SDK default) - `Authorization: Bearer cm_your_key` (OpenAI style) ## Basic Usage :::tabs ```python [Python] import anthropic client = anthropic.Anthropic( api_key="cm_your_key", base_url="https://api.callmissed.com" ) message = client.messages.create( model="gpt-6-sol", max_tokens=1024, system="You are a helpful assistant.", messages=[ {"role": "user", "content": "What is the capital of India?"} ] ) print(message.content[0].text) ``` ```javascript [JavaScript] import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com", }); const message = await client.messages.create({ model: "gpt-6-sol", max_tokens: 1024, system: "You are a helpful assistant.", messages: [ { role: "user", content: "What is the capital of India?" }, ], }); console.log(message.content[0].text); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-6-sol", "max_tokens": 1024, "system": "You are a helpful assistant.", "messages": [ {"role": "user", "content": "What is the capital of India?"} ] }' ``` ::: **Response:** ```json { "id": "msg-abc123def456", "type": "message", "role": "assistant", "content": [ {"type": "text", "text": "The capital of India is New Delhi."} ], "model": "gpt-6-sol", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 25, "output_tokens": 12, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0 } } ``` ## Streaming Set `stream: true` to receive Server-Sent Events with the full Anthropic streaming lifecycle: :::tabs ```python [Python] import anthropic client = anthropic.Anthropic( api_key="cm_your_key", base_url="https://api.callmissed.com" ) with client.messages.stream( model="gpt-6-sol", max_tokens=1024, messages=[{"role": "user", "content": "Tell me a short story."}] ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ``` ```javascript [JavaScript] import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com", }); const stream = client.messages.stream({ model: "gpt-6-sol", max_tokens: 1024, messages: [{ role: "user", content: "Tell me a short story." }], }); for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { process.stdout.write(event.delta.text); } } ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-6-sol", "max_tokens": 1024, "stream": true, "messages": [ {"role": "user", "content": "Tell me a short story."} ] }' ``` ::: **SSE event lifecycle:** ``` event: message_start → message metadata + input token count event: content_block_start → new content block begins event: content_block_delta → text chunks (repeats) event: content_block_stop → content block complete event: message_delta → stop_reason + output token count event: message_stop → stream complete ``` ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `model` | string | Yes | Model ID (e.g. `gpt-5.6-sol`, `sarvam-105b`, `kimi-k2.6`) | | `max_tokens` | integer | Yes | Maximum tokens to generate, `1`–`1,048,576` | | `messages` | array | Yes | 1–2,000 `{role, content}` objects. `content` is a string or an array of `text`, `image` (`base64` or `url` source), `document` (`text` source), `tool_use` and `tool_result` blocks. A `tool_result` may hold text, images, text documents and search results, and `is_error`. OpenAI-style `image_url`, `input_audio` and `file` parts are also accepted. Any other block or source type returns `400` naming it | | `system` | string or array | No | System prompt (top-level, not in messages); a string or a list of text blocks | | `stream` | boolean | No | Enable streaming (default: false) | | `temperature` | number | No | Sampling temperature, `0`–`1` | | `top_p` | number | No | Nucleus sampling, `0`–`1` | | `top_k` | integer | No | Top-K sampling, `1`–`500` | | `stop_sequences` | array | No | Up to 16 stop sequences | | `tools` | array | No | Up to 10,000 `{name, description, input_schema}` tools (128 on the GPT models listed in [Function Calling](/docs/chat-function-calling)). Server tools (for example `web_search_20250305`) return `400` | | `thinking` | object | No | `{"type": "disabled"}` turns reasoning off (`reasoning_effort: "none"`). `enabled` and `adaptive` keep the model's default reasoning; `budget_tokens` is not applied | | `output_config` | object | No | `effort` (`low`, `medium`, `high`, `xhigh`, `max`) sets the model's `reasoning_effort` and takes precedence over `thinking`. Each model keeps only the levels it supports. `format` (`{"type": "json_schema", "schema": {...}}`) requests structured JSON output | | `tool_choice` | object | No | `{"type": "auto"}`, `{"type": "any"}`, `{"type": "none"}` or `{"type": "tool", "name": "..."}` | | `metadata` | object | No | Up to 16 string, number or boolean values. `user_id` is your end user's id and enforces that user's [monthly budget](/docs/gateway-controls). `trace_id` and `session_id` are recorded on the usage row so you can filter [usage logs](/docs/usage-api) by them | | `provider` | object | No | CallMissed extension: `{"zdr": true}` requires a [zero-data-retention route](/docs/gateway-controls); `{"data_residency": "in"}` limits the request to models processed in India (`region_unavailable` otherwise) | > **Note:** Unlike the OpenAI API, `max_tokens` is **required** and `system` is a **top-level parameter** (not a message with `role: "system"`). ## Model Selection Send any model ID from the [Models](/docs/models) catalog — not just Anthropic-shaped names. The `model` field takes the same values as `/v1/chat/completions`, including legacy aliases of a catalog model. ```json { "model": "gpt-6-sol", "max_tokens": 1024, "messages": [...] } ``` ## Token Counting Estimate input token count before sending a request. The endpoint uses a BPE tokenizer (tiktoken `cl100k_base`) — close to Claude's real tokenizer on typical English prompts, and noticeably more accurate than char-length heuristics. ```bash curl -X POST https://api.callmissed.com/v1/messages/count_tokens \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-6-sol", "messages": [{"role": "user", "content": "Hello, how are you?"}], "system": "You are a helpful assistant." }' ``` **Response:** ```json {"input_tokens": 15} ``` Image blocks and PDF `document` blocks contribute a fixed estimate (~1,120 tokens each) rather than a fetch-and-resize pass, never their base64 size; text `document` blocks count their text. Tool definitions are counted against the total by serializing each to JSON and tokenizing the schema. ## Listing Models List all available models via the Anthropic-shape endpoint: ```bash curl https://api.callmissed.com/anthropic/v1/models \ -H "x-api-key: cm_your_key" ``` **Response:** ```json { "data": [ { "type": "model", "id": "gpt-5.6-sol", "display_name": "GPT-5.6 Sol", "created_at": "2023-11-14T22:13:20+00:00", "description": "Frontier model for complex professional work. Multimodal, reasoning + tools.", "category": "llm", "context_window": 1050000, "context_length": 1050000, "pricing": {"input": 4.167, "output": 20.83, "unit": "per_million_tokens", "currency": "USD"}, "supports_streaming": true, "supports_tools": true, "supports_reasoning": true, "supports_vision": true } ], "has_more": false, "first_id": "...", "last_id": "..." } ``` Fetch a single model at `GET /anthropic/v1/models/{model_id}`. ## Vision (Image Input) Send images on any model whose `supports_vision` flag is `true` in the model listing. That is the authoritative source; see the [vision list](/docs/chat-completion#vision-image-input) for the current set. Models without vision reject image content with `400 invalid_request_error` before the upstream call — you are not charged. ```bash curl -X POST https://api.callmissed.com/v1/messages \ -H "x-api-key: cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-6-sol", "max_tokens": 1024, "messages": [{ "role": "user", "content": [ {"type": "text", "text": "What is in this image?"}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}} ] }] }' ``` ## Error Format Errors return the Anthropic format (different from the OpenAI endpoints): ```json { "type": "error", "error": { "type": "authentication_error", "message": "Invalid API key" } } ``` | Error type | HTTP Status | When | |------------|-------------|------| | `invalid_request_error` | 400 / 422 | Bad request, image sent to a text-only model, a voice-only model id, a prompt longer than the model's context window, or `max_tokens` below the model's minimum | | `authentication_error` | 401 | Bad, missing, revoked or expired API key | | `billing_error` | 402 | Insufficient credits, key budget or end-user budget exhausted | | `permission_error` | 403 | Account inactive, key lacks the `llm` permission, or a free-plan key calling a paid model | | `not_found_error` | 404 | Model not found | | `request_too_large` | 413 | Request body too large | | `rate_limit_error` | 429 | Plan limit or API key rate limit exceeded | | `api_error` | 500 / 503 | Upstream model failure, or the model is under maintenance (the message names an alternative) | | `overloaded_error` | 503 / 529 | Model temporarily unavailable. Retry with backoff | | `timeout_error` | 503 | Upstream model timed out | **Rate limit headers** are returned in Anthropic format: ``` anthropic-ratelimit-requests-limit: 60 anthropic-ratelimit-requests-remaining: 45 anthropic-ratelimit-requests-reset: 2026-05-01T00:00:00+00:00 ``` ## Prompt caching Models that support prompt caching reuse repeated prompt prefixes automatically. Usage uses Anthropic's field names, with the same meaning: - `input_tokens` — prompt tokens that were not read from or written to the cache - `cache_read_input_tokens` — prompt tokens served from the cache (billed at the model's cached-input rate) - `cache_creation_input_tokens` — prompt tokens written to the cache Total prompt = `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. When streaming, the final counts arrive in the `message_delta` event. `cache_control` blocks (on `system`, message content or `tools`) are accepted so Anthropic SDK code runs unchanged, but explicit breakpoints and `ttl` are not applied today — caching works from the prompt prefix automatically. ## Differences from Anthropic This endpoint is designed to work with the Anthropic SDK out of the box. Key differences from the official Anthropic API: - **`anthropic-version` header** is accepted but not required - **Model routing** — requests can target any model in the CallMissed catalogue, not just Anthropic-shaped names. - **Token counting** uses a BPE tokenizer approximation (tiktoken `cl100k_base`). Expect ~5-10% variance from Anthropic's native counts on English prompts; larger on CJK and heavy-punctuation text. - **Tools** are supported — `tools` and `tool_choice` work as documented, and `tool_use`/`tool_result` content blocks are preserved. Send each `tool_use.id` back exactly as you received it: on some models it carries state the next turn needs. - **Vision** is supported on models whose `supports_vision` flag is `true`. Image content sent to text-only models is rejected with a `400 invalid_request_error` before the upstream call, so your credits are safe. - **Message Batches API** (`/v1/messages/batches`) is not implemented — use the regular `/v1/messages` endpoint. - **Claude models** (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, Pro plan and up) return their reasoning as standard `thinking` and `redacted_thinking` content blocks. Pass those blocks back unchanged in later turns, especially alongside `tool_result` blocks. `temperature`, `top_p` and `top_k` are ignored for these models, `tool_choice` forcing runs as `auto` on `claude-opus-5-5` and `claude-sonnet-5-5`, and the conversation must end with a `user` message (a trailing assistant prefill turn is dropped). - **Billing** uses CallMissed credits, not Anthropic billing. --- ### Batch API URL: /docs/batch > Run thousands of chat completions asynchronously from one JSONL file — OpenAI Batch API compatible. ## Overview The Batch API runs a file of chat-completion requests in the background and hands you the results as a file. Use it for work that does not need an answer right now: classifying a backlog, summarising transcripts overnight, generating evaluation sets. It is compatible with the OpenAI Batch API. An OpenAI SDK pointed at `https://api.callmissed.com/v1` works unchanged. | Endpoint | Purpose | | --- | --- | | `POST /v1/files` | Upload a JSONL input file (`purpose=batch`) | | `POST /v1/batches` | Start a batch from an uploaded file | | `GET /v1/batches/{batch_id}` | Poll status and progress | | `GET /v1/files/{file_id}/content` | Download the input, output or error file | | `POST /v1/batches/{batch_id}/cancel` | Stop a running batch | | `GET /v1/batches` | List batches (newest first) | | `GET /v1/files`, `GET /v1/files/{file_id}`, `DELETE /v1/files/{file_id}` | List, inspect and delete files | Each line runs exactly like a live `POST /v1/chat/completions` call made with the same API key: the same models, free-plan model rules, `allowed_models`, key budget, plan limits, response cache and bring-your-own-key routing apply. Streaming is the only thing a batch line cannot do. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` The key needs the **LLM** service permission (the same one chat completions needs). Creating a batch requires an API key, because every line later runs as that key. If the key is revoked or expires while the batch runs, the remaining lines fail with `api_key_revoked` or `api_key_expired`. ## 1. Write the input file One JSON object per line. `custom_id` is yours and must be unique within the file; `body` is a normal chat-completions request. ```json {"custom_id": "ticket-1041", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "sarvam-105b", "messages": [{"role": "user", "content": "Classify: my order never arrived"}], "max_tokens": 20}} {"custom_id": "ticket-1042", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "sarvam-105b", "messages": [{"role": "user", "content": "Classify: how do I change my plan?"}], "max_tokens": 20}} ``` | Field | Rule | | --- | --- | | `custom_id` | String, 1–512 characters, unique in the file | | `method` | `"POST"` | | `url` | `"/v1/chat/completions"` | | `body` | A valid chat-completions request with `model` set. `stream` must be absent or `false` | The whole file is validated on upload. An invalid line rejects the upload with `400 invalid_batch_file` and a message naming the line, so nothing is half-accepted. ## 2. Upload it ```bash curl https://api.callmissed.com/v1/files \ -H "Authorization: Bearer cm_your_api_key" \ -F purpose=batch \ -F file=@requests.jsonl ``` ```json { "id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10", "object": "file", "bytes": 412, "created_at": 1790000000, "expires_at": 1792592000, "filename": "requests.jsonl", "purpose": "batch", "status": "processed" } ``` ## 3. Create the batch ```bash curl https://api.callmissed.com/v1/batches \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "input_file_id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10", "endpoint": "/v1/chat/completions", "completion_window": "24h", "metadata": {"job": "nightly-triage"} }' ``` | Parameter | Type | Notes | | --- | --- | --- | | `input_file_id` | `string` | A file uploaded with `purpose=batch` | | `endpoint` | `string` | `/v1/chat/completions` (the only supported endpoint) | | `completion_window` | `string` | `24h` (the only supported window) | | `metadata` | `object` | Optional. Up to 16 string pairs; keys ≤ 64 characters, values ≤ 512 | Before the batch starts, every model in the file is checked against your plan and key. A model the key can never call is rejected here (`403 model_not_available` / `model_not_allowed`) rather than failing line by line. Creating a batch also needs a positive credit balance (`402 insufficient_credits`). ## 4. Poll for completion ```python import time from openai import OpenAI client = OpenAI(base_url="https://api.callmissed.com/v1", api_key="cm_your_api_key") batch_file = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch") batch = client.batches.create( input_file_id=batch_file.id, endpoint="/v1/chat/completions", completion_window="24h", ) while batch.status not in ("completed", "failed", "expired", "cancelled"): time.sleep(30) batch = client.batches.retrieve(batch.id) print(batch.request_counts) if batch.output_file_id: print(client.files.content(batch.output_file_id).text) if batch.error_file_id: print(client.files.content(batch.error_file_id).text) ``` ### Batch object ```json { "id": "batch_0b9e1f3a5c7d4e2f8a6b4c2d0e8f6a4b", "object": "batch", "endpoint": "/v1/chat/completions", "errors": null, "input_file_id": "file-6f1c2e0a9b8d4c3e8f7a6b5c4d3e2f10", "completion_window": "24h", "status": "completed", "output_file_id": "file-1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6", "error_file_id": null, "created_at": 1790000000, "in_progress_at": 1790000000, "expires_at": 1790086400, "finalizing_at": 1790000420, "completed_at": 1790000421, "failed_at": null, "expired_at": null, "cancelling_at": null, "cancelled_at": null, "request_counts": {"total": 2, "completed": 2, "failed": 0}, "metadata": {"job": "nightly-triage"} } ``` | Status | Meaning | | --- | --- | | `in_progress` | Lines are running. `request_counts` updates live | | `finalizing` | All lines are done; the result files are being written | | `completed` | Results are ready in `output_file_id` / `error_file_id` | | `cancelling` | Cancel requested; lines already running finish, the rest are skipped | | `cancelled` | Stopped. Lines that finished before the cancel are in the output file | | `expired` | The 24-hour window ran out. Finished lines are in the output file; the rest are in the error file with `batch_expired` | `validating` and `failed` exist for OpenAI compatibility; CallMissed validates the file when you upload it, so a new batch starts directly in `in_progress`. ## 5. Read the results The output file holds one line per successful request; the error file holds one line per failed request. Match lines to your input with `custom_id` — do not rely on line order. ```json {"id": "batch_req_3c5e…", "custom_id": "ticket-1041", "response": {"status_code": 200, "request_id": "chatcmpl-…", "body": {"id": "chatcmpl-…", "object": "chat.completion", "model": "sarvam-105b", "choices": [ … ], "usage": { … }}}, "error": null} ``` An error-file line carries an `error` object. When the request reached the model and was refused (for example a context-length error), `response` holds that response too; when the line never ran, `response` is `null`. ```json {"id": "batch_req_9a1b…", "custom_id": "ticket-1042", "response": null, "error": {"code": "batch_cancelled", "message": "This request was not executed because the batch was cancelled."}} ``` | `error.code` | Meaning | | --- | --- | | Any chat-completions error code | The line ran and got that error (e.g. `context_length_exceeded`, `model_not_allowed`) | | `insufficient_credits` | Your balance ran out. Every line not yet run fails with this code | | `budget_exceeded` | The key's budget cap was reached. Every line not yet run fails with this code | | `api_key_revoked` / `api_key_expired` | The creating key stopped being usable | | `account_inactive` / `account_terminated` | The account can no longer make requests | | `batch_cancelled` | Skipped because you cancelled the batch | | `batch_expired` | Not run before the 24-hour window ended | | `server_error` | The line kept failing after retries | Temporary failures (upstream errors, concurrency limits) are retried automatically, up to three attempts per line, before they are written to the error file. ## Cancel ```bash curl -X POST https://api.callmissed.com/v1/batches/batch_0b9e…/cancel \ -H "Authorization: Bearer cm_your_api_key" ``` Returns the batch in `cancelling`; it moves to `cancelled` once lines already running finish. Cancelling a batch that has already finished returns `409 batch_not_cancellable`. ## List `GET /v1/batches?limit=20&after=batch_…` returns `{object, data, first_id, last_id, has_more}`, newest first. `limit` is 1–100; pass the previous page's `last_id` as `after` for the next page. ## Files | Call | Returns | | --- | --- | | `GET /v1/files?purpose=batch&limit=20` | Your files, newest first (`purpose` is `batch` for uploads, `batch_output` for results) | | `GET /v1/files/{file_id}` | The file object | | `GET /v1/files/{file_id}/content` | The raw JSONL | | `DELETE /v1/files/{file_id}` | `{"id", "object": "file", "deleted": true}` — deletes it now instead of at expiry | Every file — the ones you upload and the result files — is deleted automatically 30 days after it was created (`expires_at`). Download results you want to keep. ## Pricing Batch lines are billed at the **same per-token rate as a live `/v1/chat/completions` call** for the model that served them, and each line appears in your usage logs with `batch_id` and `custom_id` in its metadata. Lines answered from the response cache or served on your own provider key cost what they would live. Lines that fail are not charged. ## Limits | Limit | Value | | --- | --- | | Requests per file | 10,000 | | File size | 20 MB | | Running batches per account | 5 (`429 too_many_active_batches` above that) | | Completion window | 24 hours | | File retention | 30 days | Lines of one account run a few at a time so a large batch does not crowd out your live traffic. Most batches finish well inside the window; the 24 hours is an upper bound, not a target. ## Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_batch_file` | The upload is not valid batch JSONL (the message names the line) | | `400` | `invalid_purpose` | `purpose` is not `batch` | | `400` | `invalid_input_file` | `input_file_id` is not a file uploaded with `purpose=batch` | | `400` | `unsupported_endpoint` / `invalid_completion_window` / `invalid_metadata` | Bad create parameters | | `400` | `zdr_unavailable` | [Zero data retention](/docs/gateway-controls) is on for the account or key, or a line sends `provider.zdr: true`. A batch stores its input and output files, so it cannot run under ZDR | | `400` | `region_unavailable` | [India-only processing](/docs/gateway-controls) is on for the account or key, or a line sends `provider.data_residency: "in"`, and the line's model is not processed in India | | `402` | `insufficient_credits` | No credit balance at create time | | `403` | `permission_denied` | The key lacks the LLM permission | | `403` | `api_key_required` | Batch create called without an API key | | `403` | `model_not_available` / `model_not_allowed` | A model in the file is not available to this key | | `404` | `file_not_found` / `batch_not_found` | Unknown id, or it belongs to another account | | `409` | `batch_not_cancellable` | The batch already finished | | `413` | `file_too_large` | Upload over 20 MB | | `429` | `too_many_active_batches` | Five batches already running | | `503` | `storage_unavailable` | File storage is temporarily unavailable — retry | --- ### Chat Completion URL: /docs/chat-completion > Generate text responses using our OpenAI-compatible chat completion API. :::cards /docs/chat-streaming | Streaming | play | Server-sent events for real-time responses /docs/chat-function-calling | Function Calling | settings | Tool use with structured outputs /docs/models | Model Catalog | boxes | Pick the right model for your workload /docs/anthropic-api | Anthropic API | file-text | Messages API compatible endpoint ::: ## Overview The Chat Completion API generates AI responses given a list of messages. It's fully OpenAI-compatible — use the same SDK and request format. **Endpoint:** `POST /v1/chat/completions` ### How a request flows Every chat completion takes the same path through the platform — your app never talks to the underlying provider directly: :::flow icon:app | Your app | Send `POST /v1/chat/completions` with `model` + `messages` icon:gateway | CallMissed gateway | Authenticate the `cm_` key, check credits, route by model id icon:provider | Provider | Run inference on the best-fit backend — picked from the model id icon:gateway | CallMissed gateway | Stream tokens back and deduct credits when the response completes icon:done | Your app | Receive the completion (all at once, or token-by-token when streaming) ::: > **Tip:** The model id decides routing automatically — you never pick a backend. See [How CallMissed Works](/docs/how-it-works). ## Make your first request :::steps ## Get an API key Create a key in the [dashboard](https://console.callmissed.com/developer/keys) (**Developer → API keys**). It looks like `cm_xxxx…` and is shown once. ## Point your SDK at CallMissed Set the base URL to `https://api.callmissed.com/v1` and pass your `cm_` key. No other change to your OpenAI code. ## Send messages and read the reply Call `chat.completions.create` with a `model` and a `messages` array. Read `response.choices[0].message.content`. ::: :::capabilities ### Basic completion Send a single-turn or multi-turn conversation and receive a complete response. Use any OpenAI SDK — set `base_url` to `https://api.callmissed.com/v1` and `api_key` to your `cm_` key. ### Streaming Set `stream: true` to receive tokens as they're generated. See [Streaming](/docs/chat-streaming) for full examples. ### Function calling Pass a `tools` array to let the model call your functions. See [Function Calling](/docs/chat-function-calling). ::: ## Basic Usage :::tabs ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) response = client.chat.completions.create( model="sarvam-105b", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is the capital of India?"} ] ) print(response.choices[0].message.content) ``` ```javascript [JavaScript] import OpenAI from "openai"; const client = new OpenAI({ apiKey: "cm_your_key", baseURL: "https://api.callmissed.com/v1", }); const response = await client.chat.completions.create({ model: "sarvam-105b", messages: [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "What is the capital of India?" }, ], }); console.log(response.choices[0].message.content); ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "sarvam-105b", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is the capital of India?"} ] }' ``` ::: ## Parameters | Parameter | Type | Description | |-----------|------|-------------| | `model` | string | Model ID (e.g. `sarvam-105b`, `gpt-5.6-luna`). Defaults to `sarvam-105b` when omitted | | `messages` | array | **Required.** 1–2,000 `{role, content}` objects. System prompt goes here as `{"role": "system", "content": "..."}`. `content` may be a string or an array of text, image, audio or document parts (see [Vision](#vision-image-input) and [Audio and documents](#audio-and-document-input)) | | `stream` | boolean | Enable streaming SSE responses (default `false`) | | `stream_options` | object | `{"include_usage": true}` to get token counts in the stream | | `temperature` | number | Sampling temperature, `0`–`2` | | `max_tokens` | integer | Maximum tokens to generate, `1`–`1,048,576`. A model with a documented output limit (the `max_output_tokens` field in `GET /api/v1/models`) rejects a larger value with `400 max_tokens_too_large` | | `max_completion_tokens` | integer | Same as `max_tokens` (the newer OpenAI name); used when `max_tokens` is absent | | `n` | integer | Number of completions, `1`–`5` (default `1`) | | `top_p` | number | Nucleus sampling, `0`–`1` | | `top_k` | integer | Top-K sampling, `1`–`200` | | `frequency_penalty` | number | Penalize repeated tokens, `-2`–`2` | | `presence_penalty` | number | Penalize new topics, `-2`–`2` | | `repetition_penalty` | number | Reduce repetition, `0`–`2` | | `seed` | integer | Deterministic sampling, `0`–`2^63-1` | | `stop` | string or array | Up to 16 stop sequences | | `logit_bias` | object | Token id → bias between `-100` and `100`; at most 1,024 entries | | `logprobs` | boolean | Return log probabilities | | `top_logprobs` | integer | Top N log probs per token, `0`–`20` | | `tools` | array | Up to 10,000 function definitions (128 on the GPT-4o, GPT-4.1, GPT-5 and GPT-6 models other than `gpt-6.1-sol`) — see [Function Calling](/docs/chat-function-calling) | | `tool_choice` | string or object | `"auto"`, `"none"`, `"required"`, or `{"type": "function", "function": {"name": "..."}}` | | `parallel_tool_calls` | boolean | Allow parallel function calls | | `response_format` | object | `{"type": "json_object"}` or `{"type": "json_schema", "json_schema": {...}}` | | `structured_outputs` | boolean | Enforce strict JSON schema | | `reasoning_effort` | string | `"none"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` — each model accepts a different subset and the gateway maps the rest; see the [per-model matrix](/docs/api-speed#3-reasoning-effort-by-model) | | `models` | array | Up to 32 fallback model ids, tried in order if `model` fails with a retryable error — see [Model substitution](#model-substitution) | | `user` | string | Your end user's id. Enforces that user's [monthly budget](/docs/gateway-controls) when one is set (caps apply to ids of at most 256 characters) | | `provider` | object | `{"zdr": true}` serves the request only on a [zero-data-retention route](/docs/gateway-controls), or fails with `zdr_unavailable`. `{"data_residency": "in"}` serves it only on models whose `processing` is `"india"`, or fails with `region_unavailable` ([details](/docs/gateway-controls)) | | `prompt_cache_key` | string | Up to 1,024 characters. Reuse the same key for requests that share a long prompt prefix to raise the cache hit rate — see [Prompt caching](#prompt-caching) | | `bot_id` | string | ID of one of your [bots](/docs/bots). Its knowledge base is searched with the latest user message and the top matches are added to the prompt — see [Knowledge](/docs/knowledge) | | `knowledge_top_k` | integer | With `bot_id`: how many knowledge chunks to add, `1`–`50` (default `6`) | | `knowledge_min_score` | number | With `bot_id`: minimum similarity score, `0`–`1` (default `0`) | On GPT-5 and GPT-6 family models, `temperature`, `top_p` and `logit_bias` are not supported by the model and are left out of the request; the model runs at its default sampling. > **OpenAI Python SDK note** — The OpenAI client validates kwargs against its > known parameters, so a CallMissed-specific field such as `reasoning_effort` > raises `TypeError: Completions.create() got an unexpected keyword argument`. > Pass it via `extra_body` instead: > > ```python > client.chat.completions.create( > model="kimi-k2.6", > messages=[...], > extra_body={"reasoning_effort": "none"}, > ) > ``` > > Raw HTTP / curl users can keep it at the top level — only the OpenAI SDK gates kwargs. ## Model Substitution CallMissed never substitutes your model on its own. Send a `model` and you get that model, or a clean error (`429`/`503` with `Retry-After`). To opt in to failover, list fallbacks yourself in `models`: ```json { "model": "kimi-k2.6", "models": ["kimi-k2.5", "gpt-oss-120b"], "messages": [{"role": "user", "content": "Hello"}] } ``` If `model` fails with a retryable error (an upstream outage or rate limit), the next id in `models` is tried. Each fallback must pass the same checks as `model` — your plan, the key's `allowed_models`, maintenance status, vision support and context window — and ids that fail them are skipped. The response's `model` field names the model that actually answered, and you are billed at that model's rate. Requests that send `tools`, `tool_choice`, `response_format` or `structured_outputs` never fall back, because a different model could change the result shape. Need a model that is not in the catalog? See [Models on demand](/docs/models#models-on-demand). ## Vision (Image Input) Multimodal content (text + image parts) is accepted on any model whose `supports_vision` flag is `true` in `GET /v1/models`. Models without vision support reject image content with `400 unsupported_image_input` **before** the upstream call, so you're not charged. ```python from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") resp = client.chat.completions.create( model="gpt-6-sol", # supports_vision: true messages=[{ "role": "user", "content": [ {"type": "text", "text": "What is in this image?"}, {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, ], }], ) ``` Vision-capable models: `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `grok-4.3`, `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemma-4-31b`, `glm-5.3-flash`, `deepseek-v4.1-flash-1017`, `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3.1-pro`. `GET /v1/models` is authoritative. Read `supports_vision` there rather than hard-coding this list. An `image_url` may be an `https://` URL or a base64 data URL. The Gemini models, `gemma-4-31b`, `glm-5.3-flash` and `deepseek-v4.1-flash-1017` only take image bytes, so CallMissed downloads a URL image for them: it must be publicly reachable without redirects, return an image content type, and be at most 20 MB (10 MB for `gemma-4-31b`, `glm-5.3-flash` and `deepseek-v4.1-flash-1017`). Gemini accepts PNG, JPEG, WEBP, HEIC and HEIF. If a download fails the request returns `400`; send base64 to avoid the extra fetch. ## Audio and Document Input The Gemini models (`gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`) also take audio and PDF parts: ```json {"type": "input_audio", "input_audio": {"data": "", "format": "wav"}} {"type": "file", "file": {"file_data": "data:application/pdf;base64,", "filename": "report.pdf"}} ``` Audio `format` may be `wav`, `mp3`, `aac`, `flac`, `ogg`, `opus`, `m4a`, `aiff` or `webm`. A `file` part must carry the PDF inline in `file_data`; `file_id` references are not supported. Any other model rejects these parts with `400 unsupported_audio_input` or `400 unsupported_file_input` before the upstream call, so you're not charged. Content that can't be passed on as sent is rejected with a `400` that names the part, rather than being dropped. ## Context Window Every model in the catalog advertises a `context_window` (token count for the combined prompt + completion). The `GET /v1/models` response exposes it under two keys for cross-client compatibility: - `context_window` (OpenAI/CallMissed canonical name) - `context_length` (OpenAI SDK convention — same value) ```python from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") for m in client.models.list(): extra = m.model_extra or {} print(m.id, extra.get("context_window"), extra.get("supports_vision")) ``` Snapshot — `GET /v1/models` is authoritative: | Model | context_window | |-------|----------------| | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-6-sol`, `gpt-6-luna`, `gpt-6.1-sol` | 1,050,000 | | `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` | 1,000,000 | | `glm-5.3`, `glm-5.3-flash`, `deepseek-v4.1-flash`, `deepseek-v4.1-flash-1017`, `kimi-k3.1-pro` | 1,048,576 | | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | 1,000,000 | | `gpt-4.1` | 300,000 | | `gpt-5-mini` | 400,000 | | `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code` | 262,144 | | `grok-4.3` | 200,000 | | `sarvam-105b`, `gpt-oss-120b`, `gemma-4-31b` | 131,072 | | `gpt-4o` | 128,000 | | `deepgram-think-fast`, `deepgram-phonellm-mini` | 32,768 | | `sarvam-105b-conversations` | 32,000 | Images, audio and files count toward the window by what the model reads, not by their encoded size, so a large base64 image does not by itself cause `context_length_exceeded`. A prompt that is clearly longer than the window is rejected with `400 context_length_exceeded` before the model is called (on `/v1/chat/completions`, `/v1/responses` and `/v1/messages`); a prompt close to the limit is sent to the model, and if the model finds it too long you get the same `400 context_length_exceeded` (as an error event mid-stream when `stream: true`). Either way nothing is billed. ## Prompt caching Models that support prompt caching reuse repeated prompt prefixes automatically — there is nothing to turn on. Cached prompt tokens are billed at the model's cached-input rate where one is published (see [Models](/docs/models)); a model with no cached rate bills them at its normal input rate. Tokens written to the cache are billed at the input rate, except on models that publish a separate cache-write rate (such as `gpt-6.1-sol`, `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`). Every response reports the cached share of the prompt: ```json "usage": { "prompt_tokens": 3120, "completion_tokens": 42, "total_tokens": 3162, "prompt_tokens_details": { "cached_tokens": 2944 } } ``` - `prompt_tokens` is the whole prompt, cached part included. - `prompt_tokens_details.cached_tokens` is always present (`0` on a miss or the first request). - `prompt_tokens_details.cache_write_tokens` appears when the model reported tokens written to the cache (GPT-5.6 and later bill these at their own rate). - Streaming: the same block is in the final usage chunk when you send `stream_options: {"include_usage": true}`. - `/v1/responses` reports the same numbers as `usage.input_tokens_details.cached_tokens` and `usage.input_tokens_details.cache_write_tokens`. To improve hit rates: - Put stable content first (system prompt, tool definitions, reference documents) and the changing part last. - Send a `prompt_cache_key` (up to 1,024 characters, on `/v1/chat/completions` and `/v1/responses`) and reuse it for requests that share a prefix. It is accepted on every model and not forwarded today; prompt-prefix caching is automatic, and cached tokens are reported in `usage.prompt_tokens_details.cached_tokens` where the model returns them. - Explicit per-block cache breakpoints (`cache_control` / `prompt_cache_breakpoint` on a content part) are not applied today — caching works from the prompt prefix automatically. ```json { "model": "kimi-k2.6", "prompt_cache_key": "support-bot:policy-v3", "messages": [ {"role": "system", "content": ""}, {"role": "user", "content": "Where is my order?"} ] } ``` A prefix usually needs to be at least ~1,024 tokens before it is cached, and an idle cache expires after a few minutes. Hits are not guaranteed. ## Responses API For clients built on OpenAI's newer **Responses API**, CallMissed exposes a compatible `POST /v1/responses` endpoint. It accepts a Responses-shaped body and translates to the same chat engine under the hood — so you can point an OpenAI Responses client at `https://api.callmissed.com/v1` without changes. **Endpoint:** `POST /v1/responses` :::tabs ```python [Python] from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") resp = client.responses.create( model="gpt-6-sol", input="Write a haiku about databases.", ) print(resp.output_text) ``` ```bash [cURL] curl https://api.callmissed.com/v1/responses \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-6-sol", "input": "Write a haiku about databases." }' ``` ::: | Field | Type | Notes | |-------|------|-------| | `model` | string | Any chat model id. Defaults to `kimi-k2.5` when omitted | | `input` | string or array | A plain string, or the Responses item array (messages, `function_call` and `function_call_output` items). Message content parts: `input_text`, `input_image` (`image_url`), `input_file` (inline `file_data`). A `function_call_output` `output` may be a string or an array of those parts; its images are passed to the model with the tool result | | `instructions` | string | System prompt | | `max_output_tokens` | integer | `1`–`1,048,576` | | `temperature` / `top_p` | number | `0`–`2` / `0`–`1` | | `tools` | array | Flat function tools (`{"type": "function", "name", "description", "parameters"}`). Any other tool type returns `400 unsupported_tool_type` | | `tool_choice`, `parallel_tool_calls` | | As in the Responses API. A `tool_choice` naming a hosted tool returns `400 unsupported_tool_choice` | | `reasoning` | object | `{"effort": "..."}` — mapped like `reasoning_effort` on chat completions | | `text` | object | `{"format": {"type": "json_object" \| "json_schema", ...}}` for structured output | | `stream` | boolean | Emits Responses-style SSE events (`response.output_text.delta`, …) | | `user`, `provider`, `prompt_cache_key` | | Same meaning as on `/v1/chat/completions` | - The same models, pricing, plan rules, vision and tool-calling support as `/v1/chat/completions` apply — this is a request/response-shape adapter, not a different model set. - Responses are not stored: `store` is accepted for compatibility and ignored, and `previous_response_id` returns `400 unsupported_parameter`. Send the full conversation in `input` each turn. - An `input_image` given only as `file_id`, or an `input_file` given as `file_id` or `file_url`, returns `400 unsupported_content_part` naming the part. Send the bytes inline instead. If you're starting fresh, `/v1/chat/completions` is the most widely-supported surface; use `/v1/responses` when porting an existing Responses-API integration. ## Errors All errors return the OpenAI-compatible envelope: ```json { "error": { "message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key" } } ``` | Status | `code` | When | |--------|--------|------| | `400` | `unsupported_image_input` | Image content sent to a model without vision support | | `400` | `unsupported_audio_input` / `unsupported_file_input` | `input_audio` or `file` parts sent to a model that doesn't take them | | `400` | `unsupported_content_part` | A content part that can't be passed on as sent (for example a `file_id` reference on `/v1/responses`) | | `400` | `voice_agent_only_model` | A realtime or managed-voice model id — use `POST /v1/voice/sessions` | | `400` | `zdr_unavailable` | Zero data retention is on and the model has no zero-retention route | | `400` | `region_unavailable` | India-only processing is on and the model is not processed in India | | `400` | `context_length_exceeded` | The prompt is longer than the model's context window | | `400` | `too_many_tools` | The request sends more tools than the model accepts (128 on the GPT models listed in [Function Calling](/docs/chat-function-calling)) | | `400` | `max_tokens_too_small` | `max_tokens` below 3 on `gpt-5.6-luna`, `gpt-6-sol`, `gpt-6-luna` or `gpt-6.1-sol`, or below 16 on `gpt-6.1-sol` when `tools` are sent | | `400` | `max_tokens_too_large` | `max_tokens` above the model's `max_output_tokens` (for example 65,536 on the `gemini-*` models, 16,384 on `gpt-4o`, 32,768 on `gpt-4.1`, 128,000 on the GPT-5 and GPT-6 models) | | `401` | `invalid_api_key` / `api_key_expired` | Missing, malformed or revoked key / expired key | | `402` | `insufficient_credits` | Balance exhausted, or already held by your other in-flight requests: each request holds its estimated cost (input plus `max_tokens`, or up to 4,096 output tokens when unset) until it finishes, then is charged its real cost. `X-Credits-Balance` carries the balance | | `402` | `budget_exceeded` | The key's own budget cap was reached | | `402` | `end_user_budget_exceeded` | The `user` id has used its monthly budget | | `403` | `permission_denied` | The key lacks the `llm` permission | | `403` | `account_inactive` | The account is suspended or closed | | `403` | `model_not_available` | A free-plan key calling a paid model, or a free or Starter key calling a Claude model (Pro plan and up) | | `403` | `model_not_allowed` | The key's `allowed_models` list excludes the model | | `404` | `model_not_found` | Unknown model id | | `422` | — | Request body failed validation (a field out of range, a missing `messages`). Body is `{"detail": [{loc, msg, type}]}` | | `429` | `quota_exceeded` | Monthly plan call cap reached | | `429` | `rate_limit_exceeded` | Per-key requests-per-minute exceeded | | `429` | `too_many_concurrent_requests` | Too many requests in flight on this key. Honour `Retry-After` | | `503` | `model_under_maintenance` | The model is temporarily unavailable; the message names an alternative | | `503` | `provider_error` | The model is temporarily unavailable upstream. Safe to retry | Every response carries an `X-Request-ID` header — quote it when you contact support. --- ### Function Calling URL: /docs/chat-function-calling > Use tool calls and function calling with the chat completion API. ## Overview Function calling lets the model invoke your functions. Pass a `tools` array and the model returns structured `tool_calls` when it wants to call a function. ## Defining Tools ```json { "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a city", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "City name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["city"] } } } ] } ``` ## Tool Choice | Value | Description | |-------|-------------| | `"auto"` | Model decides whether to call a tool (default) | | `"none"` | Never call tools | | `"required"` | Always call at least one tool | | `{"type": "function", "function": {"name": "get_weather"}}` | Force a specific function | Set `parallel_tool_calls: true` to allow the model to call multiple tools in one response. Up to 10,000 tools per request. Every model with `supports_tools: true` in [`GET /v1/models`](/docs/models#models-api) accepts them, except `gpt-4o`, `gpt-4.1`, `gpt-5-mini`, `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-6-sol` and `gpt-6-luna`, which accept at most 128 and return `400 too_many_tools` above that. On the GPT-5.6 and GPT-6 models, a request that sends `tools` runs with reasoning switched off (`reasoning_effort` is sent as `"none"`), because those models cannot combine function tools with reasoning on this endpoint. A request with `tools` never uses the `models` fallback list. ### Claude models `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5` (Pro plan and up) accept `tools` with reasoning on. Two things differ from the other models: - `tool_choice: "required"` or a named function is honoured on `claude-haiku-5-5`. On `claude-opus-5-5` and `claude-sonnet-5-5` it runs as `"auto"`, because those models do not support forced tool use. - When a Claude response contains tool calls on `/v1/chat/completions`, the first tool call carries an extra `anthropic_thinking` field holding the model's full turn, including its signed reasoning. Send the assistant message back **unchanged**, including that field, in your next request so the model keeps its reasoning across tool calls. On [`/v1/messages`](/docs/anthropic-api) the same reasoning comes back as standard `thinking` / `redacted_thinking` content blocks; pass them back unchanged. ## Handling Response When the model calls a tool, `finish_reason` is `"tool_calls"` and `message.content` is `null`: ```json { "choices": [{ "finish_reason": "tool_calls", "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"Mumbai\"}" } }] } }] } ``` Send the tool result back as a `tool` role message: ```json { "role": "tool", "tool_call_id": "call_abc123", "content": "{\"temperature\": 32, \"condition\": \"sunny\"}" } ``` ## Counting Tool Calls Every response includes a `usage.tool_call_count` field — the number of tool calls the model made in that response (`0` when none). It is present on both streaming and non-streaming responses, so you can track tool usage per request: ```json { "choices": [{ "finish_reason": "tool_calls", "message": { "tool_calls": [/* ... */] } }], "usage": { "prompt_tokens": 18, "completion_tokens": 25, "total_tokens": 43, "tool_call_count": 2 } } ``` When streaming, each chunk that carries a `delta.tool_calls` fragment also includes a top-level `tool_call_count` that increments as new tool calls begin — useful for showing a live "tools called" counter in your UI. The definitive total is always in the final `usage` chunk (requires `stream_options: {"include_usage": true}`). ## Full Example ```python import json from openai import OpenAI client = OpenAI(api_key="cm_your_key", base_url="https://api.callmissed.com/v1") # Step 1: Send initial request with tools response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "What's the weather in Mumbai?"}], tools=[{ "type": "function", "function": { "name": "get_weather", "description": "Get weather for a city", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } }], tool_choice="auto" ) # Step 2: Check if model wants to call a tool msg = response.choices[0].message if msg.tool_calls: tool_call = msg.tool_calls[0] # Execute your function here... result = get_weather(json.loads(tool_call.function.arguments)["city"]) # Step 3: Send result back final = client.chat.completions.create( model="sarvam-105b", messages=[ {"role": "user", "content": "What's the weather in Mumbai?"}, msg, # assistant message with tool_calls {"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result)} ] ) print(final.choices[0].message.content) ``` --- ### Streaming URL: /docs/chat-streaming > Stream chat completion responses in real-time using Server-Sent Events. ## Overview Enable streaming by setting `"stream": true`. The response is a Server-Sent Events (SSE) stream with `Content-Type: text/event-stream`. ## SSE Format Each event is a line starting with `data: ` followed by a JSON chunk: ``` data: {"id":"...","choices":[{"delta":{"role":"assistant","content":""},"finish_reason":null}]} data: {"id":"...","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]} data: {"id":"...","choices":[{"delta":{},"finish_reason":"stop"}]} data: [DONE] ``` - **First chunk** always includes `{"delta": {"role": "assistant", "content": ""}}` - **Content chunks** carry `{"delta": {"content": "token"}}` - **Final chunk** has `{"delta": {}, "finish_reason": "stop"}` - **End marker** is `data: [DONE]` - **Reasoning models** stream their thinking as `delta.reasoning_content`, separate from `delta.content`. Ignore it if you only want the answer. The response carries `X-Request-ID` and `Cache-Control: no-cache` headers. ## Errors mid-stream Errors found before the first byte (bad key, no credits, unknown model) return a normal JSON error with an HTTP status — see [Chat Completion → Errors](/docs/chat-completion#errors). If the model fails after streaming has started, the stream sends one error event and then closes **without** `data: [DONE]`: ``` data: {"error":{"message":"...","type":"server_error","code":"upstream_error","request_id":"chatcmpl-..."}} ``` `code` is `upstream_timeout`, `upstream_unavailable` or `provider_error`. Treat a stream that ends without `[DONE]` as failed, even if some content arrived. ## Usage in Stream To get token usage in the stream, set `stream_options: {"include_usage": true}`. A final chunk with a `usage` field is sent before `[DONE]`: ```json data: {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46,"tool_call_count":0,"prompt_tokens_details":{"cached_tokens":0}}} data: [DONE] ``` The `usage.tool_call_count` field is the number of tool calls the model made in this response (`0` when none). It is always present in the usage chunk. While the response streams, any chunk that carries a `delta.tool_calls` fragment also includes a running `tool_call_count` at the top level, so you can show a live counter as tools are invoked. ## Code Example :::tabs ```python [Python] from openai import OpenAI client = OpenAI( api_key="cm_your_key", base_url="https://api.callmissed.com/v1" ) stream = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Hello"}], stream=True, stream_options={"include_usage": True} ) for chunk in stream: if chunk.choices and chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True) ``` ```javascript [JavaScript] const stream = await client.chat.completions.create({ model: "sarvam-105b", messages: [{ role: "user", content: "Hello" }], stream: true, stream_options: { include_usage: true }, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) process.stdout.write(content); } ``` ```bash [cURL] curl -X POST https://api.callmissed.com/v1/chat/completions \ -H "Authorization: Bearer cm_your_key" \ -H "Content-Type: application/json" \ -d '{"model":"sarvam-105b","messages":[{"role":"user","content":"Hello"}],"stream":true}' ``` ::: --- ### Embeddings URL: /docs/embeddings > Turn text into vectors with the OpenAI-compatible embeddings endpoint — batching, dimensions, base64 output, and per-token pricing. ## Overview `POST /v1/embeddings` converts text into a dense float vector you can store in your own vector database and search with cosine similarity. It is the primitive behind retrieval, semantic search, clustering, deduplication and classification. The request and response are **OpenAI-compatible**, so the official OpenAI SDKs work unchanged once you point them at `https://api.callmissed.com/v1` with a `cm_` key. > If you want retrieval without running your own vector store, use [Knowledge & RAG](/docs/knowledge) instead — it ingests, chunks, embeds and searches for you. ## Authentication ``` Authorization: Bearer cm_your_api_key ``` This endpoint is gated by the key's **service permission**, not by a resource scope. The key needs `llm` (or `*`). There is no separate `embedding` permission — a key that can call `/v1/chat/completions` can call `/v1/embeddings`. A key without it returns `403`: ```json { "error": { "message": "This API key does not have permission for embeddings (requires LLM permission). Update key permissions in your dashboard.", "type": "invalid_request_error", "code": "permission_denied" } } ``` ## Models Both embedding models are **free-plan callable** — they are metered per input token, so your credit balance is the only governor. | Model | Dimensions | Max input | Price (per 1M input tokens) | | --- | --- | --- | --- | | `text-embedding-3-small` | 1536 | 8,192 tokens | $0.02083 | | `text-embedding-3-large` | 3072 | 8,192 tokens | $0.1354 | Start with `text-embedding-3-small`: it is the better price/performance choice for large corpora. Move to `-large` only when you have measured that retrieval quality is the bottleneck. Both appear in `GET /v1/models` with `"owned_by": "openai"`. ## Quickstart ```bash curl https://api.callmissed.com/v1/embeddings \ -H "Authorization: Bearer cm_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "model": "text-embedding-3-small", "input": "Where is my order?" }' ``` ```json { "object": "list", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023064255, -0.009327292, 0.015797347] } ], "model": "text-embedding-3-small", "usage": { "prompt_tokens": 5, "total_tokens": 5 } } ``` ```python from openai import OpenAI client = OpenAI( api_key="cm_your_api_key", base_url="https://api.callmissed.com/v1", ) resp = client.embeddings.create( model="text-embedding-3-small", input=["Where is my order?", "How do I return this?"], ) vectors = [row.embedding for row in resp.data] ``` ## Request | Field | Type | Required | Default | Constraints | | --- | --- | --- | --- | --- | | `model` | `string` | Yes | — | `text-embedding-3-small` or `text-embedding-3-large` | | `input` | `string` or `string[]` | Yes | — | Up to **128 items** per request; each item non-empty, at most **100,000 characters** and within the model's 8,192-token limit. Pre-tokenised integer arrays are **not** accepted | | `encoding_format` | `string` | No | `float` | `float` or `base64` | | `dimensions` | `integer` | No | model native | `1 <= dimensions <= 3072` (large) or `1536` (small) | | `user` | `string` | No | — | At most 256 characters. Your end user's id; enforces that user's [monthly budget](/docs/gateway-controls) when one is set | | `provider` | `object` | No | — | `{"zdr": true}` requires a [zero-data-retention route](/docs/gateway-controls); otherwise `400 zdr_unavailable`. `{"data_residency": "in"}` requires a model processed in India; otherwise `400 region_unavailable` ([details](/docs/gateway-controls)) | ### Batching Send an array to embed up to 128 strings in one round trip. The `index` on each row matches the position in your `input` array, so you can zip the results back onto your records without re-ordering. ```json { "model": "text-embedding-3-small", "input": ["first chunk", "second chunk", "third chunk"] } ``` A batch of 129 or more returns `422`: ```json { "error": { "message": "`input` array too long: 200 items (maximum 128). Split the batch across multiple requests.", "type": "invalid_request_error", "code": "invalid_request_error" } } ``` ### Shortening vectors with `dimensions` Both models support Matryoshka-style truncation. Passing `dimensions` returns a shorter, renormalised vector — smaller index, faster search, slightly lower recall. ```json { "model": "text-embedding-3-large", "input": "hello", "dimensions": 256 } ``` `dimensions` must be between `1` and the model's native size. Anything else returns `422`. > Vectors of different lengths are not comparable. Pick one model **and** one `dimensions` value per index and keep it fixed — re-embed the whole corpus if you change either. ### `encoding_format: "base64"` `base64` returns each vector as a base64 string of little-endian `float32` values instead of a JSON array. It is roughly a third of the payload size, which matters when you are embedding thousands of chunks. ```python import base64, struct raw = base64.b64decode(resp.data[0].embedding) vector = list(struct.unpack(f"<{len(raw) // 4}f", raw)) ``` ## Response | Field | Type | Notes | | --- | --- | --- | | `object` | `string` | Always `list` | | `data[].object` | `string` | Always `embedding` | | `data[].index` | `integer` | Position in your `input` array | | `data[].embedding` | `number[]` or `string` | Float array, or a base64 string when `encoding_format` is `base64` | | `model` | `string` | The model that served the request | | `usage.prompt_tokens` | `integer` | Input tokens billed | | `usage.total_tokens` | `integer` | Same as `prompt_tokens` — embeddings have no output tokens | ## Billing Embeddings are metered on **input tokens only**. Credits are deducted as `tokens / 1,000,000 x rate in credits`, where 1 credit = ₹1 ≈ US$0.0104 (US$1 = ₹96). - `text-embedding-3-small` — 2 credits per 1M input tokens - `text-embedding-3-large` — 13 credits per 1M input tokens A request that fails with a `4xx` or `5xx` is recorded in your usage log but **not charged**. Track spend with [`GET /v1/usage/summary`](/docs/usage-api). ## Errors | Status | `code` | When | | --- | --- | --- | | `400` | `invalid_request_error` | Body is not a JSON object | | `400` | `context_length_exceeded` | An input item exceeds the model's 8,192-token limit | | `401` | `invalid_api_key` / `api_key_expired` | Missing, malformed, or expired key | | `402` | `insufficient_credits` | Balance is exhausted. The `X-Credits-Balance` header carries the current balance | | `402` | `budget_exceeded` | The key's own budget cap was hit | | `403` | `permission_denied` | Key lacks the `llm` permission | | `403` | `model_not_allowed` | The key's `allowed_models` list excludes this model | | `404` | `model_not_found` | Unknown embedding model id | | `413` | `invalid_request_error` | An input item is over 100,000 characters | | `422` | `invalid_request_error` | Batch too long, empty input, bad `dimensions`, unsupported `encoding_format`, or a token array instead of a string | | `429` | `rate_limit_exceeded` | Per-key request rate exceeded. Retry with backoff | | `429` | `quota_exceeded` | Plan or monthly budget cap reached. Honour `Retry-After` | | `503` | `upstream_error` | Embedding generation failed. Safe to retry | | `503` | `service_unavailable` | Temporary capacity problem. Retry with backoff | Every error uses the standard envelope: ```json { "error": { "message": "…", "type": "invalid_request_error", "code": "model_not_found", "request_id": "req_…" } } ``` ## Building a search index 1. Chunk your documents to roughly 200–500 tokens with a little overlap. 2. Embed chunks in batches of 128 with `text-embedding-3-small`. 3. Store `{id, text, vector, metadata}` in your vector database. 4. At query time, embed the query with the **same model and `dimensions`**, then retrieve by cosine similarity. 5. Pass the top chunks to [`POST /v1/chat/completions`](/docs/chat-completion) as context. ```python query = client.embeddings.create( model="text-embedding-3-small", input="refund policy", ).data[0].embedding # hits = your_vector_db.search(query, top_k=5) ``` --- ### Model Access by Plan URL: /docs/model-access > See which models each plan tier (Free, Starter, Pro, Enterprise, Scale, Business, Corporate) can access, with cURL, Python, and JavaScript examples. ## Overview The Model Access endpoint returns which model IDs each plan tier can call, bucketed by category (LLM, STT, TTS, Image, Embedding). This is useful for: - Showing users which models they can access on their current plan - Building model selectors that grey out unavailable models - Checking whether a specific model requires an upgrade **No authentication required.** ## Endpoint ``` GET /api/v1/models/access ``` ## Response Shape ```json { "plans": { "free": { "models": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "gpt-oss-120b", "saaras:v4", "bulbul:v3", "bulbul:v4-flash", "text-embedding-3-small", "text-embedding-3-large"], "by_category": { "llm": ["sarvam-105b", "sarvam-105b-conversations", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "gpt-oss-120b"], "stt": ["saaras:v4"], "tts": ["bulbul:v3", "bulbul:v4-flash"], "image": [], "embedding": ["text-embedding-3-small", "text-embedding-3-large"] }, "restriction": "Only the models listed here, plus the retired LLM and speech IDs that still answer on the free plan (see /docs/models#retired-and-deprecated-models), are callable on the free plan. Other models require a paid plan; Claude models require Pro or higher." }, "starter": { "models": ["...every model in the catalog"], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable except Claude models, which need Pro or higher. Per-service monthly call limits apply…" }, "pro": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable. No monthly call caps; per-minute rate limits apply." }, "enterprise": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable. No monthly call caps; per-minute rate limits apply." }, "scale": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable. No monthly call caps; per-minute rate limits apply." }, "business": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable. No monthly call caps; per-minute rate limits apply." }, "corporate": { "models": ["..."], "by_category": { "llm": ["..."], "stt": ["..."], "tts": ["..."], "image": ["..."], "embedding": ["..."] }, "restriction": "All models callable. No monthly call caps; per-minute rate limits apply." } } } ``` ## Code Examples :::tabs ```bash [cURL] curl https://api.callmissed.com/api/v1/models/access ``` ```python [Python] import requests resp = requests.get("https://api.callmissed.com/api/v1/models/access") data = resp.json() # List free LLM models free_llms = data["plans"]["free"]["by_category"]["llm"] print("Free LLM models:", free_llms) # Check if a model is available on free plan model = "gpt-6-luna" is_free = model in data["plans"]["free"]["models"] print(f"{model} on free plan: {is_free}") # False ``` ```typescript [JavaScript / TypeScript] const resp = await fetch("https://api.callmissed.com/api/v1/models/access"); const data = await resp.json(); // List free LLM models const freeLLMs = data.plans.free.by_category.llm; console.log("Free LLM models:", freeLLMs); // Check if a model is available on free plan const model = "gpt-6-luna"; const isFree = data.plans.free.models.includes(model); console.log(model, "on free plan:", isFree); // false ``` ::: ## Free Plan Models The free tier includes **11 models**: | Category | Models | |----------|--------| | LLM (6) | `sarvam-105b`, `sarvam-105b-conversations`, `kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `gpt-oss-120b` | | STT (1) | `saaras:v4` | | TTS (2) | `bulbul:v3`, `bulbul:v4-flash` | | Image (0) | None. Image generation needs a paid plan. | | Embedding (2) | `text-embedding-3-small`, `text-embedding-3-large` | The retired LLM, speech-to-text and text-to-speech IDs (`glm-4.7-flash`, `glm-5.2`, `nemotron-3-super`, `gemma-4-26b-a4b-it`, `mistral-small-3.1`, `nova-3`, `whisper-large-v3-turbo`, `aura-2-en`, `aura-2-es`, `melotts`) still answer on the free plan. They are not listed. See [Retired and deprecated models](/docs/models#retired-and-deprecated-models). `restriction` is a human-readable note — branch on `models` / `by_category`, not on this text. Every other model — `kimi-k3.1-pro`, `glm-5.3`, `glm-5.3-flash`, `deepseek-v4.1-flash`, `deepseek-v4.1-flash-1017`, `gemma-4-31b`, `deepgram-think-fast`, `deepgram-phonellm-mini`, the `gemini-*` chat models, the first-party OpenAI / xAI / DeepSeek IDs, the realtime voice models, the Deepgram direct line, the other paid speech models, and the paid image models — requires a paid plan (Starter or higher), except Claude models, which need Pro or higher. ## Error Handling When a free-plan user calls a paid model, the API returns: ```json { "error": { "message": "Model 'gpt-5.6-luna' requires a paid plan. See GET /api/v1/models/access for the full list of free-plan models. Upgrade at https://console.callmissed.com/org/plans", "type": "invalid_request_error", "code": "model_not_available" } } ``` HTTP status: `403`. The request is refused before any model runs, so it is not billed. A key whose `allowed_models` list excludes the model gets `403` with `code: "model_not_allowed"` instead, on any plan. --- ### Indic Models URL: /docs/models-indic > Indic STT, TTS, and LLM models — optimized for Indian languages. ## LLM ### sarvam-105b - **Architecture:** 105B MoE, MLA architecture - **Context:** 128K tokens - **Training:** Pre-trained on 12T tokens - **Best for:** Complex reasoning, agentic tasks, long documents - **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"` ### sarvam-105b-conversations - **Architecture:** 105B MoE, tuned for conversation and voice - **Context:** 32K tokens - **Tool calling:** yes - **Streaming:** yes - **Best for:** Multi-turn dialogue, voice agents, assistants that talk - **Thinking mode:** `reasoning_effort: "low" | "medium" | "high"` Same family and same price as `sarvam-105b`, with a shorter 32K window — tuned for spoken dialogue rather than long-form work. It does not accept image input. ### Thinking Mode The `sarvam-105b` models support hybrid thinking mode: ```python response = client.chat.completions.create( model="sarvam-105b", messages=[{"role": "user", "content": "Solve this complex problem step by step"}], extra_body={"reasoning_effort": "high"} ) ``` | Value | Description | |-------|-------------| | `"none"` / `"minimal"` | Thinking off — fastest | | `"low"` | Light reasoning | | `"high"` | Deep reasoning — better quality, slower | | `"max"` | Maximum reasoning (`"xhigh"` maps here) | `"medium"` is not one of the models' levels, so it is dropped and the model runs at its default. Thinking tokens count toward `max_tokens`; when you omit it we send 4,096 so the answer is not cut off by the upstream default of 2,048. See the [reasoning_effort matrix](/docs/api-speed#3-reasoning-effort-by-model). ## Speech to Text ### saaras:v4 - **Languages:** 23 (22 Indic + English) - **Output modes:** transcribe, translate, verbatim, translit, codemix - **Auto language detection:** yes - **Telephony support:** 8kHz audio - **Endpoint:** `POST /v1/audio/transcriptions` Supported languages include: Hindi, Bengali, Gujarati, Kannada, Malayalam, Marathi, Odia, Punjabi, Tamil, Telugu, Urdu, Assamese, Bodo, Dogri, Kashmiri, Konkani, Maithili, Manipuri, Nepali, Sanskrit, Santali, Sindhi, and English. Five output modes on one model — standard transcription, English translation, verbatim (fillers kept), Latin-script transliteration, and code-mixed output. Select one with the `mode` form field; `transcribe` is the default. ## Text to Speech ### bulbul:v3 - **Voices:** 37 speakers - **Languages:** 11 - **Audio codecs:** WAV, MP3, OPUS, FLAC, AAC, Mulaw, Alaw, PCM - **Pace:** 0.5–2.0 (maps to `speed` parameter) - **Sample rates:** 8000, 16000, 22050, 24000, 48000 Hz - **Endpoint:** `POST /v1/audio/speech` Default voice: `shubh`. See the [Voices](/docs/tts-voices) page for the full list. ### bulbul:v4-flash - **Voices:** 222 persona voices - **Languages:** 9 (`hi-IN`, `en-IN`, `bn-IN`, `gu-IN`, `kn-IN`, `mr-IN`, `pa-IN`, `ta-IN`, `te-IN`) plus Hinglish; no Malayalam or Odia - **Max input:** 2,500 characters per request - **Audio codecs:** WAV, MP3, FLAC, AAC, PCM - **Pace:** 0.5–2.0 (maps to `speed` parameter) - **Sample rates:** 8000, 16000, 22050, 24000 Hz - **Endpoint:** `POST /v1/audio/speech` Default voice: `shubh_enhi_ads`. Voice IDs follow `__