# Create AI Assistant Source: https://docs.miraiminds.co/api-reference/assistant/create-ai-assistant https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/assistant/create Creates a new AI assistant with the specified configuration. The `variant.type` determines the assistant's behavior and the required `variant.config` structure. **Variants** - `abandoned_cart` — a ready-made flow; provide `variant.config.abandoned_cart` (e.g. payment plan). The system prompt is generated from the variant template, so `agent.systemPrompt` can be left empty. - `cod_to_prepaid` — converts Shopify COD orders to prepaid. Provide `variant.config.cod_to_prepaid.paymentLinkValidity`, `codFee`, and `supportContacts`. - `address_verification` — verifies or collects Shopify shipping addresses. Provide `variant.config.address_verification.minDays`, `maxDays`, and `supportContacts`. - `order_confirmation` — confirms Shopify orders before fulfillment. Optional config: `variant.config.order_confirmation.supportContacts`. - `ndr_followup` — follows up on failed delivery / NDR events. Optional config: `variant.config.ndr_followup.maxRescheduleDays`, `webhookToken`, and `supportContacts`. - `custom` — you fully author the behavior. Put your prompt in `agent.systemPrompt` and declare any dynamic variables in `variant.config.inputSchema`. For preset variants, leave `agent.systemPrompt` empty and pass the required per-call data in `payload` when initiating the call. **Using variables in `agent.systemPrompt` (custom variant)** Reference a variable with the `{{variableName}}` placeholder syntax; nested values use dot-paths, e.g. `{{customer.firstName}}`. Each variable you reference should be declared in `variant.config.inputSchema` (name + type, mark `isRequired: true` when mandatory). The actual values are supplied **per call** through the `variableValues` object when you initiate the call — at that point every `{{placeholder}}` is substituted with the matching value. Any placeholder with no matching value is left untouched in the prompt. # Get AI Assistant Source: https://docs.miraiminds.co/api-reference/assistant/get-ai-assistant https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/assistant/get/{assistantId} Fetches a single assistant by ID. Returns the full internal assistant configuration including variant config, agent identity, callSettings, analysisPlan, and knowledgeBase. # List AI Assistants Source: https://docs.miraiminds.co/api-reference/assistant/list-ai-assistants https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/assistant/list Returns all assistants in the current workspace. Each item carries the **same payload** as the get-by-id endpoint (`GET /v1/admin/assistant/get/{assistantId}`), so no field selection is needed. Takes no query parameters or request body. # Update AI Assistant Source: https://docs.miraiminds.co/api-reference/assistant/update-ai-assistant https://api.voice-agents.miraiminds.co/swagger.yaml put /v1/admin/assistant/update/{assistantId} Updates an existing assistant's configuration. All fields are optional — only the provided fields will be updated. The variant type cannot be changed. Any active campaigns for this assistant will be briefly paused and resumed during the update. # Abort Call Source: https://docs.miraiminds.co/api-reference/call/abort-call https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/abort Aborts a call that is queued or scheduled for retry. This operation is allowed when the call has no status (initial queue) or has a status of: 'busy', 'failed', 'no-answer', 'rescheduled', 'validation-failed'. It is NOT allowed if the call is 'in-progress', 'completed', 'ended', 'timeout', or already 'aborted'. # Call Events Webhook Source: https://docs.miraiminds.co/api-reference/call/call-events-webhook https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/webhooks/call-events Receives call status and action events # Initiate AI Call Source: https://docs.miraiminds.co/api-reference/call/initiate-ai-call https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/initiate # Initiate AI Web Call Source: https://docs.miraiminds.co/api-reference/call/initiate-ai-web-call https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/web # Receive Shiprocket NDR webhook Source: https://docs.miraiminds.co/api-reference/call/receive-shiprocket-ndr-webhook https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/shiprocket/ndr-webhook/{assistantId} Queues an NDR follow-up call for an assistant whose `variant.type` is `ndr_followup`. If `variant.config.ndr_followup.webhookToken` is configured, send the same value in the `x-api-key` header. # Update Call Payload Source: https://docs.miraiminds.co/api-reference/call/update-call-payload https://api.voice-agents.miraiminds.co/swagger.yaml put /v2/call/{callId} Updates the `payload` of a queued call recipient. Allowed only when the call has not yet been attempted. # Introduction Source: https://docs.miraiminds.co/api-reference/introduction Complete reference for the Voice Agents REST API. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). The Voice Agents API lets you onboard workspaces, create and manage assistants, initiate outbound calls, and handle real-time webhook events programmatically. ## Base URL All API requests target the production server: ``` https://api.voice-agents.miraiminds.co ``` A staging environment is also available for testing: ``` https://api.stage.voice-agent.miraiminds.co ``` ## Authentication Every request must include two API key headers: | Header | Description | | :-------------- | :------------------------------------- | | `x-public-key` | Your public API key for identification | | `x-private-key` | Your private API key for authorization | Most endpoints also require a `workspace` header to scope requests to a specific workspace. ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v2/call/initiate \ -H "x-public-key: pk_your_public_key" \ -H "x-private-key: sk_your_private_key" \ -H "workspace: your_workspace_id" \ -H "Content-Type: application/json" \ -d '{ ... }' ``` Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to obtain your API keys and workspace ID. ## API endpoints The API is organized into the following groups: Register a Shopify store and create a workspace with billing configuration. Create, retrieve, and update voice agent configurations. Initiate outbound calls and abort queued calls. Receive real-time call status and action events. Archive and unarchive organizations and workspaces. Browse available voice options for your agents. ## Common error codes | Code | Meaning | | :---- | :-------------------------------------------------- | | `200` | Success | | `201` | Resource created | | `400` | Bad request — check your request body or parameters | | `401` | Unauthorized — missing or invalid API keys | | `403` | Forbidden — insufficient permissions | | `404` | Not found — resource does not exist | | `429` | Rate limit exceeded — wait before retrying | | `500` | Internal server error | ## Rate limits The API enforces rate limits to ensure stability. If you receive a `429` response, wait before retrying. Assistant updates have a cooldown period to prevent overlapping update operations. Keep your `x-private-key` secure. Never expose it in client-side code or public repositories. # Complete Knowledge Base Upload Source: https://docs.miraiminds.co/api-reference/knowledge-base/complete-knowledge-base-upload https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/complete/{sessionId} Finalises an upload session and triggers background processing (chunking, indexing, embedding). Returns a `knowledgeBaseId` you can use to poll status. Processing is asynchronous — poll `GET /v1/knowledge-base/files/{knowledgeBaseId}` until `status` is `ready`, then add the file URL to `knowledgeBase.documents` on your assistant. # Delete Knowledge Base Collection Source: https://docs.miraiminds.co/api-reference/knowledge-base/delete-knowledge-base-collection https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/knowledge-base/collection/{collectionName} Permanently deletes a knowledge base collection and all its indexed data. This action cannot be undone. The collection **cannot be deleted** if it is currently assigned to an assistant. Remove it from `knowledgeBase.documents` on the assistant first. # Get Knowledge Base File Source: https://docs.miraiminds.co/api-reference/knowledge-base/get-knowledge-base-file https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/knowledge-base/files/{knowledgeBaseId} Returns a single knowledge base file by ID including its processing status. Poll this endpoint after completing an upload to check when `status` changes to `ready`. Once `ready`, add the file to `knowledgeBase.documents` on the assistant. # List Knowledge Base Files Source: https://docs.miraiminds.co/api-reference/knowledge-base/list-knowledge-base-files https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/knowledge-base/files Returns all uploaded knowledge base files for the current workspace. Use the returned file URL to set in `knowledgeBase.documents` on an assistant. Use the `collectionName` if manually configuring the RAG tool. # Start Knowledge Base Upload Session Source: https://docs.miraiminds.co/api-reference/knowledge-base/start-knowledge-base-upload-session https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/start Creates an upload session for a new knowledge base document. Returns a `sessionId` used for the subsequent chunk upload and complete calls. **Supported MIME types:** `application/pdf`, `text/plain`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `text/markdown` **Max file size:** 100 MB | **Max chunk size:** 10 MB # Upload Knowledge Base Chunk Source: https://docs.miraiminds.co/api-reference/knowledge-base/upload-knowledge-base-chunk https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/chunk/{sessionId} Uploads a single chunk of a file to an existing upload session. Send chunks sequentially starting from `chunkIndex: 0`. Use `multipart/form-data` with a `chunk` binary field and a `chunkIndex` form field. **Max chunk size:** 10 MB # Onboard a custom workspace Source: https://docs.miraiminds.co/api-reference/onboarding/onboard-a-custom-workspace https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/workspace/onboard/custom Creates a new **custom** workspace under the authenticated organization. Use this where you build assistants from scratch with your own system prompts and variables. The organization is resolved from the public/private key pair. A default outbound telephony number is auto-assigned in production. # Onboard a Shopify store (Shopify merchants only) Source: https://docs.miraiminds.co/api-reference/onboarding/onboard-a-shopify-store-shopify-merchants-only https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/workspace/onboard/shopify Shopify-specific onboarding. Uploads the store's identity, trust metrics, and policies. Returns the Workspace ID and Billing Configuration. **For all other integrations, use `POST /v2/workspace/onboard/custom` instead.** # Archive Organization or Workspace Source: https://docs.miraiminds.co/api-reference/organization/archive-organization-or-workspace https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/organization/archive Archives an organization or workspace by ID. When archiving an organization, all associated workspaces, assistants and campaigns are also archived in a cascading manner. When archiving a workspace, all its associated assistants and campaigns are archived. Requires admin or organization_admin role. # Unarchive Organization or Workspace Source: https://docs.miraiminds.co/api-reference/organization/unarchive-organization-or-workspace https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/organization/unarchive Unarchives a previously archived organization or workspace by ID. When unarchiving an organization, all associated workspaces, assistants and campaigns are also unarchived in a cascading manner. When unarchiving a workspace, the parent organization must not be archived; if the parent organization is still archived, the request will fail with a 400 error. Requires admin or organization_admin role. # platform health status Source: https://docs.miraiminds.co/api-reference/setting/platform-health-status https://api.voice-agents.miraiminds.co/swagger.yaml get /health Returns whether the platform is currently under maintenance. No authentication required. # Get a single demo flow Source: https://docs.miraiminds.co/api-reference/showcase/get-a-single-demo-flow https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/flows/{flow_id} # Get a single real customer call Source: https://docs.miraiminds.co/api-reference/showcase/get-a-single-real-customer-call https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/calls/{call_id} # List demo flows Source: https://docs.miraiminds.co/api-reference/showcase/list-demo-flows https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/flows Returns the product demo flows shown in the showcase page. These are produced demos of each use case, not real customer recordings. # List real customer call recordings Source: https://docs.miraiminds.co/api-reference/showcase/list-real-customer-call-recordings https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/calls Returns real call recordings made for live stores. Optionally filter by call category or language. # Purchase an available phone number Source: https://docs.miraiminds.co/api-reference/telephony/purchase-an-available-phone-number https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/number-pool/purchase Purchases a phone number for the organization from the telephony provider. The organization is charged the setup fee and recurring monthly rate; a `402` is returned if the organization has insufficient credit balance. # Release a purchased phone number Source: https://docs.miraiminds.co/api-reference/telephony/release-a-purchased-phone-number https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/number-pool/{telephonyNumberId} Releases a previously purchased phone number back to the provider and marks the record as `released`. Recurring billing for the number stops. # Search available phone numbers Source: https://docs.miraiminds.co/api-reference/telephony/search-available-phone-numbers https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/number-pool/search Returns a list of phone numbers available to purchase from the configured telephony provider, filtered by country and (optionally) a matching pattern. # Create an API tool Source: https://docs.miraiminds.co/api-reference/tools/create-an-api-tool https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/tool/api Creates a user-defined API tool the assistant can call. The `name` must be unique within the workspace (a slug is auto-generated from it). # Delete an API tool Source: https://docs.miraiminds.co/api-reference/tools/delete-an-api-tool https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/admin/tool/api/{toolId} Soft-deletes an API tool owned by the current workspace. # List workspace API tools Source: https://docs.miraiminds.co/api-reference/tools/list-workspace-api-tools https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/tool/api Lists all user-defined API tools owned by the current workspace. # Update an API tool Source: https://docs.miraiminds.co/api-reference/tools/update-an-api-tool https://api.voice-agents.miraiminds.co/swagger.yaml put /v1/admin/tool/api/{toolId} Updates an existing API tool. All fields are optional — only provided fields are updated. # Get Voice Gallery Source: https://docs.miraiminds.co/api-reference/voice-gallery/get-voice-gallery https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/voice-gallery # Assistant Sandbox Source: https://docs.miraiminds.co/developers/assistant-sandbox Test and iterate on your voice assistant's system prompt in real-time without affecting your production configuration. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview The **Assistant Sandbox** is a testing environment where you can experiment with your voice assistant's system prompt, make real-time tweaks, and trigger web calls — all without modifying your production assistant. It's the fastest way to fine-tune how your assistant behaves during a call. **Credits are deducted for sandbox calls.** Sandbox calls are real calls and will consume credits from your wallet just like production calls. ## Getting Started Enter your **Public Key**, **Private Key**, and **Workspace** credentials to access the sandbox, and click the **Validate** button. Sandbox Authentication Screen | Field | Description | | :------------ | :--------------------------------------------- | | `Public Key` | Your public API key | | `Private Key` | Your private/secret API key | | `Workspace` | The workspace identifier you want to work with | Once authenticated, the sandbox loads your workspace data and retrieves all assistants configured under it. After authentication, a **dropdown** appears listing all assistants created in your workspace. Select the assistant you want to test. Assistant Selection Dropdown Once you select an assistant, the screen splits into **two sections**: Sandbox Main Interface **Left Section — System Prompt & Variables** * The assistant's **system prompt** is displayed in an editable area, organized by sections like **Context**, **Objective**, and **Personality** * Below the prompt, you'll see **allowed variables** — these are system-generated variables based on the data you provide (e.g., customer name, order details, discount info) * You can only use these system-generated variables in your prompt **Right Section — Configuration** * **Payload** — A JSON text box where you paste the call payload data (e.g., customer info, cart details) * **Metadata** — A JSON text box where you paste any additional metadata for the call * **Change Assistant** — A button in the top-right to switch to a different assistant 1. Populate the **Payload** and **Metadata** fields with your JSON data 2. Optionally, edit the **system prompt** to test different conversation behaviors 3. Click the **Test Assistant** button to trigger a web call 4. You'll be connected to the assistant and can speak with it in real-time After each call, review how the assistant responded: * Modify the **system prompt** based on your findings * Adjust the **Payload** or **Metadata** as needed * Click **Test Assistant** again to test your changes instantly Repeat this cycle until the assistant behaves as expected. To test a different assistant, click the **Change Assistant** button in the top-right corner. The assistant dropdown will reappear, letting you select another assistant from your workspace. ## Important Notes **Prompt changes are not saved.** Any modifications you make to the system prompt in the sandbox are temporary. They are not stored or updated in the database. To apply changes permanently, update the assistant's prompt through the dashboard or API. **Use only system-generated variables.** The allowed variables shown in the sandbox are automatically generated based on the data you provide. Custom or arbitrary variables will not be resolved during the call. **No events or actions in sandbox.** Webhook events (such as `call.ended`, `call.completed`, `end-of-call`, `action`, etc.) and actions (such as `create-order`, `send-whatsapp`) are **not triggered** during sandbox calls. The sandbox is purely for testing prompt behavior and conversation flow. ## Typical Workflow ```mermaid theme={null} flowchart TD A[Authenticate with Public Key, Private Key & Workspace] --> B[Select Assistant from Dropdown] B --> C[Review System Prompt & Variables] C --> D[Enter Payload & Metadata JSON] D --> E[Click Test Assistant Button] E --> F[Talk with the Assistant] F --> G{Satisfied with behavior?} G -- No --> H[Tweak System Prompt] H --> E G -- Yes --> I[Update prompt in Production via Dashboard / API] F --> J{Want to test another assistant?} J -- Yes --> K[Click Change Assistant] K --> B ``` ## Best Practices * **Start with your production prompt** — Select the assistant and test its current behavior before making changes. * **Change one thing at a time** — Modify a single aspect of the prompt per call so you can clearly identify what impacts the assistant's behavior. * **Test edge cases** — Vary your payload data (e.g., different product types, price ranges, customer profiles) to ensure the prompt handles diverse scenarios. * **Note your changes** — Since sandbox edits aren't saved, copy your final working prompt and apply it to your assistant configuration manually. * **Monitor your credits** — Each sandbox call deducts credits, so plan your testing sessions efficiently. # Call Lifecycle & Webhooks Source: https://docs.miraiminds.co/developers/call-lifecycle-events Understand the lifecycle of a voice call and how to handle webhook events. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Voice Agents uses webhooks to notify your application about the state of every call in real-time. Whether a call connects, fails, or triggers a specific action (like creating an order), your configured **callback URL** receives an event payload. ## Call Lifecycle Every call progresses through a specific lifecycle. At each stage, an event is fired. ```mermaid theme={null} stateDiagram-v2 [*] --> Initiate: Call Placed Initiate --> InProgress: Connected Initiate --> Failed: Failed/Busy/No Answer InProgress --> Ended: Disconnected Ended --> Completed: Processing Done Completed --> EndOfCall: Analysis Ready EndOfCall --> LifecycleEnded: All Done Failed --> LifecycleEnded: All Done LifecycleEnded --> [*] ``` ### Lifecycle Events These events track the normal progression of a successful call. | Event | Description | | :--------------------- | :---------------------------------------------------------------------------------------- | | `call.initiate` | The call has been queued and is being dialed. | | `call.in-progress` | The recipient picked up; the AI conversation has started. | | `call.ended` | The phone connection has dropped. | | `call.completed` | Call data (recording, transcript, duration) has been processed. | | `call.timeout` | The call exceeded the maximum allowed duration. | | `end-of-call` | AI analysis is complete — summary, success evaluation, and structured data are available. | | `call.lifecycle-ended` | All processing is finished. This is the **final event** for this call. | ### Failure & Retry Events If a call cannot be established, one of these events will fire. The system may automatically retry based on your campaign settings. | Event | Description | | :----------------------- | :------------------------------------------------- | | `call.failed` | The call could not be connected (general failure). | | `call.busy` | The recipient's line was busy. | | `call.no-answer` | The recipient did not pick up. | | `call.validation-failed` | Pre-call validation failed (e.g., invalid number). | | `call.skip` | The call was skipped (e.g., DND number). | ### Other Events | Event | Description | | :----------------- | :---------------------------------------------------------------------------------------- | | `call.rescheduled` | A callback has been scheduled for a later time (lifecycle continues to the next attempt). | | `call.aborted` | The call was manually cancelled via the abort API. | After all retry attempts are exhausted, `call.lifecycle-ended` is sent to signal that no further attempts will be made for this specific call task. ## Webhook Payload Structure Every webhook event shares a common envelope structure. ```json theme={null} { "metadata": { "customer_id": "12345", "order_ref": "ORD-001" }, "event": { "type": "call.completed", "data": { "call": { "id": "c_550e8400-e29b", "status": "completed", "startedAt": "2023-10-27T10:00:00Z", "endedAt": "2023-10-27T10:02:30Z", "durationSeconds": 150, "recordingUrl": "https://api.voice-agents.com/recordings/...", "detailUrl": "https://api.voice-agents.com/calls/..." } } } } ``` ### Key Fields * **`metadata`**: The custom JSON object you passed when initiating the call. This is passed back in **every** event, allowing you to link calls to your internal records (e.g., `userId`, `orderId`). * **`event.type`**: The specific event name (e.g., `call.in-progress`, `end-of-call`). * **`event.data`**: The payload specific to the event. ### Special Payloads Some events contain additional data in `event.data`. #### `end-of-call` Contains the AI analysis, summary, and credit usage. ```json theme={null} { "analysis": { "success": true, "summary": "Customer confirmed the appointment for Tuesday.", "insights": { "sentiment": "positive", "intent": "booking_confirmed" } }, "credits": { "used": 2.5, "available": 105.0 } } ``` #### `call.lifecycle-ended` Contains a report of all attempts. ```json theme={null} { "report": { "reAttemptCount": 1, "rescheduledCount": 0, "finalStatus": "completed" } } ``` ## Actions Actions are special events triggered by the AI during the conversation when a specific task needs to be performed on your end, such as creating an order or sending a message. ### `create_order` Fires when the AI determines the customer wants to place an order and has provided all necessary details. ```json theme={null} { "event": { "type": "action", "data": { "action": "create_order", "payload": { "phone": "+15550109988", "lineItems": [{ "variantId": "123", "quantity": 1 }], "shippingAddress": { "firstName": "Jane", "lastName": "Doe", "address1": "123 Main St", "city": "New York", "zip": "10001", "country": "US" }, "paymentMode": "cod", "cartTotal": 150.00 } } } } ``` ### `send_whatsapp` Fires when the AI cannot complete a task (like creating an order) due to missing info, and triggers a fallback message via WhatsApp. | Reason | Meaning | | :------------------- | :----------------------------------------- | | `missing_address` | Customer's shipping address is incomplete. | | `missing_first_name` | Customer's name could not be determined. | | `invalid_cart_data` | Cart data is malformed or missing. | ```json theme={null} { "event": { "type": "action", "data": { "action": "send_whatsapp", "reason": "missing_address" } } } ``` ## Handling Webhooks Here is an example of how to handle these events in a Node.js Express application. ```javascript theme={null} app.post('/webhooks/voice-agent', (req, res) => { const { event, metadata } = req.body; console.log(`Received event: ${event.type} for Customer: ${metadata.customer_id}`); switch (event.type) { case 'call.in-progress': // Call connected, maybe update UI status break; case 'action': if (event.data.action === 'create_order') { // Handle order creation logic createOrder(event.data.payload); } else if (event.data.action === 'send_whatsapp') { // Handle WhatsApp fallback logic sendWhatsAppFallback(event.data.reason); } break; case 'end-of-call': // Save call summary and analysis saveCallAnalysis(event.data.call.id, event.data.analysis); break; case 'call.rescheduled': // Update next attempt time in your DB console.log(`Call rescheduled for later`); break; case 'call.failed': case 'call.busy': case 'call.no-answer': // Log failure reason console.log(`Call failed: ${event.type}`); break; default: console.log('Unhandled event:', event.type); } // Always acknowledge the webhook res.status(200).send('OK'); }); ``` ## Security To ensure that the webhooks you receive are genuinely from Voice Agents, you should verify the signature included in the headers. See [Webhook Signature Verification](/developers/webhook-signature) for implementation details. ## The same thing in v2 Most of the shape above survives — one event per state change, an envelope you acknowledge with `200`, a signature you verify. The names changed, the retry and action machinery did not carry over, and the analysis is not built yet. | v1 event | v2 | Notes | | :------------------------------------------------------ | :--------------- | :-------------------------------------------------------------------------------- | | `call.initiate` | `call.queued` | Opt-in in v2; ask to have it enabled. | | `call.in-progress` | `call.started` | Media is live. | | `call.ended` + `call.completed` | `call.completed` | One terminal event, not two. | | `call.timeout` | `call.failed` | Read `data.call.status` — it is `timeout`. | | `call.failed` / `call.busy` / `call.no-answer` | `call.failed` | One event; the status distinguishes them. | | `call.aborted` | `call.aborted` | Same meaning. | | — | `call.voicemail` | New in v2, and it is billed. | | `call.validation-failed` | — | v2 rejects a bad number at create time, with `400`. | | `call.rescheduled`, `call.skip`, `call.lifecycle-ended` | — | **v2 does not retry.** One call object, one outcome. Retry from your own dialler. | | `end-of-call` (summary, sentiment) | — | Post-call analysis is on the [roadmap](/general/roadmap). | | `action` (`create_order`, `send_whatsapp`) | — | Not in v2. | **`metadata` works differently.** v1 echoes your object back on every event. v2 hands you the `call_id` in the `202` response to [`POST /v2/calls`](/v2/calls#place-a-call) — store that against your own record before the first event can arrive, and correlate on it. `variables` you pass at create are used in the prompt; they do not come back on the event. Full reference: [v2 Webhooks](/v2/webhooks). # Create Assistant Source: https://docs.miraiminds.co/developers/create-assistant Create a new voice assistant with specific configuration. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). The `Create Assistant` endpoint allows you to programmatically create a new voice assistant. You can configure its personality, voice, tools, and other settings. ## Endpoint
POST /admin/assistant
## Request Flow ```mermaid theme={null} sequenceDiagram participant App as Your App participant API as Voice API participant DB as Database App->>API: POST /admin/assistant Note right of App: Includes config, name,
and preferences API->>API: Validate Request API->>DB: Create Assistant Record DB-->>API: Assistant Created API-->>App: 200 OK { assistant_id: "..." } ``` ## Request Parameters ### Headers | Header | Type | Required | Description | | :-------------- | :----- | :------- | :------------------------------- | | `workspace` | string | **Yes** | Your unique workspace ID. | | `organization` | string | **Yes** | Your organization ID. | | `Authorization` | string | **Yes** | Bearer token for authentication. | | `Content-Type` | string | **Yes** | Must be `application/json`. | ### Body Parameters | Parameter | Type | Required | Description | | :-------- | :----- | :------- | :-------------------------------------- | | `name` | string | **Yes** | Name of the assistant. | | `config` | object | **Yes** | Configuration object for the assistant. | ### Config Object | Parameter | Type | Required | Description | | :---------------- | :----- | :------- | :---------------------------------------------- | | `system_prompt` | string | **Yes** | The persona and instructions for the assistant. | | `end_message` | string | No | Message to speak when ending the call. | | `tool_config` | array | No | List of tools enabled for the assistant. | | `model` | object | **Yes** | LLM configuration (provider, model). | | `transcriber` | object | **Yes** | Transcriber configuration (provider, model). | | `voice` | object | **Yes** | Voice configuration (provider, voiceId, model). | | `structured_data` | array | No | Schema for structured data extraction. | ## Code Examples ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant' \ --header 'workspace: 68a4410242a5c31c24ed063b' \ --header 'organization: 68a44064be1aab154e4806ee' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "name": "test assistant", "config": { "system_prompt": "You are a helpful assistant.", "end_message": "Goodbye!", "tool_config": [], "model": { "provider": "openai", "model": "gpt-4o-mini" }, "transcriber": { "provider": "deepgram", "model": "nova-3" }, "voice": { "provider": "cartesia", "voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8", "model": "sonic-2" }, "structured_data": [] } }' ``` ```javascript Node.js theme={null} const myHeaders = new Headers(); myHeaders.append("workspace", "68a4410242a5c31c24ed063b"); myHeaders.append("organization", "68a44064be1aab154e4806ee"); myHeaders.append("Content-Type", "application/json"); myHeaders.append("Authorization", "Bearer "); const raw = JSON.stringify({ "name": "test assistant", "config": { "system_prompt": "You are a helpful assistant.", "end_message": "Goodbye!", "tool_config": [], "model": { "provider": "openai", "model": "gpt-4o-mini" }, "transcriber": { "provider": "deepgram", "model": "nova-3" }, "voice": { "provider": "cartesia", "voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8", "model": "sonic-2" }, "structured_data": [] } }); const requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow" }; fetch("https://api.voice-agents.miraiminds.co/v1/admin/assistant", requestOptions) .then((response) => response.json()) .then((result) => console.log(result)) .catch((error) => console.error(error)); ``` ```python Python theme={null} import requests import json url = "https://api.voice-agents.miraiminds.co/v1/admin/assistant" payload = json.dumps({ "name": "test assistant", "config": { "system_prompt": "You are a helpful assistant.", "end_message": "Goodbye!", "tool_config": [], "model": { "provider": "openai", "model": "gpt-4o-mini" }, "transcriber": { "provider": "deepgram", "model": "nova-3" }, "voice": { "provider": "cartesia", "voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8", "model": "sonic-2" }, "structured_data": [] } }) headers = { 'workspace': '68a4410242a5c31c24ed063b', 'organization': '68a44064be1aab154e4806ee', 'Content-Type': 'application/json', 'Authorization': 'Bearer ' } response = requests.request("POST", url, headers=headers, data=payload) print(response.text) ``` ## Response Returns a JSON object containing the created assistant details. ### Success Response (`200 OK`) ```json theme={null} { "success": true, "message": "Assistant created successfully", "data": { "_id": "6927ec5c9322ed9f9fb55c68", "name": "test assistant", "config": { ... }, "createdAt": "2025-11-27T10:00:00.000Z" } } ``` # Customer Lifecycle Source: https://docs.miraiminds.co/developers/customer-lifecycle End-to-end guide for onboarding a brand, creating an AI agent, initiating calls, aborting them, and handling webhook events. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). This guide walks through the complete lifecycle of a customer on the Voice Agents platform — from initial onboarding to receiving real-time call events via your callback URL. ## Lifecycle Overview ```mermaid theme={null} flowchart LR A[Onboard Brand] --> B[Create AI Agent] B --> C[Initiate Call] C --> D{Call Outcome} D -- Abort --> E[Abort Call] D -- Proceeds --> F[Handle Callback Events] E --> F F --> G[Lifecycle Ended] ``` *** ## Step 1 — Onboard a Brand Register your brand (Shopify store) to create a workspace with billing configuration. All subsequent API operations are scoped to this workspace.
POST /v2/workspace/onboard/shopify
### Authentication | Header | Description | | :-------------- | :------------------- | | `x-public-key` | Your public API key | | `x-private-key` | Your private API key | ### Body Parameters | Parameter | Type | Required | Description | | :-------------------------------------- | :------ | :------- | :--------------------------------------------- | | `name` | string | **Yes** | Your store/brand name | | `currencyCode` | string | **Yes** | Store currency (e.g., `INR`, `USD`) | | `timezone` | string | **Yes** | Store timezone (e.g., `Asia/Kolkata`) | | `supportContacts.phoneNumber` | string | **Yes** | Support phone number in E.164 format | | `supportContacts.email` | string | No | Support email address | | `trustSignals.valuePropositionOneLiner` | string | **Yes** | One-line brand value proposition | | `trustSignals.customersTillDate` | integer | No | Total customers served | | `trustSignals.totalOrdersFulfilled` | integer | No | Total orders fulfilled | | `trustSignals.storeRating` | number | No | Store rating (e.g., `4.8`) | | `policyFramework.returnPolicy` | object | No | Return window, processing fee, refund timeline | | `policyFramework.shippingPolicy` | object | No | Delivery timeline, free shipping threshold | | `policyFramework.codPolicy` | object | No | COD availability and additional fee | ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify' \ --header 'x-public-key: pk_your_public_key' \ --header 'x-private-key: sk_your_private_key' \ --header 'Content-Type: application/json' \ --data '{ "name": "Acme Store", "currencyCode": "INR", "timezone": "Asia/Kolkata", "supportContacts": { "phoneNumber": "+919876543210", "email": "support@acme.com" }, "trustSignals": { "valuePropositionOneLiner": "Handcrafted products using sustainable materials", "customersTillDate": 15000, "totalOrdersFulfilled": 42000, "storeRating": 4.8 }, "policyFramework": { "returnPolicy": { "windowDays": 7, "processingFee": { "amount": 50 }, "refundTimelineDays": 3 }, "shippingPolicy": { "deliveryTimeline": { "minDays": 3, "maxDays": 5 }, "freeShippingMinOrderValue": 999 }, "codPolicy": { "enabled": true, "additionalFee": { "amount": 50 } } } }' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify', { method: 'POST', headers: { 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Acme Store', currencyCode: 'INR', timezone: 'Asia/Kolkata', supportContacts: { phoneNumber: '+919876543210', email: 'support@acme.com', }, trustSignals: { valuePropositionOneLiner: 'Handcrafted products using sustainable materials', customersTillDate: 15000, totalOrdersFulfilled: 42000, storeRating: 4.8, }, policyFramework: { returnPolicy: { windowDays: 7, processingFee: { amount: 50 }, refundTimelineDays: 3 }, shippingPolicy: { deliveryTimeline: { minDays: 3, maxDays: 5 }, freeShippingMinOrderValue: 999 }, codPolicy: { enabled: true, additionalFee: { amount: 50 } }, }, }), } ); const { data } = await response.json(); console.log(data.workspace); // save your workspace ID ``` ```python Python theme={null} import requests response = requests.post( 'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify', headers={ 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', 'Content-Type': 'application/json', }, json={ 'name': 'Acme Store', 'currencyCode': 'INR', 'timezone': 'Asia/Kolkata', 'supportContacts': { 'phoneNumber': '+919876543210', 'email': 'support@acme.com', }, 'trustSignals': { 'valuePropositionOneLiner': 'Handcrafted products using sustainable materials', 'customersTillDate': 15000, 'totalOrdersFulfilled': 42000, 'storeRating': 4.8, }, 'policyFramework': { 'returnPolicy': {'windowDays': 7, 'processingFee': {'amount': 50}, 'refundTimelineDays': 3}, 'shippingPolicy': {'deliveryTimeline': {'minDays': 3, 'maxDays': 5}, 'freeShippingMinOrderValue': 999}, 'codPolicy': {'enabled': True, 'additionalFee': {'amount': 50}}, }, } ) workspace_id = response.json()['data']['workspace'] print(workspace_id) # save your workspace ID ``` **Success Response (`200 OK`)** ```json theme={null} { "success": true, "data": { "workspace": "68d63c242cd956c2bb41cd3a", "status": "active" } } ``` Save your `workspace` ID — it is required as a header in every subsequent API call. *** ## Step 2 — Create an AI Agent Create a voice assistant configured with a persona, language, voice, call settings, and optionally a knowledge base.
POST /v1/admin/assistant/create
### Headers | Header | Required | Description | | :-------------- | :------- | :------------------- | | `x-public-key` | **Yes** | Your public API key | | `x-private-key` | **Yes** | Your private API key | | `workspace` | **Yes** | Your workspace ID | ### Body Parameters | Parameter | Type | Required | Description | | :--------------------------------- | :----- | :------- | :------------------------------------------------------------------------- | | `name` | string | **Yes** | Assistant display name (max 40 chars) | | `variant.type` | string | **Yes** | Assistant type: `abandoned_cart` or `custom` | | `variant.config.systemPrompt` | string | No | Custom system prompt (for `custom` variant) | | `agent.identity.name` | string | **Yes** | Agent's spoken name (e.g., `Priya`) | | `agent.identity.gender` | string | **Yes** | `male` or `female` | | `agent.identity.voice` | string | **Yes** | Voice identifier (see [Voice Gallery](/api-reference/introduction)) | | `icpContext.language` | string | **Yes** | Call language: `hinglish`, `english`, `hindi`, `tamil`, `telugu`, and more | | `icpContext.targetAgeGroups` | array | No | `gen_z`, `millennials`, `gen_x`, `boomers` | | `icpContext.locationTiers` | array | No | `metro_urban`, `tier1`, `tier2`, `tier3`, `rural` | | `callSettings.slots` | array | No | Time windows when calls can be made (e.g., `10:00`–`17:30`) | | `callSettings.maxCallDuration` | number | No | Maximum call duration in seconds | | `callSettings.concurrentCallCount` | number | No | Max concurrent calls (up to 10) | | `callSettings.retryProtocol` | object | No | Retry behavior for no-pick-up and low-engagement calls | | `analysisPlan` | object | No | Success criteria and summary instructions for post-call AI analysis | | `knowledgeBase.faq` | array | No | FAQ pairs (`question` + `answer`) | | `knowledgeBase.documents` | array | No | External documents by URL (`pdf`, `txt`, `docx`, `markdown`) | ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create' \ --header 'x-public-key: pk_your_public_key' \ --header 'x-private-key: sk_your_private_key' \ --header 'workspace: 68d63c242cd956c2bb41cd3a' \ --header 'Content-Type: application/json' \ --data '{ "name": "Acme Abandoned Cart Agent", "variant": { "type": "abandoned_cart" }, "agent": { "identity": { "name": "Priya", "gender": "female", "voice": "priya" } }, "icpContext": { "language": "hinglish", "targetAgeGroups": ["millennials", "gen_z"], "locationTiers": ["metro_urban", "tier1"] }, "callSettings": { "slots": [ { "startTime": "10:00", "endTime": "13:00" }, { "startTime": "15:00", "endTime": "19:00" } ], "maxCallDuration": 180, "concurrentCallCount": 5, "retryProtocol": { "maxAttemptsNoPickup": 2, "maxAttemptsLowEngagement": 1, "reAttemptPeriod": 300, "maxRescheduleCount": 1 } }, "analysisPlan": { "successCriteriaPlan": "Call is successful if the customer confirmed intent to complete the purchase or provided a reason for abandonment.", "summaryPlan": "Summarize customer sentiment and whether they intend to buy." }, "knowledgeBase": { "faq": [ { "question": "What is your return policy?", "answer": "We offer 7-day returns with a ₹50 processing fee." } ] } }' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create', { method: 'POST', headers: { 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', workspace: '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Acme Abandoned Cart Agent', variant: { type: 'abandoned_cart' }, agent: { identity: { name: 'Priya', gender: 'female', voice: 'priya' }, }, icpContext: { language: 'hinglish', targetAgeGroups: ['millennials', 'gen_z'], locationTiers: ['metro_urban', 'tier1'], }, callSettings: { slots: [ { startTime: '10:00', endTime: '13:00' }, { startTime: '15:00', endTime: '19:00' }, ], maxCallDuration: 180, concurrentCallCount: 5, retryProtocol: { maxAttemptsNoPickup: 2, maxAttemptsLowEngagement: 1, reAttemptPeriod: 300, maxRescheduleCount: 1, }, }, analysisPlan: { successCriteriaPlan: 'Call is successful if the customer confirmed intent to complete the purchase.', summaryPlan: 'Summarize customer sentiment and whether they intend to buy.', }, knowledgeBase: { faq: [{ question: 'What is your return policy?', answer: 'We offer 7-day returns.' }], }, }), } ); const { data } = await response.json(); console.log(data.assistantId); // save your assistant ID ``` ```python Python theme={null} import requests response = requests.post( 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create', headers={ 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', 'workspace': '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, json={ 'name': 'Acme Abandoned Cart Agent', 'variant': {'type': 'abandoned_cart'}, 'agent': { 'identity': {'name': 'Priya', 'gender': 'female', 'voice': 'priya'}, }, 'icpContext': { 'language': 'hinglish', 'targetAgeGroups': ['millennials', 'gen_z'], 'locationTiers': ['metro_urban', 'tier1'], }, 'callSettings': { 'slots': [ {'startTime': '10:00', 'endTime': '13:00'}, {'startTime': '15:00', 'endTime': '19:00'}, ], 'maxCallDuration': 180, 'concurrentCallCount': 5, 'retryProtocol': { 'maxAttemptsNoPickup': 2, 'maxAttemptsLowEngagement': 1, 'reAttemptPeriod': 300, 'maxRescheduleCount': 1, }, }, 'analysisPlan': { 'successCriteriaPlan': 'Call is successful if the customer confirmed intent to purchase.', 'summaryPlan': 'Summarize customer sentiment and purchase intent.', }, 'knowledgeBase': { 'faq': [{'question': 'What is your return policy?', 'answer': 'We offer 7-day returns.'}], }, } ) assistant_id = response.json()['data']['assistantId'] print(assistant_id) # save your assistant ID ``` **Success Response (`200 OK`)** ```json theme={null} { "success": true, "data": { "assistantId": "6927ec5c9322ed9f9fb55c68" } } ``` Save the `assistantId` — it is required when initiating calls. `variant.type` is immutable after creation. Choose `abandoned_cart` for cart recovery flows or `custom` for fully flexible prompts. *** ## Step 3 — Initiate a Call Trigger an outbound call to a customer using the assistant you created.
POST /v2/call/initiate
### Headers | Header | Required | Description | | :-------------- | :------- | :------------------- | | `x-public-key` | **Yes** | Your public API key | | `x-private-key` | **Yes** | Your private API key | | `workspace` | **Yes** | Your workspace ID | ### Body Parameters | Parameter | Type | Required | Description | | :------------------ | :------ | :------- | :-------------------------------------------------------------------------------------- | | `phoneNumber` | string | **Yes** | Customer's phone number in E.164 format (e.g., `+919876543210`) | | `assistant` | string | **Yes** | The `assistantId` from Step 2 | | `callbackUrl` | string | **Yes** | HTTPS URL to receive webhook events for this call | | `priority` | boolean | No | Set `true` to jump the call queue | | `payload` | object | No | Call context data (customer info, cart items, pricing) passed to the AI during the call | | `metadata` | object | No | Arbitrary key-value pairs echoed back in every webhook event | | `metadata.discount` | object | No | Discount code to offer during the call (`code`, `value`, `codeType`, `applyAs`) | #### `payload` Object The `payload` provides the AI with context about the customer and their cart. All fields are optional but recommended for `abandoned_cart` assistants. | Field | Type | Description | | :---------------------------------- | :----- | :----------------------------------------------------------------------------- | | `customer.firstName` | string | Customer's first name | | `customer.lastName` | string | Customer's last name | | `customer.email` | string | Customer's email | | `customer.phone` | string | Customer's phone | | `lineItems` | array | Cart items — each with `title`, `quantity`, and `variant.id` + `variant.title` | | `subtotalPriceSet.shopMoney.amount` | string | Cart subtotal | | `totalPriceSet.shopMoney.amount` | string | Cart total | | `abandonedCheckoutUrl` | string | Direct URL to the abandoned checkout | | `shippingAddress` | object | Customer's shipping address | ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v2/call/initiate' \ --header 'x-public-key: pk_your_public_key' \ --header 'x-private-key: sk_your_private_key' \ --header 'workspace: 68d63c242cd956c2bb41cd3a' \ --header 'Content-Type: application/json' \ --data '{ "phoneNumber": "+919876543210", "assistant": "6927ec5c9322ed9f9fb55c68", "callbackUrl": "https://your-app.com/webhooks/voice-agent", "priority": false, "payload": { "id": "gid://shopify/AbandonedCheckout/66509168181329", "abandonedCheckoutUrl": "https://acme.com/checkout/recover?token=abc123", "customer": { "firstName": "Riya", "lastName": "Shah", "email": "riya@example.com", "phone": "+919876543210" }, "lineItems": [ { "title": "Handcrafted Tote Bag", "quantity": 1, "variant": { "id": "gid://shopify/ProductVariant/44001234567", "title": "Brown / Medium" } } ], "subtotalPriceSet": { "shopMoney": { "amount": "1895.0" } }, "totalPriceSet": { "shopMoney": { "amount": "1945.0" } }, "shippingAddress": { "city": "Mumbai", "province": "Maharashtra", "country": "India", "zip": "400001" } }, "metadata": { "orderId": "ORD-9876", "customerId": "cust_001", "discount": { "code": "SAVE10", "description": "10% off your cart", "value": 10, "codeType": "percentage", "applyAs": "additional" } } }' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://api.voice-agents.miraiminds.co/v2/call/initiate', { method: 'POST', headers: { 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', workspace: '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, body: JSON.stringify({ phoneNumber: '+919876543210', assistant: '6927ec5c9322ed9f9fb55c68', callbackUrl: 'https://your-app.com/webhooks/voice-agent', priority: false, payload: { id: 'gid://shopify/AbandonedCheckout/66509168181329', abandonedCheckoutUrl: 'https://acme.com/checkout/recover?token=abc123', customer: { firstName: 'Riya', lastName: 'Shah', email: 'riya@example.com', phone: '+919876543210', }, lineItems: [ { title: 'Handcrafted Tote Bag', quantity: 1, variant: { id: 'gid://shopify/ProductVariant/44001234567', title: 'Brown / Medium' }, }, ], subtotalPriceSet: { shopMoney: { amount: '1895.0' } }, totalPriceSet: { shopMoney: { amount: '1945.0' } }, shippingAddress: { city: 'Mumbai', province: 'Maharashtra', country: 'India', zip: '400001' }, }, metadata: { orderId: 'ORD-9876', customerId: 'cust_001', discount: { code: 'SAVE10', description: '10% off your cart', value: 10, codeType: 'percentage', applyAs: 'additional' }, }, }), } ); const { data } = await response.json(); console.log(data.callId); // save for abort/tracking ``` ```python Python theme={null} import requests response = requests.post( 'https://api.voice-agents.miraiminds.co/v2/call/initiate', headers={ 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', 'workspace': '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, json={ 'phoneNumber': '+919876543210', 'assistant': '6927ec5c9322ed9f9fb55c68', 'callbackUrl': 'https://your-app.com/webhooks/voice-agent', 'priority': False, 'payload': { 'id': 'gid://shopify/AbandonedCheckout/66509168181329', 'abandonedCheckoutUrl': 'https://acme.com/checkout/recover?token=abc123', 'customer': { 'firstName': 'Riya', 'lastName': 'Shah', 'email': 'riya@example.com', 'phone': '+919876543210', }, 'lineItems': [ { 'title': 'Handcrafted Tote Bag', 'quantity': 1, 'variant': {'id': 'gid://shopify/ProductVariant/44001234567', 'title': 'Brown / Medium'}, } ], 'subtotalPriceSet': {'shopMoney': {'amount': '1895.0'}}, 'totalPriceSet': {'shopMoney': {'amount': '1945.0'}}, 'shippingAddress': {'city': 'Mumbai', 'province': 'Maharashtra', 'country': 'India', 'zip': '400001'}, }, 'metadata': { 'orderId': 'ORD-9876', 'customerId': 'cust_001', 'discount': {'code': 'SAVE10', 'description': '10% off your cart', 'value': 10, 'codeType': 'percentage', 'applyAs': 'additional'}, }, } ) call_id = response.json()['data']['callId'] print(call_id) # save for abort/tracking ``` **Success Response (`200 OK`)** ```json theme={null} { "success": true, "data": { "callId": "c_550e8400-e29b-41d4-a716-446655440000", "status": "queued" } } ``` The call is placed asynchronously. The `callId` is returned immediately — real-time status updates are delivered to your `callbackUrl`. *** ## Step 4 — Abort a Call Cancel a queued or in-progress call using the `callId` returned during initiation.
POST /v2/call/abort
### Body Parameters | Parameter | Type | Required | Description | | :-------- | :----- | :------- | :------------------------------------------------ | | `callId` | string | **Yes** | The `callId` returned when the call was initiated | ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v2/call/abort' \ --header 'x-public-key: pk_your_public_key' \ --header 'x-private-key: sk_your_private_key' \ --header 'workspace: 68d63c242cd956c2bb41cd3a' \ --header 'Content-Type: application/json' \ --data '{ "callId": "c_550e8400-e29b-41d4-a716-446655440000" }' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://api.voice-agents.miraiminds.co/v2/call/abort', { method: 'POST', headers: { 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', workspace: '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, body: JSON.stringify({ callId: 'c_550e8400-e29b-41d4-a716-446655440000', }), } ); const result = await response.json(); console.log(result.message); // "Call aborted successfully" ``` ```python Python theme={null} import requests response = requests.post( 'https://api.voice-agents.miraiminds.co/v2/call/abort', headers={ 'x-public-key': 'pk_your_public_key', 'x-private-key': 'sk_your_private_key', 'workspace': '68d63c242cd956c2bb41cd3a', 'Content-Type': 'application/json', }, json={'callId': 'c_550e8400-e29b-41d4-a716-446655440000'} ) print(response.json()['message']) ``` **Success Response (`200 OK`)** ```json theme={null} { "success": true, "message": "Call aborted successfully", "data": { "callId": "c_550e8400-e29b-41d4-a716-446655440000", "status": "aborted" } } ``` After a successful abort, your `callbackUrl` will receive a `call.aborted` event. Calls that have already reached `call.completed` or `call.lifecycle-ended` state cannot be aborted. *** ## Step 5 — Handle Callback URL & Lifecycle Events The `callbackUrl` you provided when initiating the call receives real-time webhook events as the call progresses. Your custom `metadata` is echoed back in every event so you can correlate events to your internal records. ### Call Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> Initiate: Call Placed Initiate --> InProgress: Connected Initiate --> Failed: Failed / Busy / No Answer InProgress --> Ended: Disconnected Ended --> Completed: Data Processed Completed --> EndOfCall: AI Analysis Ready EndOfCall --> LifecycleEnded: All Done Failed --> LifecycleEnded: All Done LifecycleEnded --> [*] ``` ### Lifecycle Events Reference | Event | Trigger | | :--------------------- | :----------------------------------------------------------- | | `call.initiate` | Call has been queued and is dialing | | `call.in-progress` | Customer answered — AI conversation started | | `call.ended` | Phone connection dropped | | `call.completed` | Recording, transcript, and duration are ready | | `call.timeout` | Call exceeded maximum allowed duration | | `end-of-call` | AI analysis complete — summary and structured data available | | `call.lifecycle-ended` | Final event — all processing finished | | `call.failed` | Call could not connect | | `call.busy` | Customer's line was busy | | `call.no-answer` | Customer did not pick up | | `call.aborted` | Call was cancelled via the abort API | | `call.rescheduled` | Retry scheduled for a later time | ### Webhook Payload Structure Every event uses the same envelope with your `metadata` echoed back: ```json theme={null} { "metadata": { "customerId": "cust_001", "orderId": "ORD-9876" }, "event": { "type": "call.completed", "data": { "call": { "id": "c_550e8400-e29b-41d4-a716-446655440000", "status": "completed", "startedAt": "2025-11-27T10:00:00Z", "endedAt": "2025-11-27T10:02:30Z", "durationSeconds": 150, "recordingUrl": "https://api.voice-agents.miraiminds.co/recordings/...", "detailUrl": "https://api.voice-agents.miraiminds.co/calls/..." } } } } ``` The `end-of-call` event additionally includes AI analysis and credit usage: ```json theme={null} { "event": { "type": "end-of-call", "data": { "analysis": { "success": true, "summary": "Customer confirmed intent to complete the purchase after receiving the discount code.", "insights": { "sentiment": "positive", "intent": "purchase_confirmed" } }, "credits": { "used": 2.5, "available": 105.0 } } } } ``` ### Handling Webhooks — Example ```javascript theme={null} app.post('/webhooks/voice-agent', (req, res) => { // Always respond immediately — do not wait for processing res.status(200).send('OK'); const { event, metadata } = req.body; console.log(`[${event.type}] customer=${metadata.customerId}`); switch (event.type) { case 'call.initiate': updateCallStatus(metadata.customerId, 'dialing'); break; case 'call.in-progress': updateCallStatus(metadata.customerId, 'in-progress'); break; case 'call.aborted': updateCallStatus(metadata.customerId, 'aborted'); break; case 'call.failed': case 'call.busy': case 'call.no-answer': logCallFailure(metadata.customerId, event.type); break; case 'call.completed': saveRecording(event.data.call.id, event.data.call.recordingUrl); break; case 'end-of-call': saveAnalysis(metadata.customerId, event.data.analysis); deductCredits(event.data.credits.used); break; case 'call.lifecycle-ended': closeCallRecord(metadata.customerId); break; default: console.log('Unhandled event:', event.type); } }); ``` ### Callback URL Tips * **HTTPS required** — plain HTTP endpoints are rejected. * **Respond with `200 OK` immediately** — Voice Agents does not wait for your processing; slow responses may trigger retries. * **Use `metadata` for correlation** — pass your internal IDs (e.g., `customerId`, `orderId`) when initiating the call; they are echoed in every event. * **Verify signatures** — validate the webhook signature on every incoming request. See [Webhook Signature Verification](/developers/webhook-signature). *** ## API Summary | Step | Method | Endpoint | Purpose | | :------------- | :----- | :------------------------------ | :-------------------------------------- | | Onboard | `POST` | `/v2/workspace/onboard/shopify` | Register a brand and create a workspace | | Create Agent | `POST` | `/v1/admin/assistant/create` | Create a voice assistant | | Initiate Call | `POST` | `/v2/call/initiate` | Trigger an outbound call | | Abort Call | `POST` | `/v2/call/abort` | Cancel a queued or active call | | Receive Events | — | your `callbackUrl` | Handle real-time call lifecycle events | # Developer Introduction Source: https://docs.miraiminds.co/developers/introduction Get started with the Voice Agents API to build programmatic voice interactions. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Welcome to the Voice Agents Developer Hub. Our API allows you to programmatically trigger voice calls, manage agents, and integrate voice capabilities directly into your applications. ## Base URL All API requests should be made to: ```bash theme={null} https://api.voice-agents.miraiminds.co/v1 ``` ## Authentication We use a workspace and organization-based authentication system. Every request to the API must include the following headers to identify your account: | Header | Description | | :-------------- | :------------------------------------------------------------------ | | `workspace` | Your unique workspace identifier (e.g., `68d63c242cd956c2bb41cd3a`) | | `organization` | Your organization identifier (e.g., `68d63c242cd956c2bb41cd12`) | | `Authorization` | Your Bearer token (e.g., `Bearer eyJhbGciOiJIUzI1Ni...`) | To get your Organization ID, Workspace ID, and Authorization Token, please contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co). ## Quick Start Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to obtain your `workspace`, `organization`, and `Authorization` token. Identify the `assistant_id` of the voice agent you want to trigger. Use our [Make a Call](/developers/make-a-call) endpoint to trigger an outbound call immediately. ## Common Error Codes | Code | Meaning | | :---- | :---------------------------------------------- | | `200` | Success | | `400` | Bad Request (Check your JSON body) | | `401` | Unauthorized (Check your workspace/org headers) | | `429` | Rate Limit Exceeded | | `500` | Internal Server Error | # Make a Call Source: https://docs.miraiminds.co/developers/make-a-call Trigger an outbound call from a specific voice assistant to a target phone number. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). The `Make a Call` endpoint allows you to programmatically trigger an outbound call to any phone number using a specific voice assistant. This is useful for automated notifications, appointment reminders, or immediate lead qualification. ## Endpoint
POST /admin/assistant/call
## Request Flow Before the call is placed, the system validates your workspace, checks the assistant's availability, and then initiates the telephony connection. ```mermaid theme={null} sequenceDiagram participant App as Your App participant API as Voice API participant Tel as Telephony Provider participant User as Customer Phone App->>API: POST /admin/assistant/call Note right of App: Includes assistant_id
and to_number API->>API: Validate Workspace & Org API->>API: Load Assistant Config API->>Tel: Initiate Call Tel-->>User: Ringing... Tel-->>API: Call Status: Queued API-->>App: 200 OK { call_id: "..." } User->>Tel: Answers Tel->>API: Stream Audio API->>Tel: AI Response ``` ## Request Parameters ### Headers | Header | Type | Required | Description | | :-------------- | :----- | :------- | :------------------------------- | | `workspace` | string | **Yes** | Your unique workspace ID. | | `organization` | string | **Yes** | Your organization ID. | | `Content-Type` | string | **Yes** | Must be `application/json`. | | `Authorization` | string | **Yes** | Bearer token for authentication. | ### Body Parameters | Parameter | Type | Required | Description | | :------------- | :----- | :------- | :---------------------------------------------------------------- | | `assistant_id` | string | **Yes** | The unique identifier of the assistant to place the call. | | `to_number` | string | **Yes** | The phone number to call in E.164 format (e.g., `+919723067241`). | ## Code Examples ```bash cURL theme={null} curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/call' \ --header 'workspace: 68d63c242cd956c2bb41cd3a' \ --header 'organization: 68d63c242cd956c2bb41cd12' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "assistant_id": "68c128a658cd7d0668bce78d", "to_number": "+919723067241" }' ``` ```javascript Node.js theme={null} const myHeaders = new Headers(); myHeaders.append("workspace", "68d63c242cd956c2bb41cd3a"); myHeaders.append("organization", "68d63c242cd956c2bb41cd12"); myHeaders.append("Content-Type", "application/json"); myHeaders.append("Authorization", "Bearer "); const raw = JSON.stringify({ "assistant_id": "68c128a658cd7d0668bce78d", "to_number": "+919723067241" }); const requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow" }; fetch("https://api.voice-agents.miraiminds.co/v1/admin/assistant/call", requestOptions) .then((response) => response.json()) .then((result) => console.log(result)) .catch((error) => console.error(error)); ``` ```python Python theme={null} import requests import json url = "https://api.voice-agents.miraiminds.co/v1/admin/assistant/call" payload = json.dumps({ "assistant_id": "68c128a658cd7d0668bce78d", "to_number": "+919723067241" }) headers = { 'workspace': '68d63c242cd956c2bb41cd3a', 'organization': '68d63c242cd956c2bb41cd12', 'Content-Type': 'application/json', 'Authorization': 'Bearer ' } response = requests.request("POST", url, headers=headers, data=payload) print(response.text) ``` ## Response Returns a JSON object containing the status of the call request. ### Success Response (`200 OK`) ```json theme={null} { "success": true, "message": "Call initiated successfully", "data": { "call_id": "c_1234567890", "status": "queued", "assistant_id": "68c128a658cd7d0668bce78d", "to_number": "+919723067241" } } ``` ### Common Errors * `400 Bad Request`: Missing `to_number` or invalid `assistant_id`. * `401 Unauthorized`: Invalid or missing `workspace`/`organization`/`Authorization` headers. * `404 Not Found`: Assistant ID does not exist in the specified workspace. # Webhook Signature Verification Source: https://docs.miraiminds.co/developers/webhook-signature Learn how to verify webhook signatures to ensure authenticity and integrity. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview All webhook events emitted by the Voice Agents Backend are cryptographically signed using HMAC-SHA256. The signature is calculated from the payload data, allowing consumers to verify the data's authenticity and integrity. This approach allows consumers to verify that: 1. **Authenticity**: The webhook originated from our service 2. **Integrity**: The payload has not been tampered with during transmission After successful signature verification, the consumer can trust and use the payload data included in the request body. ## Keys Each organization has a unique key pair automatically generated when the organization is created: * **Public Key** (`publicKey`): Format `pk_<32 hex characters>` * **Secret Key** (`privateKey`): Format `sk_<64 hex characters>` These keys are stored at the organization level and can be found in your organization settings. The secret key is used to sign webhooks, while the public key is included in webhook headers for identification purposes. Keep your secret key secure and never expose it publicly. Only the public key should be shared or used for verification. ## Signature Headers Every webhook request includes the following headers along with the payload in the request body: | Header | Description | Example | | -------------- | -------------------------------------------------- | ------------------------ | | `x-signature` | HMAC-SHA256 signature of the payload (hex encoded) | `a1b2c3d4e5f6...` | | `x-public-key` | Organization's public key identifier | `pk_1234567890abcdef...` | The request body contains the full event payload. The signature is calculated from this payload data. After successful verification, you can trust and use the payload data. ## Verification Process To verify a webhook signature, follow these steps: ### Step 1: Extract Headers Extract the following headers from the incoming request: * `x-signature`: The signature to verify against * `x-public-key`: Used to identify which organization's secret key to use ### Step 2: Get the Secret Key Using the `x-public-key` header, retrieve the corresponding secret key for your organization. This should match the secret key stored in your organization settings. ### Step 3: Reconstruct the Signature The signature is calculated from the raw payload string: 1. Get the raw request body as a UTF-8 string (exactly as received, before any JSON parsing) 2. Create an HMAC-SHA256 hash using your secret key 3. Update the hash with the raw payload string 4. Get the hex digest of the hash ### Step 4: Compare Signatures Compare the computed signature with the `x-signature` header value. They must match exactly (case-sensitive). ### Step 5: Use the Payload Data After successful signature verification, you can trust and use the payload data from the request body. The signature proves the data's authenticity and integrity. ## Code Examples ### Node.js ```javascript theme={null} const crypto = require("crypto"); function verifyWebhookSignature(req, secretKey) { // Extract headers const signature = req.headers["x-signature"]; const publicKey = req.headers["x-public-key"]; if (!signature || !publicKey) { throw new Error("Missing required signature headers"); } // Get raw body (must be the exact string, not parsed JSON) const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body); // Compute signature from payload const computedSignature = crypto.createHmac("sha256", secretKey).update(rawBody).digest("hex"); // Compare signatures (use constant-time comparison to prevent timing attacks) if (signature.length !== computedSignature.length) { throw new Error("Invalid signature"); } return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computedSignature)); } // Express.js middleware example app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { try { const publicKey = req.headers["x-public-key"]; const secretKey = getSecretKeyByPublicKey(publicKey); // Your function to retrieve secret key if (verifyWebhookSignature(req, secretKey)) { // Signature is valid, parse and use the payload data const payload = JSON.parse(req.body.toString()); console.log("Webhook verified:", payload); // Use the payload data - it's verified and trusted // Process the event: payload.event.type, payload.event.data, etc. res.status(200).json({ received: true }); } else { res.status(401).json({ error: "Invalid signature" }); } } catch (error) { console.error("Webhook verification error:", error); res.status(401).json({ error: error.message }); } }); ``` ### Python ```python theme={null} import hmac import hashlib import time import json from flask import Flask, request, jsonify app = Flask(__name__) def verify_webhook_signature(request, secret_key): """ Verify webhook signature using HMAC-SHA256 Args: request: Flask request object secret_key: Organization's secret key (str) Returns: bool: True if signature is valid, False otherwise """ # Extract headers signature = request.headers.get('x-signature') public_key = request.headers.get('x-public-key') if not all([signature, public_key]): raise ValueError('Missing required signature headers') # Get raw body (must be bytes, not parsed JSON) raw_body = request.get_data() # Compute signature from payload computed_signature = hmac.new( secret_key.encode('utf-8'), raw_body, hashlib.sha256 ).hexdigest() # Compare signatures (use constant-time comparison) return hmac.compare_digest(signature, computed_signature) @app.route('/webhook', methods=['POST']) def webhook_handler(): try: public_key = request.headers.get('x-public-key') secret_key = get_secret_key_by_public_key(public_key) # Your function to retrieve secret key if verify_webhook_signature(request, secret_key): # Signature is valid, parse and use the payload data payload = request.get_json() print(f'Webhook verified: {payload}') # Use the payload data - it's verified and trusted # Process the event: payload['event']['type'], payload['event']['data'], etc. return jsonify({'received': True}), 200 else: return jsonify({'error': 'Invalid signature'}), 401 except Exception as e: print(f'Webhook verification error: {e}') return jsonify({'error': str(e)}), 401 def get_secret_key_by_public_key(public_key): """ Retrieve secret key by public key. Replace this with your actual implementation. """ # TODO: Implement your logic to retrieve secret key from database/storage pass ``` ## Important Notes 1. **Raw Body**: Always use the raw, unparsed request body for signature verification. Do not use parsed JSON objects, as JSON serialization may differ between systems. 2. **Constant-Time Comparison**: Use constant-time comparison functions (like `crypto.timingSafeEqual` in Node.js or `hmac.compare_digest` in Python) to prevent timing attacks. 3. **Secret Key Storage**: Store secret keys securely (e.g., environment variables, secure key management systems). Never commit them to version control. 4. **Error Handling**: If signature verification fails, return a 401 Unauthorized status and log the event for security monitoring. 5. **Using Payload Data**: After successful signature verification, you can trust and use the payload data from the request body. The signature proves the data's authenticity and integrity. ## Troubleshooting ### Signature Mismatch * Ensure you're using the raw request body (before JSON parsing) * Verify you're using the correct secret key for the public key in the header * Check that the payload string matches exactly (no extra whitespace, correct encoding) ### Missing Headers * Verify that your webhook endpoint is receiving all required headers: `x-signature`, `x-public-key` * Check your reverse proxy or load balancer configuration to ensure headers are not being stripped # Campaign Management Source: https://docs.miraiminds.co/general/campaign Learn how to create and manage voice campaigns with CSV data or API integration **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Voice campaigns allow you to automate outbound calls to multiple contacts. You can create campaigns using CSV data uploads or connect directly through your existing systems via API integration. Upload contact lists using our CSV template for quick campaign setup and data management. Connect your existing systems via webhook to automatically sync contact data. Configure precise timing, timezone handling, and operating hours for optimal contact rates. Set retry logic, call duration limits, and inactivity periods to maximize efficiency. ## Setup Guide ### Step 1: Access Campaign Creation 1. **Navigate to Campaigns Page** Go to the **Campaigns** section in your Voice Agents dashboard. Campaign dashboard 2. **Create New Campaign** Click the **"Create Campaign"** button to start the setup process. Create campaign button ### Step 2: Basic Campaign Configuration 3. **Enter Campaign Name** Provide a descriptive name for your campaign that clearly identifies its purpose. Campaign name field 4. **Select Voice Assistant** Choose the voice assistant that will handle all calls for this campaign. Assistant selection 5. **Configure Webhook Integration** Enable webhook integration if you need to add data from your platform through API. When enabled, CSV upload becomes optional. Webhook configuration ### Step 3: Data Management Setup #### Option A: CSV Data Upload 6. **Download CSV Template** Click **"Download CSV Template"** to get the correct format for your contact data. Download CSV template 7. **Prepare Your Contact Data** Update the downloaded CSV template with your contact information, ensuring all required fields are completed. 8. **Upload CSV File** Upload your completed CSV file using the file upload interface. CSV upload interface 9. **Preview Contact Data** Review your uploaded data in the right panel preview to verify accuracy before proceeding. Data preview panel #### Option B: API Integration When webhook is enabled, your platform can send contact data directly through API calls, eliminating the need for manual CSV uploads. ### Step 4: Schedule Configuration 10. **Set Campaign Dates** Configure your campaign timeline: * **Start Date**: Select when the campaign should begin * **End Date**: Choose when the campaign should conclude Date selection interface 11. **Configure Timezone** Select the appropriate timezone for your campaign to ensure calls are made at the right local times. Timezone configuration 12. **Set Operating Hours** Define your campaign's daily calling window: * **Start Time**: When calls can begin each day * **End Time**: When calls should stop each day Operating hours configuration ### Step 5: Call Behavior Settings 13. **Configure Retry Logic** Set how the system handles unsuccessful call attempts: * **Retry Count**: Number of attempts to reach each contact * **Re-Attempt Period**: Time interval between retry attempts Retry configuration Re-Attempt Configuration 14. **Set Call Duration Limits** Configure call timing parameters: * **Maximum Call Duration**: Longest allowable call time * **Inactivity Period**: Wait time during no response before ending calls Maximum call duration settings Inactivity period settings ### Step 6: Launch Campaign 15. **Create** Once all settings are configured, click **"Create Campaign"** to Create your campaign. Create campaign final step 16. **Handle Data Validation** After clicking create, the system processes your data: * **Valid Data**: Successfully stored and included in the campaign * **Invalid/Missing Data**: Not stored but displayed in a validation modal Any invalid or missing entries will be shown in list format, allowing you to download this data for correction and potential re-upload. ### Step 7: Launch and Manage Campaign 17. **Launch Your Campaign** After creating your campaign, navigate to the campaign list to launch it. Click the **"Play"** button next to your campaign to start making calls. Campaign list with play button 18. **Track Campaign Status** Monitor your campaign's progress by visiting the **Campaign Status** page. Here you can view: * Call completion rates * Success/failure statistics * Real-time campaign progress * Contact attempt history Campaign status tracking page 19. **Copy Webhook API Endpoint** If you enabled webhook integration, you can copy the API endpoint for your platform integration. Click **"Copy Webhook"** to get the endpoint URL for sending data via API. Copy webhook endpoint button Use this endpoint to programmatically add contacts to your campaign or trigger campaign events from your existing systems. ## What's Next? Once your campaign is created and launched, you can: * **Monitor Performance** - Track call success rates and campaign progress * **Manage Active Campaigns** - Pause, resume, or modify running campaigns * **Analyze Results** - Review detailed campaign analytics and outcomes * **Export Reports** - Download campaign performance data ## Best Practices ### Data Preparation * Test your CSV data with a small sample before uploading large datasets * Ensure phone numbers are properly formatted for your target regions * Include all required fields to minimize data validation issues ### Campaign Optimization * Set reasonable retry limits to avoid overwhelming contacts * Configure appropriate operating hours to respect contact preferences * Monitor initial performance and adjust settings as needed ### Compliance Considerations * Ensure you have proper consent to contact all numbers in your campaign * Respect do-not-call lists and opt-out requests * Follow local regulations for automated calling # Frequently Asked Questions Source: https://docs.miraiminds.co/general/faq Answers to all your questions **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). # Voice Agent Overview Source: https://docs.miraiminds.co/general/overview Understand what voice agents are, how they work, and how they can transform your customer interactions. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Voice agents are AI-powered conversational systems that automate voice interactions at scale. They combine advanced natural language processing, speech recognition, and intelligent call flow management to handle customer communications without human intervention. Unlike traditional phone systems or chatbots, voice agents can: * **Understand natural speech** in real-time conversations * **Process complex requests** and provide contextual responses * **Connect to your existing systems** to access data and perform actions * **Route calls intelligently** between automated handling and human agents * **Learn and adapt** from each interaction to improve performance ## How Voice Agents Work Voice agents operate through a sophisticated pipeline that transforms spoken conversations into actionable outcomes: ### 1. Speech Recognition Advanced speech-to-text technology converts caller audio into structured text, handling various accents, speaking speeds, and background noise. ### 2. Natural Language Understanding AI models analyze the converted text to understand: * **Intent** - What the caller wants to accomplish * **Entities** - Key information like names, dates, product IDs * **Context** - Previous conversation history and current situation ### 3. Decision Engine The voice agent determines the appropriate response based on: * Predefined conversation flows * Integration with your business systems * Real-time data lookup and validation * Escalation rules for complex scenarios ### 4. Response Generation Dynamic response creation that: * Provides accurate, contextual information * Maintains conversational flow * Adapts tone and style to match your brand * Handles follow-up questions naturally ### 5. Action Execution Voice agents can perform real actions like: * Booking appointments in your calendar system * Processing orders and payments * Updating customer records * Triggering workflows in connected platforms ## Key Benefits ### Cost Efficiency * **Pay-per-minute pricing** - Only pay for productive conversation time * **No idle time charges** - Eliminate costs of agents waiting for calls * **Reduced staffing needs** - Handle high call volumes without hiring ### 24/7 Availability * **Always-on service** - Never miss a call due to business hours * **No breaks or vacations** - Consistent availability year-round * **Instant response** - Eliminate hold times and queue delays ### Unlimited Scalability * **Handle millions of calls** simultaneously without infrastructure limits * **Instant scaling** during peak periods or marketing campaigns * **No training delays** - New capacity available immediately ### Universal Integration * **Connect to any system** via APIs and webhooks * **Sync with existing tools** like CRM, scheduling, and e-commerce platforms * **Maintain data consistency** across all customer touchpoints ## Common Use Cases Voice agents excel in scenarios that require: ### Customer Support * Answer frequently asked questions * Troubleshoot common issues * Route complex problems to specialists * Provide order status and tracking information ### Sales and Lead Qualification * Qualify inbound leads automatically * Schedule sales appointments * Provide product information and pricing * Follow up on abandoned carts or inquiries ### Appointment Scheduling * Book, reschedule, and cancel appointments * Send confirmation and reminder notifications * Handle availability checking across multiple calendars * Manage waitlists and cancellations ### Order Management * Process new orders over the phone * Handle returns and exchanges * Provide shipping updates * Manage subscription changes ## Getting Started Voice agents can be deployed across multiple channels and integrated with your existing business systems. The setup process typically involves: 1. **Define conversation flows** for your specific use cases 2. **Connect integrations** to your business systems and databases 3. **Configure routing rules** for escalation to human agents 4. **Test and refine** the voice agent's responses and actions 5. **Deploy and monitor** performance across your communication channels Voice agents represent the next evolution in customer communication - combining the personal touch of voice interaction with the efficiency and scalability of AI automation. ## Next Steps Ready to implement voice agents for your business? Explore our integration guides to connect voice agents with your existing systems: * [Shopify Integration](/integrations/shopify) - E-commerce order management * [WhatsApp Integration](/integrations/whatsapp) - Multi-channel messaging * [Google Calendar Integration](/integrations/google-calendar) - Appointment scheduling * [Klaviyo Integration](/integrations/klaviyo) - Marketing automation Or dive into specific use cases to see voice agents in action: * [Retail Use Cases](/usecases/retail) - Customer support and sales * [B2B Use Cases](/usecases/b2b) - Lead qualification and scheduling * [Healthcare Use Cases](/usecases/healthcare) - Appointment management and patient support # Roadmap Source: https://docs.miraiminds.co/general/roadmap What is shipping next — tiers, node graphs, the orchestration platform, model APIs, voices and integrations — with target dates. What is coming, in the order we expect to ship it. Dates are **targets, not contracts** — they move when call quality demands it, and quality wins every time. Before you plan a launch around a row on this page, confirm it with your account contact. | What | Target | Status | | :--------------------------------------------------------------------------------------------------- | :------------------------ | :----------------------------------- | | [Post-call analytics on `t1`](#post-call-analytics-everywhere) — AI summary + QA score in the v2 API | August 2026 | rolling out | | [Warm transfer on every tier](#warm-transfer-everywhere) — `ended_reason: transferred` goes live | August 2026 | rolling out | | [`t3` conversational tier](/general/tiers) — premium voice, always-on path | August 2026 | ✅ **live, and now the default tier** | | [Campaigns](/v2/campaigns) — server-side dialler with windows, retries, budgets and a report | August 2026 | ✅ **live** | | [Transcripts + recordings](/v2/calls#get-the-transcript) in the v2 API | August 2026 | rolling out | | [New voices](#new-voices) — fresh additions to the catalogue | monthly, from August 2026 | ongoing | | [Model APIs](#model-apis) — ASR, TTS and LLM as standalone endpoints | September 2026 | in development | | [Six new TTS languages](#new-tts-languages) — Telugu, Tamil, Marathi, Kannada, Gujarati, Bengali | September – October 2026 | in development | | [Next-generation TTS](#next-generation-tts) — the voice engine behind every tier, upgraded | October 2026 | in development | | Node graphs + `t5` — author calls as versioned graphs | Q4 2026 | schema preview published | | [Orchestration platform](#orchestration-platform) — visual node-graph builder for your team | Q4 2026 | design | | [Email + channel integrations](#channels) — email follow-ups from call outcomes | Q4 2026 | design | | Self-serve signup and wallet top-up | Q4 2026 | design | ## Post-call analytics everywhere Every call on every tier gets opened: an AI summary and a QA score, on 100% of calls, not a sample. The capability is in the [feature matrix](/general/tiers#feature-matrix) today; what lands in August is the **v2 API surface** — an analysis object on the call and a webhook event when it is ready. Until then the summaries exist but are not queryable over v2. ## Warm transfer everywhere Human handoff is part of every tier — a caller who asks for a person gets one. The August rollout wires the last mile: [`ended_reason: transferred`](/v2/calls#ended-reasons) starts emitting, and transfer targets become configurable per agent. Ask your account contact to enable it on your workspace as the rollout reaches you. ## Model APIs The ASR, TTS and LLM that power the call path, exposed as standalone endpoints — for teams that want the models without the telephony. Streaming ASR tuned for Indian telephone audio, the TTS voice catalogue, and the call-tuned LLM, each behind the same `sk_live_` auth as the rest of v2. ## New TTS languages Six languages join the voice engine over the next two months: **Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali** — together the mother tongue of 429 million people, most of whom cannot be reached in Hindi at all. Per-language dates are announced as each clears evaluation. The full story, with the data, is on the [language roadmap](/v2/tts-languages). ## Next-generation TTS The next revision of the voice engine: lower first-audio latency and a step up in naturalness on Hindi and code-switched speech. It replaces the engine under every tier when it clears our quality bar — no API change, your calls just start sounding better. ## Orchestration platform Node graphs get a canvas. Build, test and version call flows visually — the same graphs `t5` runs from JSON, authored by your ops team instead of your engineers. Validation runs as you draw, and every published version is immutable, so a live campaign never runs an unreviewed edit. ## New voices The catalogue grows monthly from August 2026. New voices appear in the [voice gallery](/v2/voices) as they clear evaluation — listen to `sampleAudio` before you switch; voice choice moves answer-through rates more than prompt wording does. ## Channels Calls stop being an island: email follow-ups triggered by call outcomes first ("summary of what we agreed", "payment link after a confirmed order"), other channels after. Designed so a call outcome, not a human, is what pulls the trigger. # Billing & tiers Source: https://docs.miraiminds.co/general/tiers What a minute costs, how minutes are counted, what each tier can do, and what happens at zero balance. One number to remember: **₹3 per minute**. That is the `t3` conversational tier, it is what a new key is issued at, and it is what every example in these docs uses unless it says otherwise. | Tier | Rate | Status | | :-------------------- | :-------------- | :----------------------- | | `t1` — transactional | ₹1 / minute | ✅ Live | | `t3` — conversational | **₹3 / minute** | ✅ Live — **the default** | | `t5` — orchestrated | ₹5 / minute | 🔜 Not live | `t5` is published so you can plan against it. Requesting it today returns `501 tier_unavailable` — see [when it arrives](#coming-soon). **Why `t3` is the default.** It is the always-on path: it speaks through a premium third-party voice catalogue with no dependency on our own GPU fleet, so it is the tier that is up whenever the API is up. `t1` runs on the Mira stack at a third of the price and a smaller voice catalogue — a deliberate trade, and the right one for high-volume transactional work. Ask for it explicitly: ```json theme={null} { "tier": "t1" } ``` ## Feature matrix What the tier buys is not a discount, it is **what the call can do**. | Capability | `t1` ₹1 | `t3` ₹3 | `t5` ₹5 | | :------------------------------------------------------ | :------: | :------------: | :------------: | | Core call (ASR → LLM → TTS, Mira stack) | ✅ | ✅ | ✅ | | Voicemail detection | ✅ basic | ✅ proper | ✅ | | Clean call ending | ✅ | ✅ | ✅ | | Post-call analytics (AI summary + QA score, every call) | ✅ | ✅ | ✅ | | Human handoff (warm transfer) | ✅ | ✅ | ✅ | | Premium TTS voice option + fallback | ❌ | ✅ | ✅ | | Node-graph runtime | ❌ | ✅ (our graph) | ✅ (your graph) | | IVR detect + navigate | ❌ | ✅ | ✅ | | Customer-authored node graph | ❌ | ❌ | ✅ | | Priority capacity | standard | reserved floor | reserved floor | **Every call is opened.** On every tier, each completed call gets an AI summary and a QA score — 100% of calls, not a sample. Warm transfer to a human is part of the same lattice: any tier can hand a caller to a person. See the [roadmap](/general/roadmap) for rollout status of both on `t1`. **Read the ❌ as a contract.** A ❌ is not "degraded", it is "absent". On `t1` there is no node-graph runtime and no IVR navigation — the call is one prompt, start to finish. That simplicity is what makes ₹1 possible. ### Picking a tier * **`t3` (default)** — start here. Premium voices, no dependency on our GPU fleet, and the tier every example in these docs is written against. If you are not sure, you want this. * **`t1`** — one job, under two minutes, no branching, at volume. Order confirmations, delivery windows, appointment reminders, OTP-adjacent notifications, "are you still interested?". If you can write the whole call as one prompt, it ends when the customer says yes or no, and the volume makes ₹2 a minute worth optimising for, drop to `t1`. Its voice catalogue is two voices — `ashu` and `aishe`, see [Voices](/v2/voices). * **`t5`** — you want to author the flow yourself, node by node, and version it like code. Not live. ## What a minute costs Billing is **per-minute blocks, rounded up, with a one-minute minimum.** The minute is the billing unit, not just the price: a call is not prorated to the second, and a part-minute is a whole minute. | Call duration | Billed minutes | `t3` cost | `t1` cost | | :------------ | :------------- | :-------- | :-------- | | 8 seconds | 1 | ₹3.00 | ₹1.00 | | 59 seconds | 1 | ₹3.00 | ₹1.00 | | 61 seconds | 2 | ₹6.00 | ₹2.00 | | 96 seconds | 2 | ₹6.00 | ₹2.00 | | 3 min 01 s | 4 | ₹12.00 | ₹4.00 | ### Metering `duration_secs` on the [call object](/v2/calls#the-call-object) is the true media duration in seconds. The charge is that duration rounded **up** to the next whole minute, never fewer than one, times the tier rate. A call that never connected is not billed at all — see the table below. ```python theme={null} import math def cost_inr(duration_secs: int, per_min_inr: float) -> float: minutes = max(1, math.ceil(duration_secs / 60)) return minutes * per_min_inr ``` You never have to compute this — `cost_inr` on the call object and the debit row in the [ledger](/v2/wallet#list-transactions) are authoritative. Use the formula to forecast, not to reconcile. ### What is and is not billable | Outcome | `status` | Billed | | :-------------------------------------------- | :---------- | :----: | | Conversation completed | `completed` | ✅ | | Answering machine, message left or hung up on | `voicemail` | ✅ | | Hit the duration cap | `timeout` | ✅ | | Rang out | `no_answer` | ❌ | | Line busy | `busy` | ❌ | | Could not connect, or our pipeline errored | `failed` | ❌ | | You cancelled before it connected | `aborted` | ❌ | The rule underneath: **if media went live, you pay for it.** A voicemail consumed a real dial, real TTS and real minutes of carrier time, so it is billed. A call our own pipeline broke is not. ### Forecasting a month Take your answer rate and your mean answered-call duration: Round each answered call up to a whole minute first, then multiply. Averaging the seconds and dividing by 60 will under-forecast, because the rounding happens per call and never cancels out. ```text theme={null} monthly ₹ = answered_calls × mean(ceil(secs / 60)) × rate ``` A campaign of 50,000 dials at a 35% answer rate and 80 seconds mean talk time, on `t3`: ```text theme={null} 50,000 × 0.35 = 17,500 answered 80 seconds -> 2 billed minutes each 17,500 × 2 × ₹3 = ₹105,000 (the same list on t1: ₹35,000) ``` Unanswered dials cost nothing, so the 32,500 that rang out are free. Because the boundary is the minute, **the lever is which side of a boundary your calls land on.** Trimming an 80-second call to 55 seconds halves its cost; trimming it to 70 saves nothing. Look at the distribution of `duration_secs`, not the mean — a cluster sitting just past 60 or just past 120 is the cheapest thing you will ever fix, and a prompt that caps reply length is usually what moves it. ## Wallet Billing is prepaid against one INR wallet per workspace. ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/wallet \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json theme={null} { "balance_inr": 500, "currency": "INR", "updated_at": "2026-07-26T09:12:44Z" } ``` * **Debits happen at call end**, one ledger row per billable call, carrying the `call_id`. * **Top-ups are not self-serve yet** — see the [roadmap](/general/roadmap). Your Mirai contact credits the workspace; it lands within minutes and applies immediately. * **The ledger is the source of truth** for reconciliation: [`GET /v2/wallet/transactions`](/v2/wallet#list-transactions). See the [Wallet reference](/v2/wallet) for the full API. ## At zero balance **Before dialling**, we check the wallet can cover at least one minute at the call's tier. If it cannot: ```json title="402 Payment Required" theme={null} { "error": { "code": "insufficient_balance", "message": "wallet balance 0.40 INR is below the minimum for one minute at t3" } } ``` * No phone rings. No charge. No call object is created. * The `Idempotency-Key` is **not** consumed — retry with the same key after topping up. * **Calls already in flight are not killed.** A call that started with credit runs to its natural end and then debits. Your balance can therefore dip below what the pre-dial check implied during a busy minute. * **Inside a [campaign](/v2/campaigns) this is not an error.** The campaign pauses itself with `pause_reason: "insufficient_balance"`, keeps every contact's place, and continues from there once you top up and start it again. Alert on balance yourself, hourly, at a threshold covering a day of traffic. A `402` mid-campaign is an expensive way to find out. ```python theme={null} wallet = httpx.get(f"{API}/v2/wallet", headers=auth).raise_for_status().json() if wallet["balance_inr"] < DAILY_BURN_INR: alert_ops(f"mirai wallet at ₹{wallet['balance_inr']}") ``` ```javascript theme={null} const wallet = await fetch(`${API}/v2/wallet`, { headers: auth }).then((r) => r.json()); if (wallet.balance_inr < DAILY_BURN_INR) { await alertOps(`mirai wallet at ₹${wallet.balance_inr}`); } ``` ## Setting the tier Your key carries a default tier — `t3` unless you asked for something else. Every call and every campaign inherits it unless you override: ```json theme={null} { "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t1" } ``` Override downward per call when a particular job is simple enough for `t1`. There is no way to set a tier per *agent*: the same agent can be run at either rate. The call object reports the `tier` it ran at alongside `cost_inr`, so you can always see which rate card a given call was billed under. A [campaign](/v2/campaigns) sets its tier once, at create time, and every call it places inherits it. ## Coming soon `t3` [went live in August 2026](/general/roadmap) and is now the default. `t5` is in the contract but not in production. Today: ```json title="501 Not Implemented" theme={null} { "error": { "code": "tier_unavailable", "message": "Tier t5 is not available on this deployment yet." } } ``` Design against it if you like — the field name and the values will not change — but do not ship a code path that depends on it until we tell you it is live. Current target: **`t5` with node graphs in Q4 2026**. See the [roadmap](/general/roadmap), and ask your account contact to confirm before you plan a launch around it. # Abandon Cart Recovery Source: https://docs.miraiminds.co/guides/abandon-cart-recovery Complete guide to setting up automated abandon cart recovery using Voice Agents with GoKwik integration and Shopify coupon automation. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Abandon cart recovery is one of the most powerful applications of Voice Agents in e-commerce, capable of recovering up to 20% of abandoned carts through intelligent, automated outreach. This guide provides complete setup instructions for implementing abandon cart recovery using GoKwik webhook integration and [Shopify coupon automation](/integrations/shopify). **Estimated Time:** \~30 minutes
**Core Requirements:** GoKwik, [Shopify](/integrations/shopify), and [WhatsApp](/integrations/whatsapp) integrations.
Instantly detect abandoned carts via GoKwik webhooks and trigger a personalized AI voice call within minutes. Automatically generate unique, time-sensitive Shopify discount coupons to encourage purchase completion. Combine an AI voice call with an immediate WhatsApp follow-up containing the checkout link and coupon code.
## How It Works The recovery system operates through a seamless, automated workflow triggered in real-time: 1. **Cart Abandoned:** A customer abandons their cart on your GoKwik checkout page. 2. **Webhook Trigger:** GoKwik instantly sends a webhook notification to the Voice Agents platform with the cart and customer details. 3. **Coupon Generated:** The system connects to your [Shopify store](/integrations/shopify) and automatically generates a unique, time-limited discount coupon. 4. **AI Voice Call:** Within minutes, an AI Voice Agent places a personalized call to the customer, referencing their cart items and offering the discount. 5. **WhatsApp Follow-up:** Immediately after the call, a [WhatsApp message](/integrations/whatsapp) is sent containing the direct GoKwik checkout link and the unique coupon code, making it easy to complete the purchase. ## Data Access & Permissions We prioritize your data security and privacy. Our system is designed to be non-intrusive and only accesses the minimal data required to function. **Strictly Read-Only and Action-Specific** The Voice Agents system **NEVER** manipulates cart contents, modifies payment information, or alters customer data in GoKwik or Shopify. It only listens for events and performs specific, authorized actions. * **GoKwik Integration:** * **Access:** Listens to the Abandon Cart webhook to receive customer contact info, cart contents, and the cart recovery link. * **Permissions:** Read-only. * **[Shopify Integration](/integrations/shopify):** * **Access:** Reads product information (like titles) using the product ID provided by GoKwik. * **Permissions:** Primarily requires permission to **create and manage discount codes**. * **[WhatsApp Integration](/integrations/whatsapp):** * **Access:** No access to your WhatsApp data. * **Permissions:** Requires permission to **send messages** using your pre-approved templates. ## Setup Guide Follow these steps to configure your automated abandon cart recovery campaign. ### Step 1: GoKwik Webhook Integration This is the recommended method for real-time recovery. 1. **Contact the GoKwik Integration Team.** * Email: `integration@gokwik.co` * Subject: `Voice Agents Abandon Cart Webhook Setup` * Body: "Hello, please add the abandon cart webhook integration for our account to send notifications to the Voice Agents platform. Our webhook endpoint is: `[Your Webhook URL]`" 2. **Get Your Webhook Endpoint.** * You can find this unique URL in your abandon cart campaign page on the Voice Agents platform. Webhook Configuration 3. **How It Works:** * Once configured, GoKwik will send a JSON payload with cart details to this endpoint whenever a cart is abandoned. Our platform automatically extracts the relevant information (customer name, phone, cart items) to initiate the recovery call. #### GoKwik Webhook Data Structure ```json theme={null} { "cartId": "688bb9076b9a7a596ca17c30", "timeInitiated": "Aug 01, 2025 12:12 AM", "custPhone": "xxxxxxxxxx", "custName": "John Doe", "custEmail": "testjohndoe@gmail.com", "line_items": [ { "productName": "Travel Fresh Pro (Pack of 3) - Pack of 3", "productQuantity": 1, "productVariant": "46338199126323", "productPrice": 1499.0 } ], "cartTotal": 1499.0, "subtotal": 1499.0, "shippingCharges": 0.0, "codCharges": 0.0, "prepaidDiscount": 0.0, "couponDiscount": 0.0, "checkoutStage": "ORDER_SCREEN", "communicationSent": 0, "NoOfSMSSent": 0, "NoOfWhatsappSent": 0, "communicationTime": "", "channel": "SHOPIFY", "abandonLink": "https://urturms.com/?cart-resume-id=688bb9076b9a7a596ca17cxx&type=report,fastrr,", "source": "fastrr", "address": "", "recoverStatus": "NOT_RECOVERED", "recoverFastrrOrderId": "", "recoverPlatformOrderId": "", "recoverClientOrderId": "", "recoverOrderCreatedAt": "" } ``` ### Step 2: Shopify Integration Setup This is required for automatically generating discount codes. For detailed setup instructions, see our [complete Shopify integration guide](/integrations/shopify). 1. **Connect Your Store:** In the Voice Agents dashboard, navigate to **Integrations** and select **Shopify**. 2. **Authorize:** Enter your Shopify store URL (`your-store.myshopify.com`) and follow the prompts to authorize the connection. 3. **Configure Coupon Settings:** * **Discount Percentage:** Set a default value (e.g., 10%). * **Coupon Validity:** Set an expiration time (e.g., 1 hour) to create urgency. * **Usage Limits:** Configure as single-use per customer. ### Step 3: WhatsApp Integration Setup This is required for sending the follow-up message with the checkout link. 1. **Connect Your Account:** In the Voice Agents dashboard, navigate to **Integrations** and select **WhatsApp**. 2. **Authorize:** Follow the steps to connect your WhatsApp Business Account (WABA). 3. **Configure Message Template:** * Select or create an approved message template for abandon cart recovery. * Ensure it includes placeholders for the customer's name, coupon code, and the GoKwik checkout link. ### Step 4: Tracking Recovery Success To measure the effectiveness of your campaigns, we automatically add UTM parameters to the checkout link. * **UTM Parameters:** The `abandonLink` from GoKwik is appended with parameters like `&utm_source=voiceagents&utm_medium=call`. * **Attribution:** This allows you to see all recovered orders attributed directly to Voice Agents on your GoKwik dashboard, giving you a clear view of your ROI. ## Alternative Method: CSV Upload **Not Recommended** Using the CSV method significantly delays outreach, reducing the "heat of the moment" urgency and lowering conversion rates. Only use this if real-time webhook integration is not possible. 1. **Export Data:** Export your abandoned cart data from the GoKwik dashboard. 2. **Format CSV:** Ensure the file includes columns for customer phone, name, cart items, and the checkout URL. 3. **Upload Manually:** Upload the formatted CSV to a new campaign on the Voice Agents platform. ## Best Practices for Optimization * **Timing is Key:** The AI call should be triggered within 5-15 minutes of abandonment for the highest impact. * **Smart Discounts:** Start with a 5-10% discount. A/B test different offers to find what works best for your audience. * **Clear Call to Action:** Both the voice call and WhatsApp message should clearly guide the user to complete their purchase using the provided link and code. * **Monitor & Refine:** Regularly check your campaign's performance metrics (recovery rate, coupon redemption rate) and refine your call scripts and offers accordingly. # How Discounts Work Source: https://docs.miraiminds.co/guides/how-discounts-work Understanding the intelligent discount system with applied and additional discounts, including how voice agents offer context-aware deals during calls. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview The Voice Agent discount system intelligently manages two types of discounts during customer calls: **applied discounts** (already in the cart) and **additional discounts** (offered during the conversation). The agent adapts its approach based on customer interest, offering additional incentives when needed to close the sale. ## Discount Types ### Applied Discount An **applied discount** is one that's already active in the customer's cart when the voice agent call begins. This could be: * A first-time user discount * A discount from a previous interaction The voice agent will mention this discount when the customer asks about available deals. ### Additional Discount An **additional discount** is offered by the voice agent during the call as a strategic incentive. The agent offers this when: * The customer shows less interest in purchasing * The customer mentions the price is too high * The customer has concerns about the product * The customer explicitly asks for more discount This creates a personalized sales conversation where the agent can negotiate to close the deal. ## How It Works The discount system follows an intelligent flow during each call: 1. **Customer Asks About Discounts:** When a customer inquires about available discounts, the voice agent first mentions any **applied discount** already in their cart. 2. **Customer Shows Hesitation:** If the customer indicates they're not ready to buy (due to price, product concerns, or wanting a better deal), the agent offers the **additional discount**. 3. **Discount Application Logic:** The system applies the additional discount based on the `applyAs` setting: * **additional**: The new discount stacks on top of the existing one * **override**: The new discount replaces the existing one 4. **Webhook Event:** After the call ends, the `create-order` webhook event includes only the **additional discount** details (code and reason), since the applied discount is already reflected in the cart. ## Discount Configuration You configure discounts by including a `discount` object in your `initiate-call` API request: ```json theme={null} { "discount": { "code": "NEW_USER", "amount": "50", "type": "%tage", "applyAs": "additional" } } ``` ### Field Descriptions * **`code`** (string): The discount code that will be applied (e.g., "NEW\_USER", "SAVE25", "FLASH50") * **`amount`** (string): The discount value * For percentage discounts (`%tage`): A number between 0-100 * For fixed discounts (`fixed`): A static amount in your currency * **`type`** (string): The discount type * `"%tage"`: Percentage-based discount (e.g., 25% off) * `"fixed"`: Fixed amount discount (e.g., ₹500 off) * **`applyAs`** (string): How the discount should be applied * `"additional"`: Stack this discount on top of any existing applied discount * `"override"`: Replace any existing applied discount with this one ## The applyAs Logic Understanding the `applyAs` field is crucial for controlling how discounts combine. ### Additional Mode (`applyAs: "additional"`) In **additional** mode, the new discount is applied **on top of** the existing applied discount. Both discounts work together. **Example:** * Product price: ₹1,000 * Applied discount: 10% (₹100 off) → Cart shows ₹900 * Additional discount offered: 25% (₹250 off the original price) * **Final price: ₹650** (both discounts apply: ₹1,000 - ₹100 - ₹250) **When to Use** Use **additional** mode when you want to reward customers with cumulative savings. This works well for loyalty programs or special promotions where you want customers to feel they're getting an exceptional deal. ### Override Mode (`applyAs: "override"`) In **override** mode, the new discount **replaces** the existing applied discount. Only the additional discount applies. **Example:** * Product price: ₹1,000 * Applied discount: 10% (₹100 off) → Cart shows ₹900 * Additional discount offered: 25% (₹250 off the original price) * **Final price: ₹750** (only the 25% discount applies: ₹1,000 - ₹250) **When to Use** Use **override** mode when you want to offer a better deal than what's currently applied, but don't want discounts to stack excessively. This is ideal for protecting your margins while still giving customers an upgrade. ## Real-World Scenarios Let's walk through all possible discount scenarios to see how the voice agent handles each case. ### Scenario 1: Both Discounts Available (Additional Mode) **Conversation Flow:** > **Customer:** "Do you have any discounts?"\ > **Agent:** "Yes! You already have a 10% welcome discount applied to your cart with code WELCOME10." > > **Customer:** "Hmm, the price is still a bit high for me."\ > **Agent:** "I understand! Let me offer you an additional 25% discount with code EXTRA25. This will be applied on top of your current 10% discount, giving you even more savings!" **Result:** Both discounts apply. Cart total reflects 10% + 25% = 35% total savings. ### Scenario 2: Both Discounts Available (Override Mode) **Conversation Flow:** > **Customer:** "Is there any discount on this?"\ > **Agent:** "Absolutely! You currently have a 10% discount applied with code WELCOME10." > > **Customer:** "Can I get a better deal?"\ > **Agent:** "I can offer you a special 25% discount with code SPECIAL25. This will replace your current 10% discount, giving you a better overall saving!" **Result:** The 25% discount replaces the 10% discount. Cart updates to reflect only the 25% saving. ### Scenario 3: Only Applied Discount Available **Conversation Flow:** > **Customer:** "Are there any discounts?"\ > **Agent:** "Yes! You have a 15% discount already applied to your cart with code SAVE15." > > **Customer:** "Can I get more discount?"\ > **Agent:** "I'm sorry, but the 15% discount with code SAVE15 is the best offer available for this product right now. It's already a great deal!" **Result:** Only the applied discount is active. No additional discount to offer. ### Scenario 4: Only Additional Discount Available **Conversation Flow:** > **Customer:** "Do you have any offers right now?"\ > **Agent:** "Currently, there's no discount applied to your cart." > > **Customer:** "That's too expensive. I'm not sure about this."\ > **Agent:** "I completely understand! Let me help you with that. I can offer you an exclusive 50% discount with code FIRST50 to make this purchase more affordable!" **Result:** The 50% additional discount is applied during the call. ### Scenario 5: No Discounts Available **Conversation Flow:** > **Customer:** "Is there any discount on this item?"\ > **Agent:** "I'm sorry, but there are no discounts available for this product at the moment. However, it's a premium quality item that offers great value!" > > **Customer:** "Can you give me any discount?"\ > **Agent:** "Unfortunately, I don't have any discount codes to offer right now. But this product is worth the investment for its quality and features!" **Result:** No discounts available. Agent focuses on value proposition. ## Webhook Event Details After each call, when an order is created, the `create-order` webhook event is triggered. The discount information in this webhook follows a specific pattern: ### Webhook Payload Structure ```json theme={null} { "orderId": "12345", "customerId": "67890", "discount": { "code": "EXTRA25", "reason": "Customer requested additional discount due to price concerns" } // ... other order details } ``` ### Important Notes **Only Additional Discount Returned** The webhook payload includes **only the additional discount** that was offered by the voice agent during the call. The applied discount is **not included** because it's already reflected in the cart total. * **`code`**: The discount code that was offered during the call (only if an additional discount was provided) * **`reason`**: An optional explanation of why the discount was offered (e.g., "Customer showed hesitation", "Customer requested better deal") **Why only additional discount?** The applied discount is already part of the cart state before the call begins, so it's already factored into the order total. The webhook only reports new information—what the voice agent actively did during the conversation. ## Best Practices ### Choosing Additional vs Override * **Use `additional` when:** * Running promotional campaigns where stacking is expected * Rewarding loyal customers with extra benefits * You want to create a "wow" moment with combined savings * Your margins can support stacked discounts * **Use `override` when:** * You want to control maximum discount limits * Protecting profit margins is critical * Offering a better single discount is more attractive than stacking * Simplifying the customer's understanding (one clear discount) # Mirai Voice Source: https://docs.miraiminds.co/index AI voice agents that make real phone calls — ₹1 a minute, on our own infrastructure in India. Give an agent a script and a number, and it makes the call. Hindi, English, and the Hinglish most real calls actually are — on GPUs, models and SIP lines we own, in India, at **₹1 a minute**. Key → agent → call → webhook. Five minutes, one real phone call. Two resources, one auth header, one error envelope. What a minute costs, and what is and is not billable. What is shipping, and when. ## Get a key Create one yourself in the [console](https://sandbox.voice.miraiminds.co) under **Developers**. It is shown once, and you can rotate or revoke from the same page. ```bash theme={null} pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice ``` ```python theme={null} from mirai import Mirai mirai = Mirai() # reads MIRAI_API_KEY agent = mirai.agents.create( name="Order confirmation", system_prompt="You are आशु from Acme. Confirm the order and ask whether the delivery address is unchanged.", first_message="नमस्ते, मैं Acme से आशु बोल रहा हूं।", voice_id="ashu", language="hi", ) call = mirai.calls.create(agent_id=agent.id, to="+919876543210") print(mirai.calls.wait(call.id).status) # 'completed' ``` Prefer plain HTTP? Every page here carries the same thing in cURL, Python and Node. Start with the [Quickstart](/v2/quickstart). # Google Calendar Source: https://docs.miraiminds.co/integrations/google-calendar Voice agent can access and book your google calendar on call **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview > Work in progress # Klaviyo Integration Source: https://docs.miraiminds.co/integrations/klaviyo Integrate Voice Agent with Klaviyo **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Integrating Voice Agent with Klaviyo enables your brand to add a powerful calling channel to your marketing campaigns. By connecting a Voice Agent webhook node in Klaviyo flows, you can reach customers with personalized voice calls, improving engagement and conversion rates. Voice Agents can deliver campaign messages, collect feedback, and assist customers directly, making your outreach more interactive and effective. Boost recovery rates by up to 30% with automated voice calls, outperforming traditional email or WhatsApp campaigns. Send timely voice reminders for replenishable products, ensuring customers never run out of essentials. Engage customers with calls to gather solid, actionable feedback, improving your products and services. Use customer data from Klaviyo to personalize voice interactions, increasing relevance and response rates. ## Setup Guide ### Step 1: Add Webhook Node in Klaviyo Flow 1. **Navigate to Your Klaviyo Flow** Open your existing Klaviyo flow or create a new one where you want to add voice agent integration. 2. **Add Webhook Node** Click the "+" button to add a new action and select **Webhook** from the available options. Add webhook node ### Step 2: Create Voice Agent Campaign with Webhook Support 3. **Access Voice Agents Dashboard** Navigate to the **Campaigns** section in your Voice Agents platform and create a new campaign. 4. **Enable Webhook Integration** When creating your campaign, make sure to enable webhook support. This allows Klaviyo to send contact data directly to your voice agent campaign. Enable webhook integration For complete campaign setup instructions, see our [Campaign Management Guide](/general/campaign). ### Step 3: Copy Webhook Details 5. **Copy Webhook Endpoint** After creating your campaign with webhook support enabled, click the **"Copy Webhook"** button to reveal the webhook configuration modal. Copy webhook button This will display: * **Webhook URL** - The endpoint where Klaviyo will send data * **Secret Key** - For secure authentication between platforms ### Step 4: Configure Klaviyo Webhook 6. **Setup Webhook in Klaviyo** Return to your Klaviyo flow and configure the webhook node with the details from your Voice Agent campaign: * Paste the **Webhook URL** in the endpoint field * Add the **Secret Key** for authentication * Configure any additional payload data you want to send Setup webhook configuration 7. **Test and Activate** Test your webhook connection to ensure data flows correctly between Klaviyo and Voice Agents, then activate your flow. ## What's Next? Once your integration is complete, your Klaviyo flows can automatically trigger voice agent calls for: * **Abandoned Cart Recovery** - Reach customers who left items in their cart * **Post-Purchase Follow-up** - Collect feedback and encourage repeat purchases * **Replenishment Reminders** - Notify customers when it's time to reorder * **Win-back Campaigns** - Re-engage inactive customers with personalized calls The voice agent will receive customer data from Klaviyo and can personalize conversations based on purchase history, preferences, and behavior patterns. # Limechat Integration Source: https://docs.miraiminds.co/integrations/limechat Connect your Limechat account to enable voice agents for customer support and automated messaging assistance. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Voice Agents seamlessly integrates with Limechat to provide intelligent customer support and automated messaging capabilities. Your customers can interact naturally with voice agents through Limechat's platform for instant support, order inquiries, and personalized assistance. Handle customer inquiries and support requests automatically through Limechat's messaging platform. Voice agents can check order status, process requests, and handle order-related inquiries seamlessly. Provide instant responses to common questions and route complex queries to human agents when needed. Connect across multiple messaging channels supported by Limechat for comprehensive customer engagement. ## Setup Guide To enable your Voice Agent platform to connect with Limechat, you'll need to obtain two crucial pieces of information directly from your Limechat account: your Limechat Account ID and API Access Token. ### Step 0: Access Voice Agents Integration Tab 1. **Navigate to Voice Agents Dashboard** Log in to your Voice Agents platform and go to the integration tab. Voice Agents Integration Tab ### Step 1: Obtain Your Limechat Account ID 1. **Log in to your Limechat dashboard** Access your Limechat account through the web interface. 2. **Locate Account ID** Your Account ID can typically be found directly on the Limechat Dashboard. Limechat Account ID Location ### Step 2: Obtain Your Limechat API Access Token 1. **Access Profile Settings** While logged into your Limechat dashboard, click on your profile picture at the extreme bottom left. Limechat Profile Button 2. **Navigate to Profile Settings** Select **Profile Settings** from the menu. 3. **Find Access Token** Scroll down to the bottom of the Profile Settings page. 4. **Copy the Access Token** Copy the Access Token and keep it secure - you'll need this to connect with Voice Agents. Limechat Access Token ### Step 3: Connect to Voice Agents Platform 1. **Complete Limechat Connection** Click the **Connect** button next to Limechat, then: * Enter your Limechat Account ID from Step 1 * Paste the API Access Token from Step 2 * Click **Connect** to finalize the integration ## What's Next? Once connected, your voice agent can immediately start handling: * **Customer inquiries** - "I need help with my order" * **Support requests** - "How do I track my shipment?" * **General questions** - "What are your business hours?" * **Product information** - "Tell me more about this product" # Shopify Integration Source: https://docs.miraiminds.co/integrations/shopify Connect your Shopify store to enable voice agents for customer support, order management, and automated sales assistance. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Voice Agents seamlessly integrates with Shopify to provide intelligent customer support, order tracking, inventory inquiries, and sales assistance. Your customers can interact naturally with voice agents to check order status, find products, apply discounts, and get instant support. Voice agents can check order status, process returns, and handle shipping inquiries automatically. Help customers find products, check inventory, and provide detailed product information through voice. Handle common support requests like account issues, discount codes, and general store policies. Upsell, cross-sell, and guide customers through the purchase process with intelligent recommendations. ## Required Permissions Your voice agent needs specific Shopify API permissions to function effectively: ### Essential Scopes * **`read_customers`** - Access customer information for personalized support * **`read_products`** - Provide product details and inventory status * **`read_orders`** - Check order status and history * **`write_orders`** - Process order modifications and cancellations * **`read_discounts`** - Apply available discount codes * **`write_discounts`** - Create promotional codes for customers ### Optional Scopes (based on your use case) * **`read_inventory`** - Real-time inventory checking * **`read_shipping`** - Shipping rate calculations * **`write_customers`** - Update customer information * **`read_returns`** - Handle return requests ## Setup Guide ### Part 1: Create Shopify Private App 1. **Access Shopify Admin** Log in to your Shopify admin dashboard. 2. **Navigate to Settings** Click the **Settings** option in the bottom-left corner of your admin panel. Shopify admin dashboard 3. **Open Apps and Sales Channels** Select **Apps and sales channels** from the settings menu. Settings menu 4. **Access App Development** Click **Develop apps** in the top navigation bar. Apps and sales channels 5. **Create New App** Click the **Create an app** button to start the setup process. Develop apps button 6. **Configure App Details** Enter your app name and select the app developer, then click **Create app**. Create an app 7. **Set API Permissions** Click **Configure Admin API scopes** to set up the required permissions. App configuration 8. **Select Required Scopes** Enable the necessary permissions listed above. At minimum, select: * `read_customers` * `read_products` * `read_orders` * `write_orders` * `read_discounts` * `write_discounts` Add additional scopes based on your specific use case, then save your changes. Configure API scopes 9. **Install the App** Click **Install app** to activate the app in your store. Install app Install app confirmation 10. **Copy Access Token** After installation, copy the generated access token. **Keep this secure** - you'll need it to connect with Voice Agents. Access token ### Part 2: Connect to Voice Agents Platform 11. **Access Voice Agents Dashboard** Log in to your Voice Agents platform and navigate to the integrations page. Voice Agents dashboard 12. **Complete Shopify Connection** Click the **Connect** button next to Shopify, then: * Enter your store domain (without `https://` or `http://`) * Paste the access token from step 10 * Click **Connect** to finalize the integration Connect Shopify ## What's Next? Once connected, your voice agent can immediately start handling: * **Order inquiries** - "What's the status of my order #1234?" * **Product questions** - "Do you have this item in stock?" * **Customer support** - "How do I return an item?" * **Sales assistance** - "What's your best-selling product?" # WhatsApp Integration Source: https://docs.miraiminds.co/integrations/whatsapp Connect WhatsApp Business API with Voice Agents to enable seamless customer communication through voice and text messaging. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Voice Agents integrates with WhatsApp Business API to provide a unified communication experience. Customers can seamlessly switch between voice calls and WhatsApp messaging, with conversation context maintained across both channels. This creates a more flexible and accessible customer service experience. Seamlessly transition conversations between voice and WhatsApp messaging while maintaining full context. Send images, documents, location data, and interactive buttons through WhatsApp integration. Provide instant responses via WhatsApp even when voice agents are busy with other customers. Connect with customers worldwide using their preferred messaging platform. ## Prerequisites Before setting up WhatsApp integration, ensure you have: * **WhatsApp Business Account** - Verified business account on WhatsApp * **Meta Business Manager** - Access to Meta Business Manager with admin permissions * **Phone Number** - Verified business phone number (cannot be used on regular WhatsApp) * **Voice Agents Account** - Active Voice Agents platform subscription ## Required Information You'll need to collect these details during setup: * **App ID** - Your Meta app identifier * **Phone Number ID** - WhatsApp Business phone number identifier * **WhatsApp Business Account ID** - Business account identifier * **Access Token** - Permanent access token with messaging permissions * **Webhook Verify Token** - Custom token for webhook verification ## Setup Guide ### Step 1: Create or Select Meta App **Option A: Create New App** 1. Visit [Meta for Developers](https://developers.facebook.com/) 2. Click **"Create App"** in the top-right corner 3. Select **"Business"** as your app type 4. Provide required details: * App name (e.g., "Your Company Voice Agent") * Contact email address * Business Manager account (if applicable) 5. Click **"Create App"** to proceed **Option B: Use Existing App** If you already have a Meta app: 1. Access your Meta for Developers dashboard 2. Select your existing app from the **"My Apps"** section 3. Ensure the app has appropriate permissions for WhatsApp Business Meta Developer Dashboard ### Step 2: Configure WhatsApp Business API 1. **Add WhatsApp Product** * In your app dashboard, navigate to **"WhatsApp" → "API Setup"** * If WhatsApp isn't added, click **"Add to App"** next to WhatsApp 2. **Configure Phone Number** * Under **"Send and receive messages"**, go to **"Step 1: Select phone numbers"** * In the **"From"** dropdown, you'll see: * Test numbers (for development only) * Your verified WhatsApp Business numbers * To add a new number, click **"➕ Add Phone Number"** WhatsApp API Setup ### Step 3: Collect Required IDs Once your phone number is configured: 1. **Select your verified WhatsApp Business number** from the dropdown 2. **Record these important values:** * **Phone Number ID** - Found below the selected number * **WhatsApp Business Account ID** - Located to the right of the Phone Number ID * **App ID** - Displayed in the top-left corner of the dashboard WhatsApp Business IDs Keep these IDs secure and accessible - you'll need them to complete the Voice Agents integration. ### Step 4: Generate Permanent Access Token 1. **Open WhatsApp Business Manager** Navigate to [Meta Business Manager](https://business.facebook.com/) and access WhatsApp Manager. WhatsApp Business Manager 2. **Select Business Portfolio** Choose your business portfolio from the top-left dropdown menu. 3. **Access Business Settings** Click **"Business Settings"** to manage your business configuration. Business Settings 4. **Navigate to System Users** In the left sidebar, select **"System Users"** under the Users section. 5. **Select Admin User** Choose a system user with **Admin** or **Full Access** permissions. System Users 6. **Generate Access Token** Click **"Generate New Token"** and configure: * **App Selection**: Choose the app you configured in previous steps App Selection * **Token Expiration**: Select **"Never"** for a permanent token Token Expiration * **Required Permissions**: * `whatsapp_business_messaging` - Send and receive messages * `whatsapp_business_management` - Manage business settings 7. **Secure Your Token** Copy and securely store the generated access token. This token provides full access to your WhatsApp Business messaging capabilities. Never share your access token publicly or commit it to version control. Treat it like a password. ### Step 5: Create Message Templates WhatsApp requires pre-approved templates for business-initiated conversations: 1. **Access Template Manager** Go to **WhatsApp Manager → Message Templates**. 2. **Create New Template** Click **"Create Template"** and provide: * **Template Name**: Descriptive name (e.g., `order_confirmation`) * **Category**: * **Marketing** - Promotional content * **Utility** - Account updates, order status * **Authentication** - Verification codes, security alerts * **Language**: Your target language (e.g., `en_US`) * **Message Content**: Include dynamic variables using `{{1}}`, `{{2}}` syntax 3. **Submit for Review** Templates typically get reviewed within 24 hours. Only approved templates can be used for business-initiated messages. 4. **Template Best Practices** * Keep messages concise and relevant * Use clear variable placeholders * Follow WhatsApp's messaging policies * Create templates for common use cases (order updates, appointment reminders, support responses) Template Creation ### Step 6: Connect to Voice Agents 1. **Access Voice Agents Dashboard** Log in to your Voice Agents platform and navigate to **Integrations**. 2. **Configure WhatsApp Integration** Click **"Connect"** next to WhatsApp and provide: * **App ID** (from Step 3) * **Phone Number ID** (from Step 3) * **WhatsApp Business Account ID** (from Step 3) * **Access Token** (from Step 4) * **Webhook Verify Token** (create a custom secure string) 3. **Test Connection** Send a test message to verify the integration is working correctly. ## Use Cases & Capabilities Once integrated, your Voice Agents can: ### Customer Support * **Seamless handoffs** between voice and WhatsApp * **Rich media sharing** like product images, receipts, and documents * **Quick replies** with predefined response options * **Interactive buttons** for common actions ### Business Communications * **Order confirmations** with tracking links * **Appointment reminders** with rescheduling options * **Support ticket updates** with status changes * **Marketing messages** (with proper opt-in consent) ### Advanced Features * **Location sharing** for store locations or delivery tracking * **Multi-language support** with localized templates * **Analytics integration** for message performance tracking * **CRM synchronization** for unified customer profiles ## Best Practices ### Compliance & Privacy * Always obtain proper consent before messaging customers * Respect opt-out requests immediately * Follow WhatsApp Business Policy guidelines * Implement proper data retention policies ### Message Optimization * Use approved templates for business-initiated messages * Keep response times under 24 hours during business conversations * Provide clear next steps in every interaction * Use rich media strategically to enhance communication ### Integration Management * Monitor webhook delivery and response times * Implement proper error handling and retry logic * Set up alerts for API rate limit warnings * Regularly review and update message templates # B2B Source: https://docs.miraiminds.co/usecases/b2b Voice Agents has deep integration with shopify **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview > Work in progress # Healthcare Source: https://docs.miraiminds.co/usecases/healthcare Voice Agents has deep integration with shopify **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview > Work in progress # Retail Source: https://docs.miraiminds.co/usecases/retail Voice Agents has deep integration with shopify **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). ## Overview Voice Agents revolutionize retail operations by providing intelligent, automated customer service and sales support through AI-powered conversational interfaces. These agents are specifically designed to handle the unique challenges and opportunities in the retail industry. Retail AI Agents Overview ### How Retail AI Agents Work Retail AI agents combine a practical knowledge base, automated integrations, and flexible ways to talk to customers — all designed to make running your store easier and more efficient. **Knowledge base (what the agent knows)** * **Customer service playbook**: Clear steps and friendly scripts for handling common questions and issues * **Marketing content**: Up‑to‑date product details, promotions, and brand messaging * **Sales playbook**: Conversation tips and tactics that help turn interest into purchases * **Standard operating procedures**: Consistent workflows for orders, returns, and fulfillment * **Training resources**: Guides and examples that keep the agent improving over time **Automation and system connections** * **Shopify**: Sync orders, check stock, and access customer profiles directly from your store * **WooCommerce**: Same seamless flow for WordPress shops * **ERP systems**: Tie into back‑office systems for pricing, inventory, and fulfillment data * **CRM / LMS**: Use customer histories and training records to personalize responses and improve accuracy * **Calendar**: Book appointments and show availability in real time * **Email**: Send confirmations, follow‑ups, and promotional messages automatically **How customers interact** * **Chat**: Website widgets, social DMs, and WhatsApp for quick text conversations * **Voice**: Phone or VoIP interactions for hands‑free support and a more human touch ### Why this matters for retail * Provide friendly, 24/7 support so customers get answers anytime * Scale support and sales without adding staff — handle many conversations at once * Keep messaging consistent across channels for a reliable brand experience * Automate routine tasks like order processing to reduce manual work and mistakes * Offer personalized product suggestions based on customer data * Monitor stock in real time and automate reorder alerts to prevent stockouts You can deploy these agents wherever your customers are — on your website, social platforms, or over the phone — so they fit your business and your customers' preferences. ## Abandon Cart Recovery - A Detailed Use Case One of the most impactful applications of Voice Agents in retail is automated abandon cart recovery. This sophisticated system can recover up to 20% of abandoned carts through intelligent, personalized outreach. Abandon Cart Recovery Process ### How It Works The abandon cart recovery system operates through a seamless, multi-step process: 1. **Smart Customer Identification** - The system automatically identifies customers who have abandoned their shopping carts and uses sophisticated AI filters to determine the best candidates for outreach calls. 2. **Human-like Voice Engagement** - A natural-sounding AI agent makes personalized phone calls to customers, addressing them by name and referencing their specific abandoned items. 3. **Intelligent Conversation Flow** - The agent engages in natural conversation to: * Understand why the customer didn't complete their purchase * Address any concerns or objections they might have * Offer personalized solutions or incentives when appropriate 4. **Dynamic Incentive Creation** - Based on the conversation, the system can generate personalized discount coupons that are active for a limited time (typically one hour) to create urgency. 5. **Multi-channel Follow-up** - After the call, the system automatically sends a WhatsApp message with a direct checkout link and the personalized coupon code, making it easy for customers to complete their purchase. **Key Benefits:** * **20% average cart recovery rate** - Significantly higher than traditional email-based recovery * **Personalized approach** - Each interaction is tailored to the specific customer and their abandoned items * **Immediate response** - Customers receive help within hours of abandoning their cart * **Cost-effective** - Automated system scales without increasing staff costs * **Data-driven insights** - Learn why customers abandon carts and improve your checkout process This approach transforms a common e-commerce challenge into a revenue opportunity while providing genuine value to customers who may have encountered obstacles during their shopping journey. ### Getting Started with Retail AI Agents Bringing Voice Agents into your retail business is easier than you might think. Below is a simple, practical path to get started and some tips to make them work well for your customers. **Quick setup (4 easy steps)** 1. **Gather your content** — Upload product catalogs, FAQs, return policies, and any service guides the agent should know. 2. **Connect your systems** — Link Shopify, inventory, CRM, or any other tools so the agent can access orders, stock, and customer info. 3. **Choose channels** — Pick where customers will talk to the agent (website chat, social DMs, WhatsApp, phone). Start with one or two and expand later. 4. **Train and test** — Run common scenarios, fine‑tune replies, and have staff test handoffs to humans. **Practical tips** * Start by automating the highest-volume, repetitive requests (order status, returns, store hours). * Make handoffs to human agents smooth and obvious for complex issues. * Keep product details and pricing up to date so answers stay accurate. * Track customer satisfaction and common failure points to prioritize improvements. * Roll out new features gradually based on real customer feedback. **What to expect (ROI)** Most retailers see noticeable improvements within weeks: faster response times, fewer routine tickets, and happier customers. Over time, that translates into better conversion rates and lower support costs while your team focuses on higher‑value work. # Assistants Source: https://docs.miraiminds.co/v1/assistants Create, read, list and update v1 assistants — prompts, identity, variants, call settings and analysis plans. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). An **assistant** is v1's agent: prompt, voice identity, telephony bindings, call settings, knowledge base and post-call analysis plan. All endpoints are workspace-scoped — send the `workspace` header. ## Variants | `variant.type` | Use for | | :--------------- | :------------------------------------------------------------------------------------------------------------------------------- | | `custom` | **Recommended for everything you author.** You write `agent.systemPrompt` and declare variables in `variant.config.inputSchema`. | | `abandoned_cart` | A built-in Shopify cart-recovery flow. Configure `variant.config.abandoned_cart`. | ## Create an assistant ```http theme={null} POST /v1/admin/assistant/create ``` ### Core fields | Field | Type | Required | Notes | | :-------------------------------- | :-------- | :------- | :---------------------------------------------------------------------------------------------------------------------- | | `name` | string | yes | Max 40 characters. Unique per workspace. | | `variant.type` | enum | yes | `custom` \| `abandoned_cart` | | `variant.config.inputSchema` | array | no | Variables the prompt references. See [variables](#variables). | | `agent.identity` | object | yes | `{ name, gender, voice }` — `gender` is `male` or `female`, `voice` is a slug from the [voice gallery](#voice-gallery). | | `agent.systemPrompt` | string | no | The instructions. Supports `{{placeholders}}`. | | `agent.firstMessage` | string | no | Spoken on connect. If empty, a greeting is generated from the identity and ICP language. | | `agent.endMessage` | string | no | Spoken just before hanging up. | | `agent.tools` | string\[] | no | [API tool](/v1/tools) `_id` values the assistant may call. | | `telephony.inbound` / `.outbound` | string | no | Telephony number `_id`s. | | `icpContext` | object | no | See [ICP context](#icp-context). | | `callSettings` | object | no | See [call settings](#call-settings). | | `analysisPlan` | object | no | See [analysis plan](#analysis-plan). | | `knowledgeBase` | object | no | See [Knowledge Base](/v1/knowledge-base). | | `preCall` | object | no | See [pre-call enrichment](#pre-call-enrichment). | ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/assistant/create \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Update Assistant", "variant": { "type": "custom", "config": { "inputSchema": [ { "name": "customerName", "type": "string", "isRequired": true }, { "name": "orderId", "type": "string", "isRequired": true }, { "name": "deliveryDate", "type": "string", "isRequired": false } ] } }, "agent": { "identity": { "name": "priya", "gender": "female", "voice": "priya" }, "systemPrompt": "You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}. If a delivery date is available, mention it: expected {{deliveryDate}}.", "firstMessage": "Hi {{customerName}}, this is Priya from Acme.", "endMessage": "Thank you for your time. Have a great day!" }, "icpContext": { "language": "hinglish" }, "callSettings": { "slots": [{ "startTime": "09:00", "endTime": "21:00" }], "maxCallDuration": 200, "concurrentCallCount": 5, "retryProtocol": { "maxAttemptsNoPickup": 2, "maxAttemptsLowEngagement": 1, "reAttemptPeriod": 300, "maxRescheduleCount": 1 } } }' ``` ```python theme={null} import os, httpx API = "https://api.voice-agents.miraiminds.co" H = { "x-public-key": os.environ["MIRAI_PUBLIC_KEY"], "x-private-key": os.environ["MIRAI_PRIVATE_KEY"], "workspace": os.environ["MIRAI_WORKSPACE"], } assistant = httpx.post( f"{API}/v1/admin/assistant/create", headers=H, json={ "name": "Order Update Assistant", "variant": { "type": "custom", "config": { "inputSchema": [ {"name": "customerName", "type": "string", "isRequired": True}, {"name": "orderId", "type": "string", "isRequired": True}, ] }, }, "agent": { "identity": {"name": "priya", "gender": "female", "voice": "priya"}, "systemPrompt": "You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}.", "firstMessage": "Hi {{customerName}}, this is Priya from Acme.", }, "icpContext": {"language": "hinglish"}, "callSettings": { "slots": [{"startTime": "09:00", "endTime": "21:00"}], "maxCallDuration": 200, "concurrentCallCount": 5, "retryProtocol": {"maxAttemptsNoPickup": 2, "reAttemptPeriod": 300}, }, }, timeout=30, ).raise_for_status().json() print(assistant["data"]["_id"]) ``` ```javascript theme={null} const API = "https://api.voice-agents.miraiminds.co"; const H = { "x-public-key": process.env.MIRAI_PUBLIC_KEY, "x-private-key": process.env.MIRAI_PRIVATE_KEY, workspace: process.env.MIRAI_WORKSPACE, "Content-Type": "application/json", }; const res = await fetch(`${API}/v1/admin/assistant/create`, { method: "POST", headers: H, body: JSON.stringify({ name: "Order Update Assistant", variant: { type: "custom", config: { inputSchema: [ { name: "customerName", type: "string", isRequired: true }, { name: "orderId", type: "string", isRequired: true }, ], }, }, agent: { identity: { name: "priya", gender: "female", voice: "priya" }, systemPrompt: "You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}.", firstMessage: "Hi {{customerName}}, this is Priya from Acme.", }, icpContext: { language: "hinglish" }, callSettings: { slots: [{ startTime: "09:00", endTime: "21:00" }], maxCallDuration: 200, concurrentCallCount: 5, retryProtocol: { maxAttemptsNoPickup: 2, reAttemptPeriod: 300 }, }, }), }); const { data } = await res.json(); ``` **`200 OK`** — the created assistant under `data`. | Status | Cause | | :----- | :---------------------------------------------------------- | | `400` | Validation error | | `409` | An assistant with this name already exists in the workspace | ### Variables Declare each variable in `variant.config.inputSchema`, reference it in `agent.systemPrompt` / `agent.firstMessage` as `{{name}}`, and supply its value per call. Nested values use dot paths: `{{customer.firstName}}`. ```json theme={null} { "inputSchema": [ { "name": "customerName", "type": "string", "isRequired": true }, { "name": "orderId", "type": "string", "isRequired": true } ] } ``` `type` is one of `string`, `number`, `boolean`, `object`, `array`; nested shapes use `fields`. **A placeholder with no matching value is left in the prompt verbatim** — the assistant will read the braces aloud. Always send every required variable. **Documented inconsistency** The API description text calls the per-call values object `variableValues`, while the `POST /v2/call/initiate` request schema and all of its examples use **`payload`**. `payload` is what the endpoint accepts. See [Calls](/v1/calls#initiate-a-call). ### ICP context Shapes tone, vocabulary and pace. | Field | Values | | :---------------- | :------------------------------------------------------------------------------------------------------------------ | | `language` | `hinglish`, `english`, `hindi`, `telugu`, `tamil`, `kannada`, `malayalam`, `gujarati`, `punjabi`, `odia`, `marathi` | | `targetAgeGroups` | `gen_z`, `millennials`, `gen_x`, `boomers` | | `locationTiers` | `metro_urban`, `tier1`, `tier2`, `tier3`, `rural` | | `targetAudience` | `male`, `female`, `children` | ### Call settings | Field | Type | Notes | | :-------------------- | :----- | :------------------------------------------------------------------------------------------------------------------------------ | | `slots` | array | **Required.** At least one `{ startTime, endTime }` in `HH:MM`, in the workspace timezone. Calls run only inside these windows. | | `maxCallDuration` | number | Seconds. | | `concurrentCallCount` | number | Maximum 10. | | `retryProtocol` | object | See below. **Required.** | ```json theme={null} { "retryProtocol": { "maxAttemptsNoPickup": 2, "maxAttemptsLowEngagement": 1, "reAttemptPeriod": 300, "maxRescheduleCount": 1 } } ``` | Field | Default | Meaning | | :------------------------- | :------ | :---------------------------------------------------- | | `maxAttemptsNoPickup` | `2` | Rang, nobody answered — retry this many times. | | `maxAttemptsLowEngagement` | `1` | Answered but cut immediately — retry this many times. | | `reAttemptPeriod` | `300` | Seconds between attempts. | | `maxRescheduleCount` | `1` | Callbacks the customer can request. Maximum 5. | `slots` is v1's calling-window enforcement. Set it to your compliant window — see [India calling rules](/v2/limits#india-calling-rules). ### Analysis plan Post-call AI evaluation. Results arrive on the [`end-of-call`](/v1/webhooks#end-of-call) event. | Field | What it does | | :-------------------- | :---------------------------------------------------------------------------------- | | `successCriteriaPlan` | A prompt that must return `true` or `false`. Write explicit, enumerated conditions. | | `summaryPlan` | A prompt producing a plain-English summary. Tell it what to cover. | | `callInsightPlan` | Structured fields extracted from the call. | ```json theme={null} { "analysisPlan": { "successCriteriaPlan": "Return true ONLY if the customer explicitly confirmed the order AND agreed to the total price AND chose a payment method. Return false if they were 'just checking' or asked for more discount after the final price.", "summaryPlan": "Summarize: (1) the customer issue, (2) the resolution, (3) sentiment, (4) follow-up needed." } } ``` Vague success criteria produce vague booleans. Enumerate the conditions and state the false cases explicitly. ### Pre-call enrichment If `preCall.apiPlan.url` is set, we call your endpoint before the call connects (inbound or outbound) and merge the returned data into the call's variables, so your `{{placeholders}}` can use it — greeting an inbound caller by name, for example. We send `number` and `assistantId` as query parameters for `method: get`, or as a JSON body for `method: post`. Keep the endpoint fast: it sits in front of the call. ## Get an assistant ```http theme={null} GET /v1/admin/assistant/get/{assistantId} ``` ```bash theme={null} curl https://api.voice-agents.miraiminds.co/v1/admin/assistant/get/68c128a658cd7d0668bce78d \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" ``` `200` with the assistant under `data`; `404` if it is not in this workspace. ## List assistants ```http theme={null} GET /v1/admin/assistant/list ``` Returns every assistant in the workspace under `data`. ## Update an assistant ```http theme={null} PUT /v1/admin/assistant/update/{assistantId} ``` Send the fields you want to change. ```bash theme={null} curl -X PUT https://api.voice-agents.miraiminds.co/v1/admin/assistant/update/68c128a658cd7d0668bce78d \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "callSettings": { "maxCallDuration": 180, "concurrentCallCount": 3 } }' ``` | Status | Cause | | :----- | :---------------------------------------------- | | `400` | The assistant is archived and cannot be updated | | `404` | Not found | | `409` | Another assistant already has that name | There is no delete. [Archive](/v1/workspaces#archive) the workspace or organization instead. ## Voice gallery ```http theme={null} GET /v1/admin/voice-gallery ``` The catalogue of available voices. The `slug` is what goes in `agent.identity.voice` — and in [v2's](/v2/agents#voice) `voice.voice_id`. ```bash theme={null} curl https://api.voice-agents.miraiminds.co/v1/admin/voice-gallery \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "Voices fetched successfully.", "data": [ { "_id": "6690a1b2c3d4e5f600000301", "slug": "priya", "sampleAudio": "https://cdn.miraiminds.co/voices/priya.wav" } ] } ``` Listen to `sampleAudio` before you pick. Voice choice moves answer-through rates more than prompt wording does. # Calls Source: https://docs.miraiminds.co/v1/calls Initiate outbound calls, start web calls, abort queued calls and update a queued call's payload. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). All call endpoints are workspace-scoped — send the `workspace` header. These paths are named `/v2/call/…` but they belong to the **v1 API** on `api.voice-agents.miraiminds.co`. See [hosts, not prefixes](/v1/overview#hosts-not-prefixes). ## Initiate a call ```http theme={null} POST /v2/call/initiate ``` | Field | Type | Required | Description | | :------------ | :------ | :------- | :------------------------------------------------------------------------------ | | `phoneNumber` | string | yes | E.164 recommended: `+919876543210`. | | `assistant` | string | yes | Assistant `_id`. | | `payload` | object | no | Values for the assistant's variables. Shape depends on the variant — see below. | | `callbackUrl` | string | no | HTTPS [webhook](/v1/webhooks) URL. | | `priority` | boolean | no | Jump the queue. | | `metadata` | object | no | Arbitrary object echoed back on **every** webhook event. Use it to correlate. | ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v2/call/initiate \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+919876543210", "assistant": "68c128a658cd7d0668bce78d", "callbackUrl": "https://example.com/webhooks/call-events", "payload": { "customerName": "Jane Smith", "orderId": "ORD-12345", "orderValue": 2500 }, "metadata": { "internalOrderId": "8842" } }' ``` ```python theme={null} import os, httpx API = "https://api.voice-agents.miraiminds.co" H = { "x-public-key": os.environ["MIRAI_PUBLIC_KEY"], "x-private-key": os.environ["MIRAI_PRIVATE_KEY"], "workspace": os.environ["MIRAI_WORKSPACE"], } call = httpx.post( f"{API}/v2/call/initiate", headers=H, json={ "phoneNumber": "+919876543210", "assistant": "68c128a658cd7d0668bce78d", "callbackUrl": "https://example.com/webhooks/call-events", "payload": {"customerName": "Jane Smith", "orderId": "ORD-12345"}, "metadata": {"internalOrderId": "8842"}, }, timeout=30, ).raise_for_status().json() print(call["callId"], call["status"]) # 68f0… initiate ``` ```javascript theme={null} const API = "https://api.voice-agents.miraiminds.co"; const H = { "x-public-key": process.env.MIRAI_PUBLIC_KEY, "x-private-key": process.env.MIRAI_PRIVATE_KEY, workspace: process.env.MIRAI_WORKSPACE, "Content-Type": "application/json", }; const call = await fetch(`${API}/v2/call/initiate`, { method: "POST", headers: H, body: JSON.stringify({ phoneNumber: "+919876543210", assistant: "68c128a658cd7d0668bce78d", callbackUrl: "https://example.com/webhooks/call-events", payload: { customerName: "Jane Smith", orderId: "ORD-12345" }, metadata: { internalOrderId: "8842" }, }), }).then((r) => r.json()); console.log(call.callId, call.status); ``` ```json title="200 OK" theme={null} { "callId": "68f0a1b2c3d4e5f600000900", "status": "initiate" } ``` This endpoint returns **`200`**, not `201`, and the body is **not** wrapped in `{ status_code, message, data }`. Read `callId` and `status` off the top level. | Status | Cause | | :----- | :------------------ | | `400` | Validation error | | `404` | Assistant not found | ### `payload` by variant **`custom`** — a flat (or nested) object whose keys match the variables you declared in `variant.config.inputSchema`: ```json theme={null} { "payload": { "orderId": "ORD-12345", "customerName": "Jane Smith", "orderValue": 2500, "status": "pending", "notes": "Customer requested callback" } } ``` **`abandoned_cart`** — the Shopify abandoned-checkout object: `id`, `abandonedCheckoutUrl`, `customer`, `lineItems`, `totalPriceSet`, `discountCodes`, `shippingAddress`, and so on. Pass Shopify's payload through largely unchanged. There is no `GET` for a call in v1 — outcomes arrive by [webhook](/v1/webhooks) only. If you need to read call state on demand, that is [`GET /v2/calls/{id}`](/v2/calls#get-a-call) in v2. ### `metadata` Whatever you put here comes back on every event for that call: ```json theme={null} { "metadata": { "internalOrderId": "8842", "userId": "u_991" } } ``` Use it to join the webhook to your own records without keeping a `callId` table. v2 dropped `metadata` — correlate on `call.id` there. ## Initiate a web call ```http theme={null} POST /v2/call/web ``` Starts a browser-based call and returns a join token. | Field | Type | Required | | :------------- | :----- | :-------------------------------------------------- | | `assistant` | string | yes | | `systemPrompt` | string | no — overrides the assistant's prompt for this call | | `payload` | object | no | | `metadata` | object | no | ```json title="201 Created" theme={null} { "success": true, "token": "eyJhbGciOiJIUzI1NiIs…", "error": null } ``` Note the `201` here versus `200` on `/v2/call/initiate`. ## Abort a call ```http theme={null} POST /v2/call/abort ``` The call ID goes in the **body**, not the path. ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v2/call/abort \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "callId": "68f0a1b2c3d4e5f600000900" }' ``` ```json title="200 OK" theme={null} { "message": "Call aborted successfully" } ``` A bare `message` — no `status_code`, no `data`. **Allowed** when the call has no status yet (initial queue) or is `busy`, `failed`, `no-answer`, `rescheduled` or `validation-failed`. **Rejected** with `400` when it is `in-progress`, `completed`, `ended`, `timeout` or already `aborted`. | Status | Cause | | :----- | :----------------------------------------- | | `400` | Already aborted, in progress, or completed | | `404` | Call not found | Aborting a retryable call is how you stop the whole retry chain — otherwise `retryProtocol` keeps dialling. ## Update a queued call's payload ```http theme={null} PUT /v2/call/{callId} ``` Replaces the `payload` of a call that has not been attempted yet. ```bash theme={null} curl -X PUT https://api.voice-agents.miraiminds.co/v2/call/68f0a1b2c3d4e5f600000900 \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "payload": { "customer": { "firstName": "Jane", "phone": "+919876543210" }, "orderId": "ORD-12345" } }' ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "Call payload updated successfully." } ``` The payload is **replaced**, not merged. Send the whole object. | Status | Cause | | :----- | :--------------------------------------------------------------- | | `400` | Validation error, or the call is already in progress / completed | | `401` | Unauthorized | | `404` | Call not found | ## Call statuses | Status | Meaning | | :------------------ | :-------------------------------------------------- | | `initiate` | Queued and being dialled. | | `in-progress` | Answered; the conversation is running. | | `ended` | The connection dropped. | | `completed` | Recording, transcript and duration processed. | | `timeout` | Exceeded `maxCallDuration`. | | `failed` | Could not connect. | | `validation-failed` | Pre-call validation failed, e.g. an invalid number. | | `busy` | Line busy. | | `no-answer` | Nobody picked up. | | `skip` | Skipped, e.g. a DND number. | | `rescheduled` | A callback is scheduled; the lifecycle continues. | | `aborted` | Cancelled through the abort API. | Each status has a matching `call.` [webhook event](/v1/webhooks). # Knowledge base Source: https://docs.miraiminds.co/v1/knowledge-base Upload documents for retrieval-augmented answers — chunked upload, indexing status, collections. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Upload documents to a workspace and the assistant can search them mid-call to answer policy and product questions. The knowledge base is a v1 capability. [v2](/v2/overview) has no RAG yet. **Limits:** 100 MB per file, 10 MB per chunk, 1,000 chunks maximum. Supported types: `application/pdf`, `text/plain`, `text/markdown`, and `.docx` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`). ## Upload a document Three steps: start a session, push the chunks, complete. ```http theme={null} POST /v1/knowledge-base/upload/start ``` ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/start \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "fileName": "product-catalog.pdf", "totalChunks": 1, "fileSize": 524288, "mimeType": "application/pdf" }' ``` ```json title="201 Created" theme={null} { "status_code": 201, "message": "Upload session created.", "data": { "sessionId": "sess_abc123xyz" } } ``` `totalChunks` must match what you actually send. Target 10 MB per chunk. `400` if the file exceeds 100 MB or the MIME type is unsupported. ```http theme={null} POST /v1/knowledge-base/upload/chunk/{sessionId} ``` `multipart/form-data` with `chunk` (binary) and `chunkIndex` (zero-based). ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/chunk/sess_abc123xyz \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -F "chunk=@part-0.bin" \ -F "chunkIndex=0" ``` `400` for a missing chunk, a negative index, or a chunk over 10 MB. `404` if the session does not exist. ```http theme={null} POST /v1/knowledge-base/upload/complete/{sessionId} ``` ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/complete/sess_abc123xyz \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" ``` ```json title="200 OK" theme={null} { "message": "Upload completed. Processing has started in the background.", "sessionId": "sess_abc123xyz", "knowledgeBaseId": "6701a1b2c3d4e5f600000050", "knowledgeBaseStatus": "processing" } ``` `400` if the session is not in the `uploading` state or is already linked to a knowledge base. Indexing runs in the background. Poll `GET /v1/knowledge-base/files/{knowledgeBaseId}` until `status` is `ready`. ### Full upload, in code ```python theme={null} import os, time, math, httpx API = "https://api.voice-agents.miraiminds.co" H = { "x-public-key": os.environ["MIRAI_PUBLIC_KEY"], "x-private-key": os.environ["MIRAI_PRIVATE_KEY"], "workspace": os.environ["MIRAI_WORKSPACE"], } CHUNK = 10 * 1024 * 1024 def upload(path: str, mime: str) -> str: size = os.path.getsize(path) total = math.ceil(size / CHUNK) start = httpx.post( f"{API}/v1/knowledge-base/upload/start", headers=H, json={ "fileName": os.path.basename(path), "totalChunks": total, "fileSize": size, "mimeType": mime, }, timeout=30, ).raise_for_status().json() session = start["data"]["sessionId"] with open(path, "rb") as fh: for i in range(total): httpx.post( f"{API}/v1/knowledge-base/upload/chunk/{session}", headers=H, files={"chunk": fh.read(CHUNK)}, data={"chunkIndex": i}, timeout=300, ).raise_for_status() done = httpx.post( f"{API}/v1/knowledge-base/upload/complete/{session}", headers=H, timeout=60 ).raise_for_status().json() kb_id = done["knowledgeBaseId"] while True: # indexing is async f = httpx.get( f"{API}/v1/knowledge-base/files/{kb_id}", headers=H, timeout=30 ).raise_for_status().json()["data"] if f["status"] in ("ready", "failed"): if f["status"] == "failed": raise RuntimeError(f"indexing failed for {kb_id}") return kb_id time.sleep(5) ``` ```javascript theme={null} import fs from "node:fs"; import path from "node:path"; const API = "https://api.voice-agents.miraiminds.co"; const H = { "x-public-key": process.env.MIRAI_PUBLIC_KEY, "x-private-key": process.env.MIRAI_PRIVATE_KEY, workspace: process.env.MIRAI_WORKSPACE, }; const CHUNK = 10 * 1024 * 1024; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function upload(filePath, mimeType) { const buf = fs.readFileSync(filePath); const totalChunks = Math.ceil(buf.length / CHUNK); const start = await fetch(`${API}/v1/knowledge-base/upload/start`, { method: "POST", headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify({ fileName: path.basename(filePath), totalChunks, fileSize: buf.length, mimeType, }), }).then((r) => r.json()); const sessionId = start.data.sessionId; for (let i = 0; i < totalChunks; i++) { const form = new FormData(); form.append("chunk", new Blob([buf.subarray(i * CHUNK, (i + 1) * CHUNK)])); form.append("chunkIndex", String(i)); await fetch(`${API}/v1/knowledge-base/upload/chunk/${sessionId}`, { method: "POST", headers: H, body: form, }); } const done = await fetch( `${API}/v1/knowledge-base/upload/complete/${sessionId}`, { method: "POST", headers: H } ).then((r) => r.json()); for (;;) { const { data } = await fetch( `${API}/v1/knowledge-base/files/${done.knowledgeBaseId}`, { headers: H } ).then((r) => r.json()); if (data.status === "ready") return done.knowledgeBaseId; if (data.status === "failed") throw new Error("indexing failed"); await sleep(5000); } } ``` ## List files ```http theme={null} GET /v1/knowledge-base/files ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "Knowledge base files retrieved successfully.", "data": [ { "_id": "6701a1b2c3d4e5f600000050", "fileName": "product-catalog.pdf", "collectionName": "rag_acme_product_catalog_v1", "type": "application/pdf", "size": 524288, "status": "ready", "processingPercentage": 100, "workspace": "6690a1b2c3d4e5f600000002", "createdAt": "2026-06-30T10:00:00.000Z" } ] } ``` | `status` | Meaning | | :----------- | :--------------------------------------------- | | `processing` | Indexing; read `processingPercentage` (0–100). | | `ready` | Searchable. | | `failed` | Indexing failed. Re-upload. | ## Get one file ```http theme={null} GET /v1/knowledge-base/files/{knowledgeBaseId} ``` Same object under `data`. This is the endpoint you poll after `complete`. ## Delete a collection ```http theme={null} DELETE /v1/knowledge-base/collection/{collectionName} ``` Deletes by `collectionName`, not by file `_id`. ```bash theme={null} curl -X DELETE https://api.voice-agents.miraiminds.co/v1/knowledge-base/collection/rag_acme_product_catalog_v1 \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" ``` | Status | Cause | | :----- | :--------------------------------------- | | `404` | No such collection in this workspace | | `409` | The collection is in use by an assistant | Detach it from the assistant first. ## Writing documents that retrieve well * **Short, self-contained sections.** Retrieval returns fragments, not documents. A fragment that only makes sense with the page around it is a fragment the assistant will read out wrong. * **Put the question in the text.** A heading of "Return policy" retrieves worse than a line reading "How do I return an item?". * **One fact per paragraph.** Tables and multi-column PDFs chunk badly — flatten them to prose before uploading. * **Say numbers explicitly.** "Returns accepted within 7 days of delivery" is retrievable; "within the standard window" is not. # v1 API (stable) Source: https://docs.miraiminds.co/v1/overview The original Voice Agents API — workspaces, assistants, calls, telephony, tools and knowledge base. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). v1 is the API that runs today's production integrations. It is **stable and supported** — nothing here is being removed. ```bash theme={null} https://api.voice-agents.miraiminds.co ``` | Environment | Base URL | | :---------- | :-------------------------------------------- | | Production | `https://api.voice-agents.miraiminds.co` | | Staging | `https://api.stage.voice-agent.miraiminds.co` | **v1 or v2?** v1 has the broad surface: inbound calls, phone-number purchase, knowledge base/RAG, API tools, campaigns, post-call analysis. [v2](/v2/overview) has the clean one: one auth header, `GET` on calls, a wallet API and idempotency — but only outbound calls and agents. They address the same workspace and can run side by side. See the [mapping table](/v2/migration). ## Hosts, not prefixes **The `/v2/` paths on this host are v1** v1's call endpoints are named `POST /v2/call/initiate`, `POST /v2/call/abort` and so on. That `/v2/` is a path segment inside **this** API, unrelated to the [v2 API](/v2/overview). What distinguishes the two APIs is the **host**: | API | Host | | :----------------- | :------------------------------- | | v1 (this one) | `api.voice-agents.miraiminds.co` | | [v2](/v2/overview) | `sandbox.voice.miraiminds.co` | Onboard, archive, health. [Reference](/v1/workspaces). The v1 equivalent of an agent. [Reference](/v1/assistants). Initiate, abort, update payload, web calls. [Reference](/v1/calls). 15 event types and the `x-signature` scheme. [Reference](/v1/webhooks). ## Authentication Every request except `GET /health` carries two key headers. Workspace-scoped endpoints add a third. | Header | Required on | Value | | :-------------- | :------------------------- | :----------------------------------------- | | `x-public-key` | everything | `pk_` + 32 hex | | `x-private-key` | everything | `sk_` + 64 hex | | `workspace` | workspace-scoped endpoints | the workspace `_id` returned at onboarding | ```bash theme={null} curl https://api.voice-agents.miraiminds.co/v1/admin/assistant/list \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" ``` A few admin endpoints (organization archive/unarchive) additionally require an `admin` or `organization_admin` role, carried as a JWT bearer token. v1 pairs are issued with your account. For a new v2 key you do not need to ask anyone — create it in the [console](https://sandbox.voice.miraiminds.co) under **Developers**, and see [Migrating from v1](/v2/migration). The `x-private-key` is a secret **and** the webhook signing key. Never put it in a browser, a mobile app, or a repo. ## Entity hierarchy ```text theme={null} Organization └── Workspace (holds assistants + telephony numbers) └── Assistant ├── Telephony (inbound + outbound phone numbers) ├── Knowledge Base (documents + FAQ for RAG) └── Analysis Plan (post-call AI evaluation) ``` IDs are 24-character Mongo ObjectIds: `6690a1b2c3d4e5f600000002`. ## Response envelopes v1 wraps most successful responses: ```json theme={null} { "status_code": 200, "message": "Assistants fetched successfully.", "data": { } } ``` Errors use a flat shape where `code` is a **number**: ```json theme={null} { "code": 400, "message": "Validation error" } ``` **Known inconsistencies — these are contract, not bugs to work around** * `POST /v2/call/initiate` returns **`200`**, while `POST /v2/call/web`, `POST /v1/number-pool/purchase`, `POST /v1/admin/tool/api` and `POST /v1/knowledge-base/upload/start` return **`201`**. * `POST /v2/call/abort` returns a bare `{ "message": "…" }` with no `status_code` wrapper. `PUT /v2/call/{callId}` returns `{ "status_code", "message" }` with no `data`. * Some error bodies use `code`, some use `status_code`. Read the HTTP status first and the body second. Handle these explicitly rather than assuming a uniform envelope. [v2](/v2/overview) is uniform. ## Status codes | Code | Meaning | | :---- | :------------------------------------------------------------------- | | `200` | Success | | `201` | Created (see the list above for which endpoints) | | `400` | Validation error, or the resource is archived | | `401` | Missing or invalid key headers | | `402` | Insufficient credit balance | | `403` | Insufficient role, or the operation is not allowed for this org type | | `404` | Not found in this workspace | | `409` | Already exists / already released / in use | | `429` | Rate limit exceeded | | `500` | Internal error | ## Health ```http theme={null} GET /health ``` No authentication. ```bash theme={null} curl https://api.voice-agents.miraiminds.co/health ``` ```json theme={null} { "status_code": 200, "message": "Platform is operational.", "data": { "underMaintenance": false, "status": "healthy" } } ``` Check `data.underMaintenance` before starting a large campaign. ## Quickstart `POST /v2/workspace/onboard/custom`. A default telephony number is assigned automatically in production. [How](/v1/workspaces#onboard-a-custom-workspace) `POST /v1/admin/assistant/create` with `variant.type: custom` and your `agent.systemPrompt`. [How](/v1/assistants#create-an-assistant) `POST /v2/call/initiate` with `callbackUrl` for webhooks. [How](/v1/calls#initiate-a-call) # Telephony Source: https://docs.miraiminds.co/v1/telephony Search, purchase and release phone numbers from the number pool. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Numbers belong to the **organization** and are bound to assistants through `telephony.inbound` / `telephony.outbound`. Every new workspace gets a default number automatically in production. Check what you already own before buying. ## Search available numbers ```http theme={null} GET /v1/number-pool/search ``` | Query param | Required | Description | | :------------ | :------- | :-------------------------------------------------------- | | `countryCode` | yes | 2-letter ISO, case-insensitive. `IN`, `US`. | | `numberType` | no | `local`. | | `pattern` | no | Digits or letters to match within the number, e.g. `415`. | | `limit` | no | 1–50, default 20. | | `provider` | no | Default `miraiminds`. | ```bash theme={null} curl -G https://api.voice-agents.miraiminds.co/v1/number-pool/search \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ --data-urlencode "countryCode=IN" \ --data-urlencode "pattern=815" \ --data-urlencode "limit=10" ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "Available numbers fetched successfully.", "data": [ { "number": "+918155550101", "countryCode": "IN", "numberType": "local", "monthlyRateCents": 100, "setupFeeCents": 0 } ] } ``` Rates are in **cents**, not rupees — `monthlyRateCents: 100` is 1.00 of the provider's billing unit. ## Purchase a number ```http theme={null} POST /v1/number-pool/purchase ``` ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/number-pool/purchase \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "number": "+918155550101", "provider": "miraiminds", "countryCode": "IN", "numberType": "local" }' ``` ```json title="201 Created" theme={null} { "status_code": 201, "message": "Phone number purchased successfully.", "data": { "_id": "6700a1b2c3d4e5f600000222", "number": "+918155550101", "provider": "miraiminds", "organization": "6690a1b2c3d4e5f600000001", "workspace": null, "status": "active", "providerNumberId": "6690a1b2c3d4e5f600000090", "monthlyRateCents": 100, "setupFeeCents": 0, "countryCode": "IN", "numberType": "local", "createdAt": "2026-07-26T10:00:00.000Z", "updatedAt": "2026-07-26T10:00:00.000Z" } } ``` Keep `data._id` — that is what goes in an assistant's `telephony.inbound` or `telephony.outbound`, not the number itself. | Status | Cause | | :----- | :-------------------------- | | `400` | Validation error | | `402` | Insufficient credit balance | | `404` | Organization not found | Note the `201` here, versus `200` on most other v1 endpoints. ## Release a number ```http theme={null} DELETE /v1/number-pool/{telephonyNumberId} ``` ```bash theme={null} curl -X DELETE https://api.voice-agents.miraiminds.co/v1/number-pool/6700a1b2c3d4e5f600000222 \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "Phone number released successfully." } ``` The path takes the telephony record `_id`, not the phone number. | Status | Cause | | :----- | :----------------------------------- | | `404` | No such number for this organization | | `409` | Already released | Releasing is irreversible — the number returns to the provider pool and you are unlikely to get it back. Detach it from every assistant first, or their calls will fail. # API tools Source: https://docs.miraiminds.co/v1/tools Give an assistant HTTP tools it can call mid-conversation. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). An **API tool** lets an assistant call your HTTP endpoint during a call — look up an order, check stock, book a slot. Create the tool once per workspace, then list its `_id` in the assistant's `agent.tools`. API tools are a v1 capability. [v2](/v2/overview) has no equivalent yet. ## Create a tool ```http theme={null} POST /v1/admin/tool/api ``` | Field | Type | Required | Description | | :------------ | :------ | :------- | :--------------------------------------------------------------------------- | | `name` | string | yes | Human name. A unique `slug` is derived from it. | | `description` | string | yes | **Tells the model when to call this tool.** The single most important field. | | `url` | string | yes | Your endpoint. | | `method` | enum | yes | `get`, `post`, `put`, `patch`, `delete`. | | `headers` | array | no | `[{ key, value }]` — static headers such as auth. | | `parameters` | object | no | `{ required: [names], properties: [ToolProperty] }`. | | `isActive` | boolean | no | Default `true`. | ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/tool/api \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "name": "Check Order Status", "description": "Look up the current status of a customer order by its ID. Use this whenever the customer asks where their order is.", "url": "https://api.example.com/orders/status", "method": "post", "headers": [{ "key": "Authorization", "value": "Bearer " }], "parameters": { "required": ["orderId"], "properties": [ { "name": "orderId", "description": "The order identifier to look up.", "type": "string" } ] }, "isActive": true }' ``` ```json title="201 Created" theme={null} { "status_code": 201, "message": "API tool created successfully.", "data": { "_id": "6710a1b2c3d4e5f600000020", "name": "Check Order Status", "slug": "check-order-status", "workspace": "6690a1b2c3d4e5f600000002", "isActive": true, "config": { "description": "Look up the current status of a customer order by its ID…", "url": "https://api.example.com/orders/status", "method": "post" }, "createdAt": "2026-07-26T10:00:00.000Z" } } ``` | Status | Cause | | :----- | :---------------------------------------------------------- | | `400` | Validation error | | `409` | A tool with a similar name already exists in this workspace | ### Parameters ```json theme={null} { "parameters": { "required": ["orderId"], "properties": [ { "name": "orderId", "description": "The order identifier, e.g. ORD-12345.", "type": "string" }, { "name": "channel", "description": "Where the order was placed.", "type": "string", "enum": ["web", "app", "store"] } ] } } ``` `type` is `string`, `number`, `boolean`, `object` or `array`. Nested objects use `properties` and their own `required` list; arrays use `items`. A property with a fixed `value` is sent as-is rather than being asked of the model. ### Writing tool descriptions The `description` is a prompt, not documentation. The model reads it to decide whether to call the tool, mid-conversation, under latency pressure. * **Say when, not what.** "Use this whenever the customer asks where their order is" beats "Returns order status." * **Name the trigger phrases** your customers actually use. * **Describe each parameter** in the same terms. `orderId` — "the order identifier, e.g. ORD-12345" — tells the model what shape to extract. * **Keep the endpoint fast.** A tool call happens between turns; a slow endpoint is dead air on a live phone call. Budget a few hundred milliseconds. ## List tools ```http theme={null} GET /v1/admin/tool/api ``` Returns every API tool in the workspace under `data`. ## Update a tool ```http theme={null} PUT /v1/admin/tool/api/{toolId} ``` Send only the fields you are changing. ```bash theme={null} curl -X PUT https://api.voice-agents.miraiminds.co/v1/admin/tool/api/6710a1b2c3d4e5f600000020 \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "workspace: 6690a1b2c3d4e5f600000002" \ -H "Content-Type: application/json" \ -d '{ "description": "Updated guidance for when to use this tool.", "isActive": false }' ``` `isActive: false` is the safe way to take a tool out of service — assistants stop calling it without you having to edit each one. | Status | Cause | | :----- | :------------------------------ | | `400` | Validation error | | `404` | Tool not found | | `409` | Name collides with another tool | ## Delete a tool ```http theme={null} DELETE /v1/admin/tool/api/{toolId} ``` ```json title="200 OK" theme={null} { "status_code": 200, "message": "API tool deleted successfully." } ``` Remove the tool `_id` from every assistant's `agent.tools` first. ## Attaching tools to an assistant ```json theme={null} { "agent": { "identity": { "name": "priya", "gender": "female", "voice": "priya" }, "systemPrompt": "…When the customer asks about an order, use the order status tool before answering.", "tools": ["6710a1b2c3d4e5f600000020"] } } ``` Mention the tool's *purpose* in the system prompt as well as in the tool's own description. Two nudges are more reliable than one. # Webhooks Source: https://docs.miraiminds.co/v1/webhooks v1 lifecycle events, the action events, and the x-signature verification scheme. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). Pass `callbackUrl` when you [initiate a call](/v1/calls#initiate-a-call) and v1 POSTs events there as the call progresses. **v2 signs differently** v1 uses `x-signature` (a bare hex HMAC over the raw body) plus `x-public-key`. [v2](/v2/webhooks) uses `X-Mirai-Signature: t=…,v1=…` with a replay window. If you run both, use **two separate routes** — the schemes are not interchangeable. ## Envelope ```json theme={null} { "metadata": { "internalOrderId": "8842" }, "event": { "type": "call.completed", "data": { "call": { "id": "68f0a1b2c3d4e5f600000900", "status": "completed", "startedAt": "2026-07-26T09:15:02.000Z", "endedAt": "2026-07-26T09:16:38.000Z", "durationSeconds": 96, "recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3", "detailUrl": "https://app.miraiminds.co/calls/68f0…" } } } } ``` | Field | Description | | :----------- | :---------------------------------------------------------------------------------------------- | | `metadata` | Exactly the object you sent at initiate. Present on **every** event. | | `event.type` | See below. | | `event.data` | Event-specific. `CallEventData` for `call.*` and `end-of-call`; `ActionEventData` for `action`. | ## Lifecycle events ```mermaid theme={null} stateDiagram-v2 [*] --> initiate: call placed initiate --> in_progress: connected initiate --> failed: failed / busy / no-answer in_progress --> ended: disconnected ended --> completed: processing done completed --> end_of_call: analysis ready end_of_call --> lifecycle_ended failed --> lifecycle_ended lifecycle_ended --> [*] ``` | Event | Fires when | | :--------------------- | :--------------------------------------------------------------- | | `call.initiate` | Queued and being dialled. | | `call.in-progress` | Answered; the conversation started. | | `call.ended` | The phone connection dropped. | | `call.completed` | Recording, transcript and duration processed. | | `call.timeout` | Exceeded the maximum duration. | | `end-of-call` | Analysis is complete — summary, success flag, insights, credits. | | `call.lifecycle-ended` | **Final event.** No further attempts for this call task. | ### Failure and retry events | Event | Fires when | | :----------------------- | :--------------------------------------------------------- | | `call.failed` | General connection failure. | | `call.busy` | Line busy. | | `call.no-answer` | Nobody picked up. | | `call.validation-failed` | Pre-call validation failed. | | `call.skip` | Skipped, e.g. a DND number. | | `call.rescheduled` | A callback was scheduled; the lifecycle continues. | | `call.aborted` | Cancelled through the [abort API](/v1/calls#abort-a-call). | `retryProtocol` on the assistant decides how many attempts follow a failure. `call.lifecycle-ended` is what tells you the chain is over — **that** is the event to close your record on, not `call.failed`. ## `end-of-call` Carries the [analysis plan](/v1/assistants#analysis-plan) output and credit usage. ```json theme={null} { "metadata": { "internalOrderId": "8842" }, "event": { "type": "end-of-call", "data": { "call": { "id": "68f0a1b2c3d4e5f600000900", "status": "completed", "durationSeconds": 96, "recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3" }, "analysis": { "success": true, "summary": "Customer confirmed the order and chose cash on delivery.", "requiredActions": ["create-order", "send-whatsapp"], "insights": { "interested": true, "callback_requested": false } }, "credits": { "used": 1.5, "available": 98.5 }, "report": { "reAttemptCount": 0, "rescheduledCount": 0 } } } } ``` | Field | Description | | :---------------------------- | :---------------------------------------------------- | | `analysis.success` | The boolean your `successCriteriaPlan` returned. | | `analysis.summary` | Output of your `summaryPlan`. | | `analysis.requiredActions` | Post-call actions the analysis asked for. | | `analysis.insights` | Structured fields from your `callInsightPlan`. | | `credits.used` / `.available` | Credits consumed by this call, and the balance after. | | `report` | Attempt counters for this call task. | ## `action` events Fired when the conversation produced a business action for you to execute. ```json theme={null} { "event": { "type": "action", "data": { "action": "create_order", "call": { "id": "68f0a1b2c3d4e5f600000900" }, "reason": null, "payload": { "email": "jane@example.com", "phone": "+919876543210", "lineItems": [{ "title": "Sample Product", "quantity": 1 }], "shippingAddress": { "firstName": "Jane", "lastName": "Smith", "address1": "12 MG Road", "city": "Bengaluru", "province": "Karnataka", "zip": "560001", "country": "India", "phone": "+919876543210" }, "cartTotal": 1895, "codCharge": 50, "discount": { "code": "SAVE10", "reason": "abandoned cart recovery" }, "note": "Customer confirmed on call", "abandonedCheckoutId": "gid://shopify/AbandonedCheckout/66509168181329" } } } } ``` | `action` | Meaning | | :------------------ | :--------------------------------------------- | | `create_order` | Place the order described in `payload`. | | `send_whatsapp` | Send a WhatsApp follow-up to `payload.number`. | | `mark_prepaid` | The customer chose prepaid. | | `update_address` | The customer gave a corrected address. | | `confirmed_address` | The customer confirmed the address on file. | `reason` is set when the action exists because something was missing: `missing_address`, `missing_first_name`, `invalid_cart_data`. ## Signature verification Every delivery carries two headers: | Header | Value | | :------------- | :-------------------------------------------------------------------------------------- | | `x-signature` | Hex HMAC-SHA256 of the **raw request body**, keyed with your organization's private key | | `x-public-key` | Your organization's public key, so you know which secret to verify with | There is no timestamp and no replay window in v1. Recompute the HMAC over the raw bytes and compare in constant time. ```python theme={null} import hashlib import hmac import os from flask import Flask, request app = Flask(__name__) PRIVATE_KEY = os.environ["MIRAI_PRIVATE_KEY"] # sk_…, the same key you send as x-private-key def verify_v1(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "") @app.post("/webhooks/call-events") def call_events(): if not verify_v1(request.get_data(), request.headers.get("x-signature", ""), PRIVATE_KEY): return {"error": "invalid signature"}, 401 body = request.get_json() event_type = body["event"]["type"] call = body["event"]["data"].get("call", {}) handle(event_type, call, body.get("metadata", {})) return "", 200 ``` ```javascript theme={null} import express from "express"; import crypto from "node:crypto"; const app = express(); const PRIVATE_KEY = process.env.MIRAI_PRIVATE_KEY; // sk_… function verifyV1(rawBody, signature, secret) { const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(signature ?? "", "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // raw body — a re-serialised object will not match app.post( "/webhooks/call-events", express.raw({ type: "application/json" }), (req, res) => { if (!verifyV1(req.body, req.get("x-signature"), PRIVATE_KEY)) { return res.status(401).json({ error: "invalid signature" }); } const body = JSON.parse(req.body.toString("utf8")); handle(body.event.type, body.event.data.call, body.metadata); res.sendStatus(200); } ); app.listen(3000); ``` ### Rules 1. **Use the raw body.** Sign the exact bytes received, before any JSON parsing. Re-serialising changes key order and spacing, and the digest with it. 2. **Constant-time compare.** `hmac.compare_digest` / `crypto.timingSafeEqual`. 3. **Keep the private key out of the browser.** It signs webhooks *and* authenticates API calls. 4. **Return `200` fast.** Enqueue the work; do not do it inline. 5. **Expect duplicates.** v1 events carry no event ID — dedupe on `event.type` + `call.id`, and make handlers idempotent. ### Troubleshooting | Symptom | Cause | | :---------------------- | :----------------------------------------------------------------------------------------------- | | Signature never matches | Body was parsed before hashing, or a proxy re-encoded it | | Headers missing | A reverse proxy or WAF stripped `x-signature` | | Wrong key | `x-public-key` identifies which organization's secret to use — check it matches the one you have | # Workspaces & organizations Source: https://docs.miraiminds.co/v1/workspaces Onboard a workspace, archive and unarchive organizations and workspaces. **This documents the v1 product.** It is kept for integrations already running on it. If you are building something new, start with the [Quickstart](/v2/quickstart). A **workspace** holds assistants and telephony numbers and belongs to an **organization**. The workspace `_id` you get here is the `workspace` header value for every workspace-scoped call afterwards. ## Onboard a custom workspace ```http theme={null} POST /v2/workspace/onboard/custom ``` The recommended path for every non-Shopify integration. | Field | Type | Required | Description | | :-------------------------------------- | :-------- | :------- | :---------------------------------------------- | | `name` | string | yes | Display name; a unique slug is derived from it. | | `currencyCode` | string(3) | yes | ISO 4217, uppercased. `INR`, `USD`. | | `timezone` | string | yes | IANA identifier, e.g. `Asia/Kolkata`. | | `supportContacts.phoneNumber` | string | yes | E.164. | | `supportContacts.email` | string | no | | | `trustSignals.valuePropositionOneLiner` | string | yes | One line used to ground the assistant. | ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v2/workspace/onboard/custom \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Support Line", "currencyCode": "INR", "timezone": "Asia/Kolkata", "supportContacts": { "phoneNumber": "+919876543210", "email": "support@acme.com" }, "trustSignals": { "valuePropositionOneLiner": "Premium 24x7 support for Acme customers." } }' ``` ```json title="200 OK" theme={null} { "message": "Workspace onboarded successfully.", "data": { "_id": "6690a1b2c3d4e5f600000002", "name": "Acme Support Line", "variant": "custom" } } ``` Keep `data._id` — it is your `workspace` header from now on. **Auto-assigned number** In production a default telephony number is assigned to every new workspace. List it through the [Telephony](/v1/telephony) APIs before buying another. | Status | Cause | | :----- | :----------------------------------------------------- | | `400` | Validation error, or the organization is archived | | `403` | Stand-alone organizations cannot create new workspaces | | `409` | A workspace with this name already exists | ## Onboard a Shopify store ```http theme={null} POST /v2/workspace/onboard/shopify ``` Shopify merchants only — creates the workspace and configures billing in one step. Returns `201` with `workspace`, `status` and a `billing` block: ```json title="201 Created" theme={null} { "workspace": "6690a1b2c3d4e5f600000002", "status": "active", "billing": { "type": "prepaid", "creditBalance": 100, "negativeCreditAllowance": 100 } } ``` `negativeCreditAllowance` is an overdraft: calls keep running that far past zero before the workspace is suspended. ## Archive ```http theme={null} POST /v1/admin/organization/archive ``` Requires an `admin` or `organization_admin` role. ```bash theme={null} curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/organization/archive \ -H "x-public-key: pk_1234567890abcdef1234567890abcdef" \ -H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \ -H "Content-Type: application/json" \ -d '{ "type": "workspace", "id": "6690a1b2c3d4e5f600000002" }' ``` | Field | Values | | :----- | :---------------------------- | | `type` | `organization` \| `workspace` | | `id` | ObjectId of the target | ```json title="200 OK" theme={null} { "status_code": 200, "message": "Workspace archived successfully.", "data": null } ``` Archiving **cascades**: archiving an organization archives its workspaces, assistants and campaigns; archiving a workspace archives its assistants and campaigns. Archived assistants cannot be updated (`400`) and cannot place calls. ## Unarchive ```http theme={null} POST /v1/admin/organization/unarchive ``` Same body. Unarchiving also cascades. A workspace cannot be unarchived while its parent organization is still archived: ```json title="400 Bad Request" theme={null} { "code": 400, "message": "Cannot unarchive workspace. Parent Organization is archived." } ``` Unarchive the organization first. # Agents Source: https://docs.miraiminds.co/v2/agents Create, read, update and delete the reusable configuration a call runs. An **agent** is the reusable configuration a call runs: what it says first, how it behaves, which voice, which language, and when it must stop. Calls reference an agent by ID; per-call differences go in [`variables`](/v2/calls#variables). Base URL `https://sandbox.voice.miraiminds.co`. All endpoints require `Authorization: Bearer sk_live_…`. ## The agent object ```json theme={null} { "id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "object": "agent", "name": "Order Confirmation", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}…", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300, "end_call": { "enabled": true, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": true }, "voicemail": { "action": "hangup" }, "background_sound": { "sound": "off" }, "created_at": "2026-07-26T09:14:02Z", "updated_at": "2026-07-26T09:14:02Z" } ``` ### Fields | Field | Type | Required | Description | | :------------------ | :--------------- | :------- | :------------------------------------------------------------------------------------------------------------------- | | `name` | string | yes | Human label. Shown in logs and the console. | | `system_prompt` | string | yes | The agent's instructions. Supports `{{variable}}` placeholders. | | `first_message` | string | yes | Spoken the moment the callee answers, before the model runs. Keep it short — it is your first-audio latency. | | `voice` | object | yes | See [Voice](#voice). | | `language` | string | yes | BCP-47 conversation language, e.g. `hi-IN`, `en-IN`. Drives ASR and the model's default output language. | | `max_duration_secs` | integer | no | Hard cap. The call is ended with `ended_reason: exceeded-max-duration`. Minimum `30`, maximum `1800`, default `300`. | | `end_call` | object | no | See [Ending a call](#ending-a-call). | | `voicemail` | object | no | See [Voicemail](#voicemail). | | `background_sound` | string \| object | no | Room ambience under the agent's voice. See [Background sound](#background-sound). Default `off`. | Server-set and read-only: `id`, `object`, `created_at`, `updated_at`. ### Voice ```json theme={null} { "voice": { "voice_id": "ashutosh", "language": "hi-IN" } } ``` | Field | Type | Description | | :--------- | :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `voice_id` | string | A voice your [tier](/general/tiers) can say. On `t3` (the default) it is a name from the Sarvam catalogue, e.g. `ashutosh`, and it is **required**. On `t1` it is `ashu` (the default) or `aishe`, and nothing else. See [Voices](/v2/voices). | | `language` | string | Rendering language for that voice. Omit to inherit the agent's `language`. **Accepted and stored, not yet honoured** — see below. | **`voice.language` is recorded, not yet applied** The field is validated, stored and echoed back, and it travels with the call payload — but the voice today renders in the agent's top-level `language`. Set both to the same value and you get what you expect; set them differently and the top-level one wins until per-voice rendering ships. Hear the catalogue and pick a `voice_id` in the [console](https://sandbox.voice.miraiminds.co). See [Voices](/v2/voices). ### Ending a call The agent gets an `end_call` tool. When it fires, the agent speaks `message` and hangs up. | Field | Type | Default | Description | | :-------- | :------ | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | boolean | `true` | Set `false` to remove the tool entirely — the call then ends only on the callee hanging up or `max_duration_secs`. **Accepted and stored; enforcement rolling out.** | | `message` | string | `""` | Spoken farewell, played **after** the agent's own goodbye when the call ends. Empty by default — the agent's last line is the farewell. Live today. | | `confirm` | boolean | `true` | Require a second `end_call` within the confirmation window before hanging up. Guards against fast models ending on a bare "ok". **Accepted and stored; enforcement rolling out.** | **Only `message` is enforced today** `enabled` and `confirm` are validated, stored, echoed back and carried into the call payload, but the agent currently reads only `end_call.message`. Until the worker picks the other two up, assume the tool is available and confirmation behaves at its default. Set them now — they take effect without any change on your side. Leave `confirm: true` unless you are running a one-turn notification. It costs one extra turn in the rare case and prevents the far more expensive failure of hanging up on a customer mid-sentence. ### Voicemail ```json theme={null} { "voicemail": { "action": "message", "message": "Hi, this is Acme calling about your order. We'll try again later." } } ``` | Field | Type | Description | | :-------- | :-------------------- | :-------------------------------------------------------------------- | | `action` | `hangup` \| `message` | What to do when an answering machine is detected. Default `hangup`. | | `message` | string | Required when `action` is `message`. Spoken once, then the call ends. | Either way the call ends with `status: voicemail` and fires a [`call.voicemail`](/v2/webhooks#callvoicemail) event. Voicemail detection is available on every tier — see the [feature matrix](/general/tiers#feature-matrix). ### Background sound An agent answering out of complete silence is the loudest tell that nobody is there. `background_sound` plays a room under the voice for the whole call. ```json theme={null} { "background_sound": "office" } ``` Or, with the level: ```json theme={null} { "background_sound": { "sound": "office", "volume": 0.2 } } ``` | Field | Type | Description | | :------- | :---------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sound` | `off` \| `office` | Which room. Default `off`. | | `volume` | number | `0`–`1`, default `0.08`. The default sits about 22 dB under the agent's voice — a room you notice when the line goes quiet, never one that competes with the words. Raise it if you want the room forward; a bed at `0.3` is already conversational-level. | Two things worth knowing before you turn it on: * The bed is in the **call recording** too, because it is what the callee heard. If you run your own transcription over recordings, test it first. * It does not touch what the agent hears. Ambience is mixed into the outbound audio only, so your callers' speech recognition is unchanged. *** ## Create an agent ```http theme={null} POST /v2/agents ``` ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Confirmation", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300, "end_call": { "enabled": true, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": true }, "voicemail": { "action": "hangup" } }' ``` ```python theme={null} import os, httpx API = "https://sandbox.voice.miraiminds.co" auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"} agent = httpx.post( f"{API}/v2/agents", headers=auth, json={ "name": "Order Confirmation", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": {"voice_id": "ashutosh", "language": "hi-IN"}, "language": "hi-IN", "max_duration_secs": 300, "end_call": {"enabled": True, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": True}, "voicemail": {"action": "hangup"}, }, timeout=30, ).raise_for_status().json() print(agent["id"]) ``` ```javascript theme={null} const API = "https://sandbox.voice.miraiminds.co"; const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` }; const res = await fetch(`${API}/v2/agents`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Order Confirmation", system_prompt: "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.", first_message: "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", voice: { voice_id: "ashutosh", language: "hi-IN" }, language: "hi-IN", max_duration_secs: 300, end_call: { enabled: true, message: "धन्यवाद, आपका दिन शुभ हो।", confirm: true }, voicemail: { action: "hangup" }, }), }); if (!res.ok) throw new Error(JSON.stringify(await res.json())); const agent = await res.json(); ``` **`201 Created`** — the full [agent object](#the-agent-object). | Error | `error.code` | Cause | | :---- | :---------------- | :--------------------------------------------------------------------------- | | `400` | `invalid_request` | Missing required field, unknown `voice_id`, `max_duration_secs` out of range | | `401` | `unauthorized` | Bad or missing key | *** ## Get an agent ```http theme={null} GET /v2/agents/{id} ``` ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```python theme={null} agent = httpx.get( f"{API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", headers=auth, timeout=30 ).raise_for_status().json() ``` ```javascript theme={null} const agent = await fetch( `${API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ`, { headers: auth } ).then((r) => r.json()); ``` **`200 OK`** — the agent object. **`404 not_found`** if the agent was deleted, if the ID does not exist, or if it exists in another workspace — all three answer identically, so a `404` is never a hint that the ID is real. Never `403`: that status means your key has been revoked and nothing else. See [errors](/v2/errors#error-codes). *** ## List agents ```http theme={null} GET /v2/agents?limit=&cursor= ``` ```bash theme={null} curl "https://sandbox.voice.miraiminds.co/v2/agents?limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "data": [ { "id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "object": "agent", "name": "Order Confirmation", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}…", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300, "end_call": { "enabled": true, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": true }, "voicemail": { "action": "hangup" }, "background_sound": { "sound": "off" }, "created_at": "2026-07-26T09:14:02Z", "updated_at": "2026-07-26T09:14:02Z" } ], "has_more": false, "next_cursor": null } ``` Each element is the **full** [agent object](#the-agent-object) — the same shape `GET /v2/agents/{id}` returns, prompts included. There is no trimmed summary form, so listing a page of agents with long prompts is a large response: page with `limit` rather than pulling everything at once. Deleted agents are excluded. See [pagination](/v2/overview#pagination). *** ## Update an agent ```http theme={null} PATCH /v2/agents/{id} ``` Send only the fields you are changing. Nested objects are replaced wholesale — to change `end_call.message`, send the whole `end_call` object. ```bash theme={null} curl -X PATCH https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max_duration_secs": 180, "voice": { "voice_id": "neha", "language": "hi-IN" } }' ``` ```python theme={null} agent = httpx.patch( f"{API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", headers=auth, json={"max_duration_secs": 180, "voice": {"voice_id": "neha", "language": "hi-IN"}}, timeout=30, ).raise_for_status().json() ``` ```javascript theme={null} const agent = await fetch( `${API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ`, { method: "PATCH", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ max_duration_secs: 180, voice: { voice_id: "neha", language: "hi-IN" }, }), } ).then((r) => r.json()); ``` **`200 OK`** — the updated agent object. An update takes effect on the **next** call. Calls already `queued`, `dialing` or `in_progress` keep the configuration they were created with. *** ## Delete an agent ```http theme={null} DELETE /v2/agents/{id} ``` ```bash theme={null} curl -X DELETE https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` **`204 No Content`**. The delete is soft: the agent stops appearing in `GET /v2/agents` and can no longer be used for new calls, but historical calls keep resolving their `agent_id`. Calls already in flight are not affected. *** ## Writing a good prompt A v2 call is a **single-prompt** call — one job, start to finish, usually in under two or three minutes. The [prompting guide](/v2/prompting-guide) is the long version; the short one: * **Name the job in the first line.** "Confirm order `{{order_id}}`. Nothing else." * **Cap reply length explicitly.** "One or two short sentences." Long turns are the single biggest driver of perceived latency. * **State the ending condition.** "When the customer confirms or refuses, thank them and call `end_call`." Without this the model keeps talking. * **Put per-call data in `variables`, not the prompt.** One agent, thousands of calls, no re-create. * **Write in the language you will speak.** A Hindi call driven by an English prompt code-switches badly. Write the Hindi lines in Devanagari. Prompts that cause trouble: multi-branch scripts ("if they say X, then go through the following seven questions"), instructions that assume the model remembers a previous call, and anything that needs a database lookup mid-call — that needs the node-graph runtime, which is a `t5` feature — see [tiers](/general/tiers). # Calls Source: https://docs.miraiminds.co/v2/calls Place outbound calls, inspect their status, abort them, and list history. A **call** is one outbound dial attempt driven by an [agent](/v2/agents). Creating a call is asynchronous: you get `202 Accepted` immediately and the outcome arrives by [webhook](/v2/webhooks) (or by polling [`GET /v2/calls/{id}`](#get-a-call)). Base URL `https://sandbox.voice.miraiminds.co`. ## The call object ```json theme={null} { "id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA", "object": "call", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "status": "completed", "tier": "t3", "ended_reason": "customer-ended-call", "created_at": "2026-08-10T09:15:00Z", "started_at": "2026-08-10T09:15:02Z", "ended_at": "2026-08-10T09:16:38Z", "duration_secs": 96, "cost_inr": 6, "recording_available": true, "transcript_available": true, "analysis": null } ``` | Field | Type | Description | | :--------------------- | :-------------- | :------------------------------------------------------------------------------------------------------ | | `id` | string | `call_`. Also the idempotency anchor and the workflow ID. | | `agent_id` | string | The agent this call ran. | | `to` | string | E.164 destination. | | `status` | enum | See [statuses](#statuses). | | `tier` | string | The rate card this call was billed under — `t3` unless you asked for `t1`. See [tiers](/general/tiers). | | `ended_reason` | string \| null | Why it ended. See [ended reasons](#ended-reasons). `null` while running. | | `created_at` | string | RFC 3339, UTC. When we accepted the request. | | `started_at` | string \| null | RFC 3339, UTC. Set when **media goes live**, not when we dialled. `null` if the call never connected. | | `ended_at` | string \| null | RFC 3339, UTC. | | `duration_secs` | integer \| null | Billable seconds of live media. `0` for calls that never connected, `null` while running. | | `cost_inr` | number \| null | Rupees charged. `null` until the call ends, and `null` for an ending that is not billable. | | `recording_available` | boolean | Whether [`GET /recording`](#get-the-recording) will return audio. | | `transcript_available` | boolean | Whether [`GET /transcript`](#get-the-transcript) will return turns. | | `analysis` | object \| null | Post-call summary, when the agent was created with `analysis.enabled`. `null` otherwise. | ## Statuses | Status | Terminal | Meaning | | :------------ | :------: | :--------------------------------------------------------------------------------------------------------------------- | | `queued` | | Accepted, waiting on fleet capacity. | | `dialing` | | SIP invite sent, phone is ringing. | | `in_progress` | | Media live, conversation running. | | `completed` | ✅ | Connected and finished normally. **Billable.** | | `voicemail` | ✅ | An answering machine picked up. See [voicemail](/v2/agents#voicemail). **Billable.** | | `no_answer` | ✅ | Rang out, nobody picked up. Free. | | `busy` | ✅ | Callee's line was busy. Free. | | `failed` | ✅ | Could not connect, or the pipeline errored. Free. | | `timeout` | ✅ | Hit `max_duration_secs`. **Billable.** | | `aborted` | ✅ | You cancelled it with [`POST /abort`](#abort-a-call) — from the queue, mid-ring, or mid-conversation. Free either way. | Statuses only move forward. A terminal status never changes. ```mermaid theme={null} stateDiagram-v2 [*] --> queued: POST /v2/calls queued --> dialing: capacity acquired queued --> aborted: POST /abort dialing --> aborted: POST /abort in_progress --> aborted: POST /abort dialing --> in_progress: media live dialing --> no_answer dialing --> busy dialing --> failed dialing --> voicemail: machine detected in_progress --> completed in_progress --> voicemail in_progress --> timeout: max_duration_secs in_progress --> failed completed --> [*] voicemail --> [*] no_answer --> [*] busy --> [*] failed --> [*] timeout --> [*] aborted --> [*] ``` ## Ended reasons `ended_reason` explains *why* a terminal status was reached. Match on `status` for control flow; use `ended_reason` for analytics and support. | `ended_reason` | Usual `status` | Meaning | | :------------------------ | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assistant-ended-call` | `completed` | The agent called `end_call` — the job finished. | | `customer-ended-call` | `completed` | The callee hung up. | | `exceeded-max-duration` | `timeout` | Hit `max_duration_secs`. | | `customer-did-not-answer` | `no_answer` | Rang out. | | `customer-busy` | `busy` | Line busy. | | `voicemail` | `voicemail` | An answering machine answered. Detecting it is what sets the status, so the two always travel together. | | `silence-timed-out` | `completed` | Media was live but nobody ever spoke. Billable — the agent ran. | | `idle-timed-out` | `completed` | The agent re-prompted its configured number of times and the caller never came back. Billable. | | `no-media` | `failed` | The call was placed but audio never flowed. See the note below. Not billed. | | `aborted-by-api` | `aborted` | You called [`POST /abort`](#abort-a-call). The only reason an abort produces. | | `assistant-error` | `failed` | Pipeline failure on our side. Not billed. | | `transferred` | `completed` | The call ended in a completed warm transfer to a human. Handoff is [part of every tier](/general/tiers#feature-matrix); the rollout is completing — **no call emits this yet**, see the [roadmap](/general/roadmap). | | `balance-exhausted` | `completed` | Reserved for a call ended when the wallet emptied mid-call. **Not wired — no call emits this yet.** | Treat `ended_reason` as an open vocabulary. New values are added without a version bump — always have a default branch. The last two rows above are in the vocabulary but **no call emits them today**; they arrive with the warm-transfer rollout and the `t3`/`t5` tiers — see the [roadmap](/general/roadmap). **`no_answer` is under-reported today** Some calls that genuinely rang out are reported as `failed` with `ended_reason: "no-media"` rather than as `no_answer`. Whether we can tell the two apart depends on what the carrier tells us. When the carrier returns a clear verdict (SIP 480/408 → rang out, 486/600 → busy) you get `no_answer` / `busy` correctly. When it black-holes the call instead — accepts the invite and returns nothing — we have no signal to distinguish "rang, nobody picked up" from "answered into silence", and the call ends `failed` / `no-media` after the media gate expires. **What this means for you:** if you are counting unanswered calls for retry logic, treat `no_answer`, `busy` **and** `failed` + `no-media` as the did-not-connect bucket. None of the three is billed, so your costs are unaffected either way. We are working on tightening this; the reported status will get more specific over time, never less. *** ## Create a call ```http theme={null} POST /v2/calls ``` ### Request | Field | Type | Required | Description | | :------------------ | :------ | :------- | :--------------------------------------------------------------------------------------------------------------------------- | | `agent_id` | string | yes | The agent to run. | | `to` | string | yes | E.164, e.g. `+919876543210`. | | `variables` | object | no | Flat string map substituted into the agent's prompt. See [variables](#variables). | | `webhook_url` | string | no | HTTPS URL for [lifecycle events](/v2/webhooks). | | `max_duration_secs` | integer | no | Overrides the agent's cap for this call only. | | `tier` | string | no | `t1` \| `t3`. Defaults to your key's tier — `t3` (₹3/min) on a partner key. `t5` answers `501`. See [tiers](/general/tiers). | ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-8842-confirm-1" \ -d '{ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t3", "variables": { "customer_name": "Rahul", "order_id": "8842" }, "webhook_url": "https://example.com/mirai/webhook", "max_duration_secs": 240 }' ``` ```python theme={null} import os, httpx API = "https://sandbox.voice.miraiminds.co" auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"} r = httpx.post( f"{API}/v2/calls", headers={**auth, "Idempotency-Key": "order-8842-confirm-1"}, json={ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t3", "variables": {"customer_name": "Rahul", "order_id": "8842"}, "webhook_url": "https://example.com/mirai/webhook", "max_duration_secs": 240, }, timeout=30, ) if r.status_code == 402: raise RuntimeError(r.json()["error"]["message"]) # top up the wallet call = r.raise_for_status().json() print(call["id"], call["status"]) # call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued ``` ```javascript theme={null} const API = "https://sandbox.voice.miraiminds.co"; const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` }; const res = await fetch(`${API}/v2/calls`, { method: "POST", headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": "order-8842-confirm-1", }, body: JSON.stringify({ agent_id: "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", to: "+919876543210", tier: "t3", variables: { customer_name: "Rahul", order_id: "8842" }, webhook_url: "https://example.com/mirai/webhook", max_duration_secs: 240, }), }); const body = await res.json(); if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`); console.log(body.id, body.status); // call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued ``` ```json title="202 Accepted" theme={null} { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" } ``` `202` means the call is admitted to the queue — **the phone has not rung yet**. The full call object is available from [`GET /v2/calls/{id}`](#get-a-call). ### Errors | Status | `error.code` | Cause | | :----- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `invalid_request` | Missing `agent_id`/`to`, `to` not E.164, `max_duration_secs` out of range | | `401` | `unauthorized` | Bad or missing key | | `402` | `insufficient_balance` | Wallet cannot cover the first minute at your tier. **Checked before we dial** — no partial charge. See [Wallet](/v2/wallet#402-insufficient-balance). | | `404` | `not_found` | `agent_id` does not exist in your workspace — including one that exists in someone else's | | `409` | `duplicate_call` | A request with this `Idempotency-Key` is still being processed. See [idempotency](#idempotency). | | `429` | `rate_limited` | Request rate exceeded, **or** your queue is full (500 calls accepted and not yet ended). Concurrency does **not** 429 — over-cap calls queue. `error.message` says which. Honour `Retry-After`. See [Limits](/v2/limits). | | `429` | `at_capacity` | Every line on the deployment is busy. `Retry-After: 30`. Nothing was charged. | | `501` | `tier_unavailable` | `tier` is `t5`, which is not live yet | ### Variables `variables` is a flat map of string keys to string values. Each key replaces the matching `{{key}}` placeholder in the agent's `system_prompt` and `first_message`. ```json theme={null} { "variables": { "customer_name": "Rahul", "order_id": "8842" } } ``` * Plain `{{key}}` placeholders match loosely: letters and digits only, case-insensitive, so `{{Customer Name}}`, `{{customer_name}}` and `{{CustomerName}}` all read the same variable. Only a Liquid expression with a filter (`{{ key | upcase }}`) needs the exact key. * A placeholder with no matching variable is substituted with an empty string — it does not error, and it does not leave `{{braces}}` for the agent to read aloud. Validate on your side. * Keep values short. They are part of the prompt, and the prompt is on the latency path. * Variables are **static per call**. There is no way to change them mid-call. ### Idempotency Send `Idempotency-Key` with a value derived from your own domain object: ```bash theme={null} -H "Idempotency-Key: order-8842-confirm-1" ``` If we have seen that key in the last 24 hours, we replay the original response byte for byte and place **no second call**. This is the safe way to retry a request that timed out — you cannot tell from a network timeout whether the call was placed, and without the key a retry means the customer's phone rings twice. The key is scoped to your workspace. Reusing a key with a *different* body still replays the original response — pick keys that are unique per intended call. There is one window where a reused key does not replay: while the **first** request with that key is still being processed, there is no stored response to replay yet, so the second request gets [`409 duplicate_call`](/v2/errors#error-codes). It means "already in flight, no second call placed" — retry a moment later and you will get the original response, or wait for the webhook. *** ## Get a call ```http theme={null} GET /v2/calls/{id} ``` ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/calls/call_01JZQ9B4M8N3P6R2S5T7V9W1YA \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```python theme={null} import time TERMINAL = {"completed", "voicemail", "no_answer", "busy", "failed", "timeout", "aborted"} def wait_for_call(call_id: str, timeout_secs: int = 600) -> dict: """Prefer webhooks. Poll only when you cannot host an endpoint.""" deadline = time.monotonic() + timeout_secs while time.monotonic() < deadline: call = httpx.get(f"{API}/v2/calls/{call_id}", headers=auth, timeout=30) \ .raise_for_status().json() if call["status"] in TERMINAL: return call time.sleep(5) raise TimeoutError(call_id) ``` ```javascript theme={null} const TERMINAL = new Set([ "completed", "voicemail", "no_answer", "busy", "failed", "timeout", "aborted", ]); async function waitForCall(callId, timeoutMs = 600_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const call = await fetch(`${API}/v2/calls/${callId}`, { headers: auth }) .then((r) => r.json()); if (TERMINAL.has(call.status)) return call; await new Promise((r) => setTimeout(r, 5000)); } throw new Error(`timed out waiting for ${callId}`); } ``` **`200 OK`** — the [call object](#the-call-object). **`404 not_found`** for an unknown ID, and for an ID that belongs to another workspace — the two are deliberately indistinguishable. Poll no faster than every 5 seconds, and only when you cannot receive webhooks. Polling counts against your [rate limit](/v2/limits); webhooks do not. *** ## Get the transcript ```http theme={null} GET /v2/calls/{id}/transcript ``` What was said, as turns, once the call has ended. ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/transcript \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "call_id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA", "turns": [ { "role": "assistant", "text": "नमस्ते Rohit जी, मैं Acme Retail से Priya बोल रही हूँ। एक मिनट बात कर सकती हूँ?", "start_ms": 420, "end_ms": 4980 }, { "role": "user", "text": "हाँ बोलिए", "start_ms": 5640, "end_ms": 6390 }, { "role": "assistant", "text": "आपका order AC-88213 kal shaam 6 baje deliver hoga. Ye slot theek hai?", "start_ms": 7010, "end_ms": 14880 }, { "role": "user", "text": "haan theek hai", "start_ms": 15900, "end_ms": 17240 } ] } ``` | Field | Notes | | :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | | `role` | `assistant` or `user`. Nothing else appears — internal steps are not conversation. | | `text` | The turn as spoken. Hindi, Hinglish and English arrive as they were said; nothing is translated. | | `start_ms`, `end_ms` | Milliseconds from the start of the **recording**, so a turn lines up with the audio. `end_ms` can be `null` on the last turn of a call that was cut off. | `404 not_found` while the call is still running, and for a call that produced no speech at all. Check `transcript_available` on the [call object](#the-call-object) if you want to tell those apart from a mistyped id. ```json title="404 Not Found" theme={null} { "error": { "code": "not_found", "message": "transcript not available (call still in progress or produced no speech)" } } ``` *** ## Get the recording ```http theme={null} GET /v2/calls/{id}/recording ``` `302` to a signed, time-limited URL for the call's audio (WAV). Follow the redirect — most HTTP clients do by default; `curl` needs `-L`. ```bash theme={null} curl -L https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/recording \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -o call.wav ``` ```http title="302 Found" theme={null} Location: https://storage.miraiminds.co/voice-agents/production/call_records/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/call.wav?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=… ``` **The signed link expires in 15 minutes and it is a bearer token.** It is a way to fetch this recording *now*, not a permanent URL. Store the `call_id` and ask again; do not store the link. Anyone holding it can fetch the audio until it expires, so treat it exactly as you would treat the recording. `404 not_found` while the call is running, for a call that produced no audio, and for one whose audio was never stored. `recording_available` on the call object tells you which requests are worth making. Transcripts and recordings are kept for **90 days**, the same as the call record. Pull anything you need to keep longer into your own system. *** ## Abort a call ```http theme={null} POST /v2/calls/{id}/abort ``` Cancels a call that has not ended yet. Accepted while the call is `queued`, `dialing` **or `in_progress`** — a live call is hung up on the SIP server mid-conversation, not merely flagged. Only a call that has already reached a terminal status is refused: there is nothing left to stop. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/calls/call_01JZQ9B4M8N3P6R2S5T7V9W1YA/abort \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="202 Accepted" theme={null} { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "aborted" } ``` `202` means the cancel was signalled. The call reaches `aborted` shortly after with `ended_reason: "aborted-by-api"`, and a [`call.aborted`](/v2/webhooks#callaborted) event fires. An aborted call is never billed — including one aborted after media went live. | Status | `error.code` | Cause | | :----- | :----------- | :-------------------------------------------------------------------------------- | | `404` | `not_found` | Unknown call ID, or a call in another workspace | | `409` | `conflict` | The call has already ended — its status is terminal, so there is nothing to abort | *** ## List calls ```http theme={null} GET /v2/calls?agent_id=&status=&from=&to=&limit=&cursor= ``` | Query param | Description | | :---------- | :----------------------------------------------- | | `agent_id` | Only calls run by this agent. | | `status` | Filter by one [status](#statuses). | | `from` | RFC 3339 lower bound on `created_at`, inclusive. | | `to` | RFC 3339 upper bound on `created_at`, exclusive. | | `limit` | 1–100, default 20. | | `cursor` | From `next_cursor` of the previous page. | ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/calls \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "agent_id=agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ" \ --data-urlencode "status=completed" \ --data-urlencode "from=2026-07-01T00:00:00Z" \ --data-urlencode "limit=100" ``` ```json title="200 OK" theme={null} { "data": [ { "id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA", "object": "call", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "status": "completed", "tier": "t3", "ended_reason": "customer-ended-call", "created_at": "2026-08-10T09:15:00Z", "started_at": "2026-08-10T09:15:02Z", "ended_at": "2026-08-10T09:16:38Z", "duration_secs": 96, "cost_inr": 6, "recording_available": true, "transcript_available": true, "analysis": null } ], "has_more": true, "next_cursor": "call_01K7Q9B4M8N3P6R2S5T7V9W1YB" } ``` Results are newest first. See [pagination](/v2/overview#pagination). The list endpoint is for reconciliation and reporting, not for driving your application state. For that, use [webhooks](/v2/webhooks). # Campaigns Source: https://docs.miraiminds.co/v2/campaigns Upload a contact list, set a calling window and a budget, and let the platform dial it — with retries, pacing, do-not-call suppression and a report. A **campaign** is a contact list plus the rules for dialling it: which [agent](/v2/agents) runs, what hours it may ring people, how many lines it may use at once, how often to retry, and how much it is allowed to spend. You upload the list once and start it. From there the platform owns the dialling — it paces itself, sleeps out the night, retries what did not connect, skips anything on your [do-not-call list](/v2/dnc), pauses itself when the money runs out, and hands you a per-contact report at the end. Base URL `https://sandbox.voice.miraiminds.co`. A campaign's calls are ordinary [calls](/v2/calls). They appear in `GET /v2/calls`, they emit the same `call.*` [webhooks](/v2/webhooks), and they debit the same wallet at the same per-minute rate. A campaign is a dialler on top of the call API, not a separate billing path. ## The campaign object ```json theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "name": "Order confirmations — Mumbai batch", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "tier": "t3", "status": "play", "pause_reason": null, "timezone": "Asia/Kolkata", "start_date": "2026-08-11", "end_date": "2026-08-18", "slots": [{ "start": "10:00", "end": "19:00" }], "max_concurrent": 2, "retry_count": 1, "re_attempt_period_secs": 900, "max_duration_secs": 300, "budget_paise": 50000, "spent_paise": 12900, "webhook_url": "https://example.com/mirai/webhook", "created_at": "2026-08-10T09:21:44Z", "updated_at": "2026-08-10T11:04:02Z", "counters": { "pending": 812, "dialing": 2, "completed": 174, "no_answer": 9, "suppressed": 3 } } ``` | Field | Type | Description | | :------------------------ | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------ | | `id` | string | `cmp_`. Opaque. | | `name` | string | Yours. 1–120 characters. | | `agent_id` | string | The agent every contact is dialled with. Must exist in your workspace **at create time**. | | `tier` | string | The rate card every call in this campaign bills at. Defaults to your key's tier — `t3` on a partner key. See [tiers](/general/tiers). | | `status` | enum | `draft` \| `play` \| `paused` \| `stopped` \| `completed`. See [lifecycle](#lifecycle). | | `pause_reason` | string \| null | Why *we* paused it. `null` when a human paused it, and `null` whenever it is not paused. See [pause reasons](#pause-reasons). | | `timezone` | string | IANA name, e.g. `Asia/Kolkata`. Slots and dates are **local to this**. | | `start_date` / `end_date` | string | `YYYY-MM-DD`, inclusive, local dates. | | `slots` | array | 1–4 `{start, end}` windows in `HH:MM`, local time. See [calling window](#calling-window). | | `max_concurrent` | integer | 1–50. How many of this campaign's calls may be live at once. | | `retry_count` | integer | 0–5. Retries **after** the first attempt. `0` means one attempt per contact. | | `re_attempt_period_secs` | integer | 60–86400. How long a contact waits before it is eligible again. | | `max_duration_secs` | integer | 30–1800. Per-call cap. Defaults to the agent's. | | `budget_paise` | integer \| null | Spend cap in **paise**. `null` = no cap. | | `spent_paise` | integer | Billed so far, in paise. | | `webhook_url` | string | Receives both this campaign's `campaign.*` events and every child call's `call.*` events. | | `counters` | object | Contacts by [status](#contact-statuses). Present on `GET /v2/campaigns/{id}`, omitted from list pages. | **Campaign money is in paise, call money is in rupees.** `budget_paise` and `spent_paise` are integer paise (100 paise = ₹1), so a ₹500 cap is `"budget_paise": 50000`. The [call object](/v2/calls#the-call-object) and the [wallet](/v2/wallet) use rupee fields (`amount_inr`, `balance_inr`). Nothing mixes the two units inside one object. ## Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> draft: POST /v2/campaigns draft --> play: POST /start draft --> stopped: POST /stop play --> paused: POST /pause play --> paused: budget gate paused --> play: POST /resume paused --> play: POST /start play --> completed: nothing left to dial play --> stopped: POST /stop paused --> stopped: POST /stop completed --> [*] stopped --> [*] ``` | Status | Dials? | Meaning | | :---------- | :----: | :------------------------------------------------------------------------------------------------------------------------------------------------- | | `draft` | ❌ | Created. Accepts contacts and edits. Nothing rings until you start it. | | `play` | ✅ | Running. It may still be *asleep* — outside the calling window `play` is the correct status, the dialler is simply waiting for the window to open. | | `paused` | ❌ | Reversible. New calls stop; **calls already in progress are not hung up.** | | `stopped` | ❌ | Terminal, by you. Live calls are aborted, pending contacts are marked `aborted`. | | `completed` | ❌ | Terminal, by us. Every contact reached a final state, or the date range ran out. | Only `draft` and `paused` can be edited or started. `stopped` and `completed` never dial again — and a campaign in either refuses new contacts with `409`, rather than accepting rows that would never ring. ### Pause reasons `pause_reason` distinguishes "the operator stopped this" from "we stopped it for them". A pause you asked for carries **no reason** (`null`); the two reasons below are set by the money gate. | `pause_reason` | Set when | What to do | | :--------------------- | :-------------------------------------------------------------- | :------------------------------------------------------------ | | `null` | You called [`POST /pause`](#pause-a-campaign). | Resume when you are ready. | | `insufficient_balance` | The wallet cannot cover another minute at this campaign's tier. | [Top up](/v2/wallet#top-up), then `POST /start` or `/resume`. | | `budget_exhausted` | `spent_paise` has reached `budget_paise`. | Raise `budget_paise` with `PATCH`, then resume. | Being outside the calling window is **not** a pause. The campaign stays `play` and sleeps until the next opening edge — a durable sleep, so a campaign scheduled for 10:00 tomorrow costs nothing overnight and survives our restarts. An offline fleet is not a pause either: it is a `503 fleet_offline` refusal at [start](#start-a-campaign) time. ## Contact statuses Every row you upload carries its own status. The first block mirrors the [call status](/v2/calls#statuses) vocabulary, so a campaign report reads the same way the call list does; the last three exist only inside campaigns. | Status | Terminal | Meaning | | :----------- | :------: | :--------------------------------------------------------------------------------------- | | `pending` | | Waiting to be dialled — either never attempted, or waiting out `re_attempt_period_secs`. | | `claimed` | | Picked up by the dialler this tick. | | `dialing` | | A call has been placed for this contact. | | `completed` | ✅ | Connected and finished. **Billed.** | | `voicemail` | ✅ | An answering machine answered. **Billed.** Never retried. | | `timeout` | ✅ | Hit `max_duration_secs`. **Billed.** Never retried. | | `no_answer` | ✅ | Rang out. Free. Retried while attempts remain. | | `busy` | ✅ | Line busy. Free. Retried while attempts remain. | | `failed` | ✅ | Did not connect, or our pipeline errored. Free. Retried while attempts remain. | | `exhausted` | ✅ | Retried up to `retry_count + 1` attempts and never connected. | | `suppressed` | ✅ | The number is on your [do-not-call list](/v2/dnc). Never dialled, never billed. | | `aborted` | ✅ | The campaign was stopped while this contact was pending or in flight. | `exhausted` is the one to watch: it is the honest count of "we tried everything you paid for and never reached this person". A contact that ends `no_answer` still had attempts left; one that ends `exhausted` did not. ## Calling window Three things together decide whether the dialler may ring right now, all in the campaign's own `timezone`: 1. today's local date is between `start_date` and `end_date`, inclusive; **and** 2. the local `HH:MM` falls inside at least one `slots` entry. ```json theme={null} { "timezone": "Asia/Kolkata", "start_date": "2026-08-11", "end_date": "2026-08-18", "slots": [ { "start": "10:00", "end": "13:00" }, { "start": "16:00", "end": "19:00" } ] } ``` * **1 to 4 slots.** Fewer is a `400`; five is a `400`. * **A slot may cross midnight.** `21:00`–`06:00` is open on both sides of it. Legal, and almost never what you want in India — see [calling rules](/v2/limits#india-calling-rules). * **A zero-length slot is a `400`.** `{"start":"10:00","end":"10:00"}` is rejected rather than read as "all day": guessing wrong there means ringing somebody at 03:00. * **Local means local.** Times are stored and evaluated in `timezone`, not UTC, so `Asia/Kolkata`'s half-hour offset and every daylight-saving boundary are handled for you. This is the difference between a campaign that respects the window and one that respects it except twice a year. When the date range runs out entirely, the campaign finishes as `completed` — even with contacts still `pending`. A window that never opens again cannot dial them. ## Retries | Knob | Range | Effect | | :----------------------- | :------- | :--------------------------------------------------------------------- | | `retry_count` | 0–5 | Retries **after** the first attempt. `1` means up to 2 attempts total. | | `re_attempt_period_secs` | 60–86400 | Minimum wait before a contact becomes eligible again. | Only outcomes that never reached a human are retried: `no_answer`, `busy` and `failed`. `completed`, `voicemail` and `timeout` all produced live media — the call happened, and re-dialling somebody who already spoke to us is worse than not calling at all. A retry that lands outside the calling window simply waits: eligibility is a lower bound, not a schedule. ## Budget and pacing **`max_concurrent`** (1–50) is this campaign's own ceiling on live calls. It is independent of — and additionally bounded by — your workspace [concurrency and queue-depth limits](/v2/limits). Two campaigns at `max_concurrent: 20` on a workspace provisioned for 5 concurrent calls will share those 5. **`budget_paise`** caps the spend of this campaign alone. When `spent_paise` reaches it, the campaign pauses with `budget_exhausted` — contacts keep their place, so raising the budget and resuming continues where it stood. **The wallet** is checked before every dialling tick. If it cannot cover another minute at the campaign's tier, the campaign pauses with `insufficient_balance`. [`POST /start`](#start-a-campaign) also does a wallet estimate up front and tells you how far your balance goes, in `funded_calls`. Set `budget_paise` on every campaign, even when you trust the list. It is the one control that bounds a mistake in the *list* — a duplicated CSV, a column shifted by one — rather than a mistake in the code. *** ## Create a campaign ```http theme={null} POST /v2/campaigns ``` ### Request | Field | Type | Required | Description | | :------------------------ | :------ | :------- | :--------------------------------------------------------------------------- | | `name` | string | yes | 1–120 characters. | | `agent_id` | string | yes | Must exist in your workspace. | | `tier` | string | no | `t1` \| `t3`. Defaults to your key's tier. `t5` answers `501`. | | `timezone` | string | yes | IANA name, e.g. `Asia/Kolkata`. | | `start_date` / `end_date` | string | yes | `YYYY-MM-DD`, local, inclusive. | | `slots` | array | yes | 1–4 × `{start, end}` as `HH:MM`. | | `max_concurrent` | integer | no | 1–50. Default `1`. | | `retry_count` | integer | no | 0–5. Default `0`. | | `re_attempt_period_secs` | integer | no | 60–86400. Default `900`. | | `max_duration_secs` | integer | no | 30–1800. Defaults to the agent's. | | `budget_paise` | integer | no | Positive paise. Omit for no cap. | | `webhook_url` | string | no | HTTPS. Gets `campaign.*` **and** `call.*` events. | | `contacts` | array | no | Up to **1000** rows per request. `{phone, variables?, id?}`. Add more later. | ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order confirmations — Mumbai batch", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "tier": "t3", "timezone": "Asia/Kolkata", "start_date": "2026-08-11", "end_date": "2026-08-18", "slots": [{ "start": "10:00", "end": "19:00" }], "max_concurrent": 2, "retry_count": 1, "re_attempt_period_secs": 900, "max_duration_secs": 300, "budget_paise": 50000, "webhook_url": "https://example.com/mirai/webhook", "contacts": [ { "id": "cust-1001", "phone": "+919876543210", "variables": { "customer_name": "Rohit Sharma", "order_id": "AC-88213" } }, { "id": "cust-1002", "phone": "+919876543211", "variables": { "customer_name": "Anita Desai", "order_id": "AC-88219" } } ] }' ``` ```json title="201 Created" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "name": "Order confirmations — Mumbai batch", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "tier": "t3", "status": "draft", "pause_reason": null, "timezone": "Asia/Kolkata", "start_date": "2026-08-11", "end_date": "2026-08-18", "slots": [{ "start": "10:00", "end": "19:00" }], "max_concurrent": 2, "retry_count": 1, "re_attempt_period_secs": 900, "max_duration_secs": 300, "budget_paise": 50000, "spent_paise": 0, "webhook_url": "https://example.com/mirai/webhook", "created_at": "2026-08-10T09:21:44Z", "updated_at": "2026-08-10T09:21:44Z", "contacts_accepted": 2, "contacts_duplicate": 0, "contacts_rejected": [] } ``` **The response is the campaign object itself**, with three extra top-level keys — not `{"campaign": …, "contacts": …}`. Read the id at `.id`, and the upload result at `.contacts_accepted` / `.contacts_duplicate` / `.contacts_rejected`. ### Contacts are reported row by row, not rejected as a batch A 900-row upload with two bad numbers is **not** an all-or-nothing `400`. The good rows are stored and the bad ones come back by index: ```json title="201 Created — two rows rejected, one duplicate" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "draft", "contacts_accepted": 3, "contacts_duplicate": 1, "contacts_rejected": [ { "index": 1, "phone": "9876543211", "reason": "phone must be an E.164 number" }, { "index": 4, "phone": "+91984500112", "reason": "phone must be an E.164 number" } ] } ``` | Counter | Meaning | | :------------------- | :----------------------------------------------------------------------------------------------------------------------- | | `contacts_accepted` | Rows stored and dialable. | | `contacts_duplicate` | The phone number was already in this campaign, or repeated inside this batch. Not an error — deduplication is the point. | | `contacts_rejected` | Rows we could not store, each with the **index in the array you sent** and why. | Two things *do* fail the whole request: more than 1000 rows (`400 invalid_request`), and a malformed body. Everything else is a row report. `variables` follow the same rules as [call variables](/v2/calls#variables) — a flat string map, 32 keys, 512 characters per value — and fill the same `{{placeholders}}` in the agent's prompt. `id` is your own key for the row; omit it and we mint one. ### Errors | Status | `error.code` | Cause | | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------- | | `400` | `invalid_request` | Missing `name`/`agent_id`/`timezone`/dates/`slots`, a knob out of range, a bad window, or more than 1000 contacts. | | `404` | `not_found` | `agent_id` does not exist in your workspace. Checked now, so a typo is not a campaign that fails every contact tomorrow morning. | | `501` | `tier_unavailable` | `tier` is `t5`. | | `503` | `campaigns_unavailable` | Campaigns are not enabled for your workspace. Not your request — ask your Mirai contact. | *** ## List campaigns ```http theme={null} GET /v2/campaigns?limit=&cursor= ``` ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "limit=20" ``` Returns the standard [paginated envelope](/v2/overview#pagination) of campaign objects, newest first. `counters` is omitted on list pages — fetch one campaign to get them. *** ## Get a campaign ```http theme={null} GET /v2/campaigns/{id} ``` ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "name": "Order confirmations — Mumbai batch", "tier": "t3", "status": "paused", "pause_reason": "insufficient_balance", "spent_paise": 49800, "budget_paise": 50000, "counters": { "pending": 612, "completed": 174, "no_answer": 9, "exhausted": 2, "suppressed": 3 }, "created_at": "2026-08-10T09:21:44Z", "updated_at": "2026-08-10T11:04:02Z" } ``` `counters` comes from the contact table, not from the running dialler, so a finished campaign answers exactly like a live one. That is what makes this endpoint safe to build a dashboard on. `404 not_found` for an unknown id and for one in another workspace — the two are deliberately indistinguishable. *** ## Update a campaign ```http theme={null} PATCH /v2/campaigns/{id} ``` Editable while `draft` or `paused` only. Anything else is `409 conflict`: changing the slots, the retry policy or the budget under a live dialler would mean the campaign's own report describes settings that were never in force for half of its calls. Pause it, patch it, resume it. Patchable: `name`, `slots`, `start_date`, `end_date`, `max_concurrent`, `retry_count`, `re_attempt_period_secs`, `max_duration_secs`, `budget_paise`, `webhook_url`. Not patchable: `agent_id`, `tier`, `timezone`. ```bash theme={null} curl -X PATCH https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "budget_paise": 120000, "max_concurrent": 4 }' ``` Returns `200 OK` with the campaign object. *** ## Add contacts ```http theme={null} POST /v2/campaigns/{id}/contacts ``` Up to **1000 rows per request**. Call it repeatedly for a larger list — a running campaign accepts new contacts and will pick them up on its next tick, which is the whole point of keeping the list in a table rather than in the dialler. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/contacts \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ { "id": "cust-2001", "phone": "+919812345678", "variables": { "customer_name": "Vikram Rao", "order_id": "AC-88224" } }, { "id": "cust-2002", "phone": "+919833221100", "variables": { "customer_name": "Meera Nair", "order_id": "AC-88231" } } ] }' ``` ```json title="200 OK" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "status": "play", "contacts_accepted": 2, "contacts_duplicate": 0, "contacts_rejected": [] } ``` Same body as create, for the same reason: one shape for the two routes that add contacts means one parser in your client. `409 conflict` if the campaign is `stopped` or `completed` — nothing would ever dial those rows, and accepting them silently is worse than refusing them. *** ## List contacts ```http theme={null} GET /v2/campaigns/{id}/contacts?status=&limit=&cursor= ``` | Query param | Description | | :---------- | :------------------------------------------------------------------- | | `status` | Filter by one [contact status](#contact-statuses), e.g. `exhausted`. | | `limit` | 1–100, default 20. | | `cursor` | From `next_cursor`. | ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/contacts \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "status=exhausted" \ --data-urlencode "limit=100" ``` ```json title="200 OK" theme={null} { "data": [ { "id": "cust-1002", "phone": "+919876543211", "status": "exhausted", "attempts": 2, "variables": { "customer_name": "Anita Desai", "order_id": "AC-88219" }, "last_call_id": "call_01K7QB4M8N3P6R2S5T7V9W1YA", "last_status": "no_answer", "eligible_at": "2026-08-11T11:19:02Z", "updated_at": "2026-08-11T11:34:10Z" } ], "has_more": false, "next_cursor": null } ``` `last_call_id` is an ordinary `call_` id: fetch it from [`GET /v2/calls/{id}`](/v2/calls#get-a-call) for the full outcome. *** ## Start a campaign ```http theme={null} POST /v2/campaigns/{id}/start ``` Legal from `draft` and from `paused` — starting a paused campaign is how you resume one whose dialler is no longer running (after a top-up, say), because `start` re-runs the fleet and wallet preflight that a bare [resume](#resume-a-campaign) skips. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/start \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="202 Accepted" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" } ``` `202` means the dialler is running — **not** that a phone is ringing. Outside the calling window it will sit and wait, correctly, in `play`. ### `funded_calls`: an underfunded campaign still starts If your wallet cannot cover every remaining contact, the response carries a warning rather than a refusal: ```json title="202 Accepted — partially funded" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play", "funded_calls": 166 } ``` The campaign starts, dials what the balance covers, and then pauses itself with `pause_reason: "insufficient_balance"`. Top up and start it again to continue. `funded_calls` is a one-minute-per-call estimate at this campaign's tier — a floor on how far you get, not a promise. ### Errors | Status | `error.code` | Cause | | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------ | | `409` | `conflict` | The campaign is already `play`, or is `stopped`/`completed`. | | `429` | `rate_limited` | Your workspace queue is full (500 calls accepted and not yet ended). Honour `Retry-After`. | | `503` | `fleet_offline` | Speech capacity is offline. `Retry-After: 300`. Nothing is charged and the campaign stays where it was. | | `503` | `campaigns_unavailable` | Campaigns are not enabled for your workspace. | *** ## Pause a campaign ```http theme={null} POST /v2/campaigns/{id}/pause ``` ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/pause \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="202 Accepted" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "paused" } ``` Takes effect immediately, including while the campaign is asleep waiting for tomorrow's window. **Calls already live are not hung up** — pause stops new calls, it does not cut off people mid-conversation. Use [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call) if you really need a live call to end now. Only a `play` campaign can be paused; anything else is `409 conflict`. *** ## Resume a campaign ```http theme={null} POST /v2/campaigns/{id}/resume ``` ```json title="202 Accepted" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" } ``` `409 conflict` if the campaign is not `paused` — and also if it is paused but has **no running dialler**, in which case the message tells you to use [`POST /start`](#start-a-campaign) instead. That is deliberate: a bare resume would skip the wallet and fleet preflight. *** ## Stop a campaign ```http theme={null} POST /v2/campaigns/{id}/stop ``` ```json title="202 Accepted" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "stopped" } ``` **Terminal and irreversible.** Live calls are hung up, pending contacts are marked `aborted`, and the campaign will never dial again. Legal from `draft`, `play` and `paused`. If you might want to continue later, [pause](#pause-a-campaign) instead. *** ## Campaign report ```http theme={null} GET /v2/campaigns/{id}/report GET /v2/campaigns/{id}/report?format=csv ``` ### JSON — the aggregate ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "campaign_id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "completed", "counters": { "completed": 174, "voicemail": 12, "no_answer": 9, "exhausted": 21, "suppressed": 3 }, "contacts": 219, "dialed": 216, "attempts": 251, "connected": 186, "spent_paise": 60300, "spent_inr": 603, "budget_paise": 120000 } ``` | Field | Meaning | | :-------------------------- | :----------------------------------------------------------------------------------------------------- | | `counters` | Contacts by final status. | | `contacts` | Rows uploaded. | | `dialed` | Contacts with at least one attempt. | | `attempts` | Total dial attempts, retries included. `attempts − dialed` is what retrying cost you. | | `connected` | `completed` + `voicemail` + `timeout` — the calls that produced live media, and the ones you paid for. | | `spent_paise` / `spent_inr` | The same number in both units, for convenience. | `connected / dialed` is your answer rate. `attempts / dialed` is whether `retry_count` is earning its place. ### CSV — per contact ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "format=csv" \ -o campaign.csv ``` ```csv theme={null} id,phone,status,attempts,last_call_id cust-1001,+919876543210,completed,1,call_01K7QB4M8N3P6R2S5T7V9W1YA cust-1002,+919876543211,exhausted,2,call_01K7QB4M8N3P6R2S5T7V9W1YB cust-1003,+919812345678,suppressed,0, ``` Streamed, not buffered, so it is safe on a six-figure campaign. Join `last_call_id` against [`GET /v2/calls`](/v2/calls#list-calls) for durations and costs. *** ## Webhooks Set `webhook_url` on the campaign and you receive **both** its lifecycle events and every child call's [`call.*` events](/v2/webhooks#event-types), signed with the same workspace secret and verified by the same code path. | Type | Fires when | | :------------------- | :------------------------------------------------------------------------------------- | | `campaign.started` | The first `POST /start` takes effect. Once per campaign, never on an internal restart. | | `campaign.paused` | You paused it, or the money gate did. Read `data.campaign.pause_reason`. | | `campaign.resumed` | Dialling resumed. | | `campaign.completed` | Every contact reached a final state, or the date range ran out. | | `campaign.stopped` | You stopped it. | ```json title="POST https://example.com/mirai/webhook" theme={null} { "id": "evt_01K7QC5N9P4R7S3T6V8W2X4YB", "type": "campaign.paused", "created_at": "2026-08-11T13:04:02Z", "data": { "campaign": { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "name": "Order confirmations — Mumbai batch", "status": "paused", "pause_reason": "insufficient_balance", "counters": { "pending": 612, "completed": 174, "no_answer": 9 }, "dialed": 216 } } } ``` Same envelope as a call event — `{id, type, created_at, data}` — with `data.campaign` where `data.call` would be. Verification, retries and replay protection are identical: see [Webhooks](/v2/webhooks#signature-verification). Alert on `campaign.paused` with a `pause_reason`. That event is the platform telling you a campaign has stopped spending money for a reason you can fix in one API call. *** ## A campaign that behaves 1. **Scrub first.** Push known opt-outs to [`POST /v2/dnc`](/v2/dnc) *before* you upload the list. Suppression is checked at dial time, so late additions still work — but a number dialled at 10:00 cannot be un-dialled at 10:05. 2. **Keep the window legal.** `09:00`–`21:00` IST is the outer bound for commercial calls in India, and most enterprise programmes run `10:00`–`19:00`. See [calling rules](/v2/limits#india-calling-rules). 3. **Budget every campaign.** `budget_paise` is the cheapest insurance against a bad CSV there is. 4. **Retry once, wait fifteen minutes.** `retry_count: 1`, `re_attempt_period_secs: 900` is the shape that works for most Indian mobile lists. Retrying five times in five minutes annoys people and does not connect. 5. **Start small.** Ten contacts, one slot, `max_concurrent: 1`. Read the transcripts. Then upload the rest into the same campaign. 6. **Watch `exhausted` and `suppressed` in the report.** They are the two numbers that tell you about your *list* rather than about your agent. # Do-not-call list Source: https://docs.miraiminds.co/v2/dnc Your workspace's own suppression list — numbers campaigns will never dial. The **do-not-call list** is a per-workspace set of phone numbers that [campaigns](/v2/campaigns) will not dial. Add a number and every campaign in the workspace — running, paused or not yet created — skips it. Base URL `https://sandbox.voice.miraiminds.co`. **This is your suppression list, not the national registry.** It suppresses *your* campaigns on *our* platform. It is not TRAI's DND/NCPR register, we do not scrub against that register for you, and having a number on this list is not a substitute for the consent and DND obligations that sit with the business placing the calls. See [India calling rules](/v2/limits#india-calling-rules). ## How suppression works Numbers are checked **at dial time**, in the same database statement that hands contacts to the dialler. Three consequences worth knowing: * **Late additions work.** A number added while a campaign is running is suppressed from that moment on, even though it was uploaded in the original list. * **Suppression is not deletion.** The contact stays in the campaign with [`status: "suppressed"`](/v2/campaigns#contact-statuses) and shows up in the report, so you can prove the number was uploaded and not called. * **Suppressed contacts are never billed.** No dial, no minute, no charge. It does **not** apply to [`POST /v2/calls`](/v2/calls#create-a-call). A direct single call is an explicit instruction to ring one number now; refusing it based on a list would silently swallow transactional calls — a delivery confirmation to a customer who opted out of marketing, say. Check the list yourself before placing single calls if that is the behaviour you want. ## The entry object ```json theme={null} { "object": "dnc_entry", "phone": "+919876543210", "created_at": "2026-08-10T09:44:12Z" } ``` *** ## Add a number ```http theme={null} POST /v2/dnc ``` ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/dnc \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+919876543210" }' ``` ```json title="201 Created" theme={null} { "object": "dnc_entry", "phone": "+919876543210", "added": true } ``` Adding a number that is already on the list is **not an error** — you asked for it to be suppressed and it is suppressed. You get `200 OK` with `"added": false` instead of `201`: ```json title="200 OK — already on the list" theme={null} { "object": "dnc_entry", "phone": "+919876543210", "added": false } ``` Branch on `added` if you are keeping your own count; branch on nothing at all if you are just making sure. Both answers mean the number will not be dialled. | Status | `error.code` | Cause | | :----- | :---------------------- | :---------------------------------------------------------------------- | | `400` | `invalid_request` | `phone` is not E.164 — `+919876543210`, not `9876543210`. | | `503` | `campaigns_unavailable` | Campaigns (and with them this list) are not enabled for your workspace. | *** ## List numbers ```http theme={null} GET /v2/dnc?limit=&cursor= ``` ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/dnc \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "limit=100" ``` ```json title="200 OK" theme={null} { "data": [ { "object": "dnc_entry", "phone": "+919876543210", "created_at": "2026-08-10T09:44:12Z" }, { "object": "dnc_entry", "phone": "+919812345678", "created_at": "2026-08-09T16:02:55Z" } ], "has_more": false, "next_cursor": null } ``` Standard [pagination](/v2/overview#pagination): `limit` 1–100, default 20, and `cursor` from `next_cursor`. *** ## Remove a number ```http theme={null} DELETE /v2/dnc/{phone} ``` The number goes in the path, URL-encoded — `+` becomes `%2B`. ```bash theme={null} curl -X DELETE "https://sandbox.voice.miraiminds.co/v2/dnc/%2B919876543210" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` `204 No Content` on success. `404 not_found` if the number was not on the list. **Removing a number un-suppresses it immediately.** Contacts already marked `suppressed` in a campaign stay marked — that status is a record of a decision already taken — but any future campaign, and any contact not yet claimed, will dial it. Keep an audit trail of who removed what: an opt-out you reversed is the complaint you will be asked about. *** ## Keeping it in sync The list is a set of numbers, and the operations are idempotent, so the simplest correct integration is also the right one: on every opt-out event in your own system, `POST` the number. No read-modify-write, no diffing. ```python theme={null} def on_opt_out(phone: str) -> None: r = httpx.post(f"{API}/v2/dnc", headers=auth, json={"phone": phone}, timeout=30) if r.status_code not in (200, 201): raise RuntimeError(r.json()["error"]["message"]) # 201 -> newly added, 200 -> already there. Both mean suppressed. ``` Three habits worth having: 1. **Suppress on the call, not after the campaign.** If your agent hears "stop calling me", write it to the list from the webhook handler that same minute. 2. **Suppress before you upload.** Scrubbing the list you upload *and* keeping the DNC list current are belt and braces; the DNC list is the one that protects you from the campaign you forgot to scrub. 3. **Never remove in bulk.** Removals should be individual, deliberate, and logged with a reason. # Errors Source: https://docs.miraiminds.co/v2/errors One error envelope, every status code and error code, and what to do about each. Every v2 error — validation, auth, billing, rate limiting — uses one envelope. There is no second shape to special-case. ```json theme={null} { "error": { "code": "invalid_request", "message": "to must be an E.164 phone number, got '9876543210'" } } ``` | Field | Description | | :-------- | :------------------------------------------------------------------------------------------------------------------------- | | `code` | Stable, `snake_case`, machine-readable. **Branch on this.** | | `message` | Human-readable, written for a developer reading a log. Not stable — never parse it, never show it to an end user verbatim. | ## Status codes | Status | Meaning | Retry? | | :----- | :--------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------- | | `400` | The request is malformed or a field is invalid. | No — fix the request. | | `401` | Missing, malformed or unknown key. | No. | | `402` | Wallet cannot cover the call. | After topping up. | | `403` | The key is valid but **revoked**. That is the only thing that returns `403`. | No. | | `404` | No such resource in your workspace — including one that exists in someone else's. | No. | | `409` | State conflict — a request with the same `Idempotency-Key` still in flight, or an operation the current state forbids. | No. | | `429` | Request-rate limit. Concurrency does not `429`. | Yes, with backoff. | | `500` | Our fault. | Yes, with backoff. | | `501` | The feature exists in the contract but is not live yet. | No. | | `503` | Temporarily unavailable (deploy, capacity) — or a feature that is not provisioned for your workspace. | Yes for `fleet_offline`; no for `campaigns_unavailable`, which needs provisioning, not patience. | ## Error codes | `error.code` | Status | When | Fix | | :---------------------- | :----- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_request` | `400` | Missing required field, wrong type, `to` not E.164, `max_duration_secs` out of range, unknown `voice_id`, malformed JSON. | Read `message`; it names the field. | | `unauthorized` | `401` | No `Authorization` header, not `Bearer `-prefixed, or the key is unknown. | Check the header. A trailing newline from `cat`-ing a key file is the usual culprit. | | `forbidden` | `403` | The key was revoked. Nothing else returns `403`. | Get a new key. | | `insufficient_balance` | `402` | Pre-dial wallet gate. No call was placed and nothing was charged. The same condition inside a running [campaign](/v2/campaigns) is not an error — the campaign pauses with `pause_reason: "insufficient_balance"`. | [Top up](/v2/wallet#top-up) and retry with the same `Idempotency-Key`. | | `not_found` | `404` | Unknown `agt_`/`call_`/`cmp_` ID, a soft-deleted agent, a number not on the [do-not-call list](/v2/dnc), a transcript or recording that does not exist, or an ID that belongs to another workspace. | Check the ID. For artifacts, check `transcript_available` / `recording_available` on the call. | | `duplicate_call` | `409` | A request with this `Idempotency-Key` is still being processed; when the original completes, the same key replays its stored response. | Not an error if you are retrying — no second call was placed. | | `conflict` | `409` | The operation is illegal in the current state: aborting a call that has already ended, editing or starting a campaign that is `play`, adding contacts to one that is `stopped`/`completed`, or resuming one with no running dialler. | Read the resource's `status` first. A paused campaign with no dialler is resumed with [`POST /start`](/v2/campaigns#start-a-campaign). | | `rate_limited` | `429` | Request rate exceeded, **or** your queue is full (500 calls accepted and not yet ended). Being at your *concurrency* ceiling does **not** produce this: those calls queue. `error.message` says which limit you hit. | Honour `Retry-After` and back off. See [Limits](/v2/limits). | | `at_capacity` | `429` | Every line on the deployment is busy — a fleet-wide ceiling, not your workspace's. Nothing was charged. | Honour `Retry-After` (30s) and retry. | | `tier_unavailable` | `501` | `tier` was `t5`. It is announced but not live. | Use `t3` (the default) or `t1`. See [tiers](/general/tiers). | | `campaigns_unavailable` | `503` | Campaigns are not enabled for your workspace. Every `/v2/campaigns` and `/v2/dnc` route answers this; everything else keeps working. | Not a mistake in your request, and retrying will not clear it — ask your Mirai contact to enable them. | | `fleet_offline` | `503` | Speech capacity is offline, so a [campaign start](/v2/campaigns#start-a-campaign) was refused. Nothing was charged and the campaign stayed where it was. | Honour `Retry-After` (300s) and start it again. | | `internal` | `500` | Our bug. Already alarming on our side. | Retry with backoff; if it persists, send us the `call_id`. | `error.code` is an **open enum**. New codes are added without a version bump. Always have a default branch keyed on the HTTP status. **Foreign IDs are `404`, never `403`** An `agt_`, `call_` or `cmp_` ID that exists in another workspace is answered exactly as an ID that never existed: `404 not_found`. Anything else would be an enumeration oracle — a way to probe which IDs are real on the platform by watching the status code change. `403` is reserved for one situation only: your own key has been revoked. ## Handling errors The pattern that covers everything: branch on status, then on code. ```python theme={null} import random import time import httpx class MiraiError(Exception): def __init__(self, status: int, code: str, message: str): super().__init__(f"{status} {code}: {message}") self.status, self.code, self.message = status, code, message RETRYABLE = {429, 500, 502, 503, 504} def request(client: httpx.Client, method: str, path: str, **kw) -> dict: for attempt in range(5): r = client.request(method, path, **kw) if r.is_success: return r.json() if r.content else {} body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} err = body.get("error", {}) if r.status_code in RETRYABLE and attempt < 4: # honour Retry-After when we send it, else exponential + jitter wait = float(r.headers.get("Retry-After", 2**attempt)) time.sleep(wait + random.random()) continue raise MiraiError(r.status_code, err.get("code", "unknown"), err.get("message", r.text)) raise MiraiError(r.status_code, "retries_exhausted", "gave up after 5 attempts") ``` At the call site, only two codes deserve their own branch: ```python theme={null} try: call = request(client, "POST", "/v2/calls", json=payload, headers={"Idempotency-Key": key}) except MiraiError as e: if e.code == "insufficient_balance": pause_campaign(); alert_ops(e.message) elif e.code == "duplicate_call": pass # same key still in flight — no second call else: raise ``` ```javascript theme={null} export class MiraiError extends Error { constructor(status, code, message) { super(`${status} ${code}: ${message}`); this.status = status; this.code = code; } } const RETRYABLE = new Set([429, 500, 502, 503, 504]); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function request(path, init = {}, attempt = 0) { const res = await fetch(`https://sandbox.voice.miraiminds.co${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.MIRAI_API_KEY}`, "Content-Type": "application/json", ...init.headers, }, }); if (res.ok) return res.status === 204 ? null : res.json(); const body = await res.json().catch(() => ({})); const { code = "unknown", message = res.statusText } = body.error ?? {}; if (RETRYABLE.has(res.status) && attempt < 4) { const retryAfter = Number(res.headers.get("Retry-After")); const wait = (Number.isFinite(retryAfter) ? retryAfter : 2 ** attempt) * 1000; await sleep(wait + Math.random() * 1000); return request(path, init, attempt + 1); } throw new MiraiError(res.status, code, message); } ``` At the call site: ```javascript theme={null} try { const call = await request("/v2/calls", { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(payload), }); } catch (e) { if (e.code === "insufficient_balance") { await pauseCampaign(); await alertOps(e.message); } else if (e.code !== "duplicate_call") { throw e; // duplicate_call means the same key is still in flight } } ``` ### Retry rules * **Retry** `429`, `500`, `502`, `503`, `504` and network errors. Exponential backoff with jitter; honour `Retry-After` when present. * **Never retry** `400`, `401`, `403`, `404`, `501` — the same request will fail the same way. `503 campaigns_unavailable` belongs in this group too: it is a provisioning fact, not a wobble. * **`402` is retryable only after a top-up**, not on a timer. * **Always send `Idempotency-Key` on `POST /v2/calls`.** A timeout tells you nothing about whether the phone rang; without the key, your retry is a second call to a real person. ### What a `409 duplicate_call` actually means It means a request with this `Idempotency-Key` is **still being processed**. It is the narrow race, not the normal retry path: we cannot replay a response that has not been produced yet, and we must not place a second call, so we say so. When the original request completes, the same key replays its stored response — so a retry a moment later returns the original `202` and the original `call_id`. Either way **no second call is placed**. That is usually the *success* path of a retry, not a failure: log it and move on, do not surface it as an error to your users. # FAQ Source: https://docs.miraiminds.co/v2/faq Short answers to the questions developers actually ask about the Mirai Voice API. ## Getting started **How do I get a key?** In the [console](https://sandbox.voice.miraiminds.co), under **Developers → Create key**. You get a secret key, a webhook secret and a starting wallet balance immediately, and you can rotate or revoke from the same page. If you do not have console access yet, [talk to us](https://cal.com/srikrishna-pothel-y5vpeq). **Is there a sandbox or test key?** Not yet. There is no `sk_test_` and no simulated call — every call is a real call and costs real money. Test against your own phone with a small wallet balance. The [webhook test vector](/v2/webhooks#test-vector) lets you build and verify your handler without placing a call at all. **Is there an SDK?** Yes, for Python: ```bash theme={null} pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice ``` It carries the rules you would otherwise have to re-derive: an `Idempotency-Key` on every call create so a retry never dials twice, `Retry-After`-aware backoff, cursor iterators, and `mirai.webhooks.verify()` over the raw body. Sync and async, both credential generations. The copy-pasteable cURL, Python and Node on every page here work just as well — the API is small on purpose. ## Calls **Why did `POST /v2/calls` return `202` and not `200`?** Because the phone has not rung yet. `202` means queued. The outcome arrives by [webhook](/v2/webhooks), or from [`GET /v2/calls/{id}`](/v2/calls#get-a-call). **How long until the phone rings?** Normally a couple of seconds from `202`. If you are at your concurrency ceiling the call sits in `queued` until a slot frees — it is queued, not rejected, so being over the ceiling never returns `429`. **Can I do inbound calls?** Not yet — outbound only. **Can I transfer a call to a human?** Yes — warm transfer to a human is part of every tier, see the [feature matrix](/general/tiers#feature-matrix). The transfer rollout is completing now; see the [roadmap](/general/roadmap) for status and ask your account contact to enable it on your workspace. **Can I change the prompt or variables mid-call?** No. `variables` are static per call — create the call with what you want it to say. **Can I get the recording or transcript?** Not through the v2 API. Recordings are retained 30 days; ask your Mirai contact if you need access. If your use case requires that we do not record, tell us up front. **What happens if the customer does not answer?** `status: no_answer`, a `call.failed` webhook, and no charge. v2 does not retry for you — retry from your own dialler if you want to. One caveat: when the carrier gives us no verdict at all, an unanswered call is reported as `failed` with `ended_reason: "no-media"` instead. Both are free, and both mean "did not connect" — so treat `no_answer`, `busy` and `failed` + `no-media` as one bucket in retry logic. See [ended reasons](/v2/calls#ended-reasons). **How do I stop a call I just started?** [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call). It works while the call is queued, while it is ringing, and while it is live — a live call is hung up mid-conversation. Only a call that has already ended returns `409`. Aborted calls are never billed. **Which languages work?** Hindi and Indian English are what the stack is tuned for, including Hindi-English code-switching, which is how most real calls actually sound. Write the prompt in the language you want spoken — Devanagari for Hindi. Other languages: ask before you build on them. **Does the agent sound like a human?** It sounds like a good phone voice. Callers usually work out it is a machine within a turn or two, which is why [telling them up front](/v2/limits#disclosure-that-it-is-an-ai) costs you nothing and drops early hang-ups. ## Billing **What does a call cost?** ₹1 per minute on `t1`, billed in whole minutes rounded up, minimum one minute. A 96-second call costs ₹2.00; an 8-second call costs ₹1.00. See [Billing & tiers](/general/tiers). **Am I charged for calls that do not connect?** No. `no_answer`, `busy`, `failed` and `aborted` are free. `completed`, `voicemail` and `timeout` are billed. **Am I charged for a voicemail?** Yes — media went live and audio was generated. An 18-second voicemail is one billed minute: ₹1.00 at `t1`. **What happens when I run out of credit?** `POST /v2/calls` returns [`402 insufficient_balance`](/v2/wallet#402-insufficient-balance) and nothing is dialled. Calls already in flight finish normally. **How do I top up?** Ask your Mirai contact — self-serve top-up is not built yet, and is on the [roadmap](/general/roadmap). Credits land as a `credit` row in [`GET /v2/wallet/transactions`](/v2/wallet#list-transactions) and take effect immediately. **When are `t3` and `t5` available?** They are documented but not live. Requesting them returns [`501 tier_unavailable`](/v2/errors#error-codes). Current targets: `t3` in August 2026, `t5` in Q4 2026 — see the [roadmap](/general/roadmap) and the [feature matrix](/general/tiers#feature-matrix) for what they will include. ## Webhooks **My signature never verifies. Why?** Almost always because you signed a re-serialised body. Sign the **raw bytes**. See [raw body](/v2/webhooks#raw-body). Second most common: the signed string is `"."` — the timestamp and the dot are part of it, not just the body. **Do you retry failed deliveries?** Yes — six attempts with exponential backoff over roughly 36 minutes, then dead-letter. Any `2xx` stops it. See [retries](/v2/webhooks#retries). **Can events arrive out of order or twice?** Yes to both. Dedupe on `event.id` and order your state machine by `data.call.status`, not by arrival time. **Can I have more than one webhook URL?** One per call, set as `webhook_url` at create time. Fan out on your side. **What if my endpoint is down for an hour?** The event lands in the dead-letter queue after \~36 minutes of retries. Ops can replay it. You can also reconcile from [`GET /v2/calls`](/v2/calls#list-calls) — that is what the list endpoint is for. ## Limits and compliance **How many concurrent calls can I run?** Pilot default is 5. It is a provisioning setting, not a code change — ask for more. See [Limits](/v2/limits). **Do you enforce the 9am–9pm calling window?** No. Your scheduler does. We dial when you tell us to dial. See [India calling rules](/v2/limits#india-calling-rules). **Do you scrub DND numbers?** No. That is your list and your obligation. **Do I have to tell people it is an AI?** We strongly recommend it, and it belongs in `first_message`. Your legal obligations are your own to confirm. ## Operations **What is your uptime target?** The API and the call path are operated as a pilot service. Ask your account contact for the current SLA before you put a revenue-critical flow behind it. **Where does my data go?** Media (audio, ASR, TTS, the model) runs in India. The control plane — workflow orchestration and the API — runs in Europe, off the audio path, so it adds no latency to what the caller hears. **Something is broken. What do you need from me?** The `call_id` (or `evt_id`), the UTC timestamp, and what you expected. That is enough to pull the whole workflow history. # Limits & compliance Source: https://docs.miraiminds.co/v2/limits Rate limits, concurrency, payload ceilings, and the India calling-window and DNC rules you are responsible for. Two kinds of limit apply to you: ours (technical, we enforce them) and India's (regulatory, **you** are responsible for them). ## Rate limits Limits are per **secret key**. | Limit | Pilot default | Applies to | Over the limit | | :-------------------- | :--------------------------- | :---------------------------------- | :------------------ | | Request rate | 10 requests/second, burst 20 | all `/v2/*` endpoints | `429 rate_limited` | | Concurrent live calls | 5 | calls in `dialing` or `in_progress` | the call **queues** | | Queue depth | 500 | calls accepted and not yet ended | `429 rate_limited` | **Pilot keys are provisioned individually** The numbers above are the defaults a new pilot key is issued with. Yours may differ — confirm your actual ceilings with your account contact before you size a campaign. Higher concurrency is a provisioning change, not a code change: ask. ### Request rate Exceeding the **request rate** returns `429` with `error.code: rate_limited` and a `Retry-After` header in seconds. It is about how fast you call the API, not about how many phones are ringing. ```json title="429 Too Many Requests" theme={null} { "error": { "code": "rate_limited", "message": "rate limit exceeded, retry in 0.4s" } } ``` ### Concurrency Concurrency does **not** produce a `429`. A `POST /v2/calls` beyond your concurrent-call ceiling is still accepted with `202` and sits in `queued` until a slot frees, then dials — nothing is rejected and nothing is lost. `429` is request-rate only. That makes the ceiling a **pacer**, not a gate. Two consequences: * A queued call's phone rings later than you asked. If a call is only useful inside a window, check `status` before you assume it went out — or do not submit it until you have the capacity. * Submitting a burst does not fail; it builds a queue that drains at your concurrency. ### Queue depth The queue itself **is** bounded. Once you have **500 calls accepted and not yet ended**, further `POST /v2/calls` return `429 rate_limited` with a `Retry-After` header until calls drain. The message names the ceiling, so you can tell it apart from a request-rate `429`: ```json title="429 Too Many Requests" theme={null} { "error": { "code": "rate_limited", "message": "queue depth limit reached for this workspace (500 calls accepted and not yet ended, limit 500); wait for calls to drain or ask ops to raise it" } } ``` Both limits share the `rate_limited` code on purpose: the correct client behaviour is identical — honour `Retry-After` and retry. Read `error.message` if you want to log which one you hit. The count is calls **accepted and not yet ended** — `queued` plus the handful actually `dialing` or `in_progress` — not strictly `queued`. With a concurrency of 5 the difference is at most 5 calls. Queue depth is provisioned per workspace, like concurrency. If a campaign genuinely needs to submit more than 500 rows in one batch, ask your Mirai contact before the campaign, not during it. Keeping the queue short is still the better shape, because a queue you own is one you can reorder, cancel and re-prioritise. The simplest correct pattern: ```python theme={null} # keep N calls in flight; refill as terminal webhooks arrive in_flight = 0 MAX_IN_FLIGHT = 5 # your provisioned concurrency def on_terminal_webhook(event): global in_flight in_flight -= 1 pump() def pump(): global in_flight while in_flight < MAX_IN_FLIGHT and queue: place_call(queue.pop()) in_flight += 1 ``` Do not drive pacing off a fixed `sleep()`. Answer rates vary by hour, and a fixed delay either wastes capacity or hammers the limit. ## Payload and duration ceilings | Thing | Limit | | :------------------ | :-------------------------------- | | Request body | 1 MB | | `system_prompt` | 8,000 characters | | `first_message` | 500 characters | | `variables` | 32 keys, 512 characters per value | | `max_duration_secs` | 30–1800, default 300 | | `webhook_url` | HTTPS only, 2,048 characters | | Idempotency key | 255 characters, 24-hour window | ## Campaign ceilings [Campaigns](/v2/campaigns) carry their own bounds, all enforced at the API edge with `400 invalid_request`. | Thing | Limit | Notes | | :----------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------ | | `contacts` per request | **1000 rows** | Create and `POST /contacts` both. Call it repeatedly for a longer list — a running campaign accepts new rows. | | `slots` | 1–4 per campaign | `HH:MM` local. A zero-length slot is rejected, not read as "all day". | | `max_concurrent` | 1–50 | Bounded again by your workspace concurrency — two campaigns share one ceiling. | | `retry_count` | 0–5 | Retries after the first attempt. | | `re_attempt_period_secs` | 60–86,400 | 1 minute to 24 hours. | | `max_duration_secs` | 30–1800 | Defaults to the agent's. | | `budget_paise` | positive integer | Paise, not rupees. Omit for no cap. | | `name` | 120 characters | | | Contact `variables` | 32 keys, 512 chars per value | Same rule as call variables. | Bad contact rows do **not** fail the batch: they come back by index in `contacts_rejected`. Only exceeding 1000 rows fails the whole request. Long prompts are a latency problem before they are a limit problem. The transactional tier is tuned for prompts in the hundreds of tokens, not thousands. ## Timeouts | Stage | Behaviour | | :--------------- | :--------------------------------------------------------------------------------------------------------------------------- | | API request | Respond within 30 seconds or treat as failed and retry with the same `Idempotency-Key`. | | Ring / answer | \~60 seconds. No media by then → `no_answer`. | | Call duration | Ends at `max_duration_secs` with `status: timeout`. | | Webhook delivery | Your endpoint should answer well under a second. Slow responses are treated as failures and [retried](/v2/webhooks#retries). | ## Data retention | Data | Retained | | :----------------------------------- | :---------------------------- | | Call record (status, duration, cost) | 90 days | | Call recordings and transcripts | 90 days, then deleted | | Wallet ledger | 12 months | | Campaigns and their contact rows | For the life of the workspace | Recordings and transcripts are available through the API — [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) and [`/transcript`](/v2/calls#get-the-transcript). Pull anything you need to keep beyond 90 days into your own system, and tell us up front if your use case requires that we **not** record at all. *** ## India calling rules You are the sender. Under Indian telecom regulation the obligations for commercial voice calls sit with the business placing them, not with the platform carrying them. Reading this page is not legal advice, and it is not a substitute for your own compliance review. ### Calling window Commercial and promotional voice calls are restricted to daytime hours. The window applied in practice is **09:00–21:00 IST**, and several enterprise programmes tighten it further to 10:00–19:00. **On a [campaign](/v2/campaigns), the platform enforces the window you declare.** `timezone`, `start_date`/`end_date` and 1–4 `slots` are evaluated in local time on every dialling tick: outside the window the campaign sleeps in `play` and rings nobody. Declaring `10:00`–`19:00 Asia/Kolkata` is enough — you do not need your own scheduler in front of it. ```json theme={null} { "timezone": "Asia/Kolkata", "slots": [{ "start": "10:00", "end": "19:00" }] } ``` **On a single [`POST /v2/calls`](/v2/calls#create-a-call) there is no window.** A direct call is an explicit instruction to ring one number now, and it will dial at 02:00 IST if you ask it to. Gate those in your scheduler: ```python theme={null} from datetime import datetime from zoneinfo import ZoneInfo IST = ZoneInfo("Asia/Kolkata") def within_calling_window(now=None) -> bool: now = now or datetime.now(IST) return 9 <= now.hour < 21 ``` ### Consent and DNC/DND * **Get consent before you call.** Explicit, recorded, and revocable. Keep the record — it is what you produce when a complaint arrives. * **Push your opt-outs to [`POST /v2/dnc`](/v2/dnc).** That list is enforced by us: every campaign checks it at dial time, so a number added mid-campaign is suppressed from that moment, marked `suppressed` in the report, and never billed. It does not apply to single `POST /v2/calls` — those are explicit. * **Scrub against the DND registry** (TRAI's Do Not Disturb / NCPR list) as well. We do **not** scrub against the national register for you, and our suppression list is not a substitute for it. A number can register between two campaigns. * **Honour opt-outs immediately.** If a customer says "stop calling me" during a call, that is an opt-out — capture it and suppress the number, on every channel it applies to. * **Transactional vs promotional matters.** A delivery confirmation to an existing customer is treated differently from a marketing pitch to a cold list. Classify each campaign, and do not let a transactional pretext carry a promotional payload. * **Register as required.** Commercial communication under TCCCPR 2018 runs through registered entities, headers and consent templates. Confirm your registration status with your telecom provider or counsel. ### Disclosure that it is an AI Tell people. Put it in the agent's `first_message` or its opening turn: ```json theme={null} { "first_message": "नमस्ते {{customer_name}}, मैं Acme की AI असिस्टेंट Priya बोल रही हूँ।" } ``` This is the direction regulation is moving in worldwide, it costs you one clause, and in practice it *reduces* early hang-ups — callers who feel misled hang up harder than callers who were told. ### Recording If you record calls, say so in the opening turn and keep the recordings under the same retention and access controls as the rest of your customer data. ### What we do enforce | Rule | Enforced by us | | :--------------------------------------- | :------------------------------------------------------------- | | E.164 destination format | ✅ `400 invalid_request` | | Wallet balance before dialling | ✅ `402 insufficient_balance` | | Request rate | ✅ `429 rate_limited` | | Concurrency ceiling | ✅ over-cap calls wait in `queued` | | Queue depth | ✅ `429 rate_limited` past 500 outstanding | | Maximum call duration | ✅ `status: timeout` | | Campaign budget cap | ✅ campaign pauses, `pause_reason: budget_exhausted` | | Calling window — **campaigns** | ✅ `slots` + `timezone`, evaluated in local time every tick | | Calling window — single `POST /v2/calls` | ❌ your scheduler | | Your own opt-out list | ✅ [`/v2/dnc`](/v2/dnc), checked at dial time on every campaign | | National DND / NCPR scrubbing | ❌ your list | | Consent records | ❌ your records | | AI disclosure | ❌ your prompt | Persistent complaint patterns against a workspace will get its keys suspended. That is not a regulatory mechanism, it is ours — we would rather suspend one campaign than lose the numbering range for everyone on the platform. # Migrating from v1 Source: https://docs.miraiminds.co/v2/migration Endpoint-by-endpoint and field-by-field mapping from the v1 API to v2, and what has no v2 equivalent yet. **You do not have to migrate.** [v1](/v1/overview) is stable, hosted separately at `api.voice-agents.miraiminds.co`, and is not going away. Nothing in your integration changes because v2 exists. Migrate when you want one of these: * `GET` on a call — v1 has no way to read a call's status, only webhooks. * Wallet balance and ledger over the API. * One auth header instead of three. * Idempotent call creation. * The published tier rate card. * **[Campaigns](/v2/campaigns)** — a server-side dialler with calling windows, retries, budgets, do-not-call suppression and a per-contact report. Stay on v1 if you need: inbound calls, phone-number purchase, knowledge base/RAG, API tools (function calling), web calls, or post-call analysis. **v2 has none of these yet.** The two APIs can address the same workspace side by side — migrate calls first, keep the rest on v1. ## Hosts | | v1 | v2 | | :---------- | :--------------------------------------- | :------------------------------------ | | Base URL | `https://api.voice-agents.miraiminds.co` | `https://sandbox.voice.miraiminds.co` | | Path prefix | mixed `/v1/…` and `/v2/…` | `/v2/…` throughout | **The v1 host has `/v2/` paths on it** v1's call endpoints are literally named `POST /v2/call/initiate`. That `/v2/` is a path segment inside the **v1 product**, not this API. The distinguishing thing is the **host**, not the prefix. ## Authentication | v1 | v2 | | :------------------------------------ | :-------------------------------- | | `x-public-key: pk_…` | — | | `x-private-key: sk_…` | `Authorization: Bearer sk_live_…` | | `workspace: 6690a1b2c3d4e5f600000002` | — (the key is workspace-scoped) | Three headers become one. The workspace is implied by the key, so there is no way to accidentally address the wrong workspace with the right credentials. ## Endpoints | v1 | v2 | Notes | | :------------------------------------------------------------ | :---------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | | `POST /v2/workspace/onboard/custom` | — | Your workspace is provisioned with your key. | | `POST /v1/admin/assistant/create` | [`POST /v2/agents`](/v2/agents#create-an-agent) | "Assistant" is now "agent". | | `GET /v1/admin/assistant/get/{id}` | [`GET /v2/agents/{id}`](/v2/agents#get-an-agent) | | | `GET /v1/admin/assistant/list` | [`GET /v2/agents`](/v2/agents#list-agents) | Now cursor-paginated. | | `PUT /v1/admin/assistant/update/{id}` | [`PATCH /v2/agents/{id}`](/v2/agents#update-an-agent) | `PATCH`, and partial. | | — | [`DELETE /v2/agents/{id}`](/v2/agents#delete-an-agent) | New. Soft delete. | | `POST /v2/call/initiate` | [`POST /v2/calls`](/v2/calls#create-a-call) | `200` becomes `202`. | | — | [`GET /v2/calls/{id}`](/v2/calls#get-a-call) | **New.** v1 had no call read. | | — | [`GET /v2/calls`](/v2/calls#list-calls) | **New.** | | `POST /v2/call/abort` with `{callId}` in body | [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call) | ID moves into the path. | | `PUT /v2/call/{callId}` (update payload) | — | No equivalent. Set everything at create time. | | `POST /v2/call/web` | — | Web calls are v1-only. | | `GET /v1/number-pool/*` | — | [v1 Telephony](/v1/telephony). | | `/v1/admin/tool/api` (all methods) | — | [v1 Tools](/v1/tools). | | `/v1/knowledge-base/*` | — | [v1 Knowledge Base](/v1/knowledge-base). | | [`GET /v1/admin/voice-gallery`](/v1/assistants#voice-gallery) | — | Still v1. v2 `voice_id` values come from this same catalogue — see [Voices](/v2/voices). | | — | [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript) | **New.** Turn-by-turn, as spoken. | | — | [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) | **New.** `302` to a 15-minute signed URL. v1's `recordingUrl` has a v2 home at last. | | `callSettings.slots` + your own dialler | [`POST /v2/campaigns`](/v2/campaigns#create-a-campaign) and the rest of `/v2/campaigns/*` | **New.** The calling window, retries and pacing that lived in v1's assistant config are now a first-class resource. | | — | [`POST /v2/dnc`](/v2/dnc#add-a-number) | **New.** Workspace suppression list, enforced at dial time. | | — | [`GET /v2/wallet`](/v2/wallet#get-balance) | **New.** | | — | [`GET /v2/wallet/transactions`](/v2/wallet#list-transactions) | **New.** | ## Fields ### Creating an agent / assistant | v1 | v2 | | :----------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | `name` | `name` | | `agent.systemPrompt` | `system_prompt` | | — (first line lived in the prompt) | `first_message` | | `agent.identity.voice` | `voice.voice_id` | | `icpContext.language` (`"english"`) | `language` (BCP-47, `"en-IN"`) | | `callSettings.maxCallDuration` | `max_duration_secs` | | `variant.type` / `variant.config` | — (no variants; one shape) | | `variant.config.inputSchema` | — (declare nothing; just pass `variables`) | | `agent.tools` | — (v1 only) | | `telephony.inbound` / `telephony.outbound` | — (managed) | | `callSettings.slots` (calling window) | `slots` + `timezone` on a [campaign](/v2/campaigns#calling-window) (or [your scheduler](/v2/limits#calling-window) for single calls) | | `callSettings.concurrentCallCount` | `max_concurrent` on a [campaign](/v2/campaigns); the workspace ceiling is provisioned per key, see [Limits](/v2/limits) | | `callSettings.retryProtocol` | `retry_count` + `re_attempt_period_secs` on a [campaign](/v2/campaigns#retries) | | `analysisPlan` | — (`t3`/`t5`, [not live](/general/tiers)) | ### Placing a call | v1 | v2 | | :-------------------------------- | :------------------------------------------------------------------------ | | `phoneNumber` | `to` | | `assistant` | `agent_id` | | `callbackUrl` | `webhook_url` | | `payload` (variant-shaped object) | `variables` (flat string map) | | `metadata` (echoed in webhooks) | — no equivalent; put correlation keys in `variables` or key off `call.id` | | `priority` | — (use `tier`) | | — | `max_duration_secs` per-call override | | — | `tier` | | — | `Idempotency-Key` header | ```diff title="v1 → v2 call request" theme={null} -POST https://api.voice-agents.miraiminds.co/v2/call/initiate -x-public-key: pk_1234… -x-private-key: sk_abcd… -workspace: 6690a1b2c3d4e5f600000002 +POST https://sandbox.voice.miraiminds.co/v2/calls +Authorization: Bearer sk_live_YOUR_API_KEY +Idempotency-Key: order-8842-confirm-1 { - "phoneNumber": "+919876543210", - "assistant": "68c128a658cd7d0668bce78d", - "callbackUrl": "https://example.com/mirai/webhook", - "payload": { "customerName": "Rahul", "orderId": "8842" } + "to": "+919876543210", + "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", + "webhook_url": "https://example.com/mirai/webhook", + "variables": { "customer_name": "Rahul", "order_id": "8842" } } ``` ```diff title="v1 → v2 response" theme={null} -200 OK -{ "callId": "68f0…", "status": "initiate" } +202 Accepted +{ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" } ``` ## Statuses v1's status vocabulary is hyphenated and mixes phases with outcomes. v2 uses snake\_case and separates `status` from `ended_reason`. | v1 status | v2 `status` | | :---------------------------- | :--------------------------------------------------------------------------------------------------------------- | | `initiate` | `queued`, then `dialing` | | `in-progress` | `in_progress` | | `ended`, `completed` | `completed` | | `no-answer` | `no_answer` | | `busy` | `busy` | | `failed`, `validation-failed` | `failed` (a bad `to` is a `400` at create time in v2) | | `timeout` | `timeout` | | `aborted` | `aborted` | | `skip` | contact status [`suppressed`](/v2/campaigns#contact-statuses) inside a campaign; nothing for single calls | | `rescheduled` | contact status `pending` again, until [`retry_count`](/v2/campaigns#retries) runs out and it becomes `exhausted` | | — | `voicemail` (**new**) | ## Webhooks | | v1 | v2 | | :---------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------ | | Signature header | `x-signature` (bare hex) + `x-public-key` | `X-Mirai-Signature: t=…,v1=…` | | Signed material | raw body | `"."` | | Replay protection | none | 5-minute timestamp window | | Secret | organization private key | per-workspace `whsec_…` | | Event ID | none | `id: evt_`, dedupe on it | | Envelope | `{ event: { type, data } }` | `{ id, type, created_at, data }` | | Event count | 15 types incl. `call.initiate`, `call.ended`, `end-of-call`, `action` | 6 call types + 5 [`campaign.*`](/v2/campaigns#webhooks) types | | Retries | — | 1s / 10s / 60s / 5m / 30m, then DLQ | ```diff title="v1 → v2 webhook body" theme={null} { - "metadata": { "orderId": "8842" }, - "event": { - "type": "call.completed", - "data": { - "call": { - "id": "68f0…", - "status": "completed", - "durationSeconds": 96, - "recordingUrl": "https://…" - }, - "credits": { "used": 1.5, "available": 98.5 } - } - } + "id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB", + "type": "call.completed", + "created_at": "2026-07-26T09:16:38Z", + "data": { + "call": { + "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", + "status": "completed", + "tier": "t3", + "ended_reason": "assistant-ended-call", + "duration_secs": 96, + "cost_inr": 6 + } + } } ``` Note the differences that will bite: * **`metadata` is gone.** v1 echoed an arbitrary object back on every event. In v2, correlate on `data.call.id` — store the `call_id` we return from `POST /v2/calls` against your own record. * **`recordingUrl` is not on the event.** The audio is a separate, authenticated request — [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) — which redirects to a 15-minute signed URL. A permanent link in a webhook body is a recording anybody who ever saw that body can still fetch. * **`credits` becomes `cost`** on the call itself, in rupees rather than credit units, and the running balance moves to [`GET /v2/wallet`](/v2/wallet). * **No `end-of-call` analysis event yet.** Post-call analytics (AI summary + QA score on every call) is [part of every tier](/general/tiers#feature-matrix) and its v2 API surface is rolling out — see the [roadmap](/general/roadmap). Until it lands, v1's `analysisPlan` is the only API surface for post-call analysis. ## Errors | v1 | v2 | | :--------------------------------------------------------------- | :------------------------------------------------------------------- | | `{ "code": 400, "message": "…" }` | `{ "error": { "code": "invalid_request", "message": "…" } }` | | `{ "status_code": 200, "message": "…", "data": {…} }` on success | the resource object directly | | `code` is a number | `error.code` is a `snake_case` string; the number is the HTTP status | v1's success responses wrap the payload in `{ status_code, message, data }`. v2 returns the resource itself. See the [error reference](/v2/errors). ## A migration that fits in an afternoon 1. Get a v2 key. Keep your v1 credentials — you will run both for a while. 2. Recreate your assistants as [agents](/v2/agents). Move the opening line out of `systemPrompt` into `first_message`; rename your `payload` keys to the `variables` keys your prompt references. 3. Point a **new** webhook route at the v2 [signature scheme](/v2/webhooks#signature-verification). Do not modify the v1 route — the schemes are incompatible and you need both live during cutover. 4. Switch one low-volume campaign to `POST /v2/calls`. Compare outcomes against the same campaign's v1 numbers for a day. 5. Move the rest. Leave numbers, knowledge base and tools on v1. # API overview Source: https://docs.miraiminds.co/v2/overview REST API for placing AI voice calls. One auth header, predictable envelopes, server-minted IDs. Four resources — agents, calls, campaigns and your do-not-call list — one auth header, and the same error envelope everywhere. ```bash theme={null} https://sandbox.voice.miraiminds.co ``` Key → agent → call → webhook in five minutes. [Start here](/v2/quickstart). The reusable configuration a call runs. [Reference](/v2/agents). Place, inspect and abort outbound calls; read transcripts and recordings. [Reference](/v2/calls). Dial a whole list — windows, retries, budgets, reports. [Reference](/v2/campaigns). Your suppression list, enforced at dial time. [Reference](/v2/dnc). Signed lifecycle events. [Guide](/v2/webhooks). ## Authentication Every request carries one header: ```bash theme={null} Authorization: Bearer sk_live_YOUR_API_KEY ``` A secret key is `sk_live_` followed by 32 hex characters. It is scoped to one workspace and carries your default [tier](/general/tiers) — `t3`, ₹3 a minute, unless you asked for something else. Create, rotate and revoke keys yourself in the [console](https://sandbox.voice.miraiminds.co) under **Developers**. We store only the SHA-256 of your key, so a lost key cannot be recovered — it is rotated. Never ship a secret key to a browser or a mobile app. | Situation | Status | `error.code` | | :---------------------------- | :----- | :------------- | | Header missing or key unknown | `401` | `unauthorized` | | Key revoked | `403` | `forbidden` | ## Conventions **JSON only.** Send `Content-Type: application/json` on every request with a body. Responses are always JSON, including errors. **IDs are server-minted** and prefixed by resource type. Treat them as opaque strings — do not parse them. | Resource | Format | Example | | :------------ | :------------ | :-------------------------------- | | Agent | `agt_` | `agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ` | | Call | `call_` | `call_01K7Q9B4M8N3P6R2S5T7V9W1YA` | | Campaign | `cmp_` | `cmp_01K7QAC5N9P4R7S3T6V8W2X4YB` | | Webhook event | `evt_` | `evt_01K7QC5N9P4R7S3T6V8W2X4YB` | **Errors** always use one envelope. See the full [error reference](/v2/errors). ```json theme={null} { "error": { "code": "insufficient_balance", "message": "wallet balance 0.40 INR is below the minimum for one minute at t3" } } ``` **Timestamps** are RFC 3339 in UTC: `2026-07-26T09:14:02Z`. **Money** is INR — `cost_inr`, `balance_inr`, `amount_inr` are numbers, not strings. The one exception is [campaigns](/v2/campaigns), whose `budget_paise` and `spent_paise` are integer **paise** (100 paise = ₹1). Nothing mixes the two units inside one object. **Phone numbers** are E.164 with the leading `+`: `+919876543210`. ## Pagination List endpoints take `limit` and `cursor` and return a cursor-paginated envelope. ```bash theme={null} curl "https://sandbox.voice.miraiminds.co/v2/calls?limit=50&cursor=call_01JZQ9B4M8N3P6R2S5T7V9W1YA" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json theme={null} { "data": [ { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "…": "…" } ], "has_more": true, "next_cursor": "call_01JZQ9B4M8N3P6R2S5T7V9W1YB" } ``` Pass `next_cursor` back as `cursor` to get the following page. Stop when `has_more` is `false`. Default `limit` is 20, maximum 100. ## Idempotency `POST /v2/calls` accepts an `Idempotency-Key` header. Reuse the key and you get the original response replayed instead of a second phone call. Keys are held for 24 hours. ```bash theme={null} -H "Idempotency-Key: order-8842-reminder-1" ``` Use something derived from your own domain object (an order ID, a job ID) rather than a random UUID per attempt — the point is that your retry produces the same key. See [Calls](/v2/calls#idempotency). ## Not built yet Outbound calling is what this API does. These are known gaps, listed so you find out here rather than halfway through a build: * **Inbound calls.** Outbound only. * **Buying phone numbers.** We provide the line. * **Knowledge base / RAG** and **function calling** during a call. * **Self-serve wallet top-up** — see the [roadmap](/general/roadmap). * **OAuth or user-scoped tokens.** Secret keys only. If one of these is in your way, say so — several are on the [roadmap](/general/roadmap) and the order is not fixed. # Prompting guide Source: https://docs.miraiminds.co/v2/prompting-guide How to write a system prompt and opening line for a voice agent that sounds like a person on an Indian phone call — structure, language rules, numbers, endings, and the test loop. A voice prompt is not a chatbot persona. It is a script for a two-minute phone call: one job, one caller, one ending. Everything the model writes is spoken out loud, at speaking speed, to someone holding a phone to their ear — so length is a cost, ambiguity is a hang-up, and a paragraph is a monologue. The method is the same every time. **Design** the call as a structure, **test** it against a caller who says no, **refine** one line at a time. This page is the mechanics; the thinking behind it is on [The Zen of Voice Agents](/v2/zen-of-voice-agents). ## Why voice is not chat Three constraints. Everything below follows from them. **The caller cannot scroll, and cannot re-read.** A sentence they missed is gone. Anything that matters gets said once, short, and gets read back. **A long reply is the thing they wait through.** They hear every word before they can answer. Reply length is the single biggest latency lever you own — bigger than anything in the stack. Cap it in the prompt, in words the model cannot round up: "one short sentence, maximum two". **Turn-taking replaces reading.** In chat you can ask three things in one message and let the reader sort it out. On a call, two questions in one turn get one answer and you will not know which. One ask, then stop. ## What the engine already does A lot of what people write into voice prompts is already handled below the prompt. Writing it again does not make it truer; it just spends turns and tokens. * **The opening line runs before the model does.** `first_message` is spoken the moment the callee answers. It is your first-audio latency. The model is told what was said, so it does not greet twice. * **Refusals already end the call.** When `end_call.enabled` is true (the default), the engine appends an ending rule to every prompt: if the caller refuses or asks to end — "नहीं चाहिए", "बात नहीं करनी", "रखता हूं", "call खत्म करो" — the agent does not re-sell, says one farewell sentence and calls `end_call`. Same on a second refusal. You do not write this. * **A bare "ok" does not hang up.** With [`end_call.confirm: true`](/v2/agents#ending-a-call) (the default), the first `end_call` inside the window asks the caller to confirm; the second one ends the call. * **Barge-in is automatic.** The caller can talk over the agent — after two words of caller speech the agent stops and listens. If they barge in without actual words, the agent says "हाँ, बोलिए ना, क्या बात है?". * **Silence is handled.** If the caller goes quiet, the agent checks in after about 15 seconds; after three unanswered check-ins the call ends. Nothing to write. * **Voicemail is detected** by the engine and handled by the agent's [`voicemail`](/v2/agents#voicemail) setting. Nothing to write. * **Per-call data is substituted for you.** `{{placeholders}}` in both `system_prompt` and `first_message` are filled from the call's `variables`. So do not write any of that into the prompt. Write the job. **The agent's only tool today is `end_call`.** No transfer to a human, no SMS, WhatsApp or email, no CRM lookup, no calendar, no payment link — nothing that fetches or sends mid-call. A prompt must therefore never promise "I'll transfer you", "I'll send you the link" or "I'm booking it in the system". The honest line is "the team will call you back" / "आपको team से call आएगा". Mid-call data and branching flows are the node-graph runtime — see [tiers](/general/tiers). ## The structure Every house template follows one discipline. Six blocks, in this order, nothing else: ```text theme={null} ROLE who the agent is, for whom, calling why — one sentence FACTS the specific, verifiable things it may say (edit these) CALL FLOW numbered steps; one ask per step; never assume what wasn't said IF THEY PUSH BACK one light nudge, then accept and close EXAMPLE EXCHANGE 2–4 turns showing the hard moment done right LANGUAGE RULES + CONVERSATION STYLE the fixed block, pasted verbatim ``` **ROLE** is one sentence: name, company, and the reason for the call. Not a character study. "You are मीरा, a polite female voice agent calling from Mirai Finance to remind the customer their EMI is due and capture a clear payment commitment" is a complete role. **FACTS** is the boundary of what the agent may say. Amounts, dates, policy, what it cannot do. If a claim is not in FACTS the agent does not make it — the answer is that the team will call back. This is the block your customer edits. **CALL FLOW** is numbered steps, one ask per step, in the order a person would ask them. Steps are instructions to the agent, not lines to read aloud. **IF THEY PUSH BACK** is one nudge and then acceptance. Not three. The engine already stops a hard refusal; this block is for the soft "not right now". **EXAMPLE EXCHANGE** is two to four turns of the hardest moment done right. Models copy tone from examples far more reliably than from adjectives — two good turns beat a paragraph about being warm and professional. **LANGUAGE RULES + CONVERSATION STYLE** is fixed. Paste it verbatim at the end of every prompt. It is the block below. ## The opening line `first_message` is spoken before the model runs, so it is pure first-audio latency and it should be short: name, brand, person check. Nothing else. ```text theme={null} नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है? ``` The pitch does not go in the opener. The model already knows what was said and will carry on from there. One thing catches everybody: **Hindi verbs are gendered, and the verb has to match the voice.** | `voice_id` | Voice | Say | | :--------- | :----- | :---------------- | | `ashu` | male | मैं … बोल रहा हूं | | `aishe` | female | मैं … बोल रही हूं | The console renders this for you when you pick a template. The API does not — if you switch `voice_id` on an existing agent, fix the opening line in the same request. ## Language rules for Hinglish Real Indian phone calls are not Hindi and are not English. They are Hindi grammar carrying English nouns, and an agent that picks one pure language sounds like a recording. This block is what the console appends to every template. Paste it at the end of your prompt, unchanged: ```text theme={null} LANGUAGE RULES — keep these; they make the agent sound natural - Everyday Hinglish: Hindi words in Devanagari script, NEVER romanized Hindi ('aap kab tak kar payenge' is WRONG — write 'आप कब तक कर पाएंगे'). - Everyday English words stay in Roman script: payment, EMI, order, address, delivery, challan, invoice, penalty, policy, service, slot. - No formal shuddh Hindi (भुगतान, देय, राशि, पुष्टि). - Every number is English words in Roman script — never digits, never Hindi numbers ('pandra' is WRONG, 'fifteen' is right). Identifiers digit-by-digit: 'three nine five zero zero seven'. - When the customer states or corrects any number — flat, pincode, phone, amount — read it back digit-by-digit and confirm (e.g. 'आपका pincode है four zero zero zero five three, सही है?'). If it sounds unclear or incomplete, do NOT guess — ask them to repeat it slowly. CONVERSATION STYLE - One short sentence, maximum two. - धन्यवाद appears exactly ONCE in the whole call — only in the final goodbye. - After a clear promise or confirmation: repeat it back, say goodbye, end the call. - Never invent policy or waive penalties; unknown questions → the team will call back. ``` Why each rule is there: * **Devanagari for Hindi words.** Romanized Hindi in the prompt gets read as English. "kar payenge" comes out with English vowels and the caller hears an accent, not a person. * **Roman script for English words.** Nobody on a phone call says भुगतान. They say payment. Writing the English word in Roman is not laziness — it is how the sentence is actually spoken. * **No shuddh Hindi.** Formal Hindi is the register of a government notice, not a phone call. It reads as a recording even when the audio is perfect. * **Numbers as English words.** The voice reads English number words most reliably. Digits and Hindi number words are read inconsistently. * **धन्यवाद once.** Models over-thank. Thanking four times in ninety seconds is the clearest tell that nobody is there. **For English calls** (`language: "en-IN"`) the shape is identical — drop the Devanagari rule and keep everything else. Numbers still go in as words, and identifiers are still read digit by digit. ## Numbers, dates, amounts and identifiers The model copies what you write straight into speech. Write for the ear. | Written | Put it in the prompt as | | :-------------- | :------------------------------------------------------------------------------ | | ₹5,200 | `five thousand two hundred rupees` | | 15/07 | `fifteen July` | | 2:15 PM | `two fifteen in the afternoon` — or `दोपहर two fifteen` | | Pincode 395007 | `three nine five zero zero seven` | | +91 98765 43210 | digit by digit, in groups: `nine eight seven six five, four three two one zero` | | MH02AB1234 | `MH zero two AB one two three four` | Amounts and quantities are natural words; quantities can sit inline in a Hindi sentence ("दो items"). Identifiers — pincode, phone, order id, vehicle number, challan number — are always digit by digit, never grouped into "ninety-eight thousand". **Digit-count sanity.** An Indian pincode is six digits. A mobile number is ten. If a read-back is short, the recogniser dropped digits — the agent must push back, never accept: ```text theme={null} मुझे सिर्फ three digits मिले, दोबारा बोलिए ``` **The read-back protocol** is three moves, and it belongs in every flow that touches a number: the caller states it → the agent reads it back digit by digit → the agent asks "सही है?". Nothing gets written down until the caller confirms. ## Facts, and the customer FACTS is a contract. The agent may say what is in it and nothing more. Anything outside — a policy question, an exception, a price you did not list — is answered with "the team will call you back". Never invent policy, never waive a penalty, never quote a number that is not in FACTS. **Per-call data goes in `variables`, not in the prompt.** One agent runs thousands of calls; the customer's name, amount and date change every time. Hard-coding them means one agent per customer, and nobody wants to manage that. ```json theme={null} { "variables": { "customer_name": "राहुल", "emi_amount": "five thousand two hundred", "due_date": "five July" } } ``` Notice that those values are already in spoken form. That is deliberate: the model copies a variable's value verbatim into speech, so format it for the ear at the point you build the map, not in the prompt. `5200` is a gamble; `five thousand two hundred` is not. A few properties worth knowing: * Names are matched loosely. `{{Customer Name}}`, `{{customer_name}}` and `{{CustomerName}}` are the same variable — letters and digits only, case-insensitive. * An unknown placeholder in the system prompt becomes an empty string. It does not error, and it does not leave braces for the agent to read aloud. * Values are plain strings, and braces inside a value are stripped. * Variables are static per call. There is no way to change one mid-call. * Keep them short. They are prompt tokens on every turn. **Tell the agent what day it is.** Without it the model does not know, and "tomorrow" or "this Friday" becomes a guess. The prompt supports Liquid filters, so put the real date in at call time, in IST: ```text theme={null} Today is {{ "now" | date: "%A, %d %B %Y", "Asia/Kolkata" }}. ``` With the clock too, when the script mentions a time window: ```text theme={null} {{ "now" | date: "%A, %d %B %Y, %I:%M %p", "Asia/Kolkata" }} ``` ## The flow * **One ask per step.** Two questions in one turn get one answer, and you will not know which one it was. * **Never assume what was not clearly said.** If the caller mumbled a date, the agent asks again. A confidently wrong confirmation is worse than a second question. * **Confirm by exception.** Read back only what changed, and do one consolidated read-back at the end. Confirming every field turns a ninety-second call into three minutes. * **Listen when they are dictating.** While the caller reads out an address, the agent's job is backchannels — "जी", "हम्म" — and then one read-back at the end. Interrupting a dictated address to confirm line one is how you lose the rest of it. * **Mishear recovery is surgical.** Re-ask the one field that was wrong. Never restart the flow. * **Answer, then return.** If the caller derails with a question, answer it in one line from FACTS and go straight back to the step you were on. * **Every turn moves forward.** Acknowledge and ask the next thing in one utterance. A turn that only says "ठीक है" costs the caller time and buys nothing. ## Pushback and endings One light nudge, then accept. The engine already enforces the refusal ending, so your job is the **success** ending — and it is the line people forget: ```text theme={null} When the payment date is confirmed, repeat it back in one line, say goodbye, and end the call. ``` Without it the model keeps talking. It has nothing telling it the job is done. "No" is an outcome, not a failure. Capture it and close cleanly — a refusal that ends in twenty seconds is a good call. The close is three moves: a one-line summary of what was agreed, a goodbye, then `end_call`. Say धन्यवाद once, in that goodbye. Leave [`end_call.message`](/v2/agents#ending-a-call) empty — the default — and the model's own goodbye is the farewell. If you do set a message, it is spoken *after* the model's goodbye, so it must not repeat it: a धन्यवाद in both means the caller hears it twice. Finally, size the call. `max_duration_secs` is a hard cap, and hitting it ends the call with `ended_reason: exceeded-max-duration` mid-sentence. For a one-job call, 180–300 seconds is right. The default is 300; the range is 30–1800. ## Sounding like a person * **Lead word up front.** "जी।", "अच्छा—", "ठीक है।" at the start of a turn does two things: the voice starts sooner, and it sounds like somebody who was listening rather than a system that was processing. * **Punctuation is your only expressiveness control.** Interjections and emphasis at the moments that carry them — an apology, a pushback — and flat everywhere else. Expressiveness everywhere is the same as expressiveness nowhere. * **No stage directions, no tags, no emoji, no markdown.** The voice reads the text it is given. Asterisks, bullet characters and `(warmly)` come out as noise. * **Match the caller's register.** If they answer in English, the agent continues in English. If they switch to Hindi mid-call, so does it. * **Contractions and everyday words.** Write the sentence you would say out loud, not the one you would put in an email. ## What not to write | Anti-pattern | What happens | Write instead | | :-------------------------------------------------------------------------- | :------------------------------------------------------------------------ | :------------------------------------------------------------- | | A persona paragraph — "you are a friendly, empathetic AI assistant" | Adjectives do not survive contact with a call. The model still runs long. | One ROLE sentence, and an EXAMPLE EXCHANGE that shows the tone | | Digits and currency symbols: `₹5,200`, `15/07` | Read inconsistently, sometimes as separate digits | `five thousand two hundred rupees`, `fifteen July` | | Romanized Hindi: `aap kab tak kar payenge` | Read with English vowels; sounds like an accent, not a person | Devanagari: `आप कब तक कर पाएंगे` | | Shuddh Hindi: भुगतान, देय, राशि | The register of a notice, not a call | The words people use: payment, due date | | Two questions in one turn | One answer, and you cannot tell which question it belongs to | One ask per step | | Multi-branch scripts — "if they say X, ask the following seven questions" | The model loses its place and the call sprawls | A linear flow, plus one IF THEY PUSH BACK block | | "I'll transfer you" / "I'll send the link" / "I'm booking it in the system" | The agent has no tool but `end_call`. It is a promise nobody keeps. | "the team will call you back" / "आपको team से call आएगा" | | Customer data hard-coded: "Rahul's EMI is 5200" | One agent per customer, forever | `{{customer_name}}`, `{{emi_amount}}` in `variables` | | A long list of forbidden phrases | Naming a phrase primes it. Listing what not to say teaches it to say it. | The positive rule: "one short sentence, maximum two" | | "Wait for the user to finish speaking" | The engine owns turn-taking. The prompt controls what is said, not when. | Nothing — delete the line | | "Remind them about last week's call" | There is no memory across calls | Pass the fact in `variables` | | A 600-word FACTS block | Re-read on every turn, and the model picks the wrong fact | The ten facts this call actually needs | | Emoji, markdown, stage directions | Spoken aloud as noise | Plain sentences | | An English prompt for a Hindi call | Code-switches badly and drifts back to English | Write the prompt in the language the call will be in | ## Test, then refine Every real phone call costs money — there is no test key — so the loop is designed to find problems in the cheapest place first. Agent Studio in the [console](https://sandbox.voice.miraiminds.co) has six house scripts in the template dropdown — Govt · Traffic challan, BFSI · EMI reminder, BFSI · GSTR-1 filing, D2C · Order confirmation, D2C · Service reminder, Hospitality · Booking confirmation. All six are already in the structure above. If none of them is close, use **Describe the call and we draft it → Generate script**: a one-line brief comes back as a house-style prompt and opening line to edit. The web phone in the right rail runs the agent without dialling a phone. Play the customer you are afraid of: say no, correct a digit half-way through, ask something that is not in FACTS, then go quiet and see what happens. Most prompt bugs die here, for free. ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/transcript \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` Turns come back as they were spoken, nothing translated — see [Get the transcript](/v2/calls#get-the-transcript). Read where it went wrong, change one line, run it again. Changing three lines at once tells you nothing about which one worked. The phone line is a different acoustic world from a browser mic: carrier codecs, background noise, a caller who is walking. Prompts that pass on the web phone and fail on a real handset are common, and they fail on numbers first. Twenty to fifty contacts. Read the transcripts and the campaign report before you scale — see [Campaigns](/v2/campaigns). Fix what you find, then go wide. Measure by outcome, not vibe. Did the call reach a defined end state — confirmed, refused, callback? "It sounded nice" is not a metric, and a warm agent that never gets a commitment is a failed call. ## A worked example The EMI reminder, rendered for the `aishe` voice with the agent named मीरा. `system_prompt`: ```text theme={null} ROLE You are मीरा, a polite female voice agent calling from Mirai Finance to remind the customer their EMI is due and capture a clear payment commitment. FACTS — edit these for your use case - Customer: {{customer_name}} - EMI: {{emi_amount}} rupees, due {{due_date}} - After {{due_date}}: late payment penalty may apply - Payment: Mirai Finance app or website - You cannot extend the due date or waive any penalty. CALL FLOW 1. Confirm the person. 2. State the EMI amount and due date. 3. Use the penalty hook gently; ask when they will pay. 4. Never assume a date they did not clearly say; read the promised date back. 5. Close. IF THEY REFUSE OR CAN'T COMMIT One light nudge about the penalty, then accept and note that the team will follow up. EXAMPLE EXCHANGE [User] थोड़ा टाइम मिलेगा क्या? [You] {{customer_name}} जी, {{due_date}} के बाद payment करने पर late payment penalty लग सकती है, आप कब तक payment कर पाएंगे? [User] मैं दस जुलाई तक कर दूंगा। [You] ठीक है {{customer_name}} जी, आपने बताया कि आप ten July तक payment कर देंगे, मैंने note कर लिया है. धन्यवाद, आपका दिन शुभ हो! ``` The last line is where the fixed block from [Language rules](#language-rules-for-hinglish) gets pasted, verbatim. `first_message`: ```text theme={null} नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है? ``` ### Create the agent ```json title="emi-agent.json" theme={null} { "name": "EMI reminder", "system_prompt": "ROLE\nYou are मीरा, a polite female voice agent calling from Mirai Finance to remind the customer their EMI is due and capture a clear payment commitment.\n\nFACTS — edit these for your use case\n- Customer: {{customer_name}}\n- EMI: {{emi_amount}} rupees, due {{due_date}}\n- After {{due_date}}: late payment penalty may apply\n- Payment: Mirai Finance app or website\n- You cannot extend the due date or waive any penalty.\n\nCALL FLOW\n1. Confirm the person.\n2. State the EMI amount and due date.\n3. Use the penalty hook gently; ask when they will pay.\n4. Never assume a date they did not clearly say; read the promised date back.\n5. Close.\n\nIF THEY REFUSE OR CAN'T COMMIT\nOne light nudge about the penalty, then accept and note that the team will follow up.\n\nEXAMPLE EXCHANGE\n[User] थोड़ा टाइम मिलेगा क्या?\n[You] {{customer_name}} जी, {{due_date}} के बाद payment करने पर late payment penalty लग सकती है, आप कब तक payment कर पाएंगे?\n[User] मैं दस जुलाई तक कर दूंगा।\n[You] ठीक है {{customer_name}} जी, आपने बताया कि आप ten July तक payment कर देंगे, मैंने note कर लिया है. धन्यवाद, आपका दिन शुभ हो!\n\n", "first_message": "नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?", "voice": { "voice_id": "aishe", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 240, "voicemail": { "action": "hangup" } } ``` ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d @emi-agent.json ``` ```python theme={null} import os, httpx API = "https://sandbox.voice.miraiminds.co" auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"} SYSTEM_PROMPT = open("emi_prompt.txt", encoding="utf-8").read() agent = httpx.post( f"{API}/v2/agents", headers=auth, json={ "name": "EMI reminder", "system_prompt": SYSTEM_PROMPT, "first_message": "नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?", "voice": {"voice_id": "aishe", "language": "hi-IN"}, "language": "hi-IN", "max_duration_secs": 240, "voicemail": {"action": "hangup"}, }, timeout=30, ).raise_for_status().json() print(agent["id"]) ``` ```javascript theme={null} import { readFileSync } from "node:fs"; const API = "https://sandbox.voice.miraiminds.co"; const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` }; const systemPrompt = readFileSync("emi_prompt.txt", "utf8"); const res = await fetch(`${API}/v2/agents`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ name: "EMI reminder", system_prompt: systemPrompt, first_message: "नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?", voice: { voice_id: "aishe", language: "hi-IN" }, language: "hi-IN", max_duration_secs: 240, voicemail: { action: "hangup" }, }), }); if (!res.ok) throw new Error(JSON.stringify(await res.json())); const agent = await res.json(); ``` Keep the prompt in a file next to your code and read it in — it is a script, it will change often, and it does not belong inline in a JSON literal. `end_call` is left at its defaults on purpose: confirmation on, no canned farewell, so the धन्यवाद in the EXAMPLE EXCHANGE is the only one the caller hears. ### Place the call The variable values are already in spoken form. That is the point. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: emi-88213-jul-1" \ -d '{ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "variables": { "customer_name": "राहुल", "emi_amount": "five thousand two hundred", "due_date": "five July" }, "webhook_url": "https://example.com/mirai/webhook" }' ``` ## Copy-paste skeleton Fill the angle brackets, keep everything else. ```text theme={null} ROLE You are , a polite voice agent calling from to . FACTS — edit these for your use case - Customer: {{customer_name}} - - - - You cannot . Today is {{ "now" | date: "%A, %d %B %Y", "Asia/Kolkata" }}. CALL FLOW 1. Confirm the person. 2. 3. 4. Never assume anything they did not clearly say; read it back digit-by-digit and confirm. 5. When is confirmed, summarise it in one line, say goodbye, and end the call. IF THEY PUSH BACK One light nudge about , then accept and note that the team will follow up. EXAMPLE EXCHANGE [User] [You] [User] [You] LANGUAGE RULES — keep these; they make the agent sound natural - Everyday Hinglish: Hindi words in Devanagari script, NEVER romanized Hindi ('aap kab tak kar payenge' is WRONG — write 'आप कब तक कर पाएंगे'). - Everyday English words stay in Roman script: payment, EMI, order, address, delivery, challan, invoice, penalty, policy, service, slot. - No formal shuddh Hindi (भुगतान, देय, राशि, पुष्टि). - Every number is English words in Roman script — never digits, never Hindi numbers ('pandra' is WRONG, 'fifteen' is right). Identifiers digit-by-digit: 'three nine five zero zero seven'. - When the customer states or corrects any number — flat, pincode, phone, amount — read it back digit-by-digit and confirm (e.g. 'आपका pincode है four zero zero zero five three, सही है?'). If it sounds unclear or incomplete, do NOT guess — ask them to repeat it slowly. CONVERSATION STYLE - One short sentence, maximum two. - धन्यवाद appears exactly ONCE in the whole call — only in the final goodbye. - After a clear promise or confirmation: repeat it back, say goodbye, end the call. - Never invent policy or waive penalties; unknown questions → the team will call back. ``` *** That is the whole method: one job, six blocks, numbers as words, one ending you wrote yourself. When a prompt still is not landing and the structure is right, the problem is usually a belief about what a call *is* — that is [The Zen of Voice Agents](/v2/zen-of-voice-agents). # Quickstart Source: https://docs.miraiminds.co/v2/quickstart Get a key, create an agent, place a real call and receive the webhook — in five minutes. Five minutes, four requests, one real phone call. Calls bill at your key's [tier](/general/tiers) — `t3`, ₹3 a minute, unless you ask for `t1`. Partner? [**Start building**](/v2/start-building) is the longer version of this page: the same first call, plus your first campaign and the go-live checklist. **You need:** a secret key, a phone number you are allowed to call, and a public HTTPS URL for webhooks (ngrok is fine while you build). Open the [console](https://sandbox.voice.miraiminds.co) and go to **Developers → Create key**. You get three things on the spot: | | Looks like | Used for | | :---------------------- | :--------------------------------------- | :--------------------------- | | Secret key | `sk_live_YOUR_API_KEY` | every API request | | Webhook secret | `whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8` | verifying events we send you | | Starting wallet balance | `₹500` | pays for call minutes | **The key is shown once.** We store only its SHA-256, so a lost key is rotated, never recovered. Put both secrets in your secret manager, not in git. Rotate and revoke from the same page. Rotating issues the replacement before it revokes the old key, so there is no window where neither works — deploy the new one, then revoke. Check it works: ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/wallet \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json theme={null} { "balance_inr": 500, "currency": "INR", "updated_at": "2026-07-26T09:12:44Z" } ``` An agent is the reusable configuration a call runs: prompt, opening line, voice, language, limits. Create it once, call it thousands of times. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Confirmation", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} and ask whether the delivery address is unchanged. Keep replies to one or two short sentences. When the customer is done, thank them and end the call.", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300 }' ``` ```python theme={null} import os, httpx API = "https://sandbox.voice.miraiminds.co" auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"} agent = httpx.post( f"{API}/v2/agents", headers=auth, json={ "name": "Order Confirmation", "system_prompt": ( "You are Priya from Acme. Confirm order {{order_id}} with " "{{customer_name}} and ask whether the delivery address is " "unchanged. Keep replies to one or two short sentences. When " "the customer is done, thank them and end the call." ), "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", "voice": {"voice_id": "ashutosh", "language": "hi-IN"}, "language": "hi-IN", "max_duration_secs": 300, }, timeout=30, ).raise_for_status().json() print(agent["id"]) # agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ ``` ```javascript theme={null} const API = "https://sandbox.voice.miraiminds.co"; const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` }; const res = await fetch(`${API}/v2/agents`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Order Confirmation", system_prompt: "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} " + "and ask whether the delivery address is unchanged. Keep replies to one or " + "two short sentences. When the customer is done, thank them and end the call.", first_message: "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।", voice: { voice_id: "ashutosh", language: "hi-IN" }, language: "hi-IN", max_duration_secs: 300, }), }); if (!res.ok) throw new Error(JSON.stringify(await res.json())); const agent = await res.json(); console.log(agent.id); // agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ ``` ```json title="201 Created" theme={null} { "id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "object": "agent", "name": "Order Confirmation", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300, "created_at": "2026-07-26T09:14:02Z" } ``` Keep that `id`. That prompt is the minimum that works. Before you run it at volume, read the [prompting guide](/v2/prompting-guide) — the structure, the Hinglish rules, and how to end a call cleanly. `variables` fill the `{{placeholders}}` in `system_prompt` and `first_message`. `webhook_url` is where lifecycle events land. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-8842-confirm-1" \ -d '{ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t3", "variables": { "customer_name": "Rahul", "order_id": "8842" }, "webhook_url": "https://example.com/mirai/webhook" }' ``` ```python theme={null} call = httpx.post( f"{API}/v2/calls", headers={**auth, "Idempotency-Key": "order-8842-confirm-1"}, json={ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t3", "variables": {"customer_name": "Rahul", "order_id": "8842"}, "webhook_url": "https://example.com/mirai/webhook", }, timeout=30, ).raise_for_status().json() print(call["id"], call["status"]) # call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued ``` ```javascript theme={null} const res = await fetch(`${API}/v2/calls`, { method: "POST", headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": "order-8842-confirm-1", }, body: JSON.stringify({ agent_id: "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", to: "+919876543210", tier: "t3", variables: { customer_name: "Rahul", order_id: "8842" }, webhook_url: "https://example.com/mirai/webhook", }), }); const call = await res.json(); console.log(call.id, call.status); // call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued ``` ```json title="202 Accepted" theme={null} { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" } ``` `202` means accepted, not connected — the phone has not rung yet. Poll [`GET /v2/calls/{id}`](/v2/calls#get-a-call) or, better, wait for the webhook. We POST a signed JSON event to your `webhook_url` at each lifecycle transition. **Verify the signature before you trust the body.** ```python theme={null} import hmac, hashlib, time from flask import Flask, request app = Flask(__name__) WHSEC = "whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8" TOLERANCE_SECS = 300 def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False if abs(time.time() - int(t)) > TOLERANCE_SECS: return False expected = hmac.new( secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/mirai/webhook") def hook(): if not verify(request.get_data(), request.headers.get("X-Mirai-Signature", ""), WHSEC): return "", 401 event = request.get_json() print(event["type"], event["data"]["call"]["status"]) return "", 200 # 2xx stops our retries ``` ```javascript theme={null} import express from "express"; import crypto from "node:crypto"; const app = express(); const WHSEC = "whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8"; const TOLERANCE_SECS = 300; function verify(rawBody, header, secret) { const parts = Object.fromEntries( header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }) ); const { t, v1 } = parts; if (!t || !v1) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // raw body is required — a re-serialized JSON object will not match app.post("/mirai/webhook", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.body, req.get("X-Mirai-Signature") ?? "", WHSEC)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString("utf8")); console.log(event.type, event.data.call.status); res.sendStatus(200); // 2xx stops our retries }); app.listen(3000); ``` The final event for a connected call: ```json title="POST https://example.com/mirai/webhook" theme={null} { "id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB", "type": "call.completed", "created_at": "2026-07-26T09:16:38Z", "data": { "call": { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "status": "completed", "tier": "t3", "ended_reason": "assistant-ended-call", "started_at": "2026-07-26T09:15:02Z", "ended_at": "2026-07-26T09:16:38Z", "duration_secs": 96, "cost_inr": 6 } } } ``` 96 seconds is billed as 2 minutes — ₹6.00 at `t3`. [Minutes are whole and rounded up](/general/tiers#metering), with a one-minute minimum. ## Next * **Python?** `pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice` gives you the same four requests as `mirai.agents.create` / `mirai.calls.create`, with idempotency, retries and signature verification already handled. * [Campaigns](/v2/campaigns) — upload a list and let the platform dial it, with windows, retries, budgets and a report. * [Webhooks](/v2/webhooks) — every event type, retries, replay protection. * [Calls](/v2/calls) — statuses, `ended_reason`, transcripts, recordings, abort. * [Billing & tiers](/general/tiers) — what a minute costs and what each tier can do. * [Limits](/v2/limits) — rate limits, concurrency, India calling-window rules. ## Troubleshooting the first call | Symptom | Cause | Fix | | :--------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------------------------- | | `401 unauthorized` | Missing `Bearer ` prefix, or a copy-paste with a newline | Re-copy the key; check `Authorization: Bearer sk_live_…` | | `402 insufficient_balance` | Wallet cannot cover one minute at your tier (₹3 on `t3`) | Check `GET /v2/wallet`; ask your Mirai contact for a top-up | | `400 invalid_request` on `voice_id` | The voice is not in your tier's catalogue | `t1` says `ashu` or `aishe` only; see [Voices](/v2/voices) | | `400 invalid_request` on `to` | Number not E.164 | Use `+919876543210`, not `9876543210` or `091 98765 43210` | | Call ends instantly, `status: no_answer` | Number unreachable or DND-blocked | Try another handset; see [compliance](/v2/limits#india-calling-rules) | | No webhook arrives | URL not publicly reachable, or not HTTPS | Test with `curl -X POST` against your own URL first | | Signature never verifies | Framework parsed and re-serialized the body | Sign over the **raw** bytes — see [Webhooks](/v2/webhooks#raw-body) | # Start building Source: https://docs.miraiminds.co/v2/start-building From a secret key to a running campaign in ten minutes — the whole journey, copy-paste. This is the partner on-ramp. Ten minutes, start to finish: check your key, make one agent, ring one phone, run one campaign, read the report. Every request below is copy-paste — change the key and the phone number and nothing else. **You need:** your `sk_live_` key, a phone number you are allowed to call, and (for step 5) a public HTTPS URL. That is all. ```bash theme={null} https://sandbox.voice.miraiminds.co ``` **Your key arrives from your Mirai contact**, in the same message as this link — or you mint it yourself in the [console](https://sandbox.voice.miraiminds.co) under **Developers → Create key**. It looks like `sk_live_` followed by 32 hex characters and it is shown **once**: we store only its SHA-256, so a lost key is rotated, never recovered. Put it in your secret manager now, before you go further. Issued alongside it is a webhook signing secret, `whsec_…`. That one belongs to the **workspace**, not the key — rotating a key does not change it, so your receiver keeps verifying without an outage. ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/wallet \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "balance_inr": 500, "currency": "INR", "updated_at": "2026-08-10T09:12:44Z" } ``` A `401` here is almost always a trailing newline from `cat`-ing a key file. Paste the key; do not pipe it. That balance is real money and it is what pays for the calls below. Billing is **per minute, rounded up, one-minute minimum**, at your key's [tier](/general/tiers) — `t3` at ₹3/minute on a partner key. Download both files: | File | What it is | | :------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | | [mirai-voice-sandbox.postman\_collection.json](/files/mirai-voice-sandbox.postman_collection.json) | The collection — every endpoint, with worked example responses. | | [sandbox.postman\_environment.json](/files/sandbox.postman_environment.json) | The environment — base URL, your key, your test number. | Postman → **Import** → drop in both → select the **Voice Infra — Sandbox** environment → fill in `api_key` (your `sk_live_…`) and `test_phone` (a number **you own**, E.164). Then open **0-Quickstart** and run it top to bottom: each step saves the id the next one needs, so there is nothing to copy and paste. You can skip this entirely and use the `curl` below. The collection is the faster path if more than one person on your side will touch the API. An agent is the reusable configuration a call runs: prompt, opening line, voice, language, limits. Make it once, call it thousands of times. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order confirmations", "system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} and ask whether the delivery address is unchanged. Keep replies to one or two short sentences. When the customer is done, thank them and end the call.", "first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ। एक मिनट बात कर सकती हूँ?", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300 }' ``` ```json title="201 Created" theme={null} { "id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "object": "agent", "name": "Order confirmations", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN", "max_duration_secs": 300, "created_at": "2026-08-10T09:14:02Z" } ``` Keep that `id`. `{{order_id}}` and `{{customer_name}}` are **agent placeholders**, filled per call from `variables`. They are not shell or Postman variables — leave them exactly as they are. `voice_id` depends on your tier. On `t3` it is a name from the Sarvam catalogue (`ashutosh` above); on `t1` it is `ashu` or `aishe` and nothing else. See [Voices](/v2/voices). Put your own number in `to` for this one. It will actually ring. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: first-call-1" \ -d '{ "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "tier": "t3", "variables": { "customer_name": "Rohit", "order_id": "AC-88213" }, "webhook_url": "https://example.com/mirai/webhook" }' ``` ```json title="202 Accepted" theme={null} { "id": "call_01K7QB4M8N3P6R2S5T7V9W1YA", "status": "queued" } ``` `202` means **accepted, not connected** — the phone has not rung yet. Read the outcome when it ends: ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7QB4M8N3P6R2S5T7V9W1YA \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "id": "call_01K7QB4M8N3P6R2S5T7V9W1YA", "object": "call", "status": "completed", "ended_reason": "assistant-ended-call", "duration_secs": 96, "tier": "t3", "cost_inr": 6 } ``` 96 seconds is 2 billed minutes — ₹6 at `t3`. See [what a minute costs](/general/tiers#what-a-minute-costs). **Always send `Idempotency-Key` on `POST /v2/calls`.** A network timeout tells you nothing about whether the phone rang. With the key, a retry replays the original response and places no second call. Without it, your retry rings a real person twice. Poll if you must; subscribe if you can. We `POST` a signed JSON event at each lifecycle transition, and **you verify the signature before you trust the body**. ``` X-Mirai-Signature: t=1785057398,v1=5f3c… ``` `v1` is `HMAC-SHA256(whsec, "{t}.{raw_body}")`, hex. Sign the **raw** bytes — a framework that parsed and re-serialized the JSON produces a different string and will never verify. ```python theme={null} import hmac, hashlib, time from flask import Flask, request app = Flask(__name__) WHSEC = "whsec_YOUR_WEBHOOK_SECRET" def verify(raw: bytes, header: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1 or abs(time.time() - int(t)) > 300: return False expected = hmac.new(WHSEC.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/mirai/webhook") def hook(): if not verify(request.get_data(), request.headers.get("X-Mirai-Signature", "")): return "", 401 event = request.get_json() print(event["type"], event["data"]) return "", 200 # any 2xx stops our retries ``` The events: `call.started`, `call.completed`, `call.voicemail`, `call.failed`, `call.aborted`, plus `campaign.started` / `paused` / `resumed` / `completed` / `stopped`. Dedupe on `id` — a retried delivery repeats it. Full reference and a test vector in [Webhooks](/v2/webhooks). One call proves the plumbing. A campaign is the product: upload a list, set a window and a budget, and let the platform dial it — pacing, retries, do-not-call suppression and all. ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "First campaign", "agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ", "tier": "t3", "timezone": "Asia/Kolkata", "start_date": "2026-08-11", "end_date": "2026-08-13", "slots": [{ "start": "10:00", "end": "19:00" }], "max_concurrent": 1, "retry_count": 1, "re_attempt_period_secs": 900, "budget_paise": 30000, "webhook_url": "https://example.com/mirai/webhook", "contacts": [ { "id": "cust-1001", "phone": "+919876543210", "variables": { "customer_name": "Rohit", "order_id": "AC-88213" } }, { "id": "cust-1002", "phone": "+919876543211", "variables": { "customer_name": "Anita", "order_id": "AC-88219" } } ] }' ``` ```json title="201 Created" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "object": "campaign", "status": "draft", "tier": "t3", "contacts_accepted": 2, "contacts_duplicate": 0, "contacts_rejected": [] } ``` Note the shape: the campaign object itself, with the upload result as top-level keys. Bad rows come back **by index** rather than failing the whole upload. Start it: ```bash theme={null} curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/start \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="202 Accepted" theme={null} { "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" } ``` `play` means the dialler is running. Outside `10:00`–`19:00` it waits, in `play`, until the window opens — that is correct, not stuck. Full semantics in [Campaigns](/v2/campaigns). ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```json title="200 OK" theme={null} { "campaign_id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "completed", "counters": { "completed": 1, "no_answer": 1 }, "contacts": 2, "dialed": 2, "attempts": 3, "connected": 1, "spent_paise": 600, "spent_inr": 6, "budget_paise": 30000 } ``` Per contact, as CSV: ```bash theme={null} curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ --data-urlencode "format=csv" -o campaign.csv ``` That is the whole loop. Everything after this is scale and hygiene. ## Before you go live Verify `X-Mirai-Signature` over the **raw** body, enforce the 5-minute timestamp window, and return `401` when it does not match. Dedupe on the event `id`; retries repeat it. Do not order your state machine by arrival order — a retried `call.started` can land after `call.completed`. Order by `data.call.status`. [Reference](/v2/webhooks#signature-verification). Every "stop calling me" — on a call, over WhatsApp, by email — should become a `POST /v2/dnc` in the same minute. It is idempotent, so there is no read-modify- write. Suppression is checked at dial time, so it takes effect on campaigns that are already running. [Reference](/v2/dnc). `budget_paise` bounds a mistake in the *list* — a duplicated CSV, a column shifted by one — which is the failure mode that no amount of code review catches. When the cap is hit the campaign pauses with `pause_reason: "budget_exhausted"`, keeps its place, and continues when you raise it. Alert on `campaign.paused`. `GET /v2/wallet` at a threshold covering a day of traffic. A `402` mid-campaign is an expensive way to find out you are empty — and an underfunded campaign starts, dials what it can, and then pauses itself on `insufficient_balance`. Top-ups are not self-serve yet: ask your Mirai contact. | Limit | Default | Over it | | :-------------------- | :----------------------------- | :--------------------------------------- | | Request rate | 10 req/s, burst 20 | `429 rate_limited`, honour `Retry-After` | | Concurrent live calls | 5 | nothing fails — calls wait in `queued` | | Queue depth | 500 accepted-and-not-yet-ended | `429 rate_limited`, `Retry-After: 30` | Concurrency is a **pacer, not a gate**. Higher ceilings are a provisioning change, not a code change — ask before the campaign, not during it. [Limits](/v2/limits). | `error.code` | Status | Do | | :---------------------- | :----- | :-------------------------------------------------------------------------------- | | `insufficient_balance` | `402` | Stop dialling, alert, top up. No call was placed and nothing was charged. | | `rate_limited` | `429` | Back off, honour `Retry-After`. Read `message` to see which ceiling. | | `duplicate_call` | `409` | Not an error — the same `Idempotency-Key` is still in flight. No second call. | | `at_capacity` | `429` | Every line is busy. Retry in 30 seconds. | | `fleet_offline` | `503` | Speech capacity is down. Retry in a few minutes; nothing was charged. | | `tier_unavailable` | `501` | You asked for `t5`. Use `t3` or `t1`. | | `campaigns_unavailable` | `503` | Campaigns are not enabled on your workspace. Ask us — retrying will not clear it. | Branch on status first, then on `error.code`. Full table and a retry wrapper in two languages: [Errors](/v2/errors). The calling window, consent records, national DND scrubbing and telling people they are speaking to an AI are obligations of the business placing the calls. Campaigns enforce **your** window and **your** suppression list — they do not make the calls compliant for you. Put the AI disclosure in `first_message`; it costs one clause and it reduces early hang-ups. [India calling rules](/v2/limits#india-calling-rules). ## Where to go next Windows, retries, budgets, per-contact reporting. [Reference](/v2/campaigns). Prompts, voices, voicemail behaviour, duration caps. [Reference](/v2/agents). Every event, the signature scheme, retries. [Guide](/v2/webhooks). What a minute costs and what each tier can do. [Reference](/general/tiers). Stuck on something that is not in here? [help@miraiminds.co](mailto:help@miraiminds.co) — send the `call_id` or `campaign_id` and we can see exactly what happened. # Speech endpoint (legacy) Source: https://docs.miraiminds.co/v2/text-to-speech The original /tts/v1 speech endpoint — model mira-tts-v51, separate key. Kept for existing callers; new integrations start at the TTS quickstart. This page documents the **original** speech endpoint (`/tts/v1`, model `mira-tts-v51`, separate key). New integrations should start at the [TTS quickstart](/v2/tts-quickstart) instead — the current endpoint lives at `/v1/audio/speech`, uses model `mira-tts`, streams, and takes the same `sk_live_` key as the rest of the API. This endpoint keeps working for existing callers. The speech endpoint is the voice from our agents, on its own. Send text, get a WAV back. It takes the same request shape as the OpenAI audio API, so any OpenAI SDK works by swapping `base_url` and the key. This is a **separate key** from your `sk_live_` API key — ask us for one; it is not minted from the dashboard. The engine speaks Hindi, English and the Hinglish in between. Six more languages — Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali — arrive over the next two months: see the [language roadmap](/v2/tts-languages). ## Quickstart ```bash theme={null} curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \ -H "Authorization: Bearer $MIRA_TTS_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "mira-tts-v51", "input": "नमस्ते, मैं आशु बोल रहा हूं। आपका ऑर्डर तैयार है।", "voice": "aishe" }' \ --output speech.wav ``` The response body **is** the audio: `audio/wav`, 48 kHz mono PCM16. On a 400-character input expect roughly 3 seconds end to end — the whole file is rendered before the first byte is sent, so this endpoint is built for generating clips, not for driving a live conversation. For interactive latency, use an [agent](/v2/agents) and place a [call](/v2/calls). ## Request | Field | Type | Notes | | :---------------- | :------ | :------------------------------------------------------------------------------------------------------------ | | `model` | string | **Required.** Must be `mira-tts-v51`. | | `input` | string | **Required.** Up to **600 characters**. | | `voice` | string | `ashu` (default) or `aishe`. Same catalogue as [Voices](/v2/voices). | | `response_format` | string | `wav` only, and it is the default. Anything else is a 400. | | `speech_ready` | boolean | `true` (default) runs the rewrite pass below. `false` sends your text to the engine untouched. | | `instructions` | string | Up to 500 characters, steers the rewrite (tone, pacing). Ignored when `speech_ready` is `false`. | | `lexicon` | object | Pronunciation overrides, `{"Gallabox": "Galla Box"}`. Applied as a deterministic replace, never by the model. | | `numbers` | string | `auto` (default), `english`, or `native` — which language digits are spoken in. | `model` is a stable public name, not the build number. It always points at the current production voice, which we upgrade underneath you — so a clip you generate today may sound better than one from last month without your code changing. ## The rewrite pass Raw text is rarely speech-ready. `97%` should be read as "ninety-seven percent", a product name should not be transliterated, and a bare URL should not be spelled out character by character. By default we run your text through a rewrite that fixes exactly that, preserving meaning, before it reaches the engine. Every response carries a header naming what happened: | `X-Mira-Speech-Ready` | Meaning | | :-------------------- | :---------------------------------------------------------------------- | | `rewritten` | The rewrite ran and its output was synthesised. | | `bypassed` | You sent `"speech_ready": false`. Your text, untouched. | | `fallback` | The rewrite was unavailable, so your **original** text was synthesised. | `fallback` is a degradation, never an error: you still get audio and still get a 200. If you are debugging pronunciation and the header says `fallback`, the rewrite is not what shaped that clip. ```bash title="Skip the rewrite" theme={null} curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \ -H "Authorization: Bearer $MIRA_TTS_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"mira-tts-v51","input":"Order #4417 ready","voice":"ashu","speech_ready":false}' \ --output raw.wav ``` ## Listing models ```bash theme={null} curl https://sandbox.voice.miraiminds.co/tts/v1/models \ -H "Authorization: Bearer $MIRA_TTS_KEY" ``` ```json theme={null} { "object": "list", "data": [ { "id": "mira-tts-v51", "object": "model", "owned_by": "miraiminds" } ] } ``` ## Errors Errors use the same envelope as the rest of the API — see [Errors](/v2/errors). | Status | `code` | Cause | | :----- | :------------------------ | :------------------------------------ | | 400 | `missing_input` | `input` absent or empty. | | 400 | `input_too_long` | Over 600 characters. Split it. | | 400 | `invalid_voice` | Not `ashu` or `aishe`. | | 400 | `invalid_response_format` | Anything other than `wav`. | | 400 | `invalid_instructions` | Not a string, or over 500 characters. | | 400 | `invalid_lexicon` | Not an object of string → string. | | 400 | `invalid_numbers` | Not `auto`, `english` or `native`. | | 400 | `invalid_json` | Body is not valid JSON. | | 401 | `invalid_api_key` | Missing or unknown key. | | 404 | `model_not_found` | `model` is not `mira-tts-v51`. | ```json title="404 Not Found" theme={null} { "error": { "message": "Unknown model 'tts-1'. Use 'mira-tts-v51'.", "type": "invalid_request_error", "code": "model_not_found" } } ``` ## Using an OpenAI SDK The shape matches, so point the client at us and keep your code: ```python theme={null} from openai import OpenAI client = OpenAI( base_url="https://sandbox.voice.miraiminds.co/tts/v1", api_key=MIRA_TTS_KEY, ) client.audio.speech.create( model="mira-tts-v51", voice="aishe", input="नमस्ते, आपका ऑर्डर तैयार है।", ).stream_to_file("speech.wav") ``` `lexicon`, `numbers`, `instructions` and `speech_ready` are ours, not OpenAI's. SDKs that validate their request body may reject them — send those with a plain HTTP client, or use `extra_body` where your SDK supports it. # Language roadmap Source: https://docs.miraiminds.co/v2/tts-languages Six new languages — Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali — join the voice engine over the next two months. Today the engine speaks **Hindi, English, and the Hinglish most real calls actually are**. Over the next two months it learns six more: **Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali**. Roadmap: Hindi, Hinglish and English code-switch are live today; Bengali, Marathi, Telugu, Tamil, Gujarati and Kannada arrive over the next two months, dates to be announced per language. Roadmap: Hindi, Hinglish and English code-switch are live today; Bengali, Marathi, Telugu, Tamil, Gujarati and Kannada arrive over the next two months, dates to be announced per language. Per-language dates are announced as each one clears evaluation — the same rule as everything else on the [roadmap](/general/roadmap): dates are targets, quality wins every time. A language does not ship because the calendar says so; it ships when it sounds like a person. ## Why these six Together these languages are the mother tongue of **429 million people** — roughly every third Indian. And Hindi is not a substitute for most of them: the census records, for each language, how many of its speakers also speak Hindi, and for four of the six the answer is *almost none*. Bar chart of first-language speakers per language, with the portion that does not speak Hindi highlighted: Bengali 97.2M of whom 88.9M, Marathi 83.0M of whom 48.4M, Telugu 81.1M of whom 76.5M, Tamil 69.0M of whom 68.0M, Gujarati 55.5M of whom 33.8M, Kannada 43.7M of whom 41.7M. Bar chart of first-language speakers per language, with the portion that does not speak Hindi highlighted: Bengali 97.2M of whom 88.9M, Marathi 83.0M of whom 48.4M, Telugu 81.1M of whom 76.5M, Tamil 69.0M of whom 68.0M, Gujarati 55.5M of whom 33.8M, Kannada 43.7M of whom 41.7M. | Language | First-language speakers | Also speak Hindi | Reachable only in their own language | | :-------- | ----------------------: | ---------------: | -----------------------------------: | | Bengali | 97.2M | 8.6% | **88.9M** | | Marathi | 83.0M | 41.7% | **48.4M** | | Telugu | 81.1M | 5.7% | **76.5M** | | Tamil | 69.0M | 1.5% | **68.0M** | | Gujarati | 55.5M | 39.0% | **33.8M** | | Kannada | 43.7M | 4.7% | **41.7M** | | **Total** | **429.5M** | | **357.3M** | **357 million people cannot be called in Hindi at all.** Only 1.5% of Tamil speakers report speaking Hindi; for Telugu it is 5.7%, Kannada 4.7%, Bengali 8.6%. A voice agent that speaks only Hindi does not reach these customers badly — it does not reach them. With Hindi and these six together, an agent can speak the mother tongue of **about four in five Indians**. Speaker counts are first-language speakers from the 2011 Census of India; "also speak Hindi" is Hindi reported as a second language (table C-17). These are the most recent official figures — the next census is due in 2027. ## What support means * **Same endpoint, same request shape.** The [speech endpoint](/v2/tts-quickstart) does not change — you send text in the language's own script, code-switched with English the way real conversation is, and get audio back. No new API to learn. * **Native voices, not accents.** Each language ships with its own voices in the [voice gallery](/v2/voices), evaluated by native speakers — not a Hindi voice reading Tamil. * **TTS ships first.** This page tracks the voice. A full agent conversation in a language also needs our speech recognition and LLM to clear the same bar, and that follows the voice — the speech endpoint is where each language lands first. ## Get early access Each language runs an alpha with a small set of customers before it reaches the gallery. If calls in one of these languages matter to your business, tell your account contact which one — alpha slots are allocated in order of ask, and alpha customers' text is what we tune the final voices on. # TTS quickstart Source: https://docs.miraiminds.co/v2/tts-quickstart Key → curl → Hindi audio in two minutes, then the same endpoint inside your own orchestrator. The voice from our agents, as a standalone API. It speaks OpenAI's `POST /v1/audio/speech` protocol, so every OpenAI SDK and every framework with an OpenAI TTS integration already knows how to call it — you change a base URL and a model name, not your code. ## 1. Get a key Sign in to the [console](https://sandbox.voice.miraiminds.co) with your access code, open **Developers**, and create an API key. It is a `sk_live_` secret, shown **once** — only its hash is stored, so a lost key is rotated, never recovered. The same key works for TTS and the rest of the [v2 API](/v2/overview). ## 2. First audio ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v1/audio/speech \ -H "Authorization: Bearer $MIRA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "mira-tts", "voice": "ashu", "response_format": "wav", "input": "नमस्ते, मैं आशु बोल रहा हूं। आपका ऑर्डर तैयार है।" }' \ --output speech.wav ``` The response body **is** the audio — `--output` matters, because a terminal handed 300 KB of binary is a terminal you have to reset. Play `speech.wav` and you have completed the integration; everything below is refinement. Two formats exist, and only two: | `response_format` | You get | | :---------------- | :------------------------------------------------------------------------------------------------------------------------ | | `wav` | A playable file — 48 kHz mono PCM16 with a header. | | `pcm` | The same samples, raw and **streamed** — first bytes arrive while the rest is still rendering. Use this in anything live. | Anything else (including OpenAI's default `mp3`) is a 400 naming these two. Always send `response_format` explicitly. ## 3. Pick a voice ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/voices \ -H "Authorization: Bearer $MIRA_API_KEY" ``` Currently `ashu`, `aishe` and `neha` — Hindi voices that code-switch through the English of real Indian speech. Each entry carries a `sampleAudio` URL; listen before you choose. An unknown voice is a 400, not a silent substitution. Six more languages are on the way — see the [language roadmap](/v2/tts-languages). ## 4. Stream it with the OpenAI SDK ```python theme={null} from openai import OpenAI client = OpenAI( api_key="sk_live_...", base_url="https://sandbox.voice.miraiminds.co/v1", ) with client.audio.speech.with_streaming_response.create( model="mira-tts", voice="ashu", response_format="pcm", input="चलिए numbers से start करते हैं।", ) as response: for chunk in response.iter_bytes(): ... # raw s16le mono @ 48 kHz — feed your player as it arrives ``` ## 5. Drop it into your orchestrator Frameworks with an OpenAI TTS integration need no custom code. pipecat, for example — the stock `OpenAITTSService` is the whole integration: ```python theme={null} from pipecat.services.openai.tts import VALID_VOICES, OpenAITTSService # pipecat validates voices against OpenAI's own hardcoded list (alloy, nova, …) # before it ever sends a request — without this line every Mirai voice raises # KeyError: 'ashu' client-side. VALID_VOICES.update({name: name for name in ("ashu", "aishe", "neha")}) tts = OpenAITTSService( api_key=os.environ["MIRA_API_KEY"], base_url="https://sandbox.voice.miraiminds.co/v1", sample_rate=48000, settings=OpenAITTSService.Settings(model="mira-tts", voice="ashu"), ) ``` `sample_rate=48000` makes pipecat log "OpenAI TTS only supports 24000Hz" at startup. Cosmetic — our audio is 48 kHz native and the frames are tagged correctly. A complete \~100-line agent (browser mic → ASR → LLM → Mirai TTS) built this way is available on request — ask on your onboarding thread. ## Billing Per character. Every response reports its own cost in headers, so you can meter spend without a second request: | Header | Meaning | | :--------------- | :---------------------------------- | | `X-Chars-Billed` | Characters billed for this request. | | `X-Cost-Paise` | What this request cost, in paise. | ## When something fails | Status | Cause | | :----- | :-------------------------------------------------------------------------------------------------- | | 400 | Unknown voice, or a `response_format` other than `wav` / `pcm`. The message names the valid values. | | 401 | Missing or unknown key. Check the `Authorization: Bearer sk_live_…` header. | | 404 | `model` is not `mira-tts`. | Errors use the same envelope as the rest of the API — see [Errors](/v2/errors). Stuck on something this page does not cover? Reply on your onboarding thread — a human reads it. # Voices Source: https://docs.miraiminds.co/v2/voices Choosing a voice_id for your agent — the two t1 voices, the t3 catalogue, and the one caveat about the language field. Every agent speaks in one voice, set by `voice.voice_id`. **Which voices exist depends on the [tier](/general/tiers)**, because the tiers speak through different engines. ## `t3` — the default `t3` speaks through a premium third-party catalogue (Sarvam). There is no short allowlist to memorise: pass the vendor's voice id, e.g. `ashutosh`. ```json theme={null} { "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN" } ``` On `t3` **`voice` is required** when you create an agent. There is no safe default to pick from somebody else's catalogue, so an agent without one is a `400`. ## `t1` — exactly two voices `t1` speaks through our own stack, and it loads exactly two voices: | `voice_id` | Notes | | :--------- | :---------------------------------------------------------------------------------- | | `ashu` | Hindi / Hinglish. **The default** — omit `voice` entirely on `t1` and you get this. | | `aishe` | Hindi / Hinglish, second option. | Anything else is rejected at agent-create time, and the error names both so you are not left guessing: ```json title="400 Bad Request" theme={null} { "error": { "code": "invalid_request", "message": "voice.voice_id 'priya' is not available. Use 'ashu' or 'aishe'." } } ``` The same agent can be run at either tier — `tier` is set per call and per [campaign](/v2/campaigns), not on the agent. If you create an agent with a `t3` voice and then place a `t1` call with it, the voice will not be one `t1` can say. Keep one agent per tier when you use both. ## Hearing them first There is no `GET /v2/voices` yet. The live catalogue is the voice picker in the [console](https://sandbox.voice.miraiminds.co) — open Agent Studio and you can **hear each voice** before you commit to one. The name in the picker is the `voice_id`, and the catalogue grows monthly; treat the picker, not this page, as the source of truth. **Listen before you pick.** Voice choice moves answer-through rates more than prompt wording does. ## Setting a voice on an agent ```json theme={null} { "name": "Order confirmations", "system_prompt": "…", "voice": { "voice_id": "ashutosh", "language": "hi-IN" }, "language": "hi-IN" } ``` | Field | Type | Description | | :--------- | :----- | :--------------------------------------------------------------------------------------------------------------------------- | | `voice_id` | string | A voice your tier can say. Required on `t3`; optional on `t1`, where it defaults to `ashu`. Unknown on `t1` ⇒ `400`. | | `language` | string | Rendering language. Omit to inherit the agent's top-level `language`. **Accepted and stored, not yet honoured** — see below. | Change it later with `PATCH /v2/agents/{id}`: ```bash theme={null} curl -X PATCH https://sandbox.voice.miraiminds.co/v2/agents/agt_… \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "voice": { "voice_id": "aishe", "language": "hi-IN" } }' ``` See the [Agents reference](/v2/agents#voice) for the full agent shape. ## The language caveat **`voice.language` is recorded, not yet applied** It is validated, stored, echoed back and carried with the call payload — but the voice renders in the agent's **top-level** `language`. Set both to the same value and you get what you expect; set them differently and the top-level one wins. Keep `voice.language` and the agent's `language` identical. # Wallet Source: https://docs.miraiminds.co/v2/wallet Check your prepaid balance and read the transaction ledger. Mirai Voice is **prepaid**. Every workspace has one INR wallet. Calls debit it when they end; ops credits it when you top up. If the wallet cannot cover a minute, `POST /v2/calls` returns [`402`](#402-insufficient-balance) and no call is placed. For rates and how minutes are counted, see [Billing & tiers](/general/tiers). ## Get balance ```http theme={null} GET /v2/wallet ``` ```bash theme={null} curl https://sandbox.voice.miraiminds.co/v2/wallet \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```python theme={null} import os, httpx API = "https://sandbox.voice.miraiminds.co" auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"} wallet = httpx.get(f"{API}/v2/wallet", headers=auth, timeout=30) \ .raise_for_status().json() print(wallet["balance_inr"]) # 500 ``` ```javascript theme={null} const API = "https://sandbox.voice.miraiminds.co"; const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` }; const wallet = await fetch(`${API}/v2/wallet`, { headers: auth }).then((r) => r.json()); console.log(wallet.balance_inr); // 500 ``` ```json title="200 OK" theme={null} { "balance_inr": 500, "currency": "INR", "updated_at": "2026-07-26T09:12:44Z" } ``` | Field | Type | Description | | :------------ | :----- | :--------------------------------- | | `balance_inr` | number | Spendable balance. Never negative. | | `currency` | string | Always `INR`. | | `updated_at` | string | When the balance last changed. | **Alert on this, don't discover it** Poll the balance on a schedule (hourly is plenty) and alert your own team below a threshold that covers a day of traffic. A `402` in the middle of a campaign is a much worse way to learn you are out of credit. ## List transactions ```http theme={null} GET /v2/wallet/transactions?limit=&cursor= ``` The ledger. One row per credit or debit, newest first. ```bash theme={null} curl "https://sandbox.voice.miraiminds.co/v2/wallet/transactions?limit=50" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` ```python theme={null} def all_transactions(): cursor = None while True: params = {"limit": 100, **({"cursor": cursor} if cursor else {})} page = httpx.get( f"{API}/v2/wallet/transactions", headers=auth, params=params, timeout=30 ).raise_for_status().json() yield from page["data"] if not page["has_more"]: return cursor = page["next_cursor"] spent = sum(t["amount_inr"] for t in all_transactions() if t["type"] == "debit") ``` ```javascript theme={null} async function* allTransactions() { let cursor = null; for (;;) { const qs = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) }); const page = await fetch(`${API}/v2/wallet/transactions?${qs}`, { headers: auth }) .then((r) => r.json()); yield* page.data; if (!page.has_more) return; cursor = page.next_cursor; } } let spent = 0; for await (const t of allTransactions()) if (t.type === "debit") spent += t.amount_inr; ``` ```json title="200 OK" theme={null} { "data": [ { "id": "txn_01JZQ9D6P2R5S8T4V7W9X1Y3ZB", "object": "wallet_transaction", "type": "debit", "amount_inr": 6, "balance_after_inr": 494, "call_id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "description": "call 96s (2 min, t3)", "created_at": "2026-07-26T09:16:39Z" }, { "id": "txn_01JZQ8E2M4N6P8R1S3T5V7W9XA", "object": "wallet_transaction", "type": "credit", "amount_inr": 500, "balance_after_inr": 500, "call_id": null, "description": "pilot top-up", "created_at": "2026-07-26T08:40:11Z" } ], "has_more": false, "next_cursor": null } ``` | Field | Type | Description | | :------------------ | :------------------ | :----------------------------------------------------- | | `type` | `credit` \| `debit` | Direction. | | `amount_inr` | number | Always positive; read `type` for direction. | | `balance_after_inr` | number | Balance immediately after this row. | | `call_id` | string \| null | Set on call debits, `null` on top-ups and adjustments. | | `description` | string | Human-readable. Do not parse it. | Reconcile against `call_id`: every billable call produces exactly one debit row. ## Top up There is no self-serve top-up yet — it is on the [roadmap](/general/roadmap). Until then your Mirai contact credits the workspace; the credit appears as a `credit` row within minutes and takes effect immediately. Your live balance is always `GET /v2/wallet`, and on the **Developers** page of the [console](https://sandbox.voice.miraiminds.co). ## `402` insufficient balance Before dialling, we check the wallet can cover at least one minute at the call's tier. If it cannot: ```json title="402 Payment Required" theme={null} { "error": { "code": "insufficient_balance", "message": "wallet balance 0.40 INR is below the minimum for one minute at t3" } } ``` * **The gate runs pre-dial.** No phone rings, nothing is charged, no call object is created. * **Retry after topping up**, with the same `Idempotency-Key` — the failed request did not consume it. * **Calls already in flight are not killed.** A call that started with credit runs to its natural end, then debits. A wallet can therefore end a busy minute slightly lower than the pre-dial check implied. Handle it explicitly — it is the one error that is a business condition rather than a bug: ```python theme={null} r = httpx.post(f"{API}/v2/calls", headers=auth, json=payload, timeout=30) if r.status_code == 402: pause_campaign() alert_ops(r.json()["error"]["message"]) else: r.raise_for_status() ``` ```javascript theme={null} const res = await fetch(`${API}/v2/calls`, { method: "POST", headers, body }); if (res.status === 402) { const { error } = await res.json(); await pauseCampaign(); await alertOps(error.message); } else if (!res.ok) { throw new Error(await res.text()); } ``` # Webhooks Source: https://docs.miraiminds.co/v2/webhooks Signed lifecycle events — event types, signature verification, retries and idempotency. Pass `webhook_url` when you [create a call](/v2/calls#create-a-call) and we POST a signed JSON event at each lifecycle transition. Webhooks are how you learn a call's outcome — polling is the fallback, not the design. Your endpoint must be **public HTTPS** and must return a **2xx** quickly. Any other status, or a connection failure, triggers [retries](#retries). ## Event envelope Every event has the same shape. ```json theme={null} { "id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB", "type": "call.completed", "created_at": "2026-07-26T09:16:38Z", "data": { "call": { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "object": "call", "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543210", "status": "completed", "tier": "t3", "ended_reason": "assistant-ended-call", "started_at": "2026-07-26T09:15:02Z", "ended_at": "2026-07-26T09:16:38Z", "duration_secs": 96, "cost_inr": 6 } } } ``` | Field | Description | | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `evt_`. Unique per **delivery attempt group** — retries of the same event reuse it. [Dedupe on this](#idempotency). | | `type` | See [event types](#event-types). | | `created_at` | When the event was generated, not when it was delivered. | | `data.call` | The [call object](/v2/calls#the-call-object), exactly as `GET /v2/calls/{id}` would return it at that moment. On a `campaign.*` event this is `data.campaign` instead. | Treat the envelope as **additive**: new fields appear without a version bump. Ignore what you do not recognise. ## Event types | Type | Fires when | `data.call.status` | | :--------------- | :--------------------------------------------------------------------------------------------------- | :--------------------------------------- | | `call.queued` | The call is admitted to the queue. **Opt-in** — ask your Mirai contact to enable it. | `queued` | | `call.started` | Media is live; the conversation has begun. | `in_progress` | | `call.completed` | The call connected and finished normally. | `completed` | | `call.voicemail` | An answering machine answered. | `voicemail` | | `call.failed` | The call did not connect, or the pipeline errored. | `no_answer`, `busy`, `failed`, `timeout` | | `call.aborted` | You cancelled it with [`POST /abort`](/v2/calls#abort-a-call) — queued, ringing or mid-conversation. | `aborted` | Exactly one terminal event fires per call: `call.completed`, `call.voicemail`, `call.failed` or `call.aborted`. [Campaigns](/v2/campaigns) add five more. They are delivered to the campaign's own `webhook_url`, signed with the same workspace secret, in the same envelope — with `data.campaign` where `data.call` would be. | Type | Fires when | | :------------------- | :----------------------------------------------------------------------- | | `campaign.started` | The first `POST /start` takes effect. Once per campaign. | | `campaign.paused` | You paused it, or the money gate did. Read `data.campaign.pause_reason`. | | `campaign.resumed` | Dialling resumed. | | `campaign.completed` | Every contact reached a final state, or the date range ran out. | | `campaign.stopped` | You stopped it. | A campaign's calls emit the ordinary `call.*` events as well, to the same URL. See [Campaigns → Webhooks](/v2/campaigns#webhooks) for the payload. ```mermaid theme={null} sequenceDiagram participant You participant API as Mirai Voice participant Phone as Customer You->>API: POST /v2/calls API-->>You: 202 {id, status:"queued"} API-->>You: call.queued (opt-in) API->>Phone: dial Phone-->>API: answer API-->>You: call.started Note over API,Phone: conversation Phone-->>API: hangup API-->>You: call.completed ``` ### `call.failed` Read `data.call.status` to tell the failure modes apart — `no_answer` is a retry-tomorrow, `failed` is a page-someone. ```json theme={null} { "id": "evt_01JZQ9E7Q3S6T9V5W8X1Y4Z6AC", "type": "call.failed", "created_at": "2026-07-26T09:15:44Z", "data": { "call": { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YB", "object": "call", "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543211", "status": "no_answer", "tier": "t3", "ended_reason": "customer-did-not-answer", "started_at": null, "ended_at": "2026-07-26T09:15:44Z", "duration_secs": 0, "cost_inr": null } } } ``` ### `call.voicemail` ```json theme={null} { "id": "evt_01JZQ9F8R4T7V2W5X8Y1Z4A6BD", "type": "call.voicemail", "created_at": "2026-07-26T09:18:20Z", "data": { "call": { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YC", "object": "call", "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543212", "status": "voicemail", "tier": "t3", "ended_reason": "voicemail", "started_at": "2026-07-26T09:18:02Z", "ended_at": "2026-07-26T09:18:20Z", "duration_secs": 18, "cost_inr": 3 } } } ``` `status: voicemail` always carries `ended_reason: "voicemail"` — that reason is what identifies the answering machine in the first place, so the pair is the only combination you will see. Voicemail calls **are billed** — media was live. 18 seconds bills as 18 seconds: ₹0.30 at `t1`. See [metering](/general/tiers#metering). ### `call.aborted` ```json theme={null} { "id": "evt_01JZQ9G9S5V8W3X6Y9Z2A5B7CE", "type": "call.aborted", "created_at": "2026-07-26T09:14:51Z", "data": { "call": { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YD", "object": "call", "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", "to": "+919876543213", "status": "aborted", "tier": "t3", "ended_reason": "aborted-by-api", "started_at": null, "ended_at": "2026-07-26T09:14:51Z", "duration_secs": 0, "cost_inr": null } } } ``` `aborted-by-api` is the only `ended_reason` an abort produces, whether you cancelled the call in the queue, mid-ring or mid-conversation. Aborted calls are never billed. *** ## Signature verification Every delivery carries a timestamped HMAC-SHA256 signature. ```http theme={null} X-Mirai-Signature: t=1785057398,v1=771bca27e1647dfbe59ff5d8f32d9de69be246f6e604c05bddcbaefd8604ccb3 ``` | Element | Meaning | | :------ | :----------------------------------------------------- | | `t` | Unix seconds when we signed the delivery. | | `v1` | Lowercase hex `HMAC-SHA256(secret, ".")`. | The signed string is the timestamp, a literal `.`, then the **raw request body bytes**. The secret is your per-workspace `whsec_…`, shown once when your key is issued. Verification is four steps: 1. Parse `t` and `v1` out of the header. 2. Reject if `|now − t| > 300` seconds — this is the [replay window](#replay-protection). 3. Recompute the HMAC over `t + "." + raw_body`. 4. Compare with `v1` in **constant time**. ```python theme={null} # verify.py — framework-independent core import hashlib import hmac import time TOLERANCE_SECS = 300 def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: """Return True iff the delivery is authentic and inside the replay window. raw_body must be the exact bytes we sent. A parsed-and-re-serialised dict will not match: key order and whitespace both change the digest. """ if not signature_header: return False parts = dict( p.split("=", 1) for p in signature_header.split(",") if "=" in p ) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False try: sent_at = int(t) except ValueError: return False if abs(time.time() - sent_at) > TOLERANCE_SECS: return False expected = hmac.new( secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1) ``` Flask: ```python theme={null} import os from flask import Flask, request from verify import verify_signature app = Flask(__name__) WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"] @app.post("/mirai/webhook") def mirai_webhook(): if not verify_signature( request.get_data(), # raw bytes, before any JSON parsing request.headers.get("X-Mirai-Signature", ""), WEBHOOK_SECRET, ): return {"error": "invalid signature"}, 401 event = request.get_json() if already_processed(event["id"]): return "", 200 # duplicate delivery, already handled enqueue(event) # do the slow work off the request mark_processed(event["id"]) return "", 200 # any 2xx stops our retries ``` FastAPI: ```python theme={null} import json import os from fastapi import FastAPI, Request, Response app = FastAPI() WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"] @app.post("/mirai/webhook") async def mirai_webhook(request: Request): raw = await request.body() if not verify_signature( raw, request.headers.get("x-mirai-signature", ""), WEBHOOK_SECRET ): return Response(status_code=401) event = json.loads(raw) enqueue(event) return Response(status_code=200) ``` ```javascript theme={null} // verify.js — framework-independent core import crypto from "node:crypto"; const TOLERANCE_SECS = 300; /** * @param {Buffer} rawBody exact bytes as received, before JSON.parse * @param {string} signatureHeader value of X-Mirai-Signature * @param {string} secret your whsec_… webhook secret */ export function verifySignature(rawBody, signatureHeader, secret) { if (!signatureHeader) return false; const parts = Object.fromEntries( signatureHeader.split(",").map((p) => { const i = p.indexOf("="); return i === -1 ? ["", ""] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }) ); const { t, v1 } = parts; if (!t || !v1) return false; const sentAt = Number(t); if (!Number.isFinite(sentAt)) return false; if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` Express — note `express.raw`, not `express.json`: ```javascript theme={null} import express from "express"; import { verifySignature } from "./verify.js"; const app = express(); const WEBHOOK_SECRET = process.env.MIRAI_WEBHOOK_SECRET; app.post( "/mirai/webhook", express.raw({ type: "application/json" }), async (req, res) => { if (!verifySignature(req.body, req.get("X-Mirai-Signature") ?? "", WEBHOOK_SECRET)) { return res.status(401).json({ error: "invalid signature" }); } const event = JSON.parse(req.body.toString("utf8")); if (await alreadyProcessed(event.id)) return res.sendStatus(200); await enqueue(event); // do the slow work off the request await markProcessed(event.id); res.sendStatus(200); // any 2xx stops our retries } ); app.listen(3000); ``` Next.js App Router: ```javascript theme={null} // app/api/mirai/webhook/route.js import { verifySignature } from "@/lib/verify"; export async function POST(request) { const raw = Buffer.from(await request.arrayBuffer()); const ok = verifySignature( raw, request.headers.get("x-mirai-signature") ?? "", process.env.MIRAI_WEBHOOK_SECRET ); if (!ok) return new Response(null, { status: 401 }); const event = JSON.parse(raw.toString("utf8")); await enqueue(event); return new Response(null, { status: 200 }); } ``` ### Test vector Check your implementation against this before you go anywhere near a real call. ```text title="secret" theme={null} whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8 ``` ```text title="raw body (177 bytes, exactly as shown — no trailing newline)" theme={null} {"id":"evt_01JZQ9C5N9P4R7S3T6V8W2X4YB","type":"call.completed","created_at":"2026-07-26T09:16:38Z","data":{"call":{"id":"call_01JZQ9B4M8N3P6R2S5T7V9W1YA","status":"completed"}}} ``` ```text title="header" theme={null} X-Mirai-Signature: t=1785057398,v1=771bca27e1647dfbe59ff5d8f32d9de69be246f6e604c05bddcbaefd8604ccb3 ``` Your HMAC must equal that `v1`. (The timestamp is in the past, so skip the window check while testing this vector.) ```bash theme={null} printf '%s' '1785057398.{"id":"evt_01JZQ9C5N9P4R7S3T6V8W2X4YB","type":"call.completed","created_at":"2026-07-26T09:16:38Z","data":{"call":{"id":"call_01JZQ9B4M8N3P6R2S5T7V9W1YA","status":"completed"}}}' \ | openssl dgst -sha256 -hmac 'whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8' -hex ``` ### Raw body The single most common integration bug: signing a re-serialised body. ```js theme={null} // ✗ wrong — key order, spacing and unicode escaping all differ from what we sent hmac(JSON.stringify(req.body)); // ✓ right — the bytes we sent hmac(rawBodyBuffer); ``` Capture the raw bytes **before** any body parser touches them. In Express that means `express.raw({ type: "application/json" })` on this route (mount it before any global `express.json()`); in Django, `request.body`; in Rails, `request.raw_post`; behind API Gateway, make sure the payload is not base64-transformed on the way in. ### Replay protection We reject nothing on your behalf — the timestamp is there so **you** can. Drop deliveries where `|now − t| > 300` seconds. Without that check, anyone who captures one valid delivery can replay it forever. If legitimate deliveries fail the window check, your server clock is wrong. Run NTP. ### Rotating the secret Ask ops. During rotation both the old and the new secret are considered valid for a short overlap, so verify against a list: ```python theme={null} def verify_any(raw_body, header, secrets): return any(verify_signature(raw_body, header, s) for s in secrets) ``` *** ## Retries A delivery succeeds on any `2xx`. Anything else — `4xx`, `5xx`, timeout, TLS error, DNS failure — is retried: **six attempts with exponential backoff over roughly 36 minutes**, the first fired immediately and the gaps widening up to a half-hour cap. Design against the window, not against the individual gaps — the exact delays are an implementation detail and we tune them. What is contractual is that a brief outage on your side is survivable without losing the event, and that an endpoint down for the whole 36 minutes will lose it. After the sixth attempt the event goes to a dead-letter queue. We can replay from the DLQ — a replayed event is byte-identical to the original, including its `id`, and carries a **fresh** `t` and `v1` so it passes your window check. Consequences worth planning for: * **Order is not guaranteed.** A retried `call.started` can arrive after `call.completed`. Order your own state machine by `data.call.status`, not by arrival. * **Slow endpoints get retried.** If your handler takes longer than our client timeout we treat it as a failure and send again. Acknowledge first, work later. * **A `401` from your signature check is a retryable failure to us.** That is intentional — a transient secret-loading bug on your side should not lose the event. ## Linking an event to your own records An event carries the call, not your database. The join key is `call_id`, and you have it **before** any event can arrive — `POST /v2/calls` returns it synchronously in the `202`: ```python theme={null} call = mirai.calls.create(agent_id=agent.id, to=customer.phone) Order.objects.filter(pk=order.id).update(mirai_call_id=call.id) # store it here ``` Do that write before you return from the request that placed the call. A webhook can land while the placing request is still in flight, and a handler that cannot find the call yet has to either drop the event or retry blindly. `variables` are substituted into the prompt; they are **not** echoed back on the event. Anything you need at webhook time belongs in your own row, keyed by `call_id`. ## Idempotency Assume at-least-once delivery. Dedupe on `id`: ```python theme={null} # Redis SETNX with a TTL longer than the full retry schedule (~36 min). def already_processed(event_id: str) -> bool: return not redis.set(f"mirai:evt:{event_id}", "1", nx=True, ex=86_400) ``` ```javascript theme={null} // Redis SETNX with a TTL longer than the full retry schedule (~36 min). async function alreadyProcessed(eventId) { const set = await redis.set(`mirai:evt:${eventId}`, "1", "NX", "EX", 86_400); return set === null; } ``` Or make the handler naturally idempotent — `UPDATE orders SET call_status = ? WHERE id = ?` needs no dedupe table at all. ## Local development Webhooks need a public URL. Tunnel to your laptop: ```bash theme={null} ngrok http 3000 # → https://a1b2c3d4.ngrok-free.app ``` Then pass `"webhook_url": "https://a1b2c3d4.ngrok-free.app/mirai/webhook"` when creating a call. Replay a captured delivery against your handler with `curl` to iterate without burning wallet balance: ```bash theme={null} curl -X POST http://localhost:3000/mirai/webhook \ -H "Content-Type: application/json" \ -H "X-Mirai-Signature: t=$(date +%s),v1=$(printf '%s' "$(date +%s).$(cat event.json)" \ | openssl dgst -sha256 -hmac "$MIRAI_WEBHOOK_SECRET" -hex | cut -d' ' -f2)" \ --data-binary @event.json ``` ## Checklist * [ ] Endpoint is public HTTPS and returns 2xx in well under a second. * [ ] Signature verified against the **raw** body, in constant time. * [ ] Timestamp window enforced at 300 seconds. * [ ] Events deduped on `id`. * [ ] Unknown `type` values ignored, not crashed on. * [ ] Handler does no slow work inline — enqueue and return. # The Zen of Voice Agents Source: https://docs.miraiminds.co/v2/zen-of-voice-agents Nineteen lines on writing for the ear, in the tradition of the Zen of Python. Read them before you write a prompt. Every rule in the [prompting guide](/v2/prompting-guide) started as a phone call that went wrong. These are the rules with the explanations taken out, the way the Zen of Python does it. Read them before you write a prompt; come back to them when a transcript reads badly and you cannot say why. ```text theme={null} The Zen of Voice Agents Spoken is better than written. Short is better than complete. One question is better than two. The first word matters more than the last paragraph. Silence is a bug. So is a monologue. Numbers are words. Identifiers are digits, one at a time. Read back what changed, not what was already agreed. When a digit is unclear, ask again. Never guess. Write in the language you will speak. Facts live in the prompt. The customer lives in the variables. What the prompt leaves out, the model will make up. The caller may interrupt. The agent may not. No is an answer. Nudge once, then accept it. Never promise what the agent cannot do. Thank them once. Every turn moves the call forward. The ending is part of the call. Three real calls beat thirty imagined ones. Hinglish is one honking great idea — let's do more of that. ``` ## Each line, in one breath **Spoken is better than written.** The caller cannot scroll, skim or re-read. Whatever you write is heard once, at talking speed, on a phone. Write what a person would say, not what a page would show. **Short is better than complete.** A reply is the thing the caller waits through. One short sentence, two at most; the rest can wait for the next turn. **One question is better than two.** Ask two things and the caller answers neither. One ask, then stop and listen. **The first word matters more than the last paragraph.** The opening line is spoken before the model even runs — it is your first-audio latency and your first impression. Name, brand, person check, done. And a lead word at the top of every reply ("जी।", "अच्छा—", "ठीक है।") starts the voice sooner and sounds like someone who was listening. **Silence is a bug. So is a monologue.** Dead air makes people hang up; a lecture makes them stop listening. Both are fixed by the same thing: short turns that keep moving. **Numbers are words. Identifiers are digits, one at a time.** "five thousand two hundred rupees", never ₹5,200. A pincode is "three nine five zero zero seven", and it has six digits — if the agent heard three, the recogniser dropped some, and the agent should say so. **Read back what changed, not what was already agreed.** Confirmation by exception. One consolidated read-back at the end, not a receipt after every turn. **When a digit is unclear, ask again. Never guess.** A wrong address is a failed delivery; a wrong phone number is a lost customer. "सिर्फ pincode दोबारा बोलिए" costs three seconds. **Write in the language you will speak.** A Hindi call driven by an English prompt code-switches badly. Hindi words in Devanagari, everyday English words in Roman, and no romanized Hindi anywhere. **Facts live in the prompt. The customer lives in the variables.** The prompt is what the agent may say about the business. Names, amounts, dates and order IDs arrive per call, already in spoken form. One agent, thousands of calls. **What the prompt leaves out, the model will make up.** Unstated policy gets invented; an unlisted price gets quoted. Write the facts, and write what to say when the answer is not in them: "आपको team से call आएगा." **The caller may interrupt. The agent may not.** The engine lets the caller cut in, and the agent stops. When they are dictating an address, the agent's whole job is "जी", "हम्म" — and one read-back when they are done. **No is an answer. Nudge once, then accept it.** One light nudge is persuasion; a second is harassment. Capture the refusal as an outcome and close warmly. The engine enforces this ending — the prompt has to want it too. **Never promise what the agent cannot do.** Today the agent can talk, and it can end the call. It cannot transfer, send a link, or book anything in your system. "The team will call you back" is the honest line. **Thank them once.** In the goodbye, nowhere else. Models over-thank, and a धन्यवाद in every turn sounds like a machine. **Every turn moves the call forward.** No turn is only an acknowledgement. "जी, order confirm हो गया — अब address एक बार check कर लेते हैं" is one utterance, and it advances the call. **The ending is part of the call.** Write the success ending explicitly: summarise in one line, say goodbye, end the call. Without it the model keeps talking until the timeout, and the caller sits through the awkwardness. **Three real calls beat thirty imagined ones.** Call yourself. Say no, correct a digit, ask something off-script, go quiet. Read the transcript, change one thing, call again. A phone line is a different acoustic world from a browser mic. **Hinglish is one honking great idea — let's do more of that.** Real calls are not Hindi or English; they are "payment कब तक हो जाएगा", with the Hindi in Devanagari and the English in Roman. Write the way people already talk, and the agent sounds like one of them. *** The long version, with the structure, the number tables and the test loop, is the [prompting guide](/v2/prompting-guide). After Tim Peters, *The Zen of Python* (PEP 20). Python got nineteen lines and an unwritten twentieth; so did we.