# Create AI Assistant
Source: https://docs.miraiminds.co/api-reference/assistant/create-ai-assistant
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.yaml post /v2/call/initiate
# Initiate AI Web Call
Source: https://docs.miraiminds.co/api-reference/call/initiate-ai-web-call
/api-reference/legacy-openapi.yaml post /v2/call/web
# Receive Shiprocket NDR webhook
Source: https://docs.miraiminds.co/api-reference/call/receive-shiprocket-ndr-webhook
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
Base URLs, authentication, endpoint groups and error codes 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.
## 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 get your API keys and workspace ID.
## API endpoints
The endpoints fall into these 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: the resource does not exist |
| `429` | Rate limit exceeded: wait before retrying |
| `500` | Internal server error |
## Rate limits
The API rate-limits requests to keep the service stable. If you get a `429`
response, wait before retrying. Assistant updates also have a cooldown period so
that update operations don't overlap.
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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.yaml get /v1/showcase/calls/{call_id}
# List demo flows
Source: https://docs.miraiminds.co/api-reference/showcase/list-demo-flows
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.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
/api-reference/legacy-openapi.yaml get /v1/admin/voice-gallery
# Timed voice interviews in the browser
Source: https://docs.miraiminds.co/cookbooks/browser-voice-interviews
Run a 10-minute AI voice interview in your web app, with a personalised opening, live captions, a timed wrap-up and goodbye, moderator controls, automatic scoring and a text-only record.
In this cookbook you build a voice interview that runs in the candidate's
browser. The same pattern fits any timed, spoken session: screening interviews,
mock interviews, oral assessments, language practice or structured feedback
calls.
## What you'll build
* A candidate opens your page, allows the microphone and talks to an AI
interviewer that greets them by name.
* The page shows live captions for both speakers and whether the interviewer is
listening, thinking or speaking.
* The interview keeps time on its own. At 8:00 the interviewer stops starting
new topics, at 9:30 it thanks the candidate and says goodbye, and 10:00 is a
hard stop. If the candidate gives no answer for 60 seconds, the interviewer
closes politely.
* While the interview runs, a moderator on your side can nudge the interviewer
or end the interview with a spoken goodbye.
* When it ends, your backend receives one webhook with the full transcript and a
structured score, tagged with your own interview and candidate IDs.
* No audio is stored, so the transcript is your record of the interview.
**You need:** a workspace API key (`sk_live_…`) and webhook secret
(`whsec_…`), a backend in Python 3.10+ or Node.js 18+, and an HTTPS URL that
can receive webhooks (a tunnel to your laptop works while you build).
Keep the API key on your server. The browser only receives two per-call links,
and they work only for that one interview.
## How it works
```mermaid theme={null}
sequenceDiagram
participant Page as Interview page
participant App as Your backend
participant Mirai as Mirai Voice
participant Mod as Moderator
Page->>App: POST /api/interviews
App->>Mirai: POST /v2/calls (channel web, timing, metadata)
Mirai-->>App: id, ws_url, events_url
App-->>Page: ws_url, events_url
Page->>Mirai: audio (ws_url) + live events (events_url)
Note over Page,Mirai: interview runs, captions stream
Mod->>App: "slow down" / "end now"
App->>Mirai: POST /v2/calls/{id}/control
Mirai-->>Page: call.ended
Mirai-->>App: call.completed
Mirai-->>App: call.processed (transcript + score)
```
| Time after audio starts | What happens | Set by |
| :- | :- | :- |
| 0:00 | The interviewer speaks your opening line. | `first_message` |
| 8:00 | The interviewer is told to stop starting new topics and wrap up. | `timing.wrap_up_secs_before_end: 120` |
| 9:30 | The interviewer says goodbye and the call ends as `time-limit-close`. | `timing.close_secs_before_end: 30` |
| 10:00 | Hard stop, reached only if the goodbye could not finish. | `max_duration_secs: 600` |
| Any time | If the candidate says nothing for 60 seconds, the interviewer says goodbye and the call ends as `user-silence-close`. | `timing.user_silence_close_secs: 60` |
***
## Step 1: Create the interviewer
An [agent](/v2/agents) holds everything that is the same for every interview of
one kind: the interviewer's instructions, voice and language, how it ends a
call, and how the finished conversation is scored. Everything about one
candidate goes on the call in step 2.
### Write the instructions
The agent's `system_prompt` holds up to 8,000 characters. An interview guide
fits comfortably if it contains only what the interviewer needs during the
conversation. Move anything that is only needed afterwards, such as the scoring
rubric, into [analysis](#score-the-interview-automatically), which has its own
instructions.
This layout works well for voice interviews:
```text title="system_prompt" theme={null}
# Role
You are Asha, a friendly screening interviewer for {{company_name}}.
You are interviewing {{candidate_name}} for the {{role_title}} role.
# How you speak
- This is a spoken conversation. Keep every turn under three sentences.
- Ask one question at a time, then stop and listen.
- Never read out lists, headings or numbering.
- If the candidate mixes Hindi and English, reply the same way.
# Interview plan
Cover these areas in order, about two minutes each:
1. Their current role and one recent project.
2. {{focus_areas}}
3. A time they disagreed with a teammate and what happened.
4. Why this role interests them.
Ask at most one follow-up per area, and only when an answer is vague.
# Rules
- Do not comment on answers, scores or hiring outcomes.
- Never ask about age, religion, caste, marital status, health or other
personal characteristics.
- If asked about salary or next steps, say the hiring team will follow up
by email.
- If the candidate asks you to repeat, rephrase the question more simply.
# Timekeeping
The interview has a strict time limit. When you are told that time is nearly
up, do not start a new area. Let the candidate finish, then move to closing.
# Closing
When every area is covered, thank the candidate, say the hiring team will be
in touch, and end the call.
```
The `{{placeholders}}` are filled per interview from the call's `variables`
(up to 32 variables, 512 characters each). One agent then serves every
candidate for that role type. See the [prompting guide](/v2/prompting-guide) for
more on writing for voice.
Keep one agent per interview type (for example "Sales screening" and "Support
screening") rather than one agent per candidate. Changing a prompt is then a
single edit, and every interview of that type stays comparable.
### If your interview guide is longer than 8,000 characters
The 8,000-character limit on `system_prompt` is the same for every workspace,
and there is no per-call system prompt. A call can add its `variables`, its
`first_message` and, while it runs, [instructions](#step-4-let-a-moderator-steer-the-interview).
Long interview guides usually carry material the interviewer does not need while
it talks. Move each part to where it is used:
| Part of your guide | Where it goes |
| :- | :- |
| Scoring rubric, score bands, red flags, what "good" looks like | `analysis.prompt` (2,000 characters) and `analysis.output_schema`. They run after the call and never slow the conversation. |
| Everything specific to one candidate or role: name, role, focus areas, short background | `variables`: up to 32, 512 characters each, filled into `{{placeholders}}`. |
| The personalised greeting | The call's `first_message`, written in full by your backend. |
| Rules for time: pacing, wrapping up, closing | The call's `timing` and, if needed, [time checks](#optional-give-the-interviewer-time-checks). |
| Several interview formats in one guide | One agent per format. |
| Worked example answers and long explanations | Remove them. Keep one-line rules the interviewer can act on during the call. |
A shorter prompt also makes replies faster, because the model reads the prompt
on every turn.
### Score the interview automatically
Turn on `analysis` and the agent scores each finished interview against your
rubric. The result arrives with the transcript in step 5.
| Field | Limit | Use it for |
| :- | :- | :- |
| `analysis.enabled` | — | `true` to score every call of this agent. |
| `analysis.prompt` | 2,000 characters | Your rubric: what to look for and how to score it. |
| `analysis.success_criteria` | 2,000 characters | What makes an interview "successful" (usable), independent of the candidate's score. |
| `analysis.output_schema` | 32 properties | The JSON shape of the result. |
### Create the agent
```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 @interviewer.json
```
```json title="interviewer.json" theme={null}
{
"name": "Screening interviewer",
"system_prompt": "# Role\nYou are Asha, a friendly screening interviewer for {{company_name}}. …",
"first_message": "Hi {{candidate_name}}, thanks for joining. This screening takes about ten minutes. Shall we start?",
"voice": { "voice_id": "neha" },
"language": "en-IN",
"max_duration_secs": 600,
"end_call": {
"enabled": true,
"message": "Thank you for your time today. The hiring team will be in touch. Goodbye!",
"confirm": true
},
"analysis": {
"enabled": true,
"prompt": "Score the candidate from the transcript only. communication: clarity and structure of answers. role_fit: relevant experience for the role discussed in the interview. Use 1 (weak) to 5 (strong). List at most three strengths and three concerns, each quoting or paraphrasing what the candidate said.",
"success_criteria": "The candidate answered at least three of the four interview areas.",
"output_schema": {
"type": "object",
"properties": {
"communication": { "type": "integer", "minimum": 1, "maximum": 5 },
"role_fit": { "type": "integer", "minimum": 1, "maximum": 5 },
"strengths": { "type": "array", "items": { "type": "string" }, "maxItems": 3 },
"concerns": { "type": "array", "items": { "type": "string" }, "maxItems": 3 },
"recommendation": { "type": "string", "enum": ["advance", "human_review"] }
},
"required": ["communication", "role_fit", "recommendation"]
}
}
}
```
```python theme={null}
import os
from pathlib import Path
import 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, timeout=30, json={
"name": "Screening interviewer",
"system_prompt": Path("interviewer_prompt.txt").read_text(),
"first_message": "Hi {{candidate_name}}, thanks for joining. "
"This screening takes about ten minutes. Shall we start?",
"voice": {"voice_id": "neha"},
"language": "en-IN",
"max_duration_secs": 600,
"end_call": {
"enabled": True,
"message": "Thank you for your time today. The hiring team will be in touch. Goodbye!",
"confirm": True,
},
"analysis": {
"enabled": True,
"prompt": Path("scoring_rubric.txt").read_text(),
"success_criteria": "The candidate answered at least three of the four interview areas.",
"output_schema": {
"type": "object",
"properties": {
"communication": {"type": "integer", "minimum": 1, "maximum": 5},
"role_fit": {"type": "integer", "minimum": 1, "maximum": 5},
"strengths": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
"concerns": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
"recommendation": {"type": "string", "enum": ["advance", "human_review"]},
},
"required": ["communication", "role_fit", "recommendation"],
},
},
}).raise_for_status().json()
print(agent["id"]) # save as MIRAI_AGENT_ID
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
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: "Screening interviewer",
system_prompt: await readFile("interviewer_prompt.txt", "utf8"),
first_message:
"Hi {{candidate_name}}, thanks for joining. This screening takes about ten minutes. Shall we start?",
voice: { voice_id: "neha" },
language: "en-IN",
max_duration_secs: 600,
end_call: {
enabled: true,
message: "Thank you for your time today. The hiring team will be in touch. Goodbye!",
confirm: true,
},
analysis: {
enabled: true,
prompt: await readFile("scoring_rubric.txt", "utf8"),
success_criteria: "The candidate answered at least three of the four interview areas.",
output_schema: {
type: "object",
properties: {
communication: { type: "integer", minimum: 1, maximum: 5 },
role_fit: { type: "integer", minimum: 1, maximum: 5 },
strengths: { type: "array", items: { type: "string" }, maxItems: 3 },
concerns: { type: "array", items: { type: "string" }, maxItems: 3 },
recommendation: { type: "string", enum: ["advance", "human_review"] },
},
required: ["communication", "role_fit", "recommendation"],
},
},
}),
});
const agent = await res.json();
if (!res.ok) throw new Error(`${agent.error.code}: ${agent.error.message}`);
console.log(agent.id); // save as MIRAI_AGENT_ID
```
**Keep a person in the loop.** The score is a summary that helps your team
review interviews faster. A person should make the hiring decision, so the
schema above offers `advance` or `human_review` and deliberately has no
`reject` option. Automated decisions about people are regulated in many places,
including under the GDPR and India's DPDP Act.
***
## Step 2: Start an interview from your backend
When a candidate opens the interview page, your backend creates the call with
everything specific to this candidate, stores the call ID beside its own
interview record, and gives the page two links.
| Field | Value in this recipe | Why |
| :- | :- | :- |
| `channel` | `"web"` | A [browser call](/v2/web-calls). It needs no phone number. |
| `variables` | `candidate_name`, `role_title`, `company_name`, `focus_areas` | Fill the prompt's placeholders for this candidate. |
| `first_message` | Written by your backend | A personalised opening line for this interview only. It replaces the agent's `first_message` for this call. |
| `metadata` | `interview_id`, `candidate_id` | Your own IDs. They come back unchanged on the call and in every webhook, and the interviewer never sees them. |
| `max_duration_secs` | `600` | The hard stop. |
| `timing` | wrap up at 8:00, goodbye at 9:30, close after 60 s of silence | [Timing policy](/v2/web-calls#timing-policy). |
| `recording_enabled` | `false` | Store no audio. Captions and the transcript still work. |
| `final_results` | `true` | One `call.processed` webhook with the transcript and the score. |
| `webhook_url` | Your webhook route | Where this interview's events go. |
Send an `Idempotency-Key` built from your interview ID. If the request is
retried, you get the same call back instead of a second one.
```python title="server.py" theme={null}
# pip install fastapi uvicorn httpx
import os
import uuid
import httpx
from fastapi import FastAPI, HTTPException, Request
API = "https://sandbox.voice.miraiminds.co"
AGENT_ID = os.environ["MIRAI_AGENT_ID"]
WEBHOOK_URL = os.environ["PUBLIC_WEBHOOK_URL"] # https://your-app.example/mirai/webhook
app = FastAPI()
mirai = httpx.AsyncClient(
base_url=API,
headers={"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"},
timeout=30,
)
# Stand-ins for your own code: sign-in and storage.
from myapp import current_candidate, db
@app.post("/api/interviews")
async def start_interview(request: Request):
candidate = await current_candidate(request)
interview = await db.interviews.create(candidate_id=candidate.id, role=candidate.role)
r = await mirai.post(
"/v2/calls",
headers={"Idempotency-Key": f"interview-{interview.id}"},
json={
"agent_id": AGENT_ID,
"channel": "web",
"variables": {
"candidate_name": candidate.first_name,
"role_title": candidate.role,
"company_name": "Acme",
"focus_areas": "Their experience with customer escalations "
"and how they prioritise a busy queue.",
},
"first_message": (
f"Hi {candidate.first_name}, thanks for making time for your "
f"{candidate.role} screening. It takes about ten minutes. "
"Shall we begin?"
),
"metadata": {"interview_id": interview.id, "candidate_id": candidate.id},
"max_duration_secs": 600,
"timing": {
"wrap_up_secs_before_end": 120,
"wrap_up_instruction": "Time is nearly up. Do not start a new area. "
"Let the candidate finish, then close.",
"close_secs_before_end": 30,
"close_message": "That's all the time we have. Thank you, and the "
"hiring team will be in touch. Goodbye!",
"user_silence_close_secs": 60,
"user_silence_message": "It seems you've stepped away, so I'll end "
"the interview here. Goodbye!",
},
"recording_enabled": False,
"final_results": True,
"webhook_url": WEBHOOK_URL,
},
)
call = r.json()
if r.status_code != 202:
raise HTTPException(502, call["error"]["code"])
await db.interviews.update(interview.id, mirai_call_id=call["id"], status="started")
# The page gets its two links and your own ID. Never the API key.
return {
"interview_id": interview.id,
"ws_url": call["ws_url"],
"events_url": call["events_url"],
}
```
```javascript title="server.js" theme={null}
// npm install express
import express from "express";
// Stand-ins for your own code: sign-in and storage.
import { requireCandidate, db } from "./myapp.js";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const app = express();
app.post("/api/interviews", requireCandidate, express.json(), async (req, res) => {
const candidate = req.candidate;
const interview = await db.interviews.create({ candidateId: candidate.id, role: candidate.role });
const r = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": `interview-${interview.id}`,
},
body: JSON.stringify({
agent_id: process.env.MIRAI_AGENT_ID,
channel: "web",
variables: {
candidate_name: candidate.firstName,
role_title: candidate.role,
company_name: "Acme",
focus_areas: "Their experience with customer escalations and how they prioritise a busy queue.",
},
first_message:
`Hi ${candidate.firstName}, thanks for making time for your ${candidate.role} screening. ` +
"It takes about ten minutes. Shall we begin?",
metadata: { interview_id: interview.id, candidate_id: candidate.id },
max_duration_secs: 600,
timing: {
wrap_up_secs_before_end: 120,
wrap_up_instruction: "Time is nearly up. Do not start a new area. Let the candidate finish, then close.",
close_secs_before_end: 30,
close_message: "That's all the time we have. Thank you, and the hiring team will be in touch. Goodbye!",
user_silence_close_secs: 60,
user_silence_message: "It seems you've stepped away, so I'll end the interview here. Goodbye!",
},
recording_enabled: false,
final_results: true,
webhook_url: process.env.PUBLIC_WEBHOOK_URL,
}),
});
const call = await r.json();
if (r.status !== 202) return res.status(502).json({ error: call.error.code });
await db.interviews.update(interview.id, { miraiCallId: call.id, status: "started" });
// The page gets its two links and your own ID. Never the API key.
res.json({ interview_id: interview.id, ws_url: call.ws_url, events_url: call.events_url });
});
```
What to know about the two links:
* `ws_url` is the audio socket. It works only once and must be opened within 5
minutes. If the candidate refreshes the page, create a new interview.
* `events_url` is the live caption stream. You can reopen it at any point during
the interview and for one hour after it ends.
* If the page never connects, the call ends as `failed` with
`ended_reason: "no-media"` and is not billed.
***
## Step 3: Build the interview page
The page asks for the microphone, starts the interview, plays the interviewer's
voice, and shows captions, the interviewer's state and a countdown. The audio
format is described under [Audio socket](/v2/web-calls#audio-socket) on the
Browser calls page.
```html title="interview.html" theme={null}
Interview10:00
```
Choices you can change:
* **Muting while the interviewer speaks.** With the box ticked, the page sends
silence while `agent.state` is `speaking`, so room noise is never taken for an
answer and the candidate cannot interrupt. `speaking` follows the actual audio,
not the text. Untick it to let candidates talk over the interviewer, which
feels more natural in a quiet room. Keep sending frames either way: silence
keeps the line open, and the silence timer counts only real speech.
* **Captions.** Each caption appears when its turn is complete. An interrupted
interviewer turn shows only the words the candidate actually heard.
* **Leaving.** Closing the audio socket ends the interview immediately. To end
with a spoken goodbye instead, call your backend's end route from
[step 4](#step-4-let-a-moderator-steer-the-interview).
***
## Step 4: Let a moderator steer the interview
While the interview runs, a moderator, such as a recruiter watching the
captions, can change the interviewer's direction or end the interview with a
goodbye. Your backend does both through
[live control](/v2/web-calls#live-control).
* An **instruction** is added to the interviewer's context from its next reply
and stays for the rest of the interview. It is never read aloud and does not
appear in the transcript.
* A **close** speaks a goodbye line and ends the interview as `completed`, with
`ended_reason: "api-ended-call"`.
Useful instructions for interviews:
| Situation | Instruction |
| :- | :- |
| The candidate is nervous | "The candidate seems nervous. Slow down and be encouraging." |
| An area is going long | "Move on to the next area now." |
| Something needs probing | "Ask one follow-up about how they measured the result of that project." |
| Time is short for a known reason | "Skip the remaining areas and move to closing." |
```python title="server.py (continued)" theme={null}
from myapp import current_moderator # stand-in: only your staff may steer
async def control(interview_id: str, body: dict, action_key: str) -> dict:
interview = await db.interviews.get(interview_id)
r = await mirai.post(
f"/v2/calls/{interview.mirai_call_id}/control",
headers={"Idempotency-Key": f"{interview_id}-{action_key}"},
json=body,
)
out = r.json()
if r.status_code == 409: # call_not_active: not connected yet, or already over
return {"status": "not_active"}
if r.status_code not in (200, 202):
raise HTTPException(502, out["error"]["code"])
return {"status": out["status"]} # applied | pending | rejected
@app.post("/api/interviews/{interview_id}/steer")
async def steer(interview_id: str, request: Request):
await current_moderator(request)
text = (await request.json())["text"][:1000]
return await control(interview_id, {"type": "instruction", "text": text},
action_key=f"steer-{uuid.uuid4().hex}")
@app.post("/api/interviews/{interview_id}/end")
async def end(interview_id: str, request: Request):
await current_moderator(request)
return await control(
interview_id,
{"type": "close", "message": "Thank you for your time today. Goodbye!"},
action_key="close", # the same key every time: a double click closes once
)
```
```javascript title="server.js (continued)" theme={null}
import { randomUUID } from "node:crypto";
import { requireModerator } from "./myapp.js"; // stand-in: only your staff may steer
async function control(interviewId, body, actionKey) {
const interview = await db.interviews.get(interviewId);
const r = await fetch(`${API}/v2/calls/${interview.miraiCallId}/control`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": `${interviewId}-${actionKey}`,
},
body: JSON.stringify(body),
});
const out = await r.json();
if (r.status === 409) return { status: "not_active" }; // not connected yet, or already over
if (r.status !== 200 && r.status !== 202) throw new Error(out.error.code);
return { status: out.status }; // applied | pending | rejected
}
app.post("/api/interviews/:id/steer", requireModerator, express.json(), async (req, res) => {
const text = String(req.body.text).slice(0, 1000);
res.json(await control(req.params.id, { type: "instruction", text }, `steer-${randomUUID()}`));
});
app.post("/api/interviews/:id/end", requireModerator, async (req, res) => {
// The same key every time: a double click closes once.
res.json(await control(req.params.id,
{ type: "close", message: "Thank you for your time today. Goodbye!" }, "close"));
});
```
A moderator view can show the same captions as the candidate. Give your backend
the call's `events_url`, or read the stream server-side with your API key. See
[live events](/v2/web-calls#live-events).
### End at once
`close` lets the interviewer say goodbye first. When the interview must stop
immediately, for example because the candidate reports a problem, abort it
instead. The audio stops at once and the page receives `call.ended`. The call
ends `aborted` and is not billed.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/abort \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
### Optional: give the interviewer time checks
The timing policy already handles the wrap-up at 8:00 and the goodbye at 9:30.
If you also want the interviewer to pace itself earlier, send it the remaining
time as instructions. Start the clock on the `call.started` webhook: it fires
when audio goes live, which is the moment the timing policy counts from.
```python title="server.py (continued)" theme={null}
from datetime import datetime, timedelta
TIME_CHECKS = { # seconds after audio starts -> what the interviewer is told
300: "About five minutes remain. Keep each area short so all four are covered.",
}
async def on_call_started(event: dict): # call from the webhook for "call.started"
call = event["data"]["call"]
started = datetime.fromisoformat(call["started_at"].replace("Z", "+00:00"))
interview_id = call["metadata"]["interview_id"]
for after, text in TIME_CHECKS.items():
await queue.enqueue_at(started + timedelta(seconds=after), "time_check",
interview_id=interview_id, after=after, text=text)
async def time_check(interview_id: str, after: int, text: str):
# 409 call_not_active (already over) comes back as "not_active": nothing to do.
await control(interview_id, {"type": "instruction", "text": text},
action_key=f"time-check-{after}")
```
```javascript title="server.js (continued)" theme={null}
const TIME_CHECKS = { // seconds after audio starts -> what the interviewer is told
300: "About five minutes remain. Keep each area short so all four are covered.",
};
export async function onCallStarted(event) { // call from the webhook for "call.started"
const { call } = event.data;
const started = Date.parse(call.started_at);
for (const [after, text] of Object.entries(TIME_CHECKS)) {
await queue.add("time-check",
{ interviewId: call.metadata.interview_id, after, text },
{ delay: Math.max(0, started + Number(after) * 1000 - Date.now()) });
}
}
export async function timeCheck({ interviewId, after, text }) {
// 409 call_not_active (already over) comes back as "not_active": nothing to do.
await control(interviewId, { type: "instruction", text }, `time-check-${after}`);
}
```
Instructions add up. Each one stays in the interviewer's context for the rest
of the interview, so keep them few and short.
***
## Step 5: Receive the transcript and the score
Your webhook receives these events for each interview, in order:
1. **`call.started`** when the candidate's audio connects. Use it to start any
time checks from step 4.
2. **A terminal event** as soon as the interview ends: `call.completed`,
`call.failed` or `call.aborted`. Mark the interview as over.
3. **`call.processed`**, after the transcript has settled (within about two
minutes) and the score is ready. It carries the transcript in
`data.transcript` and the score in `data.call.analysis`.
Every event carries your `metadata`, so you can find the interview without a
lookup table. Verify the signature on every delivery, and ignore any event whose
`id` you have already seen. The Webhooks page explains
[signature verification](/v2/webhooks#signature-verification).
```python title="server.py (continued)" theme={null}
import json
from fastapi import Response
from myapp import queue, verify_signature # verify_signature: see the Webhooks page
WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"] # whsec_…
@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)
if not await db.events.first_time(event["id"]): # retries keep the same id
return Response(status_code=200)
call = event["data"]["call"]
interview_id = (call.get("metadata") or {}).get("interview_id")
if event["type"] == "call.started":
await db.interviews.update(interview_id, status="in_progress")
await on_call_started(event) # optional time checks, step 4
elif event["type"] in ("call.completed", "call.failed", "call.aborted"):
await db.interviews.update(interview_id, status="ended",
ended_reason=call["ended_reason"])
elif event["type"] == "call.processed":
# Answer fast; do the work in the background.
await queue.enqueue("store_interview_result", interview_id=interview_id,
call_id=call["id"], event=event)
return Response(status_code=200)
async def store_interview_result(interview_id: str, call_id: str, event: dict):
transcript = event["data"].get("transcript") or {}
status = transcript.get("status")
if status == "fetch_required": # longer than the inline limit
r = await mirai.get(f"/v2/calls/{call_id}/transcript")
turns, status = r.json()["turns"], "ready"
else:
turns = transcript.get("turns") or []
analysis = event["data"]["call"].get("analysis") or {}
await db.interviews.update(
interview_id,
transcript_status=status, # ready | empty | failed
transcript=turns, # [{role, text, start_ms, end_ms}]
summary=analysis.get("summary"),
usable=analysis.get("success"),
scores=analysis.get("data"), # the shape of your output_schema
)
```
```javascript title="server.js (continued)" theme={null}
import { verifySignature } from "./verify.js"; // see the Webhooks page
import { queue } from "./myapp.js";
// express.raw, not express.json: the signature covers the exact bytes.
app.post("/mirai/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.get("X-Mirai-Signature") ?? "";
if (!verifySignature(req.body, signature, process.env.MIRAI_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (!(await db.events.firstTime(event.id))) return res.sendStatus(200); // retries keep the id
const { call } = event.data;
const interviewId = call.metadata?.interview_id;
if (event.type === "call.started") {
await db.interviews.update(interviewId, { status: "in_progress" });
await onCallStarted(event); // optional time checks, step 4
} else if (["call.completed", "call.failed", "call.aborted"].includes(event.type)) {
await db.interviews.update(interviewId, { status: "ended", endedReason: call.ended_reason });
} else if (event.type === "call.processed") {
// Answer fast; do the work in the background.
await queue.add("store-interview-result", { interviewId, callId: call.id, event });
}
res.sendStatus(200);
});
export async function storeInterviewResult({ interviewId, callId, event }) {
let { status, turns = [] } = event.data.transcript ?? {};
if (status === "fetch_required") { // longer than the inline limit
const r = await fetch(`${API}/v2/calls/${callId}/transcript`, { headers: auth });
({ turns } = await r.json());
status = "ready";
}
const analysis = event.data.call.analysis ?? {};
await db.interviews.update(interviewId, {
transcriptStatus: status, // ready | empty | failed
transcript: turns, // [{role, text, start_ms, end_ms}]
summary: analysis.summary,
usable: analysis.success,
scores: analysis.data, // the shape of your output_schema
});
}
```
What the parts of `call.processed` mean for an interview:
| Field | Values | What to do |
| :- | :- | :- |
| `data.transcript.status` | `ready`, `empty`, `failed`, `fetch_required` | `ready`: store the turns. `empty`: the candidate never spoke. `failed`: record it and do not wait. `fetch_required`: fetch the turns with [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript). |
| `data.call.analysis.data` | Your `output_schema` | The scores for review. |
| `data.call.analysis.success` | `true`, `false` or `null` | Whether the interview met your `success_criteria`, for example whether enough areas were covered to review it. |
| `data.call.ended_reason` | `time-limit-close`, `assistant-ended-call`, `user-silence-close`, `api-ended-call`, `customer-ended-call`, … | How it ended. See [ended reasons](/v2/calls#ended-reasons). |
| `data.processing.analysis` | `done`, `failed`, `skipped` | Whether the score was produced. |
If your webhook was down, deliveries are [retried](/v2/webhooks#retries). You
can also read `GET /v2/calls/{id}` (it reports `transcript_status`) and
`GET /v2/calls/{id}/transcript` at any time.
***
## Test it
Short timers show every behaviour in about two minutes. Use them while you
build, then switch back to the 10-minute values.
```json title="Test values for POST /v2/calls" theme={null}
{
"max_duration_secs": 120,
"timing": {
"wrap_up_secs_before_end": 60,
"wrap_up_instruction": "Time is nearly up. Do not start a new area. Let the candidate finish, then close.",
"close_secs_before_end": 20,
"close_message": "That's all the time we have. Goodbye!",
"user_silence_close_secs": 30,
"user_silence_message": "It seems you've stepped away. Goodbye!"
}
}
```
Run three interviews:
1. **Talk for the full two minutes.** At 1:00 the interviewer stops starting new
topics. At 1:40 it says the close message and the page shows "Time's up".
Your webhook receives `call.completed`, then `call.processed` with
`ended_reason: "time-limit-close"`, the transcript and a score.
2. **Say one sentence, then stay silent.** About 30 seconds later the
interviewer says the silence message and the interview ends as
`user-silence-close`.
3. **Steer and end from the moderator routes.** An instruction returns
`applied` and the next reply follows it. The end route makes the interviewer
say goodbye; the interview ends as `api-ended-call`.
| Symptom | Likely cause | Fix |
| :- | :- | :- |
| The audio socket closes as soon as it opens | `ws_url` was already used, or is older than 5 minutes | Create a new interview for each page load. |
| No sound, but captions appear | The `AudioContext` was created outside a click | Create it inside the start button's handler, as in step 3. |
| The interviewer hears itself | Echo cancellation is off, or the candidate uses speakers without it | Keep `echoCancellation: true`; suggest headphones. |
| A control returns `409 call_not_active` | The page has not connected yet, or the interview already ended | Treat it as "nothing to steer". |
| The interviewer ignores the wrap-up | The prompt does not say what "time is nearly up" means | Keep the **Timekeeping** section from step 1. |
| No `call.processed` arrives | `final_results` or `webhook_url` missing on the call | Set both on every interview. |
| `call.processed` has no score | `analysis.enabled` is off on the agent | Enable it (step 1). New interviews pick it up. |
***
## Privacy and compliance
* Tell candidates up front that they are talking to an AI interviewer, that the
conversation is transcribed, and how the transcript and score will be used.
Get their consent before the interview starts.
* With `recording_enabled: false`, no recording is stored and
`GET /v2/calls/{id}/recording` returns `404`. The transcript is kept. See
[data retention](/v2/limits#data-retention) for how long. To turn recording
off for your whole workspace, ask
[help@miraiminds.co](mailto:help@miraiminds.co).
* Put only IDs in `metadata`. Never put names, contact details or interview
answers there.
* Keep a person in the loop for hiring decisions. Use the score to sort and
review interviews, and do not use it to reject candidates automatically.
* `ws_url` and `events_url` give access to one interview. Send them only to that
candidate's page over HTTPS, and keep them out of logs and analytics. Your API
key stays on your server.
* Treat captions as untrusted text and render them with `textContent`, never as
HTML.
## Production checklist
* [ ] One agent per interview type, with a **Timekeeping** section and an
`analysis` rubric.
* [ ] `metadata` carries your interview and candidate IDs; your database stores
the returned call `id`.
* [ ] Every create uses an `Idempotency-Key` built from your interview ID.
* [ ] `max_duration_secs`, `timing`, `recording_enabled: false`,
`final_results: true` and `webhook_url` are set on every interview.
* [ ] Moderator routes check that the caller is your staff and use idempotency
keys.
* [ ] The webhook verifies signatures, ignores repeated event IDs, answers
quickly and processes `call.processed` in the background.
* [ ] Your interview status is driven by the terminal event, and your
transcript processing by `call.processed`.
* [ ] Candidates see an AI disclosure and give consent before the interview
starts.
* [ ] You ran the three test interviews above on the tier you will use in
production.
## Common questions
Yes, from your backend: `POST /v2/calls/{id}/control` with
`{"type": "instruction", "text": "…"}`. The agent follows it from its next
reply. If the action starts in the browser, the page asks your backend, which
holds the key. See [step 4](#step-4-let-a-moderator-steer-the-interview).
Yes. `timing` on the call gives the agent a wrap-up instruction a set number
of seconds before the cap, then a spoken goodbye before the cap, and a polite
close after a stretch of user silence. The clock starts when audio goes live.
To make the agent pace itself earlier, send
[time checks](#optional-give-the-interviewer-time-checks).
Yes. The create response returns the call `id`. Use a `close` control for a
spoken goodbye, or `POST /v2/calls/{id}/abort` to stop at once. See
[step 4](#end-at-once).
Yes. Open `events_url` with `EventSource`: every completed turn arrives as a
`transcript.turn` event for both speakers, in real time. Text arrives per
completed turn, not word by word. Audio travels over a WebSocket (`ws_url`)
and events over Server-Sent Events, so you need no other client library. See
[step 3](#step-3-build-the-interview-page).
Yes. `agent.state` events report `speaking`, `listening`, `processing` and
`ended`. `speaking` follows the actual audio, so it is safe for muting the
microphone while the agent talks, as the page in step 3 does.
No. It is a platform limit, the same for every workspace, and there is no
per-call system prompt. See
[If your interview guide is longer than 8,000 characters](#if-your-interview-guide-is-longer-than-8000-characters)
for where each part of a long guide belongs.
Yes. `first_message` on the call replaces the agent's opening line for that
call only. Your backend can write it in full, with no placeholders. If you do
use a `{{placeholder}}` and its value is missing, it is replaced with nothing,
so the agent never reads out the braces.
Yes, unchanged, in `data.call.metadata` of every webhook for the call and on
`GET /v2/calls/{id}`. The create response also returns the call `id` to store.
The terminal event (`call.completed`, `call.failed` or `call.aborted`) means
the conversation is over. `call.processed`, sent when you create the call with
`final_results: true`, means "everything is ready": it carries the transcript
and the score. Process the session on `call.processed`. See
[step 5](#step-5-receive-the-transcript-and-the-score).
Yes. `webhook_url` on the call applies to that call only.
It arrives inline in `call.processed` (`data.transcript.turns`), and you can
fetch it any time with `GET /v2/calls/{id}/transcript`. It is normally ready
by the time the terminal webhook arrives; `transcript_status` on the call
says `ready`. `call.processed` follows within about two minutes of the call
ending, plus the time scoring takes.
Yes. Create the call with `recording_enabled: false`: no audio is stored, and
captions, the transcript and scoring still work. To turn audio off for your
whole workspace, ask [help@miraiminds.co](mailto:help@miraiminds.co). See
[data retention](/v2/limits#data-retention) for how long transcripts are kept.
Everything in this cookbook uses [`POST /v2/calls`](/v2/web-calls#create-the-call)
with `channel: "web"`. Build new browser sessions on it.
## Related
Audio format, live events, live control and timing reference.
Event types, signature verification, retries and final results.
Every agent field, voices and ending a call.
Prompt, metadata, timing and data-retention limits.
# Receive inbound phone calls
Source: https://docs.miraiminds.co/cookbooks/inbound-calling
Buy a number through Mirai or connect your own supported telco number, then assign an agent and activate inbound calling.
**To receive inbound calls, first buy a dedicated number through Mirai or
connect a number from your own supported telco account. Then assign a published
voice agent and activate incoming calls.**
Customers can then call your business number and speak with that agent. This
cookbook takes you through both number options, your first incoming
conversation, and its call history, transcript, and recording.
## What you'll build
A phone number that answers incoming calls with your agent's greeting and
instructions. You can set it up in the console or from your backend.
Choose the option that suits your business:
| Option | What you do |
| :- | :- |
| **Buy a number through Mirai** | Open **Phone numbers → Add a number**, choose **Mirai wallet** as the billing account, complete any required business verification, and confirm the number rental. You do not need your own Plivo account or credentials. |
| **Connect your own telco** | Connect your supported provider account and import a voice number you own. You keep your provider account and its billing. **Plivo is the currently supported BYO provider.** |
Both options add a dedicated number to your workspace. You then use the same
agent assignment and activation steps. Numbers purchased through Mirai are
recurring rentals; review their charges and renewal terms before confirming.
This recipe currently supports **Indian +91 voice numbers**. WhatsApp calling has a
[separate cookbook](/cookbooks/whatsapp-calling-for-partners).
You need:
* An enabled workspace with enough wallet balance for at least one minute at
its applicable calling rate.
* A published agent in that workspace. A draft or archived agent cannot answer
incoming calls.
* A dedicated, voice-enabled number purchased through Mirai or connected from
your own supported telco. For BYO Plivo only, have your account's Auth ID and
Auth Token available.
* For API setup: your workspace API key, its assigned API host, cURL and `jq`,
or Python 3.10+ with `httpx`, or Node.js 18+.
Purchasing or importing a number is the first step. Assign a published agent
and activate incoming calls to make it answer. Test a real incoming call before
you advertise the number.
## How it works
```mermaid theme={null}
sequenceDiagram
participant Caller
participant Number as Your dedicated phone number
participant Mirai as Mirai Voice
participant App as Your backend
Caller->>Number: Calls your business number
Number->>Mirai: Delivers the incoming call
Mirai-->>Caller: Assigned agent greets and converses
Mirai->>App: Signed call events, if configured
App->>Mirai: Read call results
```
You assign the agent once. Each incoming call uses that assignment; you do not
send `POST /v2/calls` to receive a call. That endpoint starts an outgoing call.
## Set up in the console
1. Open the [console](https://console.voice.miraiminds.co) and select your
workspace. Trial workspaces use the
[sandbox console](https://sandbox.voice.miraiminds.co).
2. Open **Phone numbers** and follow either option below. If your workspace
already has a dedicated number, continue to [assign and activate](#assign-your-agent-and-activate).
### Option A: Buy a number through Mirai
1. Click **Add a number** and choose **Mirai wallet** under **Billing account**.
2. Complete **Verify your business** if prompted. Submit the requested details
and documents, then use **Refresh status** to check the review result.
3. After approval, search the available numbers and select one for your business.
4. Review the rental amount, renewal terms, and separate call-usage charges.
Confirm the rental from your workspace wallet.
5. Once the purchase completes and the number appears in your workspace,
continue to agent assignment below.
You do not need to open a separate Plivo account. Mirai manages the provider
integration for the number you rent through Mirai. Availability depends on
inventory and business verification. If your purchase is still processing,
check its status before trying again. Contact
[support](mailto:help@miraiminds.co) if the option is unavailable.
### Option B: Connect your own telco number
1. Click **Connect Plivo**, enter your Plivo Auth ID and Auth Token, and save.
2. Select **Import a number**, then **Import** beside the voice number you own.
3. Once the number appears in your workspace, continue to agent assignment below.
This option currently supports Plivo. Your provider continues to bill your own
account for its number rental and telephony usage.
### Assign your agent and activate
These steps are the same for numbers purchased through Mirai and BYO numbers:
1. Create your agent in **Agent Studio**. Set its greeting, instructions,
language, voice, and maximum call duration, then publish it.
2. Return to **Phone numbers**. Under **Incoming calls go to**, select your
published agent and click **Save**.
3. Click **Activate incoming calls**. Review the routing change, confirm that
you want incoming calls to reach Mirai, and activate.
4. Call the number from another phone and complete the [test below](#test-it).
Activation can replace the number's existing voice application. Check where
calls currently go before confirming. Numbers with messaging configured are
refused to protect their SMS/MMS routing; use a dedicated voice number.
## Set up through the API
The API uses the same number options. If you purchased a number through Mirai
in the console, list it in step 1 and skip the BYO integration and import in
step 3. You do not need Plivo credentials for a Mirai-managed number. The agent
assignment, activation, and result APIs are the same for both options.
### 1. Choose your API host
Use the host shown with your workspace's API credentials:
| Workspace | API base URL |
| :- | :- |
| Activated production workspace | `https://prod.voice.miraiminds.co` |
| Sandbox workspace | `https://sandbox.voice.miraiminds.co` |
Set `VOICE_BASE_URL` and `VOICE_API_KEY` in your backend environment. For BYO
Plivo, also set `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, and `PLIVO_NUMBER` to the
owned number in E.164 format, including `+91`. Keep secrets on your backend.
Changing the host does not activate a sandbox workspace for production.
Run the shell examples in Bash with `set -euo pipefail` so a failed request
stops the sequence:
```bash theme={null}
set -euo pipefail
: "${VOICE_BASE_URL:?Set your assigned API host}"
: "${VOICE_API_KEY:?Set your workspace API key}"
curl --fail-with-body "$VOICE_BASE_URL/v2/wallet" \
-H "Authorization: Bearer $VOICE_API_KEY"
curl --fail-with-body "$VOICE_BASE_URL/v2/phone-numbers" \
-H "Authorization: Bearer $VOICE_API_KEY"
```
The number list has `data`, `has_more`, and `next_cursor`. Follow `next_cursor`
with `?cursor=…` if needed. If your number is already listed, retain its `id`,
set `NUMBER_ID`, and skip step 3. To inspect number onboarding availability,
use `GET /v2/telephony/options`.
### 2. Create a published agent
Save this as `agent.json`, then adapt its greeting and instructions for your
business. It has no required caller inputs, so a new caller can reach it
without a customer record.
```json theme={null}
{
"name": "Inbound support",
"system_prompt": "You are Mira, an AI voice agent for our support team. Ask how you can help, listen carefully, and keep replies brief. Ask for clarification when needed. Do not invent account information or promise actions you cannot perform. If the caller is finished, say goodbye and end the call.",
"first_message": "Hello, this is Mira, an AI voice agent for the support team. How can I help you?",
"voice": { "voice_id": "neha" },
"language": "en",
"max_duration_secs": 300,
"draft": false,
"end_call": {
"enabled": true,
"message": "Thank you for calling. Goodbye.",
"confirm": false
}
}
```
```bash theme={null}
AGENT=$(curl --fail-with-body -sS "$VOICE_BASE_URL/v2/agents" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @agent.json)
AGENT_ID=$(jq -er '.id' <<< "$AGENT")
jq '{id, revision, draft, archived}' <<< "$AGENT"
```
```python theme={null}
import json
import os
from pathlib import Path
import httpx
response = httpx.post(
f"{os.environ['VOICE_BASE_URL']}/v2/agents",
headers={"Authorization": f"Bearer {os.environ['VOICE_API_KEY']}"},
json=json.loads(Path("agent.json").read_text()),
timeout=30,
)
response.raise_for_status()
agent = response.json()
print(agent["id"]) # Use this as AGENT_ID in the following steps.
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
const response = await fetch(`${process.env.VOICE_BASE_URL}/v2/agents`, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(30000),
headers: {
Authorization: `Bearer ${process.env.VOICE_API_KEY}`,
"Content-Type": "application/json",
},
body: await readFile("agent.json", "utf8"),
});
if (!response.ok) throw new Error(`Create agent failed: ${response.status}`);
const agent = await response.json();
console.log(agent.id); // Use this as AGENT_ID in the following steps.
```
Creation returns `201` with the agent object. Confirm `draft: false` and
`archived: false`. You can also use an existing published agent from
`GET /v2/agents` in the same workspace.
**Caller inputs:** incoming calls supply `caller_number` and `called_number`.
Any other required input must have a usable default in your agent's
`input_schema`; otherwise admission fails. Ask unknown callers for their name
or order number during the conversation. Do not treat caller ID as proof of
identity. See [agents](/v2/agents) and [tools](/v2/tools) for API lookups.
### 3. Connect your own telco number (BYO only)
**Skip this step if you purchased your number through Mirai or it is already
in your workspace.** The following example connects Plivo, the currently
supported BYO provider. If the Plivo integration
already exists, get its ID from `GET /v2/telephony/connections` instead of
creating it again.
```bash theme={null}
INTEGRATION=$(jq -n '{provider:"plivo", name:"Support line",
auth_id:env.PLIVO_AUTH_ID, auth_token:env.PLIVO_AUTH_TOKEN}' |
curl --fail-with-body -sS "$VOICE_BASE_URL/v2/telephony/connections" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @-)
CONNECTION_ID=$(jq -er '.id' <<< "$INTEGRATION")
# Confirm the number appears in this integration's inventory.
curl --fail-with-body "$VOICE_BASE_URL/v2/telephony/connections/$CONNECTION_ID/numbers" \
-H "Authorization: Bearer $VOICE_API_KEY"
NUMBER=$(jq -n --arg connection "$CONNECTION_ID" \
'{connection_id:$connection, number:env.PLIVO_NUMBER}' |
curl --fail-with-body -sS "$VOICE_BASE_URL/v2/phone-numbers" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @-)
NUMBER_ID=$(jq -er '.id' <<< "$NUMBER")
```
Both create requests return `201`. The integration should have
`state: "verified"`; the imported number initially has `state: "imported"`.
Import verifies ownership and leaves existing Plivo routing unchanged. Save
the returned IDs; if a response is lost, list existing resources before retrying.
### 4. Assign your agent and activate incoming calls
Read the current number before updating it. Every write uses its latest
`version`; do not hard-code version numbers.
```bash theme={null}
NUMBER=$(curl --fail-with-body -sS "$VOICE_BASE_URL/v2/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $VOICE_API_KEY")
NUMBER=$(jq --arg agent "$AGENT_ID" \
'{version, agent_id:$agent}' <<< "$NUMBER" |
curl --fail-with-body -sS -X PATCH "$VOICE_BASE_URL/v2/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @-)
if [ "$(jq -r '.state' <<< "$NUMBER")" != ready ]; then
NUMBER=$(jq '{version, replace_existing_application:false}' <<< "$NUMBER" |
curl --fail-with-body -sS "$VOICE_BASE_URL/v2/phone-numbers/$NUMBER_ID/activate" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @-)
fi
jq '{id, number, agent_id, state, connection_state, version}' <<< "$NUMBER"
```
The assignment and activation requests return `200`. Before testing, check:
```json theme={null}
{
"state": "ready",
"connection_state": "verified"
}
```
These are the relevant readiness fields, not the complete number response.
Also confirm `agent_id` matches your published agent.
If another voice application is attached, the safe example above refuses to
replace it. Review the existing routing in Plivo. Only after you decide to move
those incoming calls to Mirai, fetch the number's latest version and repeat
activation with `replace_existing_application: true`. After any activation
timeout or failure, GET the number again and inspect `state` and `setup_error`
before retrying.
### 5. Receive events and read results
Optionally set a public HTTPS `webhook_url` on the number with PATCH:
```bash theme={null}
NUMBER=$(curl --fail-with-body -sS "$VOICE_BASE_URL/v2/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $VOICE_API_KEY")
jq --arg url 'https://your-backend.example/voice/events' \
'{version, webhook_url:$url}' <<< "$NUMBER" |
curl --fail-with-body -X PATCH "$VOICE_BASE_URL/v2/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' --data-binary @-
```
Replace the example URL with your actual receiver before running this request.
Verify events using the webhook secret associated with the workspace key that
created the Plivo integration. The integration retains that signing secret;
coordinate key or provider credential rotation with support. See
[webhook verification](/v2/webhooks#signature-verification) for the signing format,
raw request-body handling, retries, and event deduplication.
Find incoming calls in the console's call history or `GET /v2/calls`. Match
`direction: "inbound"`, `phone_number_id`, and `agent_id`; retain the call `id`.
For an incoming call, `from` is the caller and `to` is your business number.
| Request | Result |
| :- | :- |
| `GET /v2/calls/{id}` | Status, direction, agent revision, duration, cost, and result availability |
| `GET /v2/calls/{id}/transcript` | The conversation transcript when available |
| `GET /v2/calls/{id}/recording` | Redirect to available recording audio |
| `GET /v2/calls/{id}/analysis` | Post-call analysis state and results, when configured |
| `POST /v2/calls/{id}/abort` | End an active call |
Authenticate each request with your workspace API key. Results can take time
to arrive after hangup; check availability on the call before fetching audio
or transcript. See the [calls reference](/v2/calls).
## Test it
1. Call the configured number from another phone. You should hear your agent's
greeting without starting a call through the API.
2. Say a short phrase, ask the agent to repeat it, and confirm you can both
hear each other. Then complete a realistic support question.
3. Hang up. Find the incoming call in history and check its number, agent,
duration, transcript, available recording, and settled cost.
4. If you configured events, verify receipt and signature handling in your
backend. Replay a saved event to confirm it does not create duplicate work.
5. Repeat the call before publishing the number to customers.
**Ready** confirms setup, not audio quality or unlimited capacity. Arrange the
concurrent-call capacity your business needs and test within that limit.
### Troubleshooting
| Symptom | What to check |
| :- | :- |
| Call ends immediately or sounds busy | Agent is published and not archived; correct assignment; funded wallet; required input defaults; available capacity |
| Number is imported but never answers | Complete activation and confirm `ready` plus `connection_state: verified` |
| Number is ready but the wrong service answers | Check its current voice application in Plivo; another change may have replaced the routing |
| `409 conflict` | Fetch the latest version. Also check whether the account or number is already connected |
| `409 messaging_conflict` | Use a dedicated voice number without conflicting SMS/MMS configuration |
| `409 not_ready` | Verify the Plivo integration and check number activation and any rental status |
| `400 invalid_credentials` | Check the Plivo Auth ID/Auth Token and their account access |
| `503 telephony_unavailable` | Read the number's `setup_error`; check provider access and contact support if setup remains unavailable |
| `403 production_workspace_required` | Complete production activation for the key's workspace |
| Connected but silent, delayed, or one-way audio | Report the call ID, time and timezone, number, and observed behavior to support |
| No webhook or signature mismatch | Check the number's webhook URL, the integration's original signing secret, and verification of raw bytes |
## Change or pause incoming calls
To switch agents, PATCH the number with its latest `version` and the new
`agent_id`. New calls use the new assignment; accepted calls retain their agent
snapshot. Publishing or editing an agent affects later calls. Archiving it or
leaving its latest configuration as a draft stops it answering new calls.
To stop new incoming calls for one number, PATCH `agent_id: ""` with the
current version. To disable an entire Plivo integration, PATCH
`/v2/telephony/connections/{id}` with its current `version` and
`state: "disabled"`. Disabling also blocks new outgoing calls through it.
`POST /v2/telephony/connections/{id}/verify` verifies credentials and re-enables
the integration.
Unassigning or disabling does not release the number, cancel its rental, or
restore its previous Plivo application. Manage rentals separately and coordinate
restoring previous voice routing if you move away from Mirai.
## Billing and privacy
Mirai call usage follows your workspace's [pricing](/general/tiers). For BYO
Plivo, Plivo bills your provider account separately. Managed number rental and
renewal charges are separate from call usage; review the displayed terms.
Tell callers they are speaking with an AI voice agent and provide appropriate
recording notices. Keep API keys, Plivo tokens, webhook secrets, transcripts,
and recording links private. Apply access controls and retention rules for your
business, including GDPR, HIPAA, or TCPA requirements where applicable. This
recipe does not establish regulatory compliance.
## Production checklist
* The workspace is enabled and funded, and the number belongs to it.
* The correct published agent is assigned, with defaults for required inputs.
* The number is ready, its integration is verified, and real incoming calls
have passed the audio and result checks.
* Webhook verification, caller notices, result access, and retention are set up.
* Your expected concurrent-call volume and number renewal arrangements are confirmed.
# Build a node based voice agent editor with Node.js
Source: https://docs.miraiminds.co/cookbooks/node-flow-builder
Build your own flow editor with React Flow and the Mirai API, from creating and updating agents to tools, variables, and phone calls. Includes a runnable Node.js example.
You can put a flow editor in your own product and save its graph directly on a
Mirai voice agent. Your users arrange conversation steps, write the conditions
between them, attach tools, and connect a phone number. Mirai runs the saved
conversation when a call starts.
The [example app on GitHub](https://github.com/MiraiMinds/node-flow-builder)
uses React Flow for the canvas and Express for the backend. It includes a
working local demo and a live mode that sends requests to your workspace.
## What you will build
The sample agent handles delivery questions. It greets the caller, moves to an
order lookup when help is needed, records whether follow-up is needed, and ends
the call. You can change the graph, save the same agent again, and place a call
using a specific saved revision.
You need Node.js 22.19 or later. Live mode also needs a workspace API key,
wallet credit for calls, and an active phone number for the phone steps. Use an
HTTPS endpoint you control for an HTTP tool. The included tool service returns
one fictional order so you can test the request format.
## Run the example
```bash theme={null}
git clone https://github.com/MiraiMinds/node-flow-builder.git
cd node-flow-builder
npm ci
cp .env.example .env
npm run dev
```
Open `http://localhost:3000`. Demo mode works without a key and resets its
agents when the server restarts. It does not run speech, execute tools, contact
Mirai, or dial a number. Its call result is explicitly marked as sample data.
To connect your workspace, set these values in `.env` and restart:
```dotenv theme={null}
MIRAI_MODE=live
MIRAI_API_KEY=your_workspace_key
MIRAI_BASE_URL=https://sandbox.voice.miraiminds.co
```
Use the API host your key was issued for. The key stays on the server. Do not
prefix it with `VITE_`, put it in localStorage, or send it to a browser.
## How the editor and API fit together
```mermaid theme={null}
sequenceDiagram
participant Editor as Your flow editor
participant Server as Your Node.js server
participant Mirai as Mirai API
participant Tool as Your HTTP tool
Editor->>Server: Save nodes, edges, tools and input schema
Server->>Mirai: Validate graph, then create or update agent
Mirai-->>Editor: Agent ID and revision through your server
Editor->>Server: Publish the saved agent
Server->>Mirai: Publish with if_revision
Editor->>Server: Call a number with input values
Server->>Mirai: Create phone call with Idempotency-Key
Mirai->>Tool: Run a tool if the active node offers it
Tool-->>Mirai: Order status
Server->>Mirai: Read call and tool receipts
Mirai-->>Editor: Results through your server
```
Your frontend owns selection, dragging, forms, and unsaved edits. The agent's
`flow` field holds the graph. Its `tools` and `input_schema` fields define what
the graph can use. There is no separate flow-agent creation endpoint.
The API calls below run on your server. The repository already has the client
in `server/mirai.js`; this wrapper makes its returned body easier to use in the
following snippets:
```js theme={null}
import { createMiraiClient } from "./server/mirai.js";
import { example, exampleTool, exampleVariables } from "./shared/example.js";
const client = createMiraiClient({
baseUrl: process.env.MIRAI_BASE_URL || "https://sandbox.voice.miraiminds.co",
apiKey: process.env.MIRAI_API_KEY,
});
const api = async (method, path, body, key) =>
(await client(method, `/v2${path}`, body, key)).body;
```
The client sends `Authorization: Bearer …`, preserves both API error shapes,
and makes one request per operation. It does not automatically retry writes.
## Step 1 Create the graph
A graph has `nodes` and `edges`. This is a complete, small example:
```js theme={null}
const flow = {
nodes: [
{
id: "hello",
type: "startCall",
position: { x: 0, y: 100 },
data: {
name: "Say hello",
greeting: "Hello {{customer_name}}, how can I help with your delivery?",
prompt:
"Find out what delivery help the caller needs. Ask one question at a time.",
},
},
{
id: "delivery",
type: "agentNode",
position: { x: 360, y: 100 },
data: {
name: "Check delivery",
prompt:
"Ask for an order number. If lookup_order is available, use it and explain the returned status. Otherwise explain that a lookup is not configured. Never invent a status.",
tools: [],
extraction_enabled: true,
extraction_variables: [
{
name: "needs_follow_up",
type: "boolean",
prompt: "True if the caller still needs help with the delivery.",
},
],
},
},
{
id: "goodbye",
type: "endCall",
position: { x: 720, y: 100 },
data: {
name: "Finish",
prompt: "Thank the caller and say goodbye briefly.",
},
},
],
edges: [
{
id: "help",
source: "hello",
target: "delivery",
data: {
label: "check_delivery",
condition: "The caller wants help with a delivery.",
},
},
{
id: "skip",
source: "hello",
target: "goodbye",
data: {
label: "finish_greeting",
condition: "The caller needs no help or wants to end the call.",
},
},
{
id: "done",
source: "delivery",
target: "goodbye",
data: {
label: "finish_delivery",
condition:
"The question is answered, follow-up is needed, or the caller wants to stop.",
},
},
],
};
```
Each edge becomes a function the model can call. `data.label` is its function
name; `data.condition` tells the model when to use it. Conditions are plain
language, not JavaScript expressions. A node change keeps the conversation
history and replaces the current instructions and offered tools.
| Node type | Purpose |
| - | - |
| `startCall` | Exactly one. Its `greeting` opens the conversation. |
| `agentNode` | A conversation step with its own instructions. |
| `endCall` | Speaks a closing reply, then ends the call. Has no outgoing edges. |
| `globalNode` | Optional shared instructions. Has no edges; used by nodes with `add_global_prompt: true`. |
Use 2–20 nodes and 1–40 edges, with a graph under 100 KB. Every conversation
node must be reachable from the start and have a path to an end. Node IDs and
transition labels use letters, digits, and underscores, with a letter or
underscore first. Use lowercase labels to avoid names that normalize alike.
The [graph reference](https://docs.miraiminds.co/v2/node-agents#the-graph)
lists the remaining field limits.
React Flow adds fields such as `selected`, `measured`, and handle IDs. Strip
them when saving, as `shared/graph.js` does:
```js theme={null}
const toApiGraph = (nodes, edges) => ({
nodes: nodes.map(({ id, type, position, data }) => ({
id,
type,
position,
data,
})),
edges: edges.map(({ id, source, target, data }) => ({
id,
source,
target,
data,
})),
});
```
Keep positions so reopening an agent restores its layout. Keep canvas state
separate from your API payload; React Flow's `type: 'smoothstep'` is a drawing
choice, not the conversation's condition.
## Step 2 Validate and create the agent
```js theme={null}
const validation = await api("POST", "/agents/flow/validate", flow);
// { ok: true, flow_revision: "…", nodes: 3, edges: 3 }
let agent = await api("POST", "/agents", {
name: "Delivery check-in",
language: "en",
voice: { voice_id: "neha", language: "en" },
draft: true,
input_schema: example.input_schema,
tools: [],
flow,
});
console.log(agent.id, agent.revision);
```
`POST /agents/flow/validate` takes the graph directly, without a `{ flow: … }`
wrapper. It checks graph rules and model compatibility without saving or
calling. Agent creation also checks input declarations and tool references.
Flow agents use their start node's greeting and node prompts. You do not need
`system_prompt` or `first_message`. Query `GET /v2/voices` for voice IDs your
deployment offers. This recipe creates a draft so phone calls wait until you
publish it.
You can also create the JSON exported from the app with cURL:
```bash theme={null}
curl --fail-with-body "$MIRAI_BASE_URL/v2/agents" \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @mirai-agent.json
```
## Step 3 Update the same agent without overwriting someone else's edit
Store the returned agent ID and numeric `revision`. Send that revision as
`if_revision` on your next update:
```js theme={null}
const editedFlow = structuredClone(agent.flow);
editedFlow.nodes.find((node) => node.id === "delivery").data.prompt +=
" Repeat the order number back before looking it up.";
agent = await api("PATCH", `/agents/${agent.id}`, {
if_revision: agent.revision,
flow: editedFlow,
});
```
When you edit tools, inputs, and the graph together, send them in one PATCH.
This lets the API validate the resulting configuration together. The `tools`
array and nested configuration blocks replace their previous values; send the
complete list or block, including entries you want to keep.
For an editor that changes only the graph, use the dedicated routes:
```js theme={null}
const view = await api("GET", `/agents/${agent.id}/flow`);
// view.agent_revision is numeric; view.flow_revision is a graph hash.
agent = await api(
"PUT",
`/agents/${agent.id}/flow?if_revision=${view.agent_revision}`,
view.flow,
);
```
Use `agent_revision`, never the graph hash, for the write precondition. A
`409 conflict` means your copy is stale. Keep the local edits, fetch the current
agent, show the difference, and let the user reconcile it. Do not automatically
resend the old graph with a newly fetched revision. The example keeps your
edits on failure and lets you export them before reloading.
After a lost response, read the agent before another write: the first request
may have succeeded. If creation had no confirmed response, inspect the agent
list before creating again.
## Step 4 Add a tool and offer it on one node
Tool definitions belong to the agent. A node carries only their names in
`data.tools`. The delivery step can offer `lookup_order` while the greeting
step offers only its transitions.
First provide an HTTPS endpoint. To try the repository's sample endpoint, put
a random `TOOL_SHARED_SECRET` of at least 24 characters in `.env`, then run
`npm run tools`. Expose port 3001 through an HTTPS tunnel on port 443. Keep the
editor on port 3000 private to your machine. The sample returns delivery data
only for `DEMO-1042`; adapt `server/tool-service.js` to query your database.
Save the same secret in Mirai from your backend:
```js theme={null}
await api("PUT", "/secrets/order_service_token", {
value: process.env.TOOL_SHARED_SECRET,
});
const lookupOrder = structuredClone(exampleTool);
lookupOrder.request.url =
"https://your-public-tool-host.example/tools/lookup-order";
lookupOrder.request.headers = {
Authorization: "Bearer {{ secrets.order_service_token }}",
};
const withTool = structuredClone(agent.flow);
withTool.nodes.find((node) => node.id === "delivery").data.tools = [
"lookup_order",
];
agent = await api("PATCH", `/agents/${agent.id}`, {
if_revision: agent.revision,
tools: [
...agent.tools.filter((tool) => tool.name !== "lookup_order"),
lookupOrder,
],
flow: withTool,
});
```
Replace the sample hostname before saving. The definition in
`shared/example.js` declares an `order_id` argument and sends it as
`{{ args.order_id }}` in an HTTP POST body. Its `when` rule tells the model to
wait until the caller provides or confirms that number. `speak_while` supplies
the line spoken during the request.
Use `{{ secrets.name }}` in a header instead of embedding the credential in
the tool JSON. Tool URLs must satisfy the API's outbound-request rules;
localhost and private network URLs are not reachable tool destinations.
| Tool phase | Where it runs |
| - | - |
| `pre_call` | Before the conversation, for work such as fetching customer context. |
| `on_call` | During the conversation, when offered by the active node and chosen by the model. |
| `post_call` | After the call, for work such as writing the outcome to your CRM. |
Only start and conversation nodes can offer on-call tools, up to ten names
per node. Each name must refer to this agent's `on_call` definition. Disabled
tools are not offered. Tool names cannot collide with transition labels
anywhere in the graph. Keep `end_call` disabled or absent: a flow ends at an
`endCall` node.
You can test the saved HTTP request without making a phone call:
```js theme={null}
const receipt = await api(
"POST",
`/agents/${agent.id}/tools/lookup_order/test`,
{
vars: exampleVariables,
args: { order_id: "DEMO-1042" },
},
);
console.log(receipt);
```
This executes your real endpoint. A tool that writes to a CRM would perform
that write during a test too. The app's demo mode does not execute this request.
See the [tools reference](https://docs.miraiminds.co/v2/tools) for conditions,
response transforms, asynchronous execution, and receipts.
## Step 5 Separate call inputs from extracted values
Inputs are facts your application knows before the call: a customer's name,
an order number, or a preferred language. Declare them in `input_schema` and
send typed values in the call's `variables` object.
```js theme={null}
agent = await api("PATCH", `/agents/${agent.id}`, {
if_revision: agent.revision,
input_schema: {
type: "object",
properties: {
customer_name: { type: "string", default: "there" },
order_id: { type: "string", default: "" },
},
additionalProperties: false,
},
});
const variables = { customer_name: "Sam", order_id: "DEMO-1042" };
const preview = await api("POST", `/agents/${agent.id}/preview`, { variables });
console.log(preview.variables);
```
Use `{{customer_name}}` in node prompts and greetings. HTTP tools use explicit
namespaces such as `{{ vars.customer_name }}` and `{{ args.order_id }}`.
Edge conditions do not interpolate variables. When an input schema is present,
declare each input your node text references.
Extraction collects facts from what the caller says. In the sample,
`needs_follow_up` is a boolean extracted when the delivery node is left. Its
definition lives in `data.extraction_variables`, with
`data.extraction_enabled: true`. It is not a required call input.
Extraction runs in the background, so do not depend on its result being ready
for the next node's opening line. Use the conversation history to continue
the call and inspect the final extracted values in its results.
For incoming phone calls, your application cannot supply a fresh variables
object with each ring. Use input defaults, ask the caller for missing facts,
or fetch context with a pre-call tool. Required inputs without defaults can
prevent inbound admission. The sample defaults the name to `there` and the
order number to an empty string.
## Step 6 Publish the saved revision
```js theme={null}
agent = await api("POST", `/agents/${agent.id}/publish`, {
if_revision: agent.revision,
});
console.log(agent.revision, agent.draft); // new revision, false
```
An agent has one current configuration. Publishing saves a new non-draft
revision. Saving edits to a published agent changes what new ordinary direct
calls use. Setting it back to draft pauses ordinary direct call admission;
there is no separate published copy that stays active behind it.
Already accepted calls keep their snapshots. For a substantial redesign of a
busy agent, create a separate agent, test it, and switch your platform's agent
mapping or number assignment when ready. The
[agent guide](https://docs.miraiminds.co/v2/agents) covers revision history and
rollback, which creates a new draft revision.
## Step 7 Connect a phone number
If your deployment enables its default calling route, you can select
**Platform default** in the app and skip number setup. Send `channel: "phone"`
and `to` without `phone_number_id`; Mirai chooses the outgoing number.
This is the route used by Console's agent dialer. Availability depends on your
deployment: a disabled route returns `trunk_unavailable`; deployments requiring
verified test calls return `phone_verification_required` and need the dedicated
test-call endpoint or a workspace number. The app displays that API error.
To choose an outgoing number yourself or assign an inbound agent, use a
workspace number as described below.
For the complete incoming-call journey, including console setup, call results,
and troubleshooting, see [Receive inbound phone calls](/cookbooks/inbound-calling).
If you already have an active workspace number, list it and skip provider
setup:
```js theme={null}
const page = await api("GET", "/phone-numbers");
console.log(page.data);
// Follow page.next_cursor when page.has_more is true.
```
To bring a number from your Plivo account, the provider import sequence is:
```js theme={null}
const integration = await api("POST", "/telephony/connections", {
provider: "plivo",
name: "Delivery support",
auth_id: process.env.PLIVO_AUTH_ID,
auth_token: process.env.PLIVO_AUTH_TOKEN,
});
const inventory = await api(
"GET",
`/telephony/connections/${integration.id}/numbers`,
);
console.log(inventory.data);
// Use a number returned by that inventory, in E.164 format.
let number = await api("POST", "/phone-numbers", {
connection_id: integration.id,
number: process.env.PLIVO_NUMBER,
});
```
Creation verifies the provider credentials. Number import requires a verified
integration and ownership of that number. These credentials stay on your
backend. The local app starts with imported workspace numbers; it does not
collect Plivo credentials or purchase numbers. Provider features and number
availability depend on your deployment and workspace setup. Inspect
`GET /v2/telephony/options` before offering onboarding in your own product.
Assign the published agent for inbound calls, then activate the number if it
is not already ready and verified:
```js theme={null}
// Or fetch an existing number: GET /v2/phone-numbers/{id}.
number = await api("PATCH", `/phone-numbers/${number.id}`, {
version: number.version,
agent_id: agent.id,
});
if (number.state !== "ready" || number.connection_state !== "verified") {
number = await api("POST", `/phone-numbers/${number.id}/activate`, {
version: number.version,
replace_existing_application: false,
});
}
```
Phone numbers use `version`; agents use `if_revision`. Read and retain each
response before the next operation. Activation configures the provider's
voice application. If another application is already attached, stop and review
that assignment before explicitly requesting a replacement. The example
always sends `replace_existing_application: false`.
Inbound assignment makes incoming calls to that number use this agent. For
outbound calls, the request explicitly chooses both the agent and the number.
You can leave an existing inbound assignment in place when testing outbound
calls. The number must have `state: "ready"` and `connection_state: "verified"`.
## Step 8 Place the call and inspect its result
Use your own test phone and confirm that the recipient expects the call. This
request rings the destination and consumes wallet credit; `test: true` is not
supported for phone calls.
```js theme={null}
const callBody = {
agent_id: agent.id,
agent_revision: agent.revision,
phone_number_id: number.id, // Omit for the platform default route.
channel: "phone",
to: process.env.TEST_PHONE_NUMBER,
variables,
max_duration_secs: 120,
recording_enabled: false,
final_results: true,
};
// Persist both values BEFORE sending in a hosted application.
const idempotencyKey = crypto.randomUUID();
const call = await api("POST", "/calls", callBody, idempotencyKey);
console.log(call.id, call.status);
const result = await api("GET", `/calls/${call.id}`);
const toolRuns = await api("GET", `/calls/${call.id}/tool_runs`);
console.log(result.status, result.flow, toolRuns.data);
```
Creating a call returns an ID, not a completed conversation. Refresh the call
until it reaches a terminal status; results may arrive after it ends. A flow
result includes `nodes_visited` and extracted `variables`, plus `final_node`
when a node was reached. Use the call's `agent_revision` to identify its saved
configuration. Inspect tool receipts separately to see what actually ran.
If a call request loses its response, retry the same body with the same
idempotency key. A new key can create a second call. The app saves an unresolved
attempt in tab-scoped sessionStorage and offers **Retry the same request**.
Keep these records in your backend database in a hosted product.
`first_message` overrides are refused for flows. Personalize the start node's
greeting through variables. A flow needs voice: text-only calls are not
supported. To add browser audio, follow the
[browser calls guide](https://docs.miraiminds.co/v2/web-calls). For a server
notification when final results are ready, add a `webhook_url`, keep
`final_results: true`, and verify the signed `call.processed` event as described
in the [webhook guide](https://docs.miraiminds.co/v2/webhooks).
## Test it
Run `npm run check` for local API-boundary tests and a production build. In
demo mode, create the agent, edit and save it, publish it, assign the demo
number, then create a demo call. Restart the server to see the empty workspace
behavior. Demo calls do not test conversation quality or provider routing.
Before using a live number, validate and save the graph, preview its inputs,
and test the order tool against the sample order. On your test call, ask for a
delivery update, provide the order ID, then end the conversation. Check the
visited nodes, extraction, and tool receipts. Make another call that needs no
help to exercise the direct path to the end node.
| Symptom | What to check |
| - | - |
| `invalid_flow` with `details` | Show each `path` and `message`. Check reachability, end paths, node tool names, and transition labels. |
| Graph validation passes, but saving fails | Agent save also checks tool definitions and declared inputs. |
| `409 conflict` on save | Keep local work, load the current revision, and reconcile the edits. |
| The tool is never offered | It must be an enabled `on_call` tool listed in the current node's `data.tools`; check any tool condition too. |
| Tool HTTP request fails | Check the public HTTPS URL, secret reference, request arguments, and tool receipt. |
| Phone number is not ready | Verify the provider integration, activate the number, and refresh its state. |
| Inbound calls cannot start | Check the published agent, number assignment, wallet, and defaults for required inputs. |
| A call returns 402 | Check the workspace wallet and the API's error message. |
| API returns 429 | Honor `Retry-After`; preserve the original call key when retrying. |
| API host returns 404 for a documented operation | Check the configured host and whether that feature is available on your deployment. |
The example has not verified your workspace's live credentials, provider
configuration, or audio. Complete the live checks with your test number before
giving the flow to customers.
## Put the editor in your platform
The sample is a local, single-workspace app. Its loopback and same-origin guards
are appropriate for that setup. A hosted product needs authenticated sessions,
workspace ownership checks on every agent, call and number, CSRF protection,
and rate limits. Derive the workspace and its key from the signed-in user on
your server. Do not accept arbitrary API hosts or workspace keys from the page.
Store unsaved drafts and call attempts in your own database. Keep the API
revision with each editor session, and treat an unresolved write as unresolved
until you read it back. Add request limits and role checks around phone
activation, assignment, publication, and calls.
Call inputs, transcripts, tool receipts, and exported JSON can contain customer
information. Apply your product's access and retention policies. This sample
disables call recording, but that does not turn off transcripts or stored call
results. Follow the calling, consent, and recording requirements that apply to
your use case; see the [limits guide](https://docs.miraiminds.co/v2/limits).
Start by adapting `shared/example.js` and the forms in `src/main.jsx`. Keep
the server boundary and revision handling when you move the editor into your
application. The [full source](https://github.com/MiraiMinds/node-flow-builder)
includes the sample tool, error handling, and tests.
# Cookbooks
Source: https://docs.miraiminds.co/cookbooks/overview
End-to-end recipes for real products built on Mirai Voice — the agent, your backend, your page, the webhooks and a production checklist, in one place.
A cookbook takes one use case from an empty workspace to production. Where the
[Documentation](/v2/overview) explains each feature on its own, a cookbook
shows how the features fit together for a specific product, with code you can
copy.
## Available cookbooks
### Phone calling
Buy a number through Mirai or connect your own supported telco number.
Assign your published agent, activate incoming calls, and test the results.
Includes console steps, API examples, and troubleshooting.
### Agent builders
Build a React Flow editor with a Node.js backend. Create and update agents,
attach tools, declare inputs, and connect a phone number. Includes source
code you can clone and run.
### Browser voice apps
A 10-minute AI interview in your web app: personalised opening, live
captions, a graceful wrap-up and goodbye, moderator controls, an automatic
score and a text-only record.
### WhatsApp voice apps
Add incoming calls and permitted callbacks to your brands' existing WhatsApp
numbers: separate agents, one partner workspace, signed results and combined
billing. Early access; production activation and call acceptance are required.
### Transcription apps
Upload a recorded call, read a draft within seconds, then listen along to
the finished transcript with speakers, correct it and export it as text,
subtitles or JSON.
## How every cookbook is laid out
Each cookbook follows the same order, so you can jump to the part you need.
| Section | What you get |
| :- | :- |
| **What you'll build** | The finished behaviour, in plain terms, and what you need before you start. |
| **How it works** | The moving parts and the order they talk to each other. |
| **Steps** | Numbered steps, each with complete code in cURL, Python and Node.js where it applies. |
| **Test it** | A short configuration that shows every behaviour in a few minutes, and a troubleshooting table. |
| **Privacy and compliance** | What the recipe stores, what you must tell your users, and how to keep keys and links safe. |
| **Production checklist** | What to confirm before real users arrive. |
Cookbooks link to the reference pages for every field and error code instead of
repeating them. When a cookbook and a reference page seem to disagree, the
reference page is correct — tell us at [help@miraiminds.co](mailto:help@miraiminds.co).
## Before you start
Every cookbook assumes:
* A workspace API key (`sk_live_…`), and its webhook secret (`whsec_…`) when
the recipe receives webhooks. See the [Quickstart](/v2/quickstart).
* A backend that can keep the key secret. The key never goes to a browser or a
mobile app.
* An HTTPS endpoint for [webhooks](/v2/webhooks), when the recipe uses them.
While you build, a tunnel to your laptop works.
Base URL in the examples: `https://sandbox.voice.miraiminds.co`. Use the API
host your key was issued for.
## Ask for a cookbook
Building something the cookbooks don't cover yet? Write to
[help@miraiminds.co](mailto:help@miraiminds.co) with a one-line description of
your use case.
# Build a transcript player for recorded calls
Source: https://docs.miraiminds.co/cookbooks/transcript-player
Let a reviewer upload a recorded call, read a draft within seconds, then listen along to the finished transcript with speakers, correct it and export it, with your API key kept on your server.
In this cookbook you build a web page where someone uploads a recorded call and
gets back a transcript they can listen along to. Your server sends the recording
to Mira Transcribe and reads the job for the page. The page shows a draft while
the job finishes, then the transcript with speakers, in step with the audio. The
same pattern fits call quality review, sales coaching, recorded interviews and
subtitles for recorded calls.
## What you'll build
* A reviewer chooses a recording (WAV, MP3 or M4A, up to 45 MB), adds a line of
context and a few names to spell right, and clicks **Transcribe**.
* A few seconds later the page shows a draft. When the job finishes, the
transcript with speakers replaces it without a reload.
* Each speaker has a colour, keyed to the voice (`speaker_id`). The roles that
come back are a first guess, so the reviewer can rename any speaker.
* Clicking a line plays the call from there. While the audio plays, the line
being spoken is marked and kept in view.
* Double-clicking a line lets the reviewer fix a word. Edits survive a reload.
* The transcript, edits included, exports as plain text, SRT subtitles or JSON.
* Your server keeps the API key, and only returns a transcript to the person who
uploaded it.
**You need:** a workspace API key (`sk_live_…`) and some credit in its wallet,
and a backend in Python 3.10+ or Node.js 18+. This recipe uses no webhooks.
Keep the API key on your server. The page only ever talks to your own routes,
and job IDs are not secrets, so your server checks who owns a job before it
returns one.
## How it works
```mermaid theme={null}
sequenceDiagram
participant Page as Player page
participant App as Your server
participant Mirai as Mirai API
Page->>App: POST /api/transcripts (file, context, vocabulary)
App->>Mirai: POST /v2/transcriptions with an Idempotency-Key
Mirai-->>App: 202, id trn_…, status queued
App-->>Page: id
loop every 1 to 4 seconds
Page->>App: GET /api/transcripts/{id}
App->>Mirai: GET /v2/transcriptions/{id}
Mirai-->>App: status, then draft, then segments
App-->>Page: the job
end
Note over App: saves the finished job and serves it from then on
Note over Page: plays the reviewer's own copy of the recording
```
The API keeps no audio, so the page plays the file the reviewer chose. For the
23-second [test call](#test-it), the page goes through these stages:
| Time after upload | Job status | What the page shows |
| :- | :- | :- |
| Under a second | `queued`, then `decoding` | "Listening to the recording…" |
| About 3 seconds | `merging`, with `draft` | The draft: one grey lane, with English words written in Devanagari |
| About 6 seconds | `completed` | Four lines from two speakers, and the cost: ₹0.12 |
A longer recording usually takes about 10 seconds plus 15% of its length, so
about 30 seconds for a two-minute call. The draft arrives before that.
***
## Step 1: Send recordings from your server
Your server holds the key and has two routes for the page:
| Route | What it does |
| :- | :- |
| `POST /api/transcripts` | Checks who is signed in and sends the file to [`POST /v2/transcriptions`](/v2/transcriptions#create-a-transcription). The `Idempotency-Key` combines the user's ID with the key the page sent, so a retried upload returns the same job. Records who owns the job and returns its ID. |
| `GET /api/transcripts/{id}` | Checks that the job belongs to the user and reads [`GET /v2/transcriptions/{id}`](/v2/transcriptions#get-a-transcription). Saves the job once it is `completed` or `failed`, and serves that copy from then on. |
Refusals from the API, such as `402 insufficient_balance`, `413` and `429`, go
back to the page with their `Retry-After` header, so the page can say what
happened. The server also serves the page itself from `public/`.
`myapp` stands for your own code. `current_user` (Python) or `requireUser`
(Node.js) identifies the signed-in user and refuses everyone else with `401`.
`db.transcripts` stores one row per job: `create` adds the owner and file name
and ignores an ID it already has, `get` returns the row or nothing, and
`save_job` stores the finished job.
The two API calls the server makes:
```bash theme={null}
# Send a recording
curl -X POST https://sandbox.voice.miraiminds.co/v2/transcriptions \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Idempotency-Key: user_42:4d6c1e2a-7a7f-4c1b-9a51-2f0e7d3b9c10" \
-F file=@transcription-test-call.wav \
-F context="A call to a university admissions desk about a course and its fees" \
-F vocabulary="Mira University, BBA, Aadhaar"
# Read the job, each time the page asks
curl https://sandbox.voice.miraiminds.co/v2/transcriptions/trn_01M3XRSFXW77RHEQ85979AJXAF \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
```python title="server.py" theme={null}
# pip install fastapi uvicorn httpx python-multipart
import os
import httpx
from fastapi import FastAPI, File, Form, Header, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
# Stand-ins for your own code: sign-in and storage.
from myapp import current_user, db
API = "https://sandbox.voice.miraiminds.co"
MAX_UPLOAD = 45 * 1024 * 1024 # the API's limit for an uploaded file
app = FastAPI()
mirai = httpx.AsyncClient(
base_url=API,
headers={"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"},
timeout=30,
)
def error(status: int, code: str, message: str, retry_after: str | None = None) -> JSONResponse:
headers = {"Retry-After": retry_after} if retry_after else None
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status, headers=headers)
def refused(r: httpx.Response) -> JSONResponse:
"""Pass a refusal from the API on to the page, with its Retry-After."""
try:
e = r.json()["error"]
except (ValueError, KeyError, TypeError):
e = {"code": "upstream_error", "message": f"Transcription answered {r.status_code}"}
return error(r.status_code, e["code"], e["message"], r.headers.get("Retry-After"))
@app.post("/api/transcripts")
async def create_transcript(
request: Request,
file: UploadFile = File(...),
context: str = Form(""),
vocabulary: str = Form(""),
idempotency_key: str = Header(..., max_length=200),
):
user = await current_user(request)
if file.size is not None and file.size > MAX_UPLOAD:
return error(413, "request_too_large", "Recordings can be up to 45 MB.")
r = await mirai.post(
"/v2/transcriptions",
# The same key for the same file: a retried upload returns the same job.
headers={"Idempotency-Key": f"{user.id}:{idempotency_key}"},
files={"file": (file.filename or "recording", file.file, file.content_type)},
data={"context": context, "vocabulary": vocabulary},
timeout=600, # the API answers once it has the whole upload
)
if r.status_code != 202:
return refused(r)
job = r.json()
await db.transcripts.create(id=job["id"], owner_id=user.id, file_name=job["file_name"])
return JSONResponse({"id": job["id"], "status": job["status"]}, status_code=202)
@app.get("/api/transcripts/{transcript_id}")
async def read_transcript(transcript_id: str, request: Request):
user = await current_user(request)
row = await db.transcripts.get(transcript_id)
if row is None or row.owner_id != user.id:
return error(404, "not_found", "No such transcript.")
if row.job is not None: # finished earlier: serve your own copy
return row.job
r = await mirai.get(f"/v2/transcriptions/{transcript_id}")
if r.status_code != 200:
return refused(r)
job = r.json()
if job["status"] in ("completed", "failed"):
await db.transcripts.save_job(transcript_id, job)
return job
app.mount("/", StaticFiles(directory="public", html=True), name="page")
```
```javascript title="server.js" theme={null}
// npm install express multer
import express from "express";
import multer from "multer";
// Stand-ins for your own code: sign-in and storage.
import { requireUser, db } from "./myapp.js";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const MAX_UPLOAD = 45 * 1024 * 1024; // the API's limit for an uploaded file
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD } });
const app = express();
const error = (res, status, code, message) => res.status(status).json({ error: { code, message } });
// Pass a refusal from the API on to the page, with its Retry-After.
async function refused(r, res) {
const body = await r.json().catch(() => ({}));
const retry = r.headers.get("retry-after");
if (retry) res.set("Retry-After", retry);
const e = body.error ?? { code: "upstream_error", message: `Transcription answered ${r.status}` };
error(res, r.status, e.code, e.message);
}
app.post("/api/transcripts", requireUser, upload.single("file"), async (req, res) => {
const key = req.get("Idempotency-Key");
if (!req.file || !key || key.length > 200) {
return error(res, 400, "invalid_request", "Send a recording and an Idempotency-Key.");
}
const form = new FormData();
form.append("file", new Blob([req.file.buffer], { type: req.file.mimetype }), req.file.originalname);
form.append("context", req.body.context ?? "");
form.append("vocabulary", req.body.vocabulary ?? "");
const r = await fetch(`${API}/v2/transcriptions`, {
method: "POST",
// The same key for the same file: a retried upload returns the same job.
headers: { ...auth, "Idempotency-Key": `${req.user.id}:${key}` },
body: form,
});
if (r.status !== 202) return refused(r, res);
const job = await r.json();
await db.transcripts.create({ id: job.id, ownerId: req.user.id, fileName: job.file_name });
res.status(202).json({ id: job.id, status: job.status });
});
app.get("/api/transcripts/:id", requireUser, async (req, res) => {
const row = await db.transcripts.get(req.params.id);
if (!row || row.ownerId !== req.user.id) return error(res, 404, "not_found", "No such transcript.");
if (row.job) return res.json(row.job); // finished earlier: serve your own copy
const r = await fetch(`${API}/v2/transcriptions/${encodeURIComponent(req.params.id)}`, { headers: auth });
if (r.status !== 200) return refused(r, res);
const job = await r.json();
if (job.status === "completed" || job.status === "failed") await db.transcripts.saveJob(job.id, job);
res.json(job);
});
app.use(express.static("public"));
// multer refuses a file over the limit before it reaches the route.
app.use((err, req, res, next) => {
if (err.code === "LIMIT_FILE_SIZE") return error(res, 413, "request_too_large", "Recordings can be up to 45 MB.");
next(err);
});
app.listen(3000, () => console.log("http://localhost:3000"));
```
Recordings over 45 MB go by link. Upload them to your own storage first, then
send JSON with a pre-signed `url` in place of the file, up to 200 MB. See
[Send a link](/v2/transcriptions#send-a-link).
## Step 2: Poll from the page
The page asks your server for the job until it is `completed` or `failed`. It
asks quickly at first, since a short recording is often done in seconds, then
every 4 seconds. On a `429` or `5xx` answer it waits for `Retry-After` and
asks again, since the job itself is unaffected. It gives up only after about a
minute without a usable answer.
```javascript title="public/poll.js" theme={null}
// Read a transcript through your server until it is finished. Quick at first,
// when a short recording is often done, then every 4 seconds.
const DELAYS_MS = [1000, 1000, 1500, 2000, 3000];
const LATER_MS = 4000;
const MAX_MISSES = 15; // about a minute without an answer
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function waitForTranscript(id, onUpdate) {
let misses = 0;
for (let n = 0; ; n++) {
await sleep(DELAYS_MS[n] ?? LATER_MS);
let res;
try {
res = await fetch(`/api/transcripts/${encodeURIComponent(id)}`, { cache: "no-store" });
} catch {
if (++misses >= MAX_MISSES) throw new Error("Lost touch with the server.");
continue;
}
if (res.status === 429 || res.status >= 500) {
// Busy, or briefly unavailable: wait as long as the answer asks, then try again.
if (++misses >= MAX_MISSES) throw new Error(`The transcript is unavailable (${res.status}).`);
await sleep(Number(res.headers.get("Retry-After") || 5) * 1000);
continue;
}
const job = await res.json();
if (!res.ok) throw new Error(job.error?.message ?? `Request failed (${res.status}).`);
misses = 0;
onUpdate(job);
if (job.status === "completed" || job.status === "failed") return job;
}
}
```
A job does not need anyone watching it. It is charged when it completes whether
or not it is read, and your server saves it the next time someone opens it.
## Step 3: Turn a job into a transcript
The page keeps the transcript as a small document: speakers, and lines that
point to a speaker by ID. Renaming a speaker is then one change that every line
and every export picks up. This module has no page code in it, so you can test
it on its own.
```javascript title="public/transcript.js" theme={null}
// A transcript as data: speakers, lines and the reader's edits. No DOM here.
const COLOURS = ["#15803d", "#1d4ed8", "#b45309", "#7e22ce", "#be185d", "#0e7490"];
// A job from GET /v2/transcriptions/{id}, finished or still merging, as
// { id, draft, duration, speakers: [{ id, label, colour }], lines: [{ speaker, start, end, text }] }.
export function fromJob(job) {
const draft = job.status !== "completed";
const raw = (draft ? job.draft : job.segments) ?? [];
const roles = new Map((job.diarization?.speakers ?? []).map((s) => [s.id, s.role]));
const speakers = [];
const byKey = new Map();
const perRole = new Map();
// Lines are grouped by speaker_id, the voice. The role is only a first label.
function speakerFor(seg) {
const key = draft ? "speech" : seg.speaker_id || `role-${seg.speaker || "unknown"}`;
if (!byKey.has(key)) {
const role = draft ? "" : seg.speaker || roles.get(seg.speaker_id) || "";
const n = (perRole.get(role) ?? 0) + 1;
perRole.set(role, n);
const label = draft ? "Speech" : role ? (n > 1 ? `${role} ${n}` : role) : `Speaker ${speakers.length + 1}`;
const speaker = { id: key, label, colour: COLOURS[speakers.length % COLOURS.length] };
speakers.push(speaker);
byKey.set(key, speaker);
}
return byKey.get(key).id;
}
const lines = raw
.filter((seg) => seg.text?.trim())
.map((seg) => ({ speaker: speakerFor(seg), start: seg.start, end: Math.max(seg.start, seg.end), text: seg.text.trim() }))
.sort((a, b) => a.start - b.start);
return { id: job.id, draft, duration: job.duration_s ?? lines.at(-1)?.end ?? 0, speakers, lines };
}
// The line being spoken at `t` seconds, or -1 between lines. When two speakers
// overlap, the line that started last wins.
export function lineAt(doc, t) {
let lo = 0, hi = doc.lines.length - 1, found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (doc.lines[mid].start <= t) { found = mid; lo = mid + 1; } else { hi = mid - 1; }
}
for (let i = found; i >= 0 && i > found - 4; i--) if (t < doc.lines[i].end) return i;
return -1;
}
// Edits return a new document, so keeping the old one is all undo needs.
export const renameSpeaker = (doc, id, label) => ({
...doc,
speakers: doc.speakers.map((s) => (s.id === id ? { ...s, label } : s)),
});
export const editLine = (doc, index, text) => ({
...doc,
lines: doc.lines.map((line, i) => (i === index ? { ...line, text, edited: true } : line)),
});
export const speakerOf = (doc, line) => doc.speakers.find((s) => s.id === line.speaker);
// 65.4 -> "1:05"
export function clock(t) {
const s = Math.floor(t);
const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, sec = String(s % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
// 65.4 -> "00:01:05,400"
function srtTime(t) {
const ms = Math.round(t * 1000);
const pad = (n, width = 2) => String(n).padStart(width, "0");
return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)},${pad(ms % 1000, 3)}`;
}
export function toTxt(doc) {
return doc.lines.map((line) => `${speakerOf(doc, line).label}: ${line.text}`).join("\n") + "\n";
}
export function toSrt(doc) {
return doc.lines
.map((line, n) => `${n + 1}\n${srtTime(line.start)} --> ${srtTime(line.end)}\n` +
`${speakerOf(doc, line).label}: ${line.text.replace(/-->/g, "→")}\n`)
.join("\n");
}
export function toJson(doc) {
const out = {
id: doc.id,
duration_s: doc.duration,
speakers: doc.speakers.map(({ id, label }) => ({ id, label })),
segments: doc.lines.map((line) => ({
speaker_id: line.speaker,
speaker: speakerOf(doc, line).label,
start: line.start,
end: line.end,
text: line.text,
})),
};
return JSON.stringify(out, null, 2) + "\n";
}
```
Lines are grouped by `speaker_id`, which identifies one person on the call.
Every line of a `speaker_id` carries the same role in `speaker` (`Agent`,
`Customer` or `Other`), and the role only supplies the first label, numbered
when two speakers share a role. On a call between an agent and a customer, the
agent is `spk0` and the customer `spk1`. A draft has no speakers yet, so all of
it goes in one lane called **Speech**. Colours follow the order in which
speakers first talk, so whoever talks first is always green.
## Step 4: Build the player page
```html title="public/index.html" theme={null}
Call transcripts
Export:
```
### Draft, then the finished transcript
While the job is `merging`, `show` draws the draft greyed out and read-only,
with export turned off. When the job completes, the finished transcript
replaces it in place. The status line shows what the job cost, or that it was
free. A [degraded](/v2/transcribe-recorded-calls#degraded-jobs) job is marked
as a single-pass transcript whose roles may be missing.
Ringing, caller tunes and hold music are left out before transcription. To
mark them on the timeline, use the times in `diarization.non_speech`.
### Play from a line and follow the audio
Clicking a line sets `audio.currentTime` to the line's `start` and plays. On
every `timeupdate`, `lineAt` finds the line being spoken; the page marks it and
scrolls it into view. If the reviewer scrolls, following pauses for six
seconds, so the list doesn't pull them back while they read ahead.
### Rename speakers and fix words
Each speaker's name is an input. Changing it renames every line of that
speaker. Double-clicking a line's text makes it editable: **Enter** keeps the
change and **Escape** drops it. Edits are saved in the browser under the job
ID, and the transcript text is always set with `textContent`, never as HTML.
### Export
The three buttons turn the current document, edits included, into a file:
`Speaker: text` lines, SRT cues with the speaker's name in front of each line,
or JSON with `speaker_id`, `speaker`, `start`, `end` and `text` per line.
### Open a transcript later
The page's address ends with the job ID (`#trn_…`). Opening that address loads
the transcript from your server and asks for the recording, because the API
does not keep it. If you keep recordings in your own storage, set `audio.src`
to a link your server signs instead.
### Run it
Put the files side by side:
```text theme={null}
server.py or server.js
myapp.py or myapp.js (your sign-in and storage)
public/
index.html
poll.js
transcript.js
```
```bash theme={null}
pip install fastapi uvicorn httpx python-multipart
MIRAI_API_KEY=sk_live_YOUR_API_KEY uvicorn server:app --port 3000
```
```bash theme={null}
npm init -y && npm pkg set type=module
npm install express multer
MIRAI_API_KEY=sk_live_YOUR_API_KEY node server.js
```
Open `http://localhost:3000`.
***
## Test it
Download the [test call](/files/transcription-test-call.wav): 23 seconds, two
synthetic voices, Hindi and English. Use this context and vocabulary:
```text theme={null}
Context: A call to a university admissions desk about a course and its fees
Vocabulary: Mira University, BBA, Aadhaar
```
Then try these:
1. Upload it. A draft appears after about 3 seconds. After about 6 seconds it
is replaced by four lines that alternate between **Agent** and
**Customer**, with "Mira University", "BBA" and "Aadhaar" in Latin script.
The status reads "Finished (₹0.12)".
2. Click the third line. Playback jumps to 0:11 and the line is marked. Let it
play: the mark moves to the fourth line at 0:20.
3. Rename **Agent** to a person's name; both of that speaker's lines change.
Double-click the second line, add punctuation and press **Enter**. Reload the
page: both edits are still there.
4. Export SRT. The first cue runs `00:00:00,000 --> 00:00:06,570`.
5. Click **Transcribe** again without choosing the file again. You get the same
job ID back, and the wallet is charged once.
6. Open the page's address in a new tab. The transcript loads from your
server; choose the file to listen along.
| Symptom | Likely cause | Fix |
| :- | :- | :- |
| The upload says the wallet is empty | `402 insufficient_balance`: the balance is zero or below | Add credit in **Billing**, then upload again. |
| The upload is refused as too large | The file is over 45 MB | Send it by link (see the tip in step 1), or compress it to a mono MP3. |
| The upload is refused as busy | `429 at_capacity`: 20 unfinished jobs in your workspace | Queue uploads on your server and send the next one after `Retry-After`. |
| "Waiting for a free slot" lasts minutes | The service is busy and the job is queued | Leave it. It keeps its place, and a job not finished in 24 hours fails free. |
| No sound when clicking a line | The page was opened from a link, so it has no audio | Choose the file, or serve the recording from your own storage. |
| The mark lags a little behind the voice | `timeupdate` fires a few times a second | Call `follow` from `requestAnimationFrame` while the audio plays. |
| Agent and Customer are the wrong way round | Roles are a best guess | Rename the speakers. Lines stay grouped by `speaker_id`. |
| One person shows up as two speakers | Speaker separation split a voice, often on poor audio or crosstalk | Rename both to the same name, or add a merge to `transcript.js` that moves one speaker's lines to the other. |
| Edits are missing on another computer | Edits are saved in the browser | Save the edited document on your server (see the checklist). |
| `429 rate_limited` while polling | Many open pages share your key's request rate | Serve finished jobs from your storage, as the server does, and cache running jobs for a second or two. |
| The page shows "No such transcript." | The job belongs to another user, or the ID is wrong | Open transcripts only from your own list. |
***
## Privacy and compliance
Tell the people on a call that it is recorded, and that a transcript will be
made and reviewed, before you record. Get consent where the law asks for it.
Call recordings and transcripts are personal data under India's Digital
Personal Data Protection Act.
The API does not keep the audio: it deletes the upload once it has been
decoded. Your server passes the file through without keeping it, and the
player plays the reviewer's own copy.
Your server does keep every finished job, with everything that was said in the
call. Treat it like the recording: limit who can open it, decide how long to
keep it, and delete it on schedule. The API does not publish a retention period
for transcripts; see
[Privacy and your data](/v2/transcribe-recorded-calls#privacy-and-your-data).
Edits are saved in the reviewer's browser. On shared computers, save them on
your server instead and clear local storage when the reviewer signs out.
Words, speakers and roles can be wrong. Don't base a decision about a person on
a transcript that nobody has checked against the audio.
The API key stays in your server's environment, and both routes check who owns
a job before they touch it.
## Production checklist
* [ ] The API key is read from the server's environment, and no route returns
it.
* [ ] Both routes check sign-in and job ownership.
* [ ] Every upload sends an `Idempotency-Key` that includes the user's ID.
* [ ] Your server and any proxy in front of it accept uploads up to 45 MB, and
larger files go by link.
* [ ] `402`, `413` and `429` are shown to the reviewer in plain words, and
`Retry-After` is honoured.
* [ ] Finished jobs are saved in your storage and served from there. A
background task reads jobs that nobody opened, so every transcript reaches
your storage.
* [ ] Edits are saved where your reviewers need them: on your server if more
than one person reviews a call.
* [ ] You have a retention period for transcripts and enforce it.
* [ ] Callers are told about recording and transcription.
* [ ] You ran the test call above and got four lines for ₹0.12.
## Related
How transcription works, languages, vocabulary, pricing and privacy.
Every field, status, response and error code.
Balance, transactions and top-ups.
Request rate and other limits shared by every `/v2` call.
# WhatsApp calling for partners
Source: https://docs.miraiminds.co/cookbooks/whatsapp-calling-for-partners
Add voice calling to the WhatsApp numbers your brands already use, with one partner workspace, a separate agent for each brand, permission-based callbacks, signed results and combined billing.
This cookbook is for platforms that already manage WhatsApp for many
businesses. You add voice calling to each brand's existing number through
your backend. Each brand keeps using your application and does not need a
separate Voice Infra login.
**Early access.** WhatsApp calling is a controlled pilot. The public
number-management APIs in this recipe are not deployed yet. Confirm their
availability with Voice Infra before running the registration steps.
General production rollout is not open yet. It still depends on reliable
two-way audio, production workspace activation and measured call capacity. The
examples below describe the integration contract. Your workspace and numbers
are not active just because this page is published.
## What you'll build
* An **AI Calling** section inside your application where a brand selects its
WhatsApp number, configures its voice agent and tests calling.
* Incoming WhatsApp calls answered by that brand's agent.
* Customer-requested callbacks after Meta confirms calling permission.
* A brand-specific history of calls, transcripts and available results.
* One partner workspace and one combined Voice Infra bill for all your brands.
For a partner with 2,000 brands, keep a separate agent and number registration
for each brand. Registering 2,000 brands does not give you capacity for 2,000
simultaneous calls, so agree on concurrent-call capacity and test it
separately.
### Is this like BYO telco?
Yes. You bring the brand's existing WhatsApp number, configure its Meta calling
settings and register it with Voice Infra. The number stays with the brand's
Meta assets, and you do not buy a Plivo number or port the number.
Your backend can create the integration each number needs through our API, but
Voice Infra still approves ownership of each number. Once the partner workspace
is enabled, adding another approved number does not require a deployment for
that brand.
**You need:** an activated partner workspace, its backend API key and webhook
signing secret, an authorized Meta integration for each number, and a public
HTTPS webhook receiver. The examples use cURL and `jq`, Python 3.10+ with
`httpx`, or Node.js 18+.
## How it works
```mermaid theme={null}
sequenceDiagram
participant Brand as Brand using your app
participant App as Your backend
participant Meta as Meta WhatsApp
participant Voice as Voice Infra
participant Caller as Customer
Brand->>App: Enable AI calling for this number
App->>Voice: Create agent
App->>Meta: Configure calling and retrieve SIP credential
App->>Voice: Register number and agent
Note over App,Voice: Ownership approval and configuration acknowledgement
Caller->>Meta: WhatsApp voice call
Meta->>Voice: Deliver the call
Voice-->>Caller: Brand's agent conducts the conversation
Voice->>App: Signed call results
App-->>Brand: Show this brand's call history
```
| Responsibility | Your platform | Voice Infra |
| :- | :- | :- |
| Brand login, permissions and number ownership | Manage the brand's Meta assets and access | Approve number ownership for activation |
| Agent setup | Collect approved instructions, greeting, language and task | Store and run the agent |
| Calling permission | Send native Meta permission requests and process replies | Execute your permitted callback request |
| Voice transport | Configure the number's Meta settings | Handle SIP, encrypted media and the speech pipeline |
| Call results | Verify, store and show results to the correct brand | Publish signed events and result APIs |
| Billing | Allocate usage and invoice your brands | Bill the partner workspace centrally |
Keep your existing Meta messaging integration. Before changing SIP settings,
check whether the number already uses another calling provider, because
changing the destination changes where its voice calls go. Meta charges remain
under your existing Meta arrangement, separate from Voice Infra billing.
## Step 1: Activate the partner workspace
Complete **Go live** and the billing setup with Voice Infra once for your partner
workspace. Confirm your billing contact, commercial terms, funded balance and
call limit. There is no public partner-signup endpoint that turns a sandbox key
into production access.
We supply these values through a private channel:
| Value | Purpose |
| :- | :- |
| Assigned API origin | The environment your key can use |
| Workspace ID | Your shared partner workspace and Meta routing hint |
| API key | Authenticate your backend to Voice Infra |
| Webhook signing secret | Verify our call events and resolver requests |
| Approved call capacity | Bound the traffic you enable for brands |
```text theme={null}
Sandbox: https://sandbox.voice.miraiminds.co
Production: https://prod.voice.miraiminds.co
```
Load `VOICE_BASE_URL`, `VOICE_API_KEY` and `VOICE_WORKSPACE_ID` from your backend
configuration or secret manager. Confirm access:
```bash theme={null}
set -euo pipefail
curl --fail-with-body "$VOICE_BASE_URL/v2/wallet" \
-H "Authorization: Bearer $VOICE_API_KEY"
curl --fail-with-body "$VOICE_BASE_URL/v2/voices" \
-H "Authorization: Bearer $VOICE_API_KEY"
```
The wallet response includes `balance_inr` and `currency`, and the voice
catalogue is under `data`. `403 production_workspace_required` means production
activation is incomplete. Changing only the base URL does not upgrade a sandbox
workspace. Before you register numbers, confirm that the public number APIs are
available.
Keep the partner key on your backend. Brands must not receive this shared key
or unrestricted access to your partner workspace. Our API enforces workspace
ownership; your application enforces which brand can access each resource.
### Store a mapping for every brand
Your database needs a durable mapping of:
```text theme={null}
brand_id → workspace_id, business_number, phone_number_id,
agent_id, number_id, number_version, enabled
```
Set `BRAND_ID` to that brand's permanent ID in your application before running
the creation examples. Run shell examples in Bash with `set -euo pipefail` so a
missing value or failed request stops the sequence.
Also retain secret-manager references for Meta access, your callback jobs, and
historical agent/number assignments. Select this mapping using the brand
identity you authenticated. A `brand_id` supplied in a request or in call
metadata is only a label and must not be used to authorize access.
## Step 2: Create the brand's agent
An [agent](/v2/agents) holds the brand's instructions, greeting, voice, language
and call duration. Use one agent per brand so that business information and
future changes stay separate.
### An acceptance test agent
Start each number with a test agent like this one, with voice `neha`, language
`en` and a 120-second limit. Its job is to confirm two-way audio, and it does
not claim to access orders or customer records. Use it before you enable a
brand's business workflow.
Save this complete definition as `agent.json`:
```json theme={null}
{
"name": "WhatsApp Calling Test",
"system_prompt": "You are Mira, a voice agent running a short WhatsApp voice-call test. Clearly say this is a test. Start in English and switch to Hindi or Hinglish if the caller prefers. Ask whether they can hear you clearly, listen to a short reply, and repeat its main point to confirm two-way audio. Keep replies brief and natural. Do not claim to access orders, customer records, payments, or other business systems. If asked about this test, explain that it checks whether a WhatsApp call can connect to a voice agent and carry audio in both directions. When the caller wants to finish, thank them and end the call.",
"first_message": "Hello! I am Mira, the voice agent for this WhatsApp calling test. Can you hear me clearly?",
"voice": {"voice_id": "neha", "language": "en"},
"language": "en",
"max_duration_secs": 120
}
```
If you already have an agent in your workspace, read it with
`GET /v2/agents/{agent_id}` and reuse its ID instead of creating a duplicate.
You cannot use an ID from another workspace.
Check the selected voice in your workspace's [voice catalogue](/v2/voices).
Set the top-level `language` as well as any voice language. Replace the test
instructions with the brand's approved content before serving real customers.
Order lookup or booking requires a real [tool integration](/v2/tools) or supplied
context. Writing an instruction in the prompt does not create that integration.
```bash theme={null}
: "${BRAND_ID:?Set the permanent brand ID from your application}"
curl --fail-with-body -X POST "$VOICE_BASE_URL/v2/agents" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: partner:$BRAND_ID:agent:v1" \
--data-binary @agent.json --output agent.created.json
AGENT_ID=$(jq -er '.id' agent.created.json)
```
```python theme={null}
import json
import os
from pathlib import Path
import httpx
response = httpx.post(
f"{os.environ['VOICE_BASE_URL']}/v2/agents",
headers={
"Authorization": f"Bearer {os.environ['VOICE_API_KEY']}",
"Idempotency-Key": f"partner:{os.environ['BRAND_ID']}:agent:v1",
},
json=json.loads(Path("agent.json").read_text()),
timeout=30,
)
response.raise_for_status()
agent_id = response.json()["id"]
# Persist agent_id in this brand's database record.
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
for (const name of ["VOICE_BASE_URL", "VOICE_API_KEY", "BRAND_ID"]) {
if (!process.env[name]) throw new Error(`Missing ${name}`);
}
const response = await fetch(`${process.env.VOICE_BASE_URL}/v2/agents`, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(30000),
headers: {
Authorization: `Bearer ${process.env.VOICE_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `partner:${process.env.BRAND_ID}:agent:v1`,
},
body: await readFile("agent.json", "utf8"),
});
if (!response.ok) throw new Error(`Agent request failed: ${response.status}`);
const { id: agentId } = await response.json();
// Persist agentId in this brand's database record.
```
The response is the agent object, including its `id` (`agt_...`). If you are not
sure a request went through, retry it with the same creation key and body. Use
`PATCH /v2/agents/{agent_id}` for later edits.
## Step 3: Configure the brand's WhatsApp number
Your existing Meta integration does this step. With Meta, check that the
selected number is eligible for calling and that your app can access it. A
messaging integration on its own does not make a number eligible for calling.
Load `META_GRAPH_VERSION`, `META_ACCESS_TOKEN` and `PHONE_NUMBER_ID` from the
brand's authorized integration. `PHONE_NUMBER_ID` is Meta's phone-number ID,
not the WABA ID. `BUSINESS_NUMBER` is the brand's E.164 number, including `+`.
Generate `meta-settings.json` from the workspace and agent IDs you stored:
```bash theme={null}
jq -n --arg ws "$VOICE_WORKSPACE_ID" --arg agent "$AGENT_ID" '{
calling: {
status: "ENABLED",
srtp_key_exchange_protocol: "SDES",
audio: {additional_codecs: ["PCMA", "PCMU"]},
sip: {
status: "ENABLED",
servers: [{
hostname: "sip.miraiminds.co",
port: 5061,
request_uri_user_params: {ws: $ws, agent: $agent}
}]
}
}
}' > meta-settings.json
curl --fail-with-body -X POST \
"https://graph.facebook.com/$META_GRAPH_VERSION/$PHONE_NUMBER_ID/settings" \
-H "Authorization: Bearer $META_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @meta-settings.json
```
The `ws` hint is our workspace ID, and `agent` is our agent ID. Neither is your
platform's brand ID or a Meta WABA ID. SDES and the additional codecs must match
this integration. Check the response before continuing.
### Retrieve the SIP password
Meta generates the number's SIP password, so the brand does not choose one.
Retrieve it with your authorized Meta token:
```bash theme={null}
umask 077
curl --fail-with-body \
"https://graph.facebook.com/$META_GRAPH_VERSION/$PHONE_NUMBER_ID/settings?include_sip_credentials=true" \
-H "Authorization: Bearer $META_ACCESS_TOKEN" \
--output meta-settings.private.json
```
The password is `calling.sip.servers[].sip_user_password` for the entry whose
hostname is `sip.miraiminds.co`. The SIP username is the business number in E.164.
Store the password in your secret manager. The Meta token, SIP password and
Voice Infra API key are three different credentials.
Do not reset an already working SIP integration just to read its password. For
an existing registered number, verify its settings and reuse its registration.
Coordinate any move to a different workspace with Voice Infra.
## Step 4: Register and activate the number
Register the number with our API. The example below builds a private request
file without printing the SIP password. Set `VOICE_WEBHOOK_URL` to the public
HTTPS URL of the call-result receiver you build in step 7.
```bash theme={null}
umask 077
jq -e --arg number "$BUSINESS_NUMBER" --arg phone_id "$PHONE_NUMBER_ID" \
--arg agent "$AGENT_ID" --arg webhook "$VOICE_WEBHOOK_URL" '
[.calling.sip.servers[] | select(.hostname == "sip.miraiminds.co")
| .sip_user_password | select(type == "string" and length > 0)] as $passwords
| if ($passwords | length) != 1 then error("Expected one SIP password")
else {
business_number: $number,
phone_number_id: $phone_id,
agent_id: $agent,
sip_username: $number,
sip_password: $passwords[0],
webhook_url: $webhook
} end
' meta-settings.private.json > number.private.json
curl --fail-with-body -X POST "$VOICE_BASE_URL/v2/whatsapp/numbers" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: partner:$BRAND_ID:number:v1" \
--data-binary @number.private.json --output number.created.json
NUMBER_ID=$(jq -er '.id' number.created.json)
```
The required request fields are `business_number`, `phone_number_id`, `agent_id`,
`sip_username` and `sip_password`. `webhook_url` and `resolver_url` are optional
public HTTPS endpoints. Leave them out until you have built them. The API works
out workspace ownership from your key, so do not send a workspace ID in the
body.
The response includes these fields (the values are examples):
```json theme={null}
{
"id": "wan_EXAMPLE",
"business_number": "+919876543210",
"phone_number_id": "123456789012345",
"agent_id": "agt_EXAMPLE",
"state": "pending",
"version": 1,
"sip_password_set": true,
"sip": {
"hostname": "sip.miraiminds.co",
"port": 5061,
"request_uri_user_params": {"ws": "ws_EXAMPLE", "agent": "agt_EXAMPLE"}
}
}
```
Store the returned `id` and `version` in the brand mapping. The full response
also includes `meta_settings_example`. Compare it with the settings you applied.
Our API never returns the SIP password. Once the password is in your secret
manager and the integration is complete, delete the temporary files that hold
it.
### Approval and status
To get ownership approved, send Voice Infra your brand identifier, business
number, workspace ID and registration ID. You do not need to send the password.
If you are onboarding many brands, arrange batch approval. There is no
self-approval API for customers.
Poll each number's status with bounded backoff:
```bash theme={null}
curl --fail-with-body "$VOICE_BASE_URL/v2/whatsapp/numbers/$NUMBER_ID" \
-H "Authorization: Bearer $VOICE_API_KEY"
```
| State | What you do |
| :- | :- |
| `pending` | Wait for approval and configuration acknowledgement |
| `active` | Run real call tests before enabling the brand |
| `failed` | Contact Voice Infra with the registration ID |
| `disabling` | New calls are blocked; existing calls may be finishing |
| `disabled` | Keep history; calling is off |
`active` means the configuration is in place. It does not prove that speech is
audible or that Meta routes calls correctly. There is no activation webhook in
this version. For inventory, use `GET /v2/whatsapp/numbers?limit=100` and follow
`next_cursor`. To reconcile activation, use individual GETs. Do not poll
thousands of numbers every second.
## Step 5: Receive incoming calls
Your application does not send a `POST /v2/calls` request for an incoming call:
1. The customer taps the voice-call button in the brand's WhatsApp chat.
2. Meta delivers the call to Voice Infra.
3. The registered agent answers and conducts the conversation.
4. Events go to the number registration's `webhook_url`, if one is set.
Read the caller's number from `identity.caller_number` in our call object. For
incoming WhatsApp calls, `to` identifies the business, so do not assume `from`
contains the caller. `identity.wacid` links the call to Meta's records.
### Optional caller context
Set `resolver_url` on the registration if you want your backend to supply
context before the agent answers. We send a signed request with this shape:
```json theme={null}
{
"event": "call.resolve",
"call_id": "call_EXAMPLE",
"direction": "inbound",
"from": "+919876543211",
"to": "+919876543210"
}
```
Verify `X-Mirai-Signature` over the raw bytes as described in step 7. Identify
the brand from the registered business number and return HTTP 200:
```json theme={null}
{
"allow": true,
"variables": {
"customer_name": "Asha",
"support_context": "The customer requested help choosing a product."
}
}
```
Reference the supplied variables in the agent's prompt, and share only context
your backend is authorized to disclose. The resolver has a one-second budget.
A valid `{"allow":false}` declines the call, while a timeout or error falls back
to the default agent. Treat the resolver as optional enrichment. Because it does
not fail closed, it cannot act as an authorization check or a per-brand spend
limit. Verify the caller before any sensitive action.
## Step 6: Request a permitted callback
Your **Call me** button records the customer's callback request. You also need
native Meta calling permission, which is separate. Use your existing Meta
messaging integration to request permission and process the authenticated
confirmation, following Meta's current messaging-window and template rules.
Do not dial merely because a generic button was clicked or a cached expiry says
permission is valid. Check the current Meta verdict for the actual brand and
recipient:
```bash theme={null}
curl --fail-with-body \
"https://graph.facebook.com/$META_GRAPH_VERSION/$PHONE_NUMBER_ID/call_permissions?user_wa_id=$CUSTOMER_WHATSAPP_DIGITS" \
-H "Authorization: Bearer $META_ACCESS_TOKEN" \
--output permission.json
jq -e '.actions | any(.action_name == "start_call" and .can_perform_action == true)' \
permission.json
```
`CUSTOMER_WHATSAPP_DIGITS` is the country code and number without `+`. Continue
only when the last command succeeds. Meta can still refuse the call if a
permission or quota changes afterward. Use the current provider verdict rather
than hard-coded country lists or permission lifetimes.
### Store the callback job before dialing
Your backend must keep a durable job with a unique `(brand_id, callback_id)` pair.
Freeze the recipient, agent, business number, request body, original timestamp
and idempotency key. Create a job only for a customer request that is still
outstanding. Permission on its own does not authorize a new callback.
Save this call request as `callback.json`, substituting values from that job:
```json theme={null}
{
"agent_id": "agt_YOUR_BRANDS_AGENT",
"route": "whatsapp",
"from": "+919876543210",
"to": "+919876543211",
"max_duration_secs": 120,
"webhook_url": "https://your-backend.example/voice/events",
"variables": {"customer_name": "Asha"},
"metadata": {"brand_id": "your-brand-id", "callback_id": "your-callback-job-id"}
}
```
Use a real receiver URL, the registered brand number and the approved recipient.
`variables` and `metadata` are optional. `agent_id`, `to`, `route: "whatsapp"`
and the registered `from` select this call. Include `webhook_url` to receive
outbound results. Inbound results go to the registration's webhook.
```bash theme={null}
# Run only after the current permission check and durable job claim succeed.
: "${STORED_CALLBACK_KEY:?Load the original callback job idempotency key}"
curl --fail-with-body -X POST "$VOICE_BASE_URL/v2/calls" \
-H "Authorization: Bearer $VOICE_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $STORED_CALLBACK_KEY" \
--data-binary @callback.json --output callback.accepted.json
```
```python theme={null}
import json
import os
from pathlib import Path
import httpx
# Run only after permission validation and a durable job claim.
response = httpx.post(
f"{os.environ['VOICE_BASE_URL']}/v2/calls",
headers={
"Authorization": f"Bearer {os.environ['VOICE_API_KEY']}",
"Idempotency-Key": os.environ["STORED_CALLBACK_KEY"],
},
json=json.loads(Path("callback.json").read_text()),
timeout=30,
)
response.raise_for_status()
call_id = response.json()["id"]
# Persist call_id on the existing callback job.
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
// Run only after permission validation and a durable job claim.
for (const name of ["VOICE_BASE_URL", "VOICE_API_KEY", "STORED_CALLBACK_KEY"]) {
if (!process.env[name]) throw new Error(`Missing ${name}`);
}
const response = await fetch(`${process.env.VOICE_BASE_URL}/v2/calls`, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(30000),
headers: {
Authorization: `Bearer ${process.env.VOICE_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": process.env.STORED_CALLBACK_KEY,
},
body: await readFile("callback.json", "utf8"),
});
if (!response.ok) throw new Error(`Call request failed: ${response.status}`);
const { id: callId } = await response.json();
// Persist callId on the existing callback job.
```
The response is HTTP 202 with the call object, including `id` (`call_...`) and
`status`. An accepted request does not mean the customer answered. Voice Infra
handles the SIP signaling and audio, so you do not send SDP to Meta's `/calls`
endpoint for this integration.
### Retry without calling twice
* Process permission webhooks through your durable queue. Duplicate deliveries
must select the same callback job.
* Retry uncertain API outcomes with the same body and idempotency key. Do not
mint a new callback ID to get around a timeout.
* Keep completed jobs for longer than our 24-hour call-idempotency window, and
stop automatic retries before the window expires so that an old webhook cannot
start a new call.
* Persist the returned call ID and read its result. Do not automatically redial
a completed or failed call, or move it to another channel.
## Step 7: Receive and display results
Voice Infra sends [signed webhooks](/v2/webhooks) to your HTTPS receiver. The
envelope contains an event ID, type, creation time and `data.call`:
```json theme={null}
{
"id": "evt_EXAMPLE",
"type": "call.completed",
"created_at": "2026-09-30T09:00:00Z",
"data": {
"call": {
"id": "call_EXAMPLE",
"agent_id": "agt_EXAMPLE",
"route": "whatsapp",
"direction": "outbound",
"from": "+919876543210",
"to": "+919876543211",
"status": "completed"
}
}
}
```
Verify `X-Mirai-Signature` using the workspace signing secret and the exact raw
request bytes. This signature is separate from Meta's webhook signature and from
your API keys. The same Python verifier works on resolver request bytes:
```python theme={null}
import hashlib
import hmac
import re
import time
def verify_signature(secret: str, header: str, raw_body: bytes) -> None:
parts = {}
for item in header.split(","):
key, separator, value = item.strip().partition("=")
if not separator or key in parts:
raise ValueError("Malformed signature")
parts[key] = value
timestamp, digest = parts.get("t", ""), parts.get("v1", "")
if not secret or not re.fullmatch(r"[0-9]{1,12}", timestamp):
raise ValueError("Missing secret or timestamp")
if not re.fullmatch(r"[a-f0-9]{64}", digest):
raise ValueError("Malformed digest")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Expired signature")
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, digest):
raise ValueError("Invalid signature")
```
In your HTTP handler, verify before parsing JSON. Insert the authenticated event
into a durable inbox with a unique `(workspace_id, event_id)` key, then return
2xx. Process the inbox asynchronously. If storage fails, return a retryable
failure instead of acknowledging an event you have lost.
For outbound calls, the stored callback job tells you which brand owns the call.
For inbound calls, match the business number and agent against your stored brand
mapping. Keep historical mappings so that late events still match. Do not assign
a call to a brand from client-provided metadata alone.
Handle `call.started`, `call.completed`, `call.failed`, `call.aborted` and, where
applicable, `call.voicemail`. If post-call processing is configured, you may
also receive `call.processed`. Use `data.call.status` and `ended_reason` to tell
outcomes apart. There is no `call.no_answer` event. Do not let a late
`call.started` overwrite a terminal result.
| API | Purpose |
| :- | :- |
| `GET /v2/calls/{call_id}` | Read current status, duration, end reason and available cost |
| `GET /v2/calls?agent_id={agent_id}&limit=100` | List a brand agent's history; follow `next_cursor` |
| `GET /v2/calls/{call_id}/transcript` | Retrieve the transcript when available |
| `GET /v2/calls/{call_id}/recording` | Retrieve recording access when available |
| `GET /v2/calls/{call_id}/analysis` | Read analysis if enabled on the agent |
| `POST /v2/calls/{call_id}/abort` | Cancel an active or queued call |
Check brand ownership before every read or control operation. The transcript,
recording and analysis can become available some time after the call ends.
## Step 8: Operate one workspace across many brands
### Combined billing
One funded partner wallet covers the brands' calls. Use
`GET /v2/wallet` for balance and `GET /v2/wallet/transactions` for reconciliation.
Match settled call costs to brands through your stored brand mapping, and use
those records for your own resale billing. Voice Infra does not create separate
invoices or balances for brands inside this shared workspace.
Confirm the workspace's current [pricing](/general/tiers) and [wallet](/v2/wallet)
terms. Do not assume a pilot charge applies to everyone. Meta charges are
separate. Coordinate changes to keys and the workspace's pinned inbound calling
configuration with Voice Infra before changing production billing assumptions.
If the shared balance runs out, every brand can be affected, so monitor it
centrally. Per-brand dashboards and fair allocation are up to your application.
This shared-workspace recipe does not provide strict per-brand inbound spend
limits, and the optional resolver cannot serve as a reliable hard budget limit.
### Onboard in batches
Use a resumable queue and save IDs after each successful step. Back off on
429/503 responses. Arrange ownership approval in batches, then test and enable
brands gradually. A database test with 2,000 registrations does not show how
many simultaneous calls your customers can make.
Measure expected calls per second, concurrent calls and average call duration
with Voice Infra before you agree on traffic limits. Monitor connection success,
time to greeting, missing or one-way audio, dropped calls, provider errors,
callback backlog, webhook delivery and wallet settlement. Healthy HTTP endpoints
and a `completed` status do not prove both parties heard the conversation.
### Update or disconnect a brand
| Change | API and behavior |
| :- | :- |
| Agent instructions or voice | PATCH the agent with `if_revision` from the latest GET. New calls use the new revision, and existing calls keep their snapshot. |
| Webhook, resolver or assigned agent | PATCH `/v2/whatsapp/numbers/{number_id}` with the current `version` and the fields you are changing. On a conflict, read the number again. |
| Assigned agent's Meta routing hint | Coordinate applying the returned `meta_settings_example` and test the cutover before resuming traffic. |
| SIP password rotation | PATCH the new `sip_password` and current `version`, then wait for `active` again. |
| Disconnect the number | Stop admitting callback jobs, DELETE the registration and poll until `disabled`. Coordinate its Meta calling destination afterward. |
For example, to change where results go, send the version from your latest
read of the number:
```json theme={null}
{"version": 1, "webhook_url": "https://your-backend.example/voice/events"}
```
DELETE blocks new calls immediately and lets existing calls finish. Keep the call
and billing history. DELETE is not a temporary pause, and there is no public API
to re-enable the registration, so coordinate restoration or workspace migration
with Voice Infra.
## Test it
Use the 120-second test agent from step 2 on one approved number. Record a call
ID for every test:
1. Call the number in WhatsApp. Listen for the greeting, say a short sentence,
and confirm the agent repeats its meaning. Do this more than once to catch
intermittent failures.
2. Make a genuine callback request through your app, accept native Meta calling
permission, and confirm your job worker starts one call with audible speech.
3. Replay the permission webhook. Confirm it reuses the job and does not ring
the customer again.
4. Test missing or revoked permission, a failed call, and a duplicate result
webhook. Confirm your app records the correct outcome.
5. Check signed result delivery, any available transcript or recording, and
settled usage.
6. Confirm another brand cannot read, change or cancel those calls through your
application.
### Troubleshooting
| Symptom | What to check |
| :- | :- |
| Production `403` | Confirm the key's workspace has completed production activation |
| Number API collection returns `404` | Confirm that the public number-management APIs are available on your assigned origin |
| Meta settings error `100 / 33` | Check the phone-number ID and token access; do not use the WABA ID |
| Registration `409 integration_conflict` | Coordinate the workspace's pinned calling configuration with Voice Infra |
| Number stays `pending` | Confirm ownership approval and configuration acknowledgement |
| Active number but no working call | Check Meta settings and real call logs. Active only means the configuration is in place |
| Callback never rings | Read the call result and check the current permission, recipient and capacity |
| Connected but silent or choppy | Pause the rollout and report the call ID, time and timezone, direction and Meta `wacid` |
| Webhook signature mismatch | Check raw bytes, workspace signing secret and clock |
| Duplicate callbacks | Inspect durable job identity and retry keys; do not rotate the key to retry |
## Privacy and compliance
Tell callers they are speaking with a voice agent and explain recording or
transcript retention where applicable. Keep keys, SIP passwords and recording
links out of browsers, application logs and public documents. Apply brand-level
access checks to stored results and set retention rules that suit your use
case. Meta calling permission does not give you consent to record.
Work out which privacy and calling requirements apply to your operation,
including GDPR, HIPAA or TCPA where relevant. Following this recipe does not
make you compliant or authorize you to handle regulated data. Confirm the
applicable platform terms and your organization's requirements before enabling
those workflows.
## Production checklist
* Production workspace activation, central funding, commercial terms and the
public APIs you need are confirmed.
* Every brand's number and agent mapping is correct, approved and active.
* Repeated incoming and permitted outgoing calls have audible two-way speech.
* Your actual Meta permission webhook, durable callback worker, result receiver
and brand dashboard have been tested together.
* Duplicate and replayed events cannot start extra calls or corrupt final results.
* Concurrent and long calls pass within the measured limit, including provider
failure, wallet exhaustion, alerting and recovery tests.
* Credential rotation, brand updates and disconnects work as documented.
* Brand isolation, retention, caller notices and settled usage are verified.
Do not enable every brand just because the first number registered. Keep each
brand in setup until its real call acceptance tests pass, then expand in
measured batches. Even when every API request succeeds, you still need number
approval, partner production activation and reliable audio.
## References
* [Agents](/v2/agents), [calls](/v2/calls), [webhooks](/v2/webhooks) and [wallet](/v2/wallet).
* [Meta SIP configuration](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/sip).
* [Meta call settings](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/call-settings).
* [Meta calling permissions](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/user-call-permissions).
Use the current Meta documentation and responses for eligibility, permission
messages and provider limits. Your existing Meta integration owns those steps.
# Assistant Sandbox
Source: https://docs.miraiminds.co/developers/assistant-sandbox
Test changes to your voice assistant's system prompt on web calls without touching 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 for your voice assistant's
system prompt. You can tweak the prompt as you go and trigger web calls to try
it, and none of this changes 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
they use credits from your wallet the same way production calls do.
## Getting Started
To open the sandbox, enter your **Public Key**, **Private Key** and **Workspace** credentials, then click the **Validate** button.
| 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 you're authenticated, the sandbox loads your workspace data and every assistant configured in it.
A **dropdown** then lists every assistant in your workspace. Select the one you want to test.
Once you select an assistant, the screen splits into two sections:
**Left section: System Prompt & Variables**
* The assistant's **system prompt** appears in an editable area, split into sections such as **Context**, **Objective** and **Personality**
* Below the prompt is the list of **allowed variables**. The system generates these from 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 that switches to a different assistant
1. Fill in 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're 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 right away
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 reappears so you can select another assistant from your workspace.
## Important Notes
**Prompt changes are not saved.** Edits you make to the system prompt in the
sandbox are temporary and are not stored in the database. To keep a change,
update the assistant's prompt through the dashboard or API.
**Use only system-generated variables.** The sandbox generates the allowed
variables from the data you provide. Custom or arbitrary variables are not
resolved during the call.
**No events or actions in sandbox.** Sandbox calls do not trigger webhook
events (such as `call.ended`, `call.completed`, `end-of-call`, `action`, etc.)
or actions (such as `create-order`, `send-whatsapp`). The sandbox is only 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 how it behaves now, before you make changes.
* **Change one thing at a time.** Edit one part of the prompt per call, so you can tell which edit changed the assistant's behavior.
* **Test edge cases.** Vary your payload data (e.g., different product types, price ranges, customer profiles) to check that the prompt handles a range of scenarios.
* **Note your changes.** Sandbox edits aren't saved, so copy your final working prompt and apply it to your assistant configuration by hand.
* **Monitor your credits.** Each sandbox call deducts credits, so plan your testing sessions with that in mind.
# Call Lifecycle & Webhooks
Source: https://docs.miraiminds.co/developers/call-lifecycle-events
The stages a voice call goes through, the webhook event each one sends, and how to handle them.
**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 sends webhooks to tell your application about the state of every
call in real time. When a call connects, fails or triggers an action such as
creating an order, your configured callback URL receives an event payload.
## Call Lifecycle
Every call moves through the lifecycle below, and each stage fires an event.
```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 follow a successful call from start to finish.
| 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. The 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 can't be connected, one of these events fires. The system may retry
automatically, depending 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. |
When all retry attempts are used up, we send `call.lifecycle-ended` to signal
that no more attempts will be made for this call task.
## Webhook Payload Structure
Every webhook event uses the same envelope.
```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. It
comes back in every event, so you can link calls to your internal records
(e.g., `userId`, `orderId`).
* **`event.type`**: The 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 events the AI sends during the conversation when your system needs
to do something, such as create an order or send a message.
### `create_order`
Fires when the AI decides that the customer wants to place an order and has given
all the details it needs.
```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 can't complete a task, such as creating an order, because
information is missing. It triggers a fallback message on 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
This example handles 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 confirm that a webhook really came from Voice Agents, verify the signature in
its headers.
See [Webhook Signature Verification](/developers/webhook-signature) for implementation details.
## The same thing in v2
v2 keeps most of this design. It still sends one event per state change, and
each event comes in an envelope you acknowledge with `200` and carries a
signature you verify. The event names are different. Retries and actions did
not carry over to v2, 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`, which 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.** Each call object has one outcome, so 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
returns the `call_id` in the `202` response to
[`POST /v2/calls`](/v2/calls#create-a-call). Store it against your own record
before the first event can arrive, and use it to match events to that record.
The `variables` you pass when you create the call go into the prompt and do not
come back on the event.
For the full reference, see [v2 Webhooks](/v2/webhooks).
# Create Assistant
Source: https://docs.miraiminds.co/developers/create-assistant
Create a voice assistant and set its prompt, model, transcriber and voice.
**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 creates a new voice assistant. You can set 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 with the details of the new assistant.
### 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
How to onboard a brand, create an AI agent, start and abort calls, and 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).
This guide follows a customer on the Voice Agents platform through the whole
lifecycle, from onboarding to receiving real-time call events at 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 (a Shopify store) to create a workspace with billing
configuration. Every later API operation is 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. Every later API call needs it as a header.
***
## Step 2 — Create an AI Agent
Create a voice assistant with a persona, language, voice and call settings, plus
an optional 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` to `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`. You need it to initiate calls.
You can't change `variant.type` after creation. Choose `abandoned_cart` for cart recovery flows, or `custom` if you want full control of the prompt.
***
## 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` gives the AI context about the customer and their cart. Every field
is optional, but we recommend sending them 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 API returns the `callId` immediately and delivers real-time status updates to your `callbackUrl`.
***
## Step 4 — Abort a Call
Cancel a queued or in-progress call with the `callId` you got when you initiated
it.
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` receives 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. Every event echoes back your custom
`metadata`, so you can match 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 and the 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, with summary and structured data available |
| `call.lifecycle-ended` | Final event, sent when all processing is 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 also includes the 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, and slow responses may trigger retries.
* **Use `metadata` for correlation.** Pass your internal IDs (e.g., `customerId`,
`orderId`) when initiating the call, and they come back in every event.
* **Verify signatures.** Check 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
The base URL, authentication headers and first steps for the Voice Agents 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).
Use the Voice Agents API to trigger voice calls and manage agents from your own
applications.
## Base URL
Send all API requests to:
```bash theme={null}
https://api.voice-agents.miraiminds.co/v1
```
## Authentication
Authentication is based on your workspace and organization. Every request must
include these 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...`) |
Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to get your organization ID, workspace ID and authorization token.
## Quick Start
Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to get your `workspace`, `organization` and `Authorization` token.
Find the `assistant_id` of the voice agent you want to use.
Use the [Make a Call](/developers/make-a-call) endpoint to place an outbound call right away.
## 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
Place an outbound call from one of your voice assistants to a 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 places an outbound call to any phone number, using the
voice assistant you choose. Use it for automated notifications, appointment
reminders or qualifying leads right away.
## Endpoint
POST /admin/assistant/call
## Request Flow
Before it places the call, the API validates your workspace, checks that the
assistant is available and then starts 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 with 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
Verify the HMAC-SHA256 signature on each webhook to confirm it came from Voice Agents and was not changed in transit.
**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 Agents backend signs every webhook event with HMAC-SHA256, and it
computes the signature from the payload. Checking the signature confirms:
1. **Authenticity**: the webhook came from our service.
2. **Integrity**: the payload was not tampered with in transit.
Once the signature checks out, you can trust and use the payload in the request
body.
## Keys
Each organization gets its own key pair, generated automatically when the
organization is created:
* **Public Key** (`publicKey`): Format `pk_<32 hex characters>`
* **Secret Key** (`privateKey`): Format `sk_<64 hex characters>`
The keys are stored at the organization level, and you can find them in your
organization settings. We sign webhooks with the secret key and send the public
key in the webhook headers to identify the organization.
Keep your secret key secure and never expose it publicly. The public key only identifies your organization and can't verify a signature: verification uses the secret key, as shown below.
## Signature Headers
Every webhook request carries these headers, 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, and the signature is calculated from it.
## Verification Process
To verify a webhook signature, follow these steps:
### Step 1: Extract Headers
Read these headers from the incoming request:
* `x-signature`: The signature to verify against
* `x-public-key`: Tells you which organization's secret key to use
### Step 2: Get the Secret Key
Use the `x-public-key` header to look up the matching secret key for your
organization. It should be the same secret key that is 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
Once the signatures match, you can trust and use the payload data from the
request body.
## 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. Don't use a parsed JSON object, because JSON serialization can
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**: Once the signature is verified, you can trust and use
the payload data from the request body.
## Troubleshooting
### Signature Mismatch
* Make sure 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 that your reverse proxy or load balancer is not stripping these headers
# Campaign Management
Source: https://docs.miraiminds.co/general/campaign
Create and manage voice campaigns from a CSV file or through the 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).
## Overview
A voice campaign places automated outbound calls to a list of contacts. You can load the contacts by uploading a CSV file, or send them from your existing systems through the API.
Upload a contact list in our CSV template to set up a campaign quickly.
Connect your existing systems through a webhook so contact data syncs
automatically.
Set the dates, timezone and operating hours to reach more of your
contacts.
Set retry logic, call duration limits and inactivity periods.
## Setup Guide
### Step 1: Access Campaign Creation
1. **Navigate to Campaigns Page**
Go to the **Campaigns** section in your Voice Agents dashboard.
2. **Create New Campaign**
Click the **"Create Campaign"** button to start the setup process.
### Step 2: Basic Campaign Configuration
3. **Enter Campaign Name**
Give the campaign a name that describes its purpose.
4. **Select Voice Assistant**
Choose the voice assistant that will handle all calls for this campaign.
5. **Configure Webhook Integration**
Enable webhook integration if you want to send contact data from your platform through the API. With it enabled, CSV upload is optional.
### 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.
7. **Prepare Your Contact Data**
Fill in the downloaded CSV template with your contacts. Complete every required field.
8. **Upload CSV File**
Upload the completed CSV file using the file upload interface.
9. **Preview Contact Data**
Check the uploaded data in the preview panel on the right before you continue.
#### Option B: API Integration
With webhook integration enabled, your platform can send contact data directly through API calls, so you don't need to upload a CSV file by hand.
### Step 4: Schedule Configuration
10. **Set Campaign Dates**
Set the campaign's start and end dates:
* **Start Date**: Select when the campaign should begin
* **End Date**: Choose when the campaign should conclude
11. **Configure Timezone**
Select the timezone for your campaign so that calls go out at the right local time.
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
### 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
14. **Set Call Duration Limits**
Configure call timing parameters:
* **Maximum Call Duration**: Longest allowable call time
* **Inactivity Period**: How long to wait with no response before ending the call
### Step 6: Launch Campaign
15. **Create**
When all the settings are done, click **"Create Campaign"**.
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
The modal lists any invalid or missing entries. You can download them, correct them and upload them again.
### Step 7: Launch and Manage Campaign
17. **Launch Your Campaign**
After you create the campaign, launch it from the campaign list. Click the **"Play"** button next to your campaign to start making calls.
18. **Track Campaign Status**
The **Campaign Status** page shows how the campaign is progressing, including:
* Call completion rates
* Success/failure statistics
* Real-time campaign progress
* Contact attempt history
19. **Copy Webhook API Endpoint**
If you enabled webhook integration, click **"Copy Webhook"** to copy the endpoint URL that your platform sends data to through the API.
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:
* Track call success rates and campaign progress
* Pause, resume or modify running campaigns
* Review detailed campaign analytics and outcomes
* Download campaign performance reports
## Best Practices
### Data Preparation
* Test your CSV data with a small sample before uploading large datasets
* Format phone numbers correctly for the regions you are calling
* Fill in all required fields to reduce validation errors
### 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
* Make sure you have proper consent to contact every number 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
What voice agents are, how they work, and what they can do for your customer 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).
Voice agents are AI systems that automate phone conversations with your customers, and they can handle a high volume of calls. They combine speech recognition, natural language processing and call flow management to handle customer calls without a person on the line.
Unlike traditional phone systems or chatbots, voice agents can:
* Understand natural speech in real-time conversations
* Handle complex requests and answer with the context in mind
* Connect to your existing systems to look up data and take actions
* Route calls between automated handling and human agents
* Learn from each interaction and improve over time
## How Voice Agents Work
Each call passes through these stages, from the caller's speech to the action the agent takes.
### 1. Speech Recognition
Speech-to-text converts the caller's audio into structured text. It handles different accents, speaking speeds and background noise.
### 2. Natural Language Understanding
AI models analyze the text to work out:
* **Intent:** what the caller wants to do
* **Entities:** details such as names, dates and product IDs
* **Context:** the conversation so far and the caller's current situation
### 3. Decision Engine
The voice agent decides how to respond based on:
* Predefined conversation flows
* Integration with your business systems
* Real-time data lookup and validation
* Escalation rules for complex scenarios
### 4. Response Generation
The agent generates each reply during the conversation. Its replies:
* Give accurate information that fits the context
* Keep the conversation flowing
* Match your brand's tone and style
* Handle follow-up questions naturally
### 5. Action Execution
Voice agents can take actions such as:
* Booking appointments in your calendar system
* Processing orders and payments
* Updating customer records
* Triggering workflows in connected platforms
## Key Benefits
### Cost Efficiency
Pricing is per minute, so you pay only for productive conversation time. There is no charge for idle time while an agent waits for calls, and you can handle high call volumes without hiring more staff.
### 24/7 Availability
Voice agents answer calls outside business hours and all year round, with no breaks or vacations, so you don't miss calls. Callers get a response right away, with no hold times or queue delays.
### Unlimited Scalability
Voice agents can handle millions of calls simultaneously without infrastructure limits. Capacity scales up immediately during peak periods or marketing campaigns, and new capacity needs no training time.
### Universal Integration
Voice agents can connect to any system through APIs and webhooks. They sync with the tools you already use, such as CRM, scheduling and e-commerce platforms, and keep data consistent across all customer touchpoints.
## Common Use Cases
Voice agents are a good fit for the following kinds of work.
### 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
You can deploy voice agents on several channels and connect them to your existing business systems. Setup usually involves these steps:
1. Define conversation flows for your 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, then monitor performance across your communication channels
## Next Steps
To connect voice agents to your existing systems, see the integration guides:
* [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
For examples of voice agents in specific industries, see the use case pages:
* [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 we are shipping next, with target dates, across tiers, node graphs, the orchestration platform, model APIs, voices and integrations.
This page lists what is coming, in the order we expect to ship it. The dates
are **targets, not contracts**. A date moves when call quality demands it, and
we choose quality over the date 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 every tier](#post-call-analytics-everywhere) — AI summary + QA score in the v2 API | August 2026 | rolling out |
| [Warm transfer on every tier](#warm-transfer-everywhere) — hand a call to a person | not yet scheduled | planned |
| [Price ladder](/general/tiers) — T5 pay as you go, T3 and T1 with a monthly commitment, one Mira stack on every tier | September 2026 | ✅ **live** |
| [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-based agents](/v2/node-agents) on every tier — phone calls, browser calls and campaigns, built through the API | September 2026 | ✅ **live**, including creation in Agent Studio from a description or call recordings |
| [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 Go live and wallet top-ups — from the console | September 2026 | ✅ **live** |
| Self-serve signup | Q4 2026 | design |
## Post-call analytics everywhere
Every call on every tier gets an AI summary and a QA score. That covers 100% of
calls, not a sample. The capability is part of
[every tier](/general/tiers#one-stack-on-every-tier) today. What lands in August
is the v2 API surface: an analysis object on the call and a webhook event when
the analysis is ready. Until then, the summaries exist but you cannot query
them over v2.
## Warm transfer everywhere
Transfer to a human is not available yet, and it has no date. The plan is for
an agent on any tier to hand a call to a person at a number you set per agent.
A call that ends this way will report
[`ended_reason: transferred`](/v2/calls#ended-reasons).
## Model APIs
Model APIs will expose the ASR, TTS and LLM that power the call path as
standalone endpoints, for teams that want the models without the telephony.
They will cover 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 they are the mother tongue of
429 million people, most of whom cannot be reached in Hindi at all. We announce
the date for each language as it clears evaluation. The
[language roadmap](/v2/tts-languages) has the full story and the data.
## Next-generation TTS
The next revision of the voice engine has lower first-audio latency and sounds
more natural on Hindi and code-switched speech. It replaces the engine under
every tier once it clears our quality bar. The API does not change, so your
calls simply start sounding better.
## Orchestration platform
The orchestration platform puts node graphs on a canvas, so you can build, test
and version call flows visually. These are the same graphs that node-based
agents run on every tier, but your ops team can author them 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 every month from August 2026. New voices appear in the
[voice gallery](/v2/voices) as they clear evaluation. Listen to a new voice in
Agent Studio before you switch, because the choice of voice moves
answer-through rates more than prompt wording does.
## Channels
Calls will connect to other channels, starting with email follow-ups triggered
by call outcomes ("summary of what we agreed", "payment link after a confirmed
order"). Other channels come after that. The design has the call outcome
trigger each follow-up, so nobody has to send it by hand.
# Billing & tiers
Source: https://docs.miraiminds.co/general/tiers
Start on T5 pay as you go, or commit monthly for a lower rate — the same Mira stack on every tier. How calls are metered, forecast and paid for.
Every company starts on **T5: ₹5 / minute for voice AI, pay as you go** — no monthly
commitment. Commit to a monthly amount and the voice rate drops:
* **₹50,000 a month → T3 at ₹3 / minute**.
* **₹1,00,000 a month → T1 at ₹1 / minute**. Call insights are optional
at ₹0.20 / minute and can be enabled in **Billing**.
T5 and T3 include Mirai phone calling and insights. On T1, optional Mirai
phone calling adds ₹0.30 / minute only when Mirai supplies the phone connection.
BYO telco, WhatsApp and browser calls have no Mirai telco charge.
| Tier | Rate | Monthly commitment | Details |
| - | - | - | - |
| T5 | ₹5 / minute | None — pay as you go | All models and features; Single-prompt and node-based agents; Bring your own telco, WhatsApp or browser calling; Mirai phone calling and call insights included; Top up anytime at the same rate: ₹5 / minute |
| T3 | ₹3 / minute | ₹50,000 | All models and features; Single-prompt and node-based agents; Bring your own telco, WhatsApp or browser calling; Mirai phone calling and call insights included; Top up anytime at the same rate: ₹3 / minute |
| T1 | ₹1 / minute | ₹1,00,000 | All models and features; Single-prompt and node-based agents; Bring your own telco, WhatsApp or browser calling; Optional Mirai phone calling: ₹0.30/min; Optional call insights: ₹0.20/min; enable in Billing; Top up anytime at the same rate: ₹1 / minute |
T3 and T1 are minimum monthly spend plans, funded upfront. Paid workspace usage counts towards the minimum; any unused minimum is settled from the wallet at the end of the billing month. Promotional credit does not count towards the minimum. Extra top-up credit carries forward. Existing agreements keep their recorded terms. All prices exclude GST.
**Enterprise:** Custom voices, Dedicated deployment, SLA, A dedicated point of contact. Contact us for a custom plan. Top up anytime at the same rate.
Your workspace's agreed contract determines your charges. Advertised rates do not replace an existing contract. T5 and T3 include Mirai phone calling and call insights. T1 adds optional Mirai phone calling at ₹0.30 / minute only on Mirai-managed phone calls. BYO telco, WhatsApp and browser calls have no Mirai telco charge. T1 call insights are optional at ₹0.20 / minute and can be enabled in Billing.
## One stack on every tier
Every tier runs on the same Mira stack. **The price is the only difference.**
Choose a tier for what you want to commit to, not for what the calls can do.
**Single-prompt and node-based agents are available on every tier.** A
[node-based agent](/v2/node-agents) takes phone calls, browser calls and
campaigns like any other agent, and is billed the same way. Create one in
Agent Studio (**Create agent → Node-based**) from a description, call
recordings, or both.
## Your plan
A company is on one plan, and every call it makes is billed at that plan's
rate.
* **Start today on T5.** Complete **Go live** in the
[console](https://sandbox.voice.miraiminds.co) — it is self-serve, with no
monthly commitment. You pay for the minutes you use from prepaid credit.
* **Or commit for a lower rate.** Choose T3 (₹50,000 a month) or T1
(₹1,00,000 a month) when you go live. A company already on T5 moves up by
contacting us today; moving up from the console's Billing page is coming.
* **Commitments are minimum spend.** Fund the minimum upfront each month. Paid
workspace usage counts towards it. At the end of the billing month, only the
unused minimum is settled from your wallet. Extra top-up credit carries forward.
Promotional credit does not count towards the minimum. Existing agreements
retain their recorded terms.
* **Top up at the same rate.** Credit you add is spent at your plan's rate.
Prices exclude GST. Calls are billed in 10-second blocks.
## What a minute costs
Billing is **pro-rated in 10-second blocks, rounded up, with a one-block
minimum.** Rates are quoted per minute; the billing unit is a sixth of one. A
part-block is a whole block, and nothing smaller than 10 seconds is billed.
| Call duration | Billed blocks | T5 voice cost | T3 voice cost | T1 voice cost |
| - | - | - | - | - |
| 8 seconds | 1 (10 s) | ₹0.84 | ₹0.50 | ₹0.17 |
| 35 seconds | 4 (40 s) | ₹3.34 | ₹2.00 | ₹0.67 |
| 59 seconds | 6 (60 s) | ₹5.00 | ₹3.00 | ₹1.00 |
| 61 seconds | 7 (70 s) | ₹5.84 | ₹3.50 | ₹1.17 |
| 96 seconds | 10 (100 s) | ₹8.34 | ₹5.00 | ₹1.67 |
| 181 seconds | 19 (190 s) | ₹15.84 | ₹9.50 | ₹3.17 |
Every tier: All models and features. Single-prompt and node-based agents. Bring your own telco, WhatsApp or browser calling. T5: Mirai phone calling and call insights included. T3: Mirai phone calling and call insights included. T1: Optional Mirai phone calling: ₹0.30/min. Optional call insights: ₹0.20/min; enable in Billing. All prices exclude GST. On T1 only, Mirai phone calls add ₹0.30 / minute and insights add ₹0.20 / minute while enabled.
### Metering
`duration_secs` on the [call object](/v2/calls#the-call-object) is the true
media duration in seconds. Voice AI and T1 insights use the duration rounded
**up** to the next 10 seconds — never less than one block. Managed provider
calling uses the provider's configured billing block. Voice AI and enabled
insights are combined before rounding to the paisa; the provider charge is
rounded separately and added to the call total. A call that never connected is
not billed at all — see the table below.
```python theme={null}
def cost_paise(duration_secs: int, rate_paise_per_min: int) -> int:
billed_secs = max(1, -(-duration_secs // 10)) * 10 # whole 10-second blocks
return -(-billed_secs * rate_paise_per_min // 60) # rounded up to the paisa
cost_paise(96, 300) # 100 billed seconds on T3 -> 500 paise, ₹5.00
```
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 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 10 seconds first, then multiply. The rounding is small, but
it never cancels out, so averaging the seconds first will under-forecast
slightly.
```text theme={null}
monthly ₹ = answered_calls × mean(ceil(secs / 10)) × rate / 6
```
A campaign of 50,000 dials at a 35% answer rate and 80 seconds mean talk time:
```text theme={null}
50,000 × 0.35 = 17,500 answered
80 seconds -> 8 billed blocks each
T5 agent usage: ₹1,16,725
T3 agent usage: ₹70,000
T1 agent usage: ₹23,450
```
These are base voice usage charges, before optional T1 telco and insights. T3 and T1 monthly commitments still apply. T5 has no monthly commitment. The monthly minimum is funded upfront; paid usage counts towards it, and only a shortfall is settled at month end. Extra top-up credit carries forward.
Unanswered dials cost nothing, so the 32,500 that rang out are free.
With a 10-second block **the cost tracks talk time almost linearly** — there is
no minute boundary to engineer around. Every block you cut off the mean reduces
the cost of every answered call. A prompt that caps reply length is still the
cheapest way to move it, and a call that ends the moment its job is done pays
for nothing after that.
## 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": 200, "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 up from the console.** Once your company has gone live, add credit
from the **Billing** page of the
[console](https://sandbox.voice.miraiminds.co) whenever you need it. On T3
and T1, each monthly commitment is credited to the same wallet. Before you
go live, your Mirai contact credits the workspace.
* **Top-up credit carries forward.** The monthly minimum still applies to T3
and T1. In **Billing**, review paid usage, the remaining minimum and promotional
credit. Each month-end shortfall appears separately in your wallet statement.
* **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. Billing is per 10-second block, but the pre-dial check asks for a
full minute of headroom. If the wallet cannot cover it:
```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 t5"
}
}
```
* 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 plan sets the tier. A key created after your company goes live carries
your plan's tier, and every call and every campaign placed with it inherits
that tier — leave `tier` out of the request:
```json theme={null}
{
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210"
}
```
If you do send `tier`, it must be your plan's. A call at any other tier is
refused with `403 contract_unavailable` before anything is dialled. There is no
per-call switch to a different rate: to change what your calls cost, change
your plan.
**Before you go live, you are on T5.** Each access code opens one sandbox
workspace with ₹200 of credit. Every call and campaign in it is billed at
T5, ₹5 a minute, whatever tier your key carries or your request sends.
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) takes its tier at create time, and every call it
places inherits it.
## Model API pricing
Text to speech is priced per character — see
[TTS billing](/v2/tts-quickstart#billing). The LLM is priced per token:
| Token type | Price per 1,000,000 tokens | USD reference |
| - | - | - |
| Input | ₹14.38 | \$0.15 |
| Output | ₹57.52 | \$0.60 |
Input and output tokens are priced separately. Prices are billed in INR; USD amounts are provided for reference. Top-ups use the same rates.
| Plan | Monthly commitment | Requests per minute |
| - | - | - |
| Pay as you go | None | 1,000 |
| Business | ₹50,000 | 5,000 |
Rate limits apply per workspace across all API keys. Shared endpoint. No contractual SLA. Monthly commitments are credited to your wallet. Unused credit never expires. All prices exclude GST.
**Enterprise:** Custom volume pricing, rate limits, dedicated endpoint and SLA. Top up anytime at the same rate.
Contact us to activate your LLM plan. Your workspace's agreed contract determines your charges and limits.
### Offline transcription
[Transcribing a recorded call](/v2/transcribe-recorded-calls) costs ₹18 per
hour of audio on every tier. A job is charged once, when it completes, for the
length of the recording to the millisecond, rounded up to the paisa: a
five-minute call costs ₹1.50. Degraded and failed jobs are free. See
[pricing](/v2/transcribe-recorded-calls#pricing).
### AI flow drafts
[Generating a flow from a brief](/v2/node-agents#generate-a-flow-from-a-brief)
or [from call recordings](/v2/node-agents#draft-a-flow-from-call-recordings)
is charged to your wallet at what the model's provider reports it cost, plus
20%. Each response's `cost` says what was charged. The token rates below are
the published guide, and what an attempt is charged when the provider reports
no cost for it. The same rates apply on every tier.
| Model | `model` | Input, per 1,000,000 tokens | Output, per 1,000,000 tokens | Typical charge |
| - | - | - | - | - |
| Gemini 3.8 Flash (default) | `gemini-3.8-flash` | ₹158.40 | ₹792 | ₹2–3 a draft from a brief |
| GPT-6 Sol | `gpt-6-sol` | ₹211.20 | ₹1,056 | ₹3–4 a draft from a brief |
| Claude Opus 5.5 | `claude-opus-5.5` | ₹422.40 | ₹2,112 | ₹6–8 a draft from a brief |
| Recording transcription (Gemini 3.8 Flash) | — | Provider's cost plus 20% | Provider's cost plus 20% | about ₹1 per minute of audio |
* A draft from two example calls is larger: typically ₹8–12.
* A recording is priced as audio, which costs more than text, so a
transcription is charged the provider's reported cost plus 20% rather than a
token rate. The audio is not stored.
* The cost of every attempt in a generation is added up and rounded up to the
paisa once.
* A generation that fails (`422`), or stops after the model had answered
(`503`), is charged for what the model did. So is a recording in which no
conversation was heard (`422`). Nothing is charged when the model never
answered.
* Charges appear on the billing statement as **AI flow drafts**, and in
[wallet transactions](/v2/wallet#a-flow-generation-row) with kind
`flow_generation`.
# Abandon Cart Recovery
Source: https://docs.miraiminds.co/guides/abandon-cart-recovery
Set up automated abandon cart recovery with Voice Agents, using GoKwik webhooks and automatic Shopify coupons.
**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 effective uses of Voice Agents in e-commerce. Automated calls and messages remind customers of the carts they left and help them finish the purchase. This guide covers the full setup, using a GoKwik webhook and [Shopify coupon automation](/integrations/shopify).
**Estimated Time:** \~30 minutes
**Core Requirements:** GoKwik, [Shopify](/integrations/shopify), and [WhatsApp](/integrations/whatsapp)
integrations.
GoKwik webhooks report abandoned carts as they happen, and a personalized
AI voice call goes out within minutes.
Generate unique, time-limited Shopify discount coupons automatically to
encourage customers to complete the purchase.
Combine an AI voice call with an immediate WhatsApp follow-up containing the
checkout link and coupon code.
## How It Works
Recovery runs automatically and starts 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:** Right after the call, the customer gets a [WhatsApp message](/integrations/whatsapp) with the direct GoKwik checkout link and their unique coupon code, so they can complete the purchase from there.
## Data Access & Permissions
Voice Agents accesses only the minimum data it needs to run.
**Strictly Read-Only and Action-Specific**
Voice Agents never changes cart contents, payment information or customer data in GoKwik or Shopify. It only listens for events and performs specific actions that you have authorized.
* **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
### 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.**
* This unique URL is on your abandon cart campaign page in the Voice Agents platform.
3. **How It Works:**
* Once this is set up, GoKwik sends a JSON payload with the cart details to this endpoint whenever a cart is abandoned. The platform pulls out the customer name, phone and cart items and uses them to start 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
You need this to generate discount codes automatically. For detailed setup instructions, see the [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
You need this to send 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.
* Make sure it has placeholders for the customer's name, the coupon code and the GoKwik checkout link.
### Step 4: Tracking Recovery Success
To measure how well your campaigns work, we automatically add UTM parameters to the checkout link. We append parameters such as `&utm_source=voiceagents&utm_medium=call` to the `abandonLink` from GoKwik. On your GoKwik dashboard you can then see all the recovered orders attributed directly to Voice Agents, which gives you a clear view of your ROI.
## Alternative Method: CSV Upload
**Not Recommended**
The CSV method delays outreach significantly. By the time the call goes out, the customer's "heat of the moment" urgency has faded, so conversion rates drop. Use it only if real-time webhook integration is not possible.
1. **Export Data:** Export your abandoned cart data from the GoKwik dashboard.
2. **Format CSV:** Make sure the file has 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
* Trigger the AI call within 5-15 minutes of abandonment, when it has the most effect.
* Start with a 5-10% discount, and A/B test different offers to find what works best for your audience.
* Make sure the voice call and the WhatsApp message both tell the customer clearly how to complete the purchase with the link and code.
* Check your campaign's performance metrics (recovery rate, coupon redemption rate) regularly, and refine your call scripts and offers based on them.
# How Discounts Work
Source: https://docs.miraiminds.co/guides/how-discounts-work
How applied and additional discounts work, and how a voice agent decides which discount to offer during a 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
During a customer call, the voice agent works with two types of discounts: **applied discounts**, which are already in the cart, and **additional discounts**, which the agent offers during the conversation. The agent watches how interested the customer is and offers an additional incentive when it needs one 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 mentions this discount when the customer asks about available deals.
### Additional Discount
An **additional discount** is one the voice agent offers during the call as an incentive. The agent offers it 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 lets the agent negotiate with each customer to close the sale.
## How It Works
During each call, discounts follow this flow:
1. **Customer Asks About Discounts:** When a customer asks about available discounts, the voice agent first mentions any applied discount already in their cart.
2. **Customer Shows Hesitation:** If the customer says they're not ready to buy (because of the price, concerns about the product, 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's details (code and reason). 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
The `applyAs` field controls how the two discounts combine.
### Additional Mode (`applyAs: "additional"`)
In **additional** mode, the new discount applies on top of the existing applied discount, so the customer gets both.
**Example:**
* Product price: ₹1,000
* Applied discount: 10% (₹100 off), so the 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. It suits loyalty programs or special promotions where you want the customer to feel they are getting a very good 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), so the 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 the one already applied but don't want discounts to stack up too far. It protects your margins and still gives the customer a better offer.
## Real-World Scenarios
The voice agent handles each possible discount scenario as follows.
### 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 are available, so the agent talks about the product's value instead.
## Webhook Event Details
After a call, when an order is created, the `create-order` webhook event fires. Its discount information has this structure:
### 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 the voice agent offered during the call. It leaves out the applied discount, which is 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")
The applied discount is part of the cart before the call begins, so the order total already includes it. The webhook reports only what the voice agent 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 the combined savings to stand out to the customer
* Your margins can support stacked discounts
* **Use `override` when:**
* You want to cap the maximum discount
* Protecting profit margins is a priority
* Offering a better single discount is more attractive than stacking
* You want the customer to see one clear discount
# Mirai Voice
Source: https://docs.miraiminds.co/index
AI voice agents that make real phone calls from our own infrastructure in India. Pay as you go, starting at ₹5 a minute.
Give an agent a script and a phone number, and it makes the call. It speaks
Hindi, English and the Hinglish that most real calls are in, and it runs on
GPUs, models and SIP lines we own in India. You can start today at **₹5 a
minute, pay as you go**, or commit monthly for a lower rate. See
[Billing & tiers](/general/tiers).
Create a key, an agent, a call and a webhook, and place one real phone call in five minutes.
The API has two resources, one auth header and one error envelope.
The price ladder, what a minute costs, and which parts of a call we bill for.
What we are shipping and when.
## Get a key
Create a key in the [console](https://sandbox.voice.miraiminds.co) under
**Developers**. The console shows it only once. You can rotate or revoke it on
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'
```
If you would rather use plain HTTP, every page in these docs also shows each
request 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
Trigger voice agent calls from your Klaviyo flows.
**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 Klaviyo integration adds phone calls to your marketing campaigns. When you add a Voice Agent webhook node to a Klaviyo flow, you can reach customers in that flow with personalized voice calls, which improves engagement and conversion rates. On the call, the voice agent can deliver campaign messages, collect feedback and help customers directly.
Raise recovery rates by up to 30% with automated voice calls, which outperform traditional email or WhatsApp campaigns.
Remind customers by phone when it's time to reorder a replenishable product, so they don't run out of essentials.
Call customers to collect actionable feedback that you can use to improve your products and services.
Use customer data from Klaviyo to personalize each call, which makes calls more relevant and raises response rates.
## Setup Guide
### Step 1: Add Webhook Node in Klaviyo Flow
1. **Navigate to Your Klaviyo Flow**
Open the Klaviyo flow where you want to add voice calls, or create a new one.
2. **Add Webhook Node**
Click the "+" button to add a new action and select **Webhook** from the available options.
### 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 you create the campaign, enable webhook support. This lets Klaviyo send contact data directly to your voice agent campaign.
For complete campaign setup instructions, see our [Campaign Management Guide](/general/campaign).
### Step 3: Copy Webhook Details
5. **Copy Webhook Endpoint**
After you create the campaign with webhook support enabled, click the **"Copy Webhook"** button to open the webhook configuration modal.
The modal shows:
* **Webhook URL**: the endpoint where Klaviyo sends data
* **Secret Key**: used to authenticate requests between the two 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
7. **Test and Activate**
Test the webhook to check that data reaches Voice Agents from Klaviyo correctly, 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, to reach customers who left items in their cart
* Post-purchase follow-up, to collect feedback and encourage repeat purchases
* Replenishment reminders, to tell customers when it's time to reorder
* Win-back campaigns, to bring inactive customers back with personalized calls
The voice agent receives customer data from Klaviyo and can personalize the conversation using purchase history, preferences and behavior patterns.
# Limechat Integration
Source: https://docs.miraiminds.co/integrations/limechat
Connect your Limechat account so voice agents can handle customer support and automated 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 Limechat for customer support and automated messaging. Your customers can reach voice agents through Limechat's platform for instant support, order questions and personalized help.
Handle customer inquiries and support requests automatically through
Limechat's messaging platform.
Voice agents can check order status, process requests and handle
order-related questions.
Provide instant responses to common questions and route complex queries
to human agents when needed.
Reach customers on the messaging channels that Limechat supports.
## Setup Guide
To connect Voice Agents to Limechat, you need two values from your Limechat account: your Limechat Account ID and your 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.
### 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 is usually shown on the Limechat Dashboard.
### Step 2: Obtain Your Limechat API Access Token
1. **Access Profile Settings**
In your Limechat dashboard, click your profile picture at the very bottom left.
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 it to connect to Voice Agents.
### 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 you connect Limechat, your voice agent can start handling these requests right away:
* 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 so voice agents can handle customer support, order management and 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
With the Shopify integration, a voice agent can handle customer support, order tracking, inventory questions and sales assistance for your store. Customers talk to the voice agent to check order status, find products, apply discounts and get support right away.
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
product recommendations.
## Required Permissions
Your voice agent needs these Shopify API permissions:
### 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 changes and cancellations
* **`read_discounts`**: apply available discount codes
* **`write_discounts`**: create promotional codes for customers
### Optional Scopes (based on your use case)
* **`read_inventory`**: check inventory in real time
* **`read_shipping`**: calculate shipping rates
* **`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.
3. **Open Apps and Sales Channels**
Select **Apps and sales channels** from the settings menu.
4. **Access App Development**
Click **Develop apps** in the top navigation bar.
5. **Create New App**
Click the **Create an app** button to start the setup process.
6. **Configure App Details**
Enter your app name and select the app developer, then click **Create app**.
7. **Set API Permissions**
Click **Configure Admin API scopes** to set up the required permissions.
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 any optional scopes your use case needs, then save your changes.
9. **Install the App**
Click **Install app** to activate the app in your store.
10. **Copy Access Token**
After installation, copy the generated access token and keep it secure. You'll need it to connect to Voice Agents.
### 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.
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
## What's Next?
Once you connect the store, your voice agent can start handling these requests right away:
* 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 the WhatsApp Business API to Voice Agents so you can reach customers by voice call and by WhatsApp message.
**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 the WhatsApp Business API, so customers can switch between voice calls and WhatsApp messages and the conversation context carries over between the two.
Move a conversation between voice and WhatsApp messaging and keep its full context.
Send images, documents, location data and interactive buttons over WhatsApp.
Reply instantly on WhatsApp, even when voice agents are busy with other customers.
Reach customers worldwide on their preferred messaging app.
## Prerequisites
Before you set up the WhatsApp integration, you need:
* **WhatsApp Business Account**: a verified business account on WhatsApp
* **Meta Business Manager**: access to Meta Business Manager with admin permissions
* **Phone Number**: a verified business phone number (it can't also be used on regular WhatsApp)
* **Voice Agents Account**: an active Voice Agents platform subscription
## Required Information
You'll collect these details during setup:
* **App ID**: your Meta app identifier
* **Phone Number ID**: the identifier of your WhatsApp Business phone number
* **WhatsApp Business Account ID**: the business account identifier
* **Access Token**: a permanent access token with messaging permissions
* **Webhook Verify Token**: a 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. Check that the app has the right permissions for WhatsApp Business
### Step 2: Configure WhatsApp Business API
1. **Add WhatsApp Product**
* In your app dashboard, go to **"WhatsApp"** and then **"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"**
### Step 3: Collect Required IDs
Once your phone number is configured:
1. **Select your verified WhatsApp Business number** from the dropdown
2. **Record these values:**
* **Phone Number ID**: shown below the selected number
* **WhatsApp Business Account ID**: to the right of the Phone Number ID
* **App ID**: in the top-left corner of the dashboard
Keep these IDs secure and on hand. 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.
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.
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.
6. **Generate Access Token**
Click **"Generate New Token"** and configure:
* **App Selection**: Choose the app you configured in previous steps
* **Token Expiration**: Select **"Never"** for a permanent token
* **Required Permissions**:
* `whatsapp_business_messaging`: send and receive messages
* `whatsapp_business_management`: manage business settings
7. **Secure Your Token**
Copy the generated access token and store it securely. It gives full access to your WhatsApp Business messaging.
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**
In **WhatsApp Manager**, go to **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**
WhatsApp usually reviews templates within 24 hours. You can use only approved templates 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)
### 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 check that the integration works.
## Use Cases & Capabilities
With the integration in place, Voice Agents supports the following.
### Customer Support
* Handoffs between voice and WhatsApp
* Rich media sharing, such as 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 where it makes the message clearer
### 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 for business-to-business calls. This page is not written yet.
**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 for healthcare calls. This page is not written yet.
**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 for retail stores, with a deep Shopify 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 Agents gives retail businesses automated customer service and sales support through AI conversations. The agents are designed specifically for retail work.
### How Retail AI Agents Work
A retail AI agent combines a practical knowledge base, automated integrations and several ways for customers to reach it, which makes your store easier and more efficient to run.
**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**: The same 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, since agents 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, on social platforms or over the phone.
## Abandon Cart Recovery - A Detailed Use Case
Automated abandon cart recovery is one of the most effective uses of Voice Agents in retail. Personalized calls and messages remind customers of the carts they left and help them finish the purchase. How many carts you recover depends on your store, your customers and your offer, so measure it on your own carts.
### How It Works
Recovery runs in these steps:
1. **Find the right customers.** The system identifies customers who have abandoned their shopping carts and uses AI filters to pick the best candidates for a call.
2. **Call the customer.** A natural-sounding AI agent calls each customer, addresses them by name and mentions the specific items they left in the cart.
3. **Hold the conversation.** During the call, the agent tries 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. **Offer an incentive.** Depending on the conversation, the system can generate a personalized discount coupon that is active for a limited time (typically one hour) to create urgency.
5. **Follow up on WhatsApp.** After the call, the system sends a WhatsApp message with a direct checkout link and the personalized coupon code, so the customer can complete the purchase from there.
Benefits:
* Calls tailored to each customer and the items they left behind
* Help for customers within hours of abandoning their cart
* An automated system that scales without increasing staff costs
* Insight into why customers abandon carts, so you can improve your checkout process
### Getting Started with Retail AI Agents
Here is a practical way to start using Voice Agents in your retail business, followed by tips for making them work well for your customers.
**Quick setup (4 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 improvements within weeks: faster response times, fewer routine tickets and happier customers. Over time, that leads to 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, including their 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, so 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();
```
A successful request returns **`200 OK`** with 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**, and 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
ICP context shapes the assistant's 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 and 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 how v1 enforces calling windows. Set it to your compliant window
(see [India calling rules](/v2/limits#india-calling-rules)).
### Analysis plan
The analysis plan is a post-call AI evaluation. Its 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."
}
}
```
Enumerate the conditions and state the false cases explicitly. If the success
criteria are vague, the boolean they produce is unreliable.
### 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. For example, the assistant can greet an
inbound caller by name.
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
```
This returns the catalogue of available voices. The `slug` is what goes in
`agent.identity.voice`, and also 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, so 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. The 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
For **`custom`**, send 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"
}
}
```
For **`abandoned_cart`**, send the Shopify abandoned-checkout object, with `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 only by
[webhook](/v1/webhooks). To read call state on demand, use
[`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`, so 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" }
```
The response is a bare `message`, with no `status_code` and 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. If you don't
abort it, `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 in chunks for retrieval-augmented answers, check indexing status and manage 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
An upload takes three steps: start a session, push the chunks, then complete
the upload.
```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}
```
Returns the 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 of a
document. If a fragment only makes sense with the page around it, the
assistant will read it 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, so
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, covering workspaces, assistants, calls, telephony, tools and the 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**, and 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 covers more: inbound calls, phone-number purchase, knowledge base/RAG, API
tools, campaigns and post-call analysis. [v2](/v2/overview) is simpler, with one
auth header, `GET` on calls, a wallet API and idempotency, but it supports only
outbound calls and agents. The two APIs 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) also require an `admin`
or `organization_admin` role, carried as a JWT bearer token.
v1 pairs are issued with your account. You can create a new v2 key yourself,
without asking anyone, in the [console](https://sandbox.voice.miraiminds.co)
under **Developers**. 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 (part of the contract, not bugs)**
* `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.
v1 has no uniform envelope, so handle each of these explicitly. The
[v2](/v2/overview) envelope 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, for
example to look up an order, check stock or book a slot. Create the tool once
per workspace, then list its `_id` in the assistant's `agent.tools`.
This page covers legacy v1 tools. For new integrations, use [v2 tools](/v2/tools):
pre-call, on-call, and post-call actions with conditions, workspace secrets,
and synchronous or background execution. v2 tools are defined on each agent;
v1 tool IDs are not accepted as v2 tool definitions.
## 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 }]` for 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 model reads the `description` as a prompt when it decides whether to call
the tool, mid-conversation and under latency pressure.
* **Say when to use the tool.** "Use this whenever the customer asks where their
order is" works better than "Returns order status."
* **Name the trigger phrases** your customers actually use.
* **Describe each parameter** in the same terms. For `orderId`, a description
like "the order identifier, e.g. ORD-12345" tells the model what shape to
extract.
* **Keep the endpoint fast.** A tool call happens between turns, so a slow
endpoint means 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, and you don't have 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. The assistant uses the tool more reliably with both in place.
# 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**, because 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, with the summary, success flag, insights and 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` tells you the chain is over, so close your record on
**that** event, not on `call.failed`.
## `end-of-call`
This event 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, so 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 that 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
```
This is 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
```
This endpoint is for Shopify merchants only. It creates the workspace and
configures billing in one step, and 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).
An agent runs either one prompt (`system_prompt`) or a graph of steps (`flow`).
See [Node-based agents](/v2/node-agents) for the graph.
Base URL `https://sandbox.voice.miraiminds.co`. All endpoints require
`Authorization: Bearer sk_live_…`.
## Tools
Add `tools` to connect your APIs before, during, or after a call. On-call HTTP
tools can run synchronously or in the background, with the result returned to
the voice agent when ready. See [Tools](/v2/tools) for setup, conditions, secrets,
callbacks, and examples.
## Node-based agents
Add `flow` to run the conversation as a graph: nodes with their own
instructions, and transitions the model takes when their conditions are met.
With a flow, `system_prompt` and `first_message` are optional and unused, and
only `pre_call` and `post_call` tools are allowed. See
[Node-based agents](/v2/node-agents) for the schema and a worked example.
## 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": "ashu", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"end_call": {
"enabled": true,
"message": "धन्यवाद, आपका दिन शुभ हो।",
"confirm": true
},
"voicemail": { "action": "hangup" },
"background_sound": { "sound": "off" },
"behavior": { "interruptions": "normal", "adaptive_interruptions": true },
"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, unless `flow` is set | The agent's instructions. Supports `{{variable}}` placeholders. Unused on a node-based agent. |
| `first_message` | string | yes, unless `flow` is set | Spoken the moment the callee answers, before the model runs. Keep it short, because it sets your first-audio latency. Unused on a node-based agent: its start node's `greeting` opens the call. |
| `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. When it is reached, the call ends with `ended_reason: exceeded-max-duration`. Minimum `30`, maximum `1800`, default `300`. |
| `tools` | array | no | Tool definitions for this agent. See [Tools](/v2/tools). PATCH replaces the full list. |
| `emit_processed_webhook` | boolean | no | Defaults to `true`. Set `false` to suppress `call.processed` while keeping analysis and post-call tools enabled. |
| `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`. |
| `behavior` | object | no | How the agent takes turns with the caller. See [Behavior](#behavior). Default `{"interruptions": "normal", "adaptive_interruptions": true}`. |
| `flow` | object \| null | no | A node graph. Makes this a [node-based agent](/v2/node-agents). Present on the agent object only when set. PATCH replaces the whole graph; `null` removes it. |
Server-set and read-only: `id`, `object`, `created_at`, `updated_at`.
### Voice
```json theme={null}
{ "voice": { "voice_id": "ashu", "language": "hi-IN" } }
```
| Field | Type | Description |
| :- | :- | :- |
| `voice_id` | string | **Required.** A voice from the catalogue: `ashu`, `neha`, `shruti` or `sameer`. The same voices are available on every [tier](/general/tiers). Anything else returns a `400` that names the allowed set. 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 the note below. |
**`voice.language` is recorded, not yet applied**
The field is validated, stored and echoed back, and it travels with the call
payload. Today, though, the voice renders in the agent's top-level `language`.
If you set both to the same value, you get what you expect. If you set them
differently, the top-level one wins until per-voice rendering ships.
Listen to 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 when the callee hangs up or `max_duration_secs` is reached. |
| `message` | string | `""` | Spoken farewell, played **after** the agent's own goodbye when the call ends. It is empty by default, so 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. This stops fast models from ending the call on a bare "ok". |
If you define an explicit `end_call` entry in `tools`, that entry controls whether
the tool is available, including its condition. Its `builtin.enabled`,
`builtin.message`, and `builtin.confirm` settings override the corresponding
agent settings when supplied. See [Tools](/v2/tools).
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#call-voicemail) event. Voicemail detection is
available on every tier. See [tiers](/general/tiers#one-stack-on-every-tier).
### Background sound
An agent speaking out of complete silence is the most obvious sign that nobody
is there. `background_sound` plays room ambience 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` to `1`, default `0.08`. The default sits about 22 dB under the agent's voice, so you notice the room when the line goes quiet but it never competes with the words. Raise it if you want the room more present. A bed at `0.3` is already at conversation level. |
Before you turn it on, note two things:
* 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.
### Behavior
`behavior` decides how easily the caller can cut the agent off while it is
speaking. You can also set it in the console: **Agent Studio → Behavior**.
```json theme={null}
{ "behavior": { "interruptions": "firm", "adaptive_interruptions": true } }
```
| Field | Type | Description |
| :- | :- | :- |
| `interruptions` | `easy` \| `normal` \| `firm` \| `never` | How much the caller has to say before the agent stops talking. Default `normal`. |
| `adaptive_interruptions` | boolean | Default `true`. The agent learns how loud the caller is from their own turns. While the agent is speaking, a voice much quieter than theirs, such as someone else in the room, cannot interrupt it, and its words are not passed to the agent. If an interruption turns out to be nothing, the agent repeats the line it was cut off on. |
| `interruptions` | The agent stops when the caller says |
| :- | :- |
| `easy` | One word, or about half a second of speech. |
| `normal` | Two words, or about 0.7 seconds of speech. |
| `firm` | Three words, or about 1.2 seconds of speech. |
| `never` | Nothing. The agent always finishes its line, then answers what the caller said. |
Acknowledgements such as "haan", "ji" and "ok" never interrupt. A clear stop
word such as "stop" or "ruko" interrupts at every setting except `never`.
On a [node-based agent](/v2/node-agents), a node with `allow_interrupt: false`
is never interrupted whatever this setting is, and `never` applies to every node.
`PATCH` merges `behavior` field by field: the fields you send change and the
others keep their values, unlike other nested objects, which are replaced
whole. Send `"behavior": null` to go back to the defaults. The setting takes
effect on the next call and is saved with each agent revision.
If your callers use a laptop's built-in microphone in a busy room, keep
`adaptive_interruptions` on and let the browser reduce noise. See
[Microphone capture](/v2/web-calls#microphone-capture).
***
## 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": "ashu", "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": "ashu", "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: "ashu", 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();
```
Responds with **`201 Created`** and 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 |
| `400` | `invalid_flow` | A problem in `flow`, or an `on_call` tool on a node-based agent. The body lists every problem with its path; see [errors](/v2/node-agents#errors). |
| `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());
```
Responds with **`200 OK`** and the agent object. You get **`404 not_found`** if
the agent was deleted, if the ID does not exist, or if it exists in another
workspace. All three cases return the same response, so a `404` never hints that
the ID is real. None of these cases returns `403`; that status only means your
key has been revoked. 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": "ashu", "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), in the same shape
`GET /v2/agents/{id}` returns, prompts included. There is no trimmed summary
form, so a page of agents with long prompts makes a large response. Page through
with `limit` instead of 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 such as `voice`,
`end_call`, `voicemail`, `background_sound` and `tools` are replaced wholesale,
so to change `end_call.message`, send the whole `end_call` object. `behavior`
is the one exception: it is merged field by field, as described in
[Behavior](#behavior). `flow` is replaced wholesale too: send the whole graph,
or use [`PUT /v2/agents/{id}/flow`](/v2/node-agents#edit-the-graph).
`"flow": null` turns a node-based agent back into a single-prompt agent.
```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());
```
Responds with **`200 OK`** and 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 single-prompt agent does one job, start to finish, usually in under two or
three minutes. The [prompting guide](/v2/prompting-guide) covers this in
detail. In short:
* **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 can then run
thousands of calls without being re-created.
* **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"), which belong in a
[node-based agent](/v2/node-agents); instructions that assume the model
remembers a previous call; and data the agent must fetch mid-call, which is a
job for an on-call [tool](/v2/tools).
# Calls
Source: https://docs.miraiminds.co/v2/calls
Place outbound and browser calls, inspect their status, steer or 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)).
A call can also run in your user's browser instead of on a phone: create it
with `channel: "web"`. See [Browser calls](/v2/web-calls) for the whole flow.
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": {
"amount_inr": 5,
"per_min_inr": 3,
"billing_block_secs": 10,
"voice_per_min_inr": 3
},
"recording_available": true,
"transcript_available": true,
"transcript_status": "ready",
"metadata": { "order_id": "8842" },
"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, which is your company's plan. 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.amount_inr` | number | Total rupees charged. `0` until the call is billed and for outcomes that are free. |
| `cost.voice_per_min_inr` | number | Voice AI rate quoted for this call, when a pricing snapshot is available. |
| `cost.insights_per_min_inr` | number | T1 insights rate when enabled; absent when no insights charge applies. |
| `cost.telco_per_min_inr` | number | Managed provider rate; absent for BYO telco, WhatsApp and browser calls. |
| `cost.telco_provider` | string | The managed provider, when a provider charge applies. |
| `cost.telco_billing_block_secs` | integer | The provider's billing block, which can differ from the voice AI block. |
| `recording_available` | boolean | Whether [`GET /recording`](#get-the-recording) will return audio. |
| `transcript_available` | boolean | Whether [`GET /transcript`](#get-the-transcript) will return turns. |
| `transcript_status` | enum | `pending` while the call runs and until its transcript is stored; `ready` when [`GET /transcript`](#get-the-transcript) returns turns; `empty` when nobody spoke; `failed` when the transcript could not be stored. |
| `metadata` | object | Your [`metadata`](#metadata) from create, unchanged. Absent when the call has none. |
| `analysis` | object \| null | Post-call summary, when the agent was created with `analysis.enabled`. `null` otherwise. |
### Flow outcome
A call run by a [node-based agent](/v2/node-agents) also carries `flow`: where
the flow finished, the steps it went through and the values it extracted. It
appears here, on each call in [`GET /v2/calls`](#list-calls), and at
`data.call.flow` in [`call.processed`](/v2/webhooks#final-results). A call run by
a single-prompt agent has no `flow` field.
```json theme={null}
{
"flow": {
"final_node": "goodbye",
"nodes_visited": ["Confirm the customer", "Offer the delivery slot", "Close"],
"variables": { "identity_confirmed": true, "slot_accepted": true },
"status": "end_call",
"tags": ["end_call"]
}
}
```
| Field | Description |
| :- | :- |
| `final_node` | The `id` of the node the call ended on. Omitted when unknown. |
| `nodes_visited` | The `name` of each node the call entered, in the order it first entered them. Always present; `[]` when none. |
| `variables` | Every extracted value, by variable name. Always present; `{}` when none. |
| `status` | How the flow ended: `end_call`, `user_hangup`, `user_idle_max_duration_exceeded`, `call_duration_exceeded`, `voicemail_detected`, `pipeline_error` or `system_cancelled`. Omitted when unknown. See [reading the outcome](/v2/node-agents#step-6-read-the-outcome). |
| `tags` | Labels recorded for the call, such as how it ended. Omitted when unknown. |
`flow` is absent until the outcome is recorded, usually within seconds of the
call ending, so it can be missing from the terminal event. `call.processed`
waits up to 2 minutes for it.
## Statuses
| Status | Terminal | Meaning |
| :- | :-: | :- |
| `queued` | | Accepted, waiting on fleet capacity. A [browser call](/v2/web-calls) stays here until the page connects its audio. |
| `dialing` | | SIP invite sent, phone is ringing. Phone calls only. |
| `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) while it was queued, ringing or mid-conversation. Free in every case. |
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` because its job was done. |
| `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 the machine is what sets the status, so this reason and that status always appear together. |
| `silence-timed-out` | `completed` | Media was live but nobody ever spoke. Billable, because 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). An abort produces this reason and no other. |
| `api-ended-call` | `completed` | You sent a [`close` control](/v2/web-calls#live-control): the agent said goodbye and the call ended. Billable. |
| `time-limit-close` | `completed` | The [timing policy](/v2/web-calls#timing-policy) closed the call before its hard cap. Billable. |
| `user-silence-close` | `completed` | The [timing policy](/v2/web-calls#timing-policy) closed the call after the user stayed silent for `user_silence_close_secs`. Billable. |
| `assistant-error` | `failed` | Pipeline failure on our side. Not billed. |
| `transferred` | `completed` | Reserved for a call that ends in a warm transfer to a human. Transfer is not available yet, so **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, so no call emits this yet.** |
Treat `ended_reason` as an open vocabulary. We add new values without a version
bump, so always have a default branch. The last two rows above are in the
vocabulary, but **no call emits them today**. They arrive when warm transfer
and later releases ship. 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 or 408 for rang out, 486 or 600 for
busy), you get `no_answer` or `busy` correctly. When it black-holes the call
instead, accepting the invite and returning nothing, we have no signal to tell
"rang, nobody picked up" apart from "answered into silence". The call then ends
as `failed` with `no-media` after the media gate expires.
If you count unanswered calls for retry logic, treat `no_answer`, `busy`, and
`failed` with `no-media` together as calls that did not connect. None of the
three is billed, so your costs are unaffected either way. We are working on
this, and the reported status will only get more specific over time.
***
## Create a call
```http theme={null}
POST /v2/calls
```
### Request
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `agent_id` | string | yes | The agent to run. |
| `channel` | string | no | `phone` (default) or `web`. `web` runs the call in a browser. See [Browser calls](/v2/web-calls). |
| `to` | string | phone only | E.164, e.g. `+919876543210`. Required for phone calls. Refused with `400 invalid_request` on `channel: "web"`. |
| `variables` | object | no | Flat string map substituted into the agent's prompt. See [variables](#variables). |
| `metadata` | object | no | Your own identifiers, up to 16 string keys and values. Returned unchanged on the call and in every webhook. Never shown to the agent. See [metadata](#metadata). |
| `first_message` | string | no | The opening line for this call only, up to 500 characters. Replaces the agent's `first_message`; `{{variable}}` placeholders work the same way. Refused for a [node-based agent](/v2/node-agents), whose start node opens the call. |
| `webhook_url` | string | no | HTTPS URL for [lifecycle events](/v2/webhooks). Applies to this call only. |
| `max_duration_secs` | integer | no | Overrides the agent's cap for this call only. |
| `timing` | object | no | Wrap-up, timed-close and user-silence timers for this call. Phone and browser calls. See [timing policy](/v2/web-calls#timing-policy). |
| `recording_enabled` | boolean | no | Default `true`. `false` stores no audio for this call: no recording, `recording_available: false`. If your workspace has recording turned off, the default is `false` and `true` is refused with `403 recording_disabled`. |
| `final_results` | boolean | no | Default `false`. `true` sends one [`call.processed`](/v2/webhooks#final-results) with the finished transcript after the call ends. |
| `tier` | string | no | `t1` \| `t3` \| `t5`. Defaults to your key's tier, which is your company's plan, so leave it out. Any other tier is refused with `403 contract_unavailable`. Before your company goes live, every call runs at `t5`, whatever you send. See [tiers](/general/tiers#setting-the-tier). |
```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",
"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",
"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",
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).
A browser call's `202` also carries the links the page needs:
```json title="202 Accepted (channel web)" theme={null}
{
"id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"status": "queued",
"transport": "web",
"ws_url": "wss://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/media?token=…",
"ws_url_expires_at": "2026-09-27T10:05:00Z",
"events_url": "https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/events?token=…"
}
```
| Field | Description |
| :- | :- |
| `transport` | `web`. |
| `ws_url` | The [audio socket](/v2/web-calls#audio-socket). Single use; connect within 5 minutes. |
| `ws_url_expires_at` | When an unused `ws_url` stops working. |
| `events_url` | The [live event stream](/v2/web-calls#live-events). Reusable for the call and for one hour after it ends. |
### Errors
| Status | `error.code` | Cause |
| :- | :- | :- |
| `400` | `invalid_request` | Missing `agent_id`/`to`, `to` not E.164, `to` sent with `channel: "web"`, `max_duration_secs` out of range, invalid `metadata`, `first_message` or `timing`. `error.message` names the field. |
| `401` | `unauthorized` | Bad or missing key |
| `402` | `insufficient_balance` | Wallet cannot cover the first minute at your tier. We check before we dial, so there is no partial charge. See [Wallet](/v2/wallet#402-insufficient-balance). |
| `403` | `recording_disabled` | `recording_enabled: true` on a workspace whose recording is turned off |
| `403` | `contract_unavailable` | The call is not covered by an active production contract: `tier` is not your company's plan, or the company has not finished going live. See [setting the tier](/general/tiers#setting-the-tier). |
| `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). |
| `400` | `invalid_flow` | `agent_id` is a [node-based agent](/v2/node-agents) and your tier's model cannot run flows. The body lists the problem with its path. Nothing was dialled. |
| `409` | `flow_agent_voice_only` | A text chat (`channel: "text"`) with a node-based agent. Node-based agents run on `phone` and `web`. |
| `409` | `flow_first_message_unsupported` | `first_message` sent for a node-based agent. Its start node's `greeting` opens the call; personalise it with `variables`. |
| `429` | `rate_limited` | Request rate exceeded, or your queue is full (500 calls accepted and not yet ended); `error.message` says which. Going over your concurrency cap does not return 429: those calls wait in the queue. 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` names a tier that is not enabled on this deployment |
### 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 replaced with an empty string.
There is no error, and no `{{braces}}` are left 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.
On a browser call, an [`instruction`](/v2/web-calls#live-control) can direct
the agent mid-call instead.
* On a [node-based agent](/v2/node-agents#variables-in-node-text), variables
fill the node prompts, the start node's greeting and extraction prompts.
Transition conditions are not filled in.
### Metadata
`metadata` holds your own identifiers, so every webhook can be matched to your
records without a lookup table:
```json theme={null}
{ "metadata": { "session_id": "sess_8f2c41", "user_id": "usr_1042" } }
```
* Up to 16 keys. A key is 1 to 40 characters: ASCII letters, digits and `_`, `.`,
`:`, `-`, starting with a letter, digit or `_`.
* Values are strings of up to 256 characters. The whole object, serialized, is
at most 4 KB.
* It is fixed when the call is created and returned unchanged on the
[call object](#the-call-object) and in `data.call` of every
[webhook](/v2/webhooks) for the call.
* The agent never sees it. Anything the agent should use goes in
[`variables`](#variables).
* A call without metadata has no `metadata` field at all.
* Put IDs in it, not secrets or personal data: it travels on every webhook.
Invalid or oversized metadata returns `400 invalid_request`.
### 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. A network timeout does not tell you whether the call was
placed, and without the key a retry makes the customer's phone ring twice.
The key is scoped to your workspace. Reusing a key with a *different* body still
replays the original response, so 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 the first request is
already in flight and no second call was placed. Retry a moment later to 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`** returns the [call object](#the-call-object). You get
**`404 not_found`** both for an unknown ID and for an ID that belongs to another
workspace. The two responses are identical on purpose.
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
```
Returns 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, because internal steps are not part of the 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_status` on the
[call object](#the-call-object) to tell them apart: `pending`, `empty` or
`failed`. For browser calls, [final results](/v2/webhooks#final-results) deliver
the same turns by webhook as soon as they are ready.
```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
```
Returns `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.**
Use it to fetch the recording right away. To get the audio later, 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, which includes every call created with
`recording_enabled: false`. `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. Abort is accepted while the call is
`queued`, `dialing` or `in_progress`. If the call is live, the SIP server hangs
it up mid-conversation. On a [browser call](/v2/web-calls), abort also closes
the browser's audio socket. Only a call that has already reached a terminal
status is refused.
Abort is immediate and silent. To end a browser call with a spoken goodbye,
send a [`close` control](/v2/web-calls#live-control) instead.
```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#call-aborted) event fires. An aborted call is never
billed, even if you abort it 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 |
***
## Live control and events
Browser calls have three more endpoints. [Browser calls](/v2/web-calls) covers
them in full.
| Endpoint | What it does |
| :- | :- |
| `POST /v2/calls/{id}/control` | Send an `instruction` the agent follows from its next reply, or `close` the call with a spoken goodbye. Browser calls in progress only. See [live control](/v2/web-calls#live-control). |
| `GET /v2/calls/{id}/control/{control_id}` | Read a control's current status: `pending`, `applied`, `rejected` or `failed`. |
| `GET /v2/calls/{id}/events` | The live event stream (Server-Sent Events): agent state, each completed turn and, for a [node-based agent](/v2/node-agents), each step. Opened with the `events_url` from create, or with your API key. See [live events](/v2/web-calls#live-events). |
| Status | `error.code` | Cause |
| :- | :- | :- |
| `400` | `unsupported_channel` | Control sent to a call that is not a browser call |
| `401` | `invalid_token` | The `token` in a media or events link is unknown, expired, already used, or for another call |
| `404` | `not_found` | Unknown call or control ID, or one in another workspace |
| `409` | `call_not_active` | Control sent to a call that is still `queued` or has already ended |
***
## 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": 5,
"recording_available": true,
"transcript_available": true,
"analysis": null
}
],
"has_more": true,
"next_cursor": "call_01K7Q9B4M8N3P6R2S5T7V9W1YB"
}
```
Results are newest first. See [pagination](/v2/overview#pagination).
Use the list endpoint for reconciliation and reporting. To drive your
application state, use [webhooks](/v2/webhooks).
# Campaigns
Source: https://docs.miraiminds.co/v2/campaigns
Upload a contact list, set a calling window and a budget, and the platform dials 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. After that the platform handles the
dialling. It paces itself, waits through the night, retries calls that did not
connect, skips numbers on your [do-not-call list](/v2/dnc), pauses itself when
the money runs out, and gives you a per-contact report at the end.
Base URL `https://sandbox.voice.miraiminds.co`.
A campaign is a dialler on top of the call API, so its 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.
## 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 | Your name for the campaign. 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, which is your company's plan. See [tiers](/general/tiers#setting-the-tier). |
| `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 timezone. |
| `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` means 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`). No
object mixes the two units.
## 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, though it may be *asleep*. Outside the calling window the status is still `play` while the dialler waits for the window to open. |
| `paused` | ❌ | Reversible. New calls stop; **calls already in progress are not hung up.** |
| `stopped` | ❌ | Terminal, set by you. Live calls are aborted and pending contacts are marked `aborted`. |
| `completed` | ❌ | Terminal, set by us when every contact has reached a final state or the date range has run out. |
Only `draft` and `paused` campaigns can be edited or started. `stopped` and
`completed` campaigns never dial again, and they refuse new contacts with `409`
because those rows would never ring.
### Pause reasons
`pause_reason` tells you whether the operator paused the campaign or we paused
it for them. A pause you asked for has no reason (`null`). The money gate sets
the two other reasons below.
| `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 window next opens. The sleep is durable, 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 has 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 or billed. |
| `aborted` | ✅ | The campaign was stopped while this contact was pending or in flight. |
Watch `exhausted`. It counts the people we tried with every attempt you paid
for and never reached. A contact that ends `no_answer` still had attempts left;
one that ends `exhausted` did not.
## Calling window
Two checks decide whether the dialler may ring right now, both 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` to `06:00` is open on both sides of
it. The API accepts this, but it is almost never what you want in India. See
[calling rules](/v2/limits#india-calling-rules).
* **A zero-length slot is a `400`.** We reject `{"start":"10:00","end":"10:00"}`
instead of reading it as "all day", because guessing wrong there means
ringing somebody at 03:00.
* **Times are local.** We store and evaluate them in `timezone`, not UTC, so
`Asia/Kolkata`'s half-hour offset and every daylight-saving boundary are
handled for you.
When the date range runs out entirely, the campaign finishes as `completed`,
even if some contacts are still `pending`, because no window remains to dial
them in.
## 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, which
means the call happened. Re-dialling somebody who already spoke to us is worse
than not calling at all.
A retry that comes due outside the calling window waits for the window to open.
The retry wait is a minimum, and the actual retry can come later.
## Budget and pacing
**`max_concurrent`** (1 to 50) is this campaign's own ceiling on live calls. It
is separate from your workspace
[concurrency and queue-depth limits](/v2/limits), which also apply. 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 if you raise the budget and resume, the campaign continues where it
stopped.
**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 limits the cost of a mistake in the list itself, such as a
duplicated CSV or a column shifted by one.
***
## 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` \| `t5`. Defaults to your key's tier, which is your company's plan, so leave it out. Calls at any other tier are not covered by your plan. Before your company goes live, campaigns run at `t5`, whatever you send. |
| `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",
"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. It is not wrapped as `{"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
If a 900-row upload has two bad numbers, we store the good rows and return the
bad ones by index instead of failing the whole upload with `400`:
```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. We deduplicate on purpose, so this is not an error. |
| `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. Every other problem is reported
per row.
`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. If
you omit it, 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. |
| `400` | `invalid_flow` | `agent_id` is a [node-based agent](/v2/node-agents) and your tier's model cannot run flows. Checked again at start and resume. |
| `404` | `not_found` | `agent_id` does not exist in your workspace. We check this at create time, so a typo fails now instead of failing every contact tomorrow morning. |
| `501` | `tier_unavailable` | `tier` names a tier that is not enabled on this deployment. |
| `503` | `campaigns_unavailable` | Campaigns are not enabled for your workspace. Your request is fine; 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 and you can safely build a
dashboard on this endpoint.
`404 not_found` for an unknown id and for one in another workspace. The two
responses are identical on purpose.
***
## Update a campaign
```http theme={null}
PATCH /v2/campaigns/{id}
```
Editable while `draft` or `paused` only. Any other status returns
`409 conflict`, because changing the slots, the retry policy or the budget under
a live dialler would leave the campaign's own report describing settings that
were never in force for half of its calls. To change a running campaign, pause
it, patch it and then 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**. For a larger list, call it repeatedly. A
running campaign accepts new contacts and picks them up on its next tick,
because the list lives 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": []
}
```
The response has the same shape as the create response, so one parser in your
client handles both routes that add contacts.
`409 conflict` if the campaign is `stopped` or `completed`. Nothing would ever
dial those rows, so we refuse them instead of accepting them silently.
***
## 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
```
Allowed from `draft` and from `paused`. Starting a paused campaign is how you
resume one whose dialler is no longer running (after a top-up, for example),
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. It does not mean a phone is ringing yet:
outside the calling window, the campaign waits in `play`, which is expected.
### `funded_calls`: an underfunded campaign still starts
If your wallet cannot cover every remaining contact, the campaign still starts
and the response carries a warning:
```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. Treat
it as a floor on how far you get, though not a guaranteed one.
### 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 and lets current conversations finish. If you need a live call to end
now, use [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call).
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 that case the message tells you to use
[`POST /start`](#start-a-campaign) instead, because 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" }
```
Stopping is terminal and cannot be undone. Live calls are hung up, pending
contacts are marked `aborted`, and the campaign will never dial again. Allowed
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, which are 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` tells you whether
`retry_count` is paying off.
### 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,
```
The CSV is streamed rather than 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
If you set `webhook_url` on the campaign, you receive both its lifecycle events
and every child call's [`call.*` events](/v2/webhooks#event-types). Both are
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
}
}
}
```
Campaign events use the same envelope as call events
(`{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 means 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` to `21:00` IST is the outer bound for
commercial calls in India, and most enterprise programmes run `10:00` to
`19:00`. See [calling rules](/v2/limits#india-calling-rules).
3. **Budget every campaign.** `budget_paise` is the cheapest insurance there is
against a bad CSV.
4. **Retry once, wait fifteen minutes.** `retry_count: 1` with
`re_attempt_period_secs: 900` works for most Indian mobile lists. Retrying
five times in five minutes annoys people and does not connect.
5. **Start small.** Begin with ten contacts, one slot and `max_concurrent: 1`,
and 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 your agent.
# Do-not-call list
Source: https://docs.miraiminds.co/v2/dnc
Your workspace's own suppression list of numbers that campaigns will never dial.
The **do-not-call list** is a per-workspace set of phone numbers that
[campaigns](/v2/campaigns) will not dial. Once you add a number, every campaign
in the workspace skips it, whether the campaign is running, paused or not yet
created.
Base URL `https://sandbox.voice.miraiminds.co`.
**This is your suppression list, not the national registry.**
It suppresses numbers for *your* campaigns on *our* platform. It is separate
from TRAI's DND/NCPR register, and we do not scrub against that register for
you. The consent and DND obligations sit with the business placing the calls,
and putting a number on this list does not meet them. See
[India calling rules](/v2/limits#india-calling-rules).
## How suppression works
We check numbers **at dial time**, in the same database statement that hands
contacts to the dialler. This has three consequences:
* **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.** We never dial them, so there are
no minutes to charge.
The list 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. If we
refused it because the number is on the list, transactional calls would be
dropped without any notice, such as a delivery confirmation to a customer who
opted out of marketing. If you want single calls checked as well, check the
list yourself before you place them.
## 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**, because the
number is already 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 }
```
If you keep your own count, branch on `added`. If you only need the number
suppressed, you can ignore it. Both responses mean the number will not be
dialled.
| Status | `error.code` | Cause |
| :- | :- | :- |
| `400` | `invalid_request` | `phone` is not E.164. Send `+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
}
```
This route uses standard [pagination](/v2/overview#pagination): `limit` from 1
to 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, so `+` becomes `%2B`.
```bash theme={null}
curl -X DELETE "https://sandbox.voice.miraiminds.co/v2/dnc/%2B919876543210" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
A successful delete returns `204 No Content`. If the number was not on the
list, you get `404 not_found`.
**Removing a number un-suppresses it immediately.** Contacts already marked
`suppressed` in a campaign stay marked, because that status records a decision
already taken. Any future campaign, and any contact not yet claimed, will dial
the number. Keep an audit trail of who removed each number. If a reversed
opt-out leads to a complaint, you will be asked about it.
***
## Keeping it in sync
The list is a set of numbers and the operations are idempotent, so the simplest
integration is also correct: on every opt-out event in your own system, `POST`
the number. You don't need to read the list first or diff it against yours.
```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.
```
We recommend three habits:
1. **Suppress on the call, not after the campaign.** If your agent hears "stop
calling me", add the number to the list from the webhook handler that same
minute.
2. **Suppress before you upload.** Scrub each contact list before you upload
it, *and* keep the DNC list current. If you forget to scrub a campaign's
list, the DNC list still keeps those numbers from being dialled.
3. **Never remove in bulk.** Remove numbers one at a time, deliberately, and
log a reason for each removal.
# Errors
Source: https://docs.miraiminds.co/v2/errors
Error envelopes, status codes, tool validation errors, and how to handle them.
Most v2 errors use the envelope below. Two validation errors are exceptions:
[tool definitions](/v2/tools#validation-and-runtime-errors) return
`{"error":"invalid_tools","details":[...]}` and
[node graphs](/v2/node-agents#errors) return
`{"error":"invalid_flow","details":[...]}`, each with every offending field
path. [Flow generation](/v2/node-agents#generation-errors) uses the same shape
for `flow_generation_failed`. Handle those shapes when saving agent tools or a
flow, or generating one.
```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. It is not stable, so never parse it or 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, or an invalid token on a browser call's media or events link or a [TTS stream](/v2/tts-websocket#from-a-browser). | No. |
| `402` | Wallet cannot cover the call, or is empty when you generate a flow or transcribe a recording. | After topping up. |
| `403` | The key is valid but **revoked**, the call is not covered by your company's plan, or it asks for recording on a workspace with recording turned off. | No. |
| `404` | No such resource in your workspace, including one that exists in someone else's. | No. |
| `413` | The request body, or a file you sent by link, is over the endpoint's limit, such as an offline transcription upload over 45 MB. | No. Send a smaller request. |
| `422` | The request was valid but the work could not produce a usable result, such as a flow generation that stayed invalid or a recording with no conversation in it. | Not as is. Change the request. |
| `409` | State conflict: a request with the same `Idempotency-Key` still in flight, or an operation the current state forbids (such as controlling a call that is not in progress, or a call option a node-based agent does not take). | No. |
| `426` | A plain HTTP request reached a WebSocket endpoint, such as the [TTS stream](/v2/tts-websocket). | No. Connect with a WebSocket client. |
| `429` | Request-rate limit, or no capacity for the request (`at_capacity`). Reaching your *call* concurrency limit does not return `429`: those calls queue. | 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` and `upstream_unavailable`. No for `campaigns_unavailable` and `transcription_unavailable`, which clear only when the feature is provisioned. |
## 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. |
| `invalid_flow` | `400` | A [node graph](/v2/node-agents) breaks a rule (including a node's `data.tools` naming a tool the agent doesn't have), a node-based agent has `end_call` enabled, or your tier's model cannot run flows. Sent as `{"error":"invalid_flow","details":[{"path","message"}]}`, not in the envelope. | Fix every listed path. See [node-based agent errors](/v2/node-agents#errors). |
| `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. |
| `invalid_token` | `401` | The `token` on a [browser call](/v2/web-calls)'s media or events link is unknown, expired, already used (the media link is single use), or belongs to another call. On a [TTS stream](/v2/tts-websocket#from-a-browser), the stream token is unknown, older than 60 seconds or already used. | Use the links exactly as returned. For audio, create a new call, because the media link cannot be reused. For a TTS stream, mint a new token for each connection. |
| `upgrade_required` | `426` | A plain HTTP request reached a WebSocket endpoint, such as the [TTS stream](/v2/tts-websocket). | Connect with a WebSocket client (`wss://`). |
| `forbidden` | `403` | The key was revoked. | Get a new key. |
| `recording_disabled` | `403` | `recording_enabled: true` on a workspace whose recording is turned off. Nothing was created. | Leave `recording_enabled` out, or send `false`. |
| `contract_unavailable` | `403` | The call is not covered by an active production contract: `tier` is not your company's plan, or the company has not finished going live. Nothing was dialled. | Leave `tier` out. Calls default to your plan's tier. See [setting the tier](/general/tiers#setting-the-tier). |
| `insufficient_balance` | `402` | The wallet failed the pre-dial balance check, or it is empty when you [generate a flow](/v2/node-agents#what-a-draft-costs), [transcribe a recording for a flow](/v2/node-agents#what-transcription-costs) or start an [offline transcription](/v2/transcribe-recorded-calls#pricing). No call was placed, no model ran and nothing was charged. Inside a running [campaign](/v2/campaigns), the same condition 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_`/`trn_` 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. To resume a paused campaign that has no dialler, use [`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), or your workspace has used its [flow generations](/v2/node-agents#generate-a-flow-from-a-brief) or [recording transcriptions](/v2/node-agents#transcribe-a-recording) for the hour, or the generator or transcriber is busy. On [offline transcription](/v2/transcriptions#errors), it means your workspace has started 120 jobs in the past hour, or uploads are busy for a few seconds. 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` | No capacity for this request. On calls, every line on the deployment is busy; that ceiling is fleet-wide, not your workspace's. On [text to speech](/v2/tts-quickstart#limits) it also means your workspace already has as many requests in flight as its limit allows (10 by default), or, when you open a [TTS stream](/v2/tts-websocket), that it already has 20 sockets open. On [offline transcription](/v2/transcriptions#errors), your workspace already has 20 unfinished jobs, and `Retry-After` is 30 seconds. Nothing was charged in any of these cases. | Honour `Retry-After` and retry. |
| `call_not_active` | `409` | A [live control](/v2/web-calls#live-control) was sent to a call that is not in progress: still `queued` (the page has not connected its audio) or already ended. | Wait for `call.started`; after a terminal event there is nothing left to control. |
| `unsupported_channel` | `400` | A [live control](/v2/web-calls#live-control) was sent to a call that is not a browser call. | Live control is for `channel: "web"` calls. To stop a phone call, use [`POST /abort`](/v2/calls#abort-a-call). |
| `flow_agent_voice_only` | `409` | A text chat (`channel: "text"`) named a [node-based agent](/v2/node-agents). Nothing was created. | Use `phone` or `web`, or a single-prompt agent for text. |
| `flow_generation_failed` | `422` | [Flow generation](/v2/node-agents#generate-a-flow-from-a-brief) could not produce a valid graph. Sent as `{"error":"flow_generation_failed","details":[{"path","message"}]}`, not in the envelope, with the generation's `id`, `model`, `usage` and `cost`. Nothing was saved; the tokens used are charged. | Rephrase the brief (name each step and branch), try another model, or fix the graph by hand. |
| `transcription_failed` | `422` | [Transcribing a recording for a flow](/v2/node-agents#transcribe-a-recording) found no conversation in it. The body carries the transcription's `id`, `model`, `duration_secs`, `usage` and `cost` beside `error`: the model listened to the audio, so it is charged. An [offline transcription](/v2/transcriptions#why-a-job-failed) uses the same code differently: it is the `error.code` of a failed job, not an HTTP error, and that job is free. | Check that the file is a phone call with speech. |
| `request_too_large` | `413` | The request is over the endpoint's limit: a [recording to transcribe for a flow](/v2/node-agents#transcribe-a-recording) over 25 MB, or an [offline transcription](/v2/transcriptions#errors) upload over 45 MB or file at `url` over 200 MB. Nothing was charged. | Send a smaller file, such as a mono mp3. For offline transcription, send a file over 45 MB by link. |
| `flow_first_message_unsupported` | `409` | A call sent `first_message` for a node-based agent. Nothing was created. | Leave `first_message` out. The start node's `greeting` opens the call; personalise it with `variables`. |
| `tier_unavailable` | `501` | `tier` names a tier that is not enabled on this deployment. | Leave `tier` out. Calls default to your plan's tier. See [tiers](/general/tiers#setting-the-tier). |
| `campaigns_unavailable` | `503` | Campaigns are not enabled for your workspace. Every `/v2/campaigns` and `/v2/dnc` route answers this; everything else keeps working. | Your request is not at fault, and retrying will not clear it. Ask your Mirai contact to enable campaigns. |
| `transcription_unavailable` | `503` | [Offline transcription](/v2/transcriptions) is not available on this deployment. Every `/v2/transcriptions` route answers this; everything else keeps working. Nothing was charged. | Retrying will not clear it. Ask your Mirai contact. |
| `upstream_unavailable` | `503` | A service the request depends on is briefly unavailable, such as the flow generator's or the transcriber's model. Nothing was saved. A flow generation whose model had already answered is charged for what it did, and the body carries `id`, `model`, `usage` and `cost`. A transcription is never charged on a `503`. | Honour `Retry-After` and retry. |
| `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. It has already raised an alert on our side. | Retry with backoff; if it persists, send us the `call_id`. |
`error.code` is an **open enum**. We add new codes without a version bump, so
always keep a default branch keyed on the HTTP status.
**Foreign IDs are `404`, never `403`**
An `agt_`, `call_`, `cmp_` or `trn_` ID that exists in another workspace is
answered exactly as an ID that never existed: `404 not_found`. Any other answer would be an
enumeration oracle, letting someone probe which IDs are real on the platform by
watching the status code change. `403` never means "someone else's ID". It means
your own key has been revoked, or your workspace is not allowed what the request
asks for (`contract_unavailable`, `recording_disabled`).
## Handling errors
To handle every error, branch on the status first and then on the 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 need 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. Use
exponential backoff with jitter, and honour `Retry-After` when present.
* **Never retry** `400`, `401`, `403`, `404`, `501`. The same request will fail
the same way. `503 campaigns_unavailable` and `503 transcription_unavailable`
belong in this group too, because they clear only when the feature is
provisioned.
* **`402` is retryable only after a top-up**, not on a timer.
* **Always send `Idempotency-Key` on `POST /v2/calls`.** A timeout does not
tell you whether the phone rang, and without the key, your retry places a
second call to a real person.
### What a `409 duplicate_call` actually means
A request with this `Idempotency-Key` is **still being processed**. This only
happens in a narrow race and is 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 return this error instead.
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`.
In both cases **no second call is placed**. For a retry this is usually a
*success*, so log it and move on instead of showing it to your users as an
error.
# FAQ
Source: https://docs.miraiminds.co/v2/faq
Short answers to common developer questions about the Mirai Voice API.
## Getting started
**How do I get a key?**
Create one 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 the key on
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, so 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
```
The SDK builds in the rules you would otherwise have to work out yourself: 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. It supports sync and async use and
both credential generations. The cURL, Python and Node examples on every page
are ready to copy and work just as well, because the API is deliberately small.
## 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 after the `202`. If you are at your concurrency
ceiling, the call waits in `queued` until a slot frees up. We queue it instead
of rejecting it, so going over the ceiling never returns `429`.
**Can I do inbound calls?**
Yes. First **buy a dedicated number through Mirai** or **connect your own
supported telco number**. Then assign a published agent and activate incoming
calls. You do not need your own Plivo account when buying through Mirai.
BYO currently supports Plivo, and numbers are currently limited to India (+91). See the
[inbound calling cookbook](/cookbooks/inbound-calling) for setup and testing.
**Can I transfer a call to a human?**
Not yet. Transfer to a human is on the [roadmap](/general/roadmap), but no
agent can transfer a call today. Until it ships, have the agent tell the caller
that the team will call them back, and follow up from your own systems.
**Can I change the prompt or variables mid-call?**
The prompt and `variables` are fixed for a call. On a
[browser call](/v2/web-calls), your backend can still direct the agent while it
runs with a [live `instruction`](/v2/web-calls#live-control), close it with a
spoken goodbye, and let it wrap up on time with a
[timing policy](/v2/web-calls#timing-policy).
**Can I get the recording or transcript?**
Yes. Fetch them with [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript)
and [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording), or receive the
transcript in a webhook with [final results](/v2/webhooks#final-results). To
store no audio, create calls with `recording_enabled: false`. Retention is on
the [limits page](/v2/limits#data-retention).
**What happens if the customer does not answer?**
The call ends with `status: no_answer`, you get a `call.failed` webhook, and
there is no charge. v2 does not retry for you, so retry from your own dialler if
you want to.
When the carrier gives us no verdict at all, we report an unanswered call as
`failed` with `ended_reason: "no-media"` instead. Both outcomes are free and
both mean the call did not connect, so treat `no_answer`, `busy`, and `failed`
with `no-media` as one bucket in your retry logic. See
[ended reasons](/v2/calls#ended-reasons).
**How do I stop a call I just started?**
Use [`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?**
The stack is tuned for Hindi and Indian English, including Hindi-English
code-switching, which is how most real calls sound. Write the prompt in the
language you want spoken, using Devanagari for Hindi. For other languages, ask
us before you build on them.
**Does the agent sound like a human?**
It sounds like a good phone voice. Callers usually work out that it is a machine
within a turn or two, so [telling them up front](/v2/limits#disclosure-that-it-is-an-ai)
costs you nothing, and it reduces early hang-ups.
## Billing
**What does a call cost?**
It depends on your plan. Every company starts on T5 at ₹5 a minute, pay as
you go. A ₹50,000 monthly commitment brings T3 at ₹3 a minute, and ₹1,00,000
brings T1 at ₹1 a minute for voice AI. On T1, call insights are on by default
at ₹0.20 a minute and can be turned off in Billing. Managed phone calling adds
your provider's configured cost; BYO telco, WhatsApp and browser calls have no
Mirai telco charge. Voice is billed in 10-second blocks, rounded up, with a
minimum of one block. On T3, a 96-second call costs ₹5.00 for voice and an
8-second call costs ₹0.50 for voice. 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, because media went live and audio was generated. An 18-second voicemail is
two billed blocks, which is ₹1.00 on T3.
**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?**
Once your company has gone live, add credit from the **Billing** page of the
[console](https://sandbox.voice.miraiminds.co); before that, ask your Mirai
contact. Credits land as a `credit` row in
[`GET /v2/wallet/transactions`](/v2/wallet#list-transactions) and take effect
immediately.
**Which tier am I on?**
Your company's plan sets the tier. Every company starts on T5, pay as you go.
Complete **Go live** in the console and you can call today. Commit ₹50,000 a
month to move to T3, or ₹1,00,000 a month to move to T1. Every tier runs on the
same Mira stack, and the price is the only difference. See
[Billing & tiers](/general/tiers).
## 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). The second most common cause is signing
only the body. The signed string is `"."`, so it includes the
timestamp and the dot.
**Do you retry failed deliveries?**
Yes. We make six attempts with exponential backoff over roughly 36 minutes, then
move the event to the dead-letter queue. Any `2xx` stops the retries. 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?**
After about 36 minutes of retries, the event goes to the dead-letter queue, and
our ops team can replay it. You can also reconcile from
[`GET /v2/calls`](/v2/calls#list-calls), which is what the list endpoint is
for.
## Limits and compliance
**How many concurrent calls can I run?**
The pilot default is 5. It is a provisioning setting, so raising it needs no
code change. Ask us for more. See [Limits](/v2/limits).
**Do you enforce the 9am to 9pm calling window?**
No. Your scheduler enforces it, and we dial when you tell us to. See
[India calling rules](/v2/limits#india-calling-rules).
**Do you scrub DND numbers?**
No. The list you call is yours, and so is the obligation to scrub it.
**Do I have to tell people it is an AI?**
We strongly recommend it, and the disclosure belongs in `first_message`. You
need to confirm your own legal obligations.
## Operations
**What is your uptime target?**
We operate the API and the call path 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 and the model) runs in India. The control plane, which is
workflow orchestration and the API, runs in Europe. It is off the audio path,
so it adds no latency to what the caller hears.
**Something is broken. What do you need from me?**
Send the `call_id` (or `evt_id`), the UTC timestamp and what you expected to
happen. With those we can 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: our technical limits, which we enforce, and
India's regulatory rules, which **you** are responsible for.
## 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` |
| TTS requests in flight | 10 **per workspace** | `/v1/audio/speech`, `/v2/tts`, and each sentence being synthesised on a TTS stream | HTTP: `429 at_capacity`. Stream: the sentence waits up to 3 seconds, then `error` `at_capacity`. |
| Open TTS streams | 200 **per workspace** | WebSockets on `/v1/audio/speech/stream` | `429 at_capacity` with `Retry-After: 5` |
| Flow generations | 30 per hour **per workspace** | [`POST /v2/agents/flow/generate`](/v2/node-agents#generate-a-flow-from-a-brief) | `429 rate_limited` with `Retry-After` |
| Recording transcriptions | 60 per hour **per workspace** | [`POST /v2/agents/flow/transcribe`](/v2/node-agents#transcribe-a-recording) | `429 rate_limited` with `Retry-After` |
| Offline transcriptions | 120 per hour **per workspace** | [`POST /v2/transcriptions`](/v2/transcriptions#create-a-transcription) | `429 rate_limited` with `Retry-After` |
| Unfinished offline transcriptions | 20 **per workspace** | jobs not yet `completed` or `failed` | `429 at_capacity` with `Retry-After: 30` |
**Pilot keys are provisioned individually**
The numbers above are the defaults a new pilot key is issued with, and yours
may differ. Confirm your actual ceilings with your account contact before you
size a campaign. Higher concurrency only needs a provisioning change, so ask for
it.
### Request rate
Exceeding the **request rate** returns `429` with `error.code: rate_limited` and
a `Retry-After` header in seconds. This limit measures how fast you call the
API, not 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
**Call** concurrency does **not** produce a `429`. A `POST /v2/calls` beyond
your concurrent-call ceiling is still accepted with `202`. It waits in `queued`
until a slot frees and then dials, so no call is rejected or lost. On the call
path, `429` is request-rate only. ([Text to speech](#text-to-speech) over HTTP
is the one exception: it has no queue, so it refuses the request.)
So the ceiling paces your calls instead of refusing them. That has 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, because your client should
handle them the same way: honour `Retry-After` and retry. Read `error.message`
if you want to log which one you hit.
The count covers calls **accepted and not yet ended**. That is `queued` plus the
few calls actually `dialing` or `in_progress`, not `queued` alone. With a
concurrency of 5, the difference is at most 5 calls.
Queue depth is provisioned per workspace, like concurrency. If a campaign needs
to submit more than 500 rows in one batch, ask your Mirai contact before the
campaign starts, not during it.
Even so, it is better to keep our queue short and hold the rest on your side,
where you can reorder, cancel and re-prioritise it. The simplest correct pattern
looks like this:
```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.
### Text to speech
Every workspace has one pool of synthesis slots, **10** by default. An HTTP
request to the [speech API](/v2/tts-quickstart) holds a slot while it runs,
and so does each sentence on a [WebSocket stream](/v2/tts-websocket) while it
is being synthesised. An open socket that is not speaking holds none.
The two handle a full pool differently. The HTTP endpoint has no queue, so it
refuses the request. On a stream, a sentence whose turn has come waits up to 3
seconds for a slot, in a queue shared by all your sockets, before it fails.
| Limit | Default | Over the limit |
| :- | :- | :- |
| Requests in flight | **10 per workspace**, shared by HTTP requests and stream sentences | HTTP: `429 at_capacity` with `Retry-After`; the request is refused, not queued. Stream: the sentence waits up to 3 seconds, then gets an `error` event with `at_capacity` and `retry_after_secs`. |
| `input` length | **2,000 characters** per HTTP request | `400 invalid_request`. Split longer text. |
| Request rate | 10 requests/second, burst 20, per key | `429 rate_limited` with `Retry-After`. Opening a socket or minting a stream token counts as a request. |
`at_capacity` can also come from a deployment-wide ceiling when the speech node
is saturated. It is the same code, and you handle it the same way. A refused
request holds no slot and is never charged.
The limit is raised on request, with no change to your code. If you run many
calls at once, ask your Mirai contact for a limit a little above your peak
number of simultaneous calls: each speaking call needs a slot most of the
time, and it starts its next sentence shortly before the current one ends.
Streams have their own limits:
| Thing | Limit |
| :- | :- |
| Open sockets | 200 per workspace. More returns `429 at_capacity` with `Retry-After: 5` before the upgrade. |
| Stream token | Valid for 60 seconds, opens one socket. |
| One message | 64 KB |
| Text waiting to be spoken | 10,000 characters per socket (`queue_full`) |
| Open contexts | 64 per socket (`too_many_contexts`) |
| One sentence | 250 characters. Longer text with no sentence end is cut. |
| Idle socket | Closed after 120 seconds with no message from you and nothing playing |
| Socket lifetime | 1 hour |
| Keepalive | We ping every 20 seconds and drop a client that has not answered for 60 seconds. |
See [Streaming text to speech](/v2/tts-websocket#limits) for what each limit
does to an open socket.
## Payload and duration ceilings
| Thing | Limit |
| :- | :- |
| Request body | 1 MB |
| `system_prompt` | 8,000 characters, on the agent. There is no per-call system prompt. |
| `first_message` | 500 characters, on the agent or per call |
| `variables` | 32 keys, 512 characters per value |
| `metadata` | 16 keys; key 1–40 characters (ASCII letters, digits, `_` `.` `:` `-`; starts with a letter, digit or `_`); value 256 characters; 4 KB serialized |
| `max_duration_secs` | 30–1800, default 300 |
| `webhook_url` | HTTPS only, 2,048 characters |
| Idempotency key | 255 characters, 24-hour window |
| TTS `input` | 2,000 characters per request |
Invalid values return `400 invalid_request`.
### Call timing
`max` below is the call's effective `max_duration_secs`. See
[timing policy](/v2/web-calls#timing-policy).
| `timing` field | Limit |
| :- | :- |
| `wrap_up_secs_before_end` | 10 to `max` − 10 |
| `close_secs_before_end` | 5 to `max` − 10, and smaller than `wrap_up_secs_before_end` when both are set |
| `user_silence_close_secs` | 15–600 |
| `wrap_up_instruction` | 1,000 characters |
| `close_message`, `user_silence_message` | 500 characters each |
### Browser calls
| Thing | Limit |
| :- | :- |
| Audio link (`ws_url`) | Single use. Connect within 5 minutes of creating the call. |
| Events link (`events_url`) | Reusable for the whole call and one hour after it ends. |
| Live event replay | Kept for one hour after the call ends. |
| Control `instruction` text | 1–1,000 characters |
| Control `close` message | 500 characters |
| Control acknowledgement | `200` within 3 seconds, otherwise `202 pending` |
| Final results inline transcript | 512 KB. Larger transcripts arrive as `fetch_required`. |
### Node-based agents
We check a [node-based agent](/v2/node-agents)'s `flow` on every save. A flow
over a graph limit returns `400 invalid_flow` with the path of each problem. A
generation or transcription request over its limits returns
`400 invalid_request`.
| Thing | Limit |
| :- | :- |
| Nodes | 2–20: exactly one `startCall`, at least one `endCall`, at most one `globalNode` |
| Transitions | 1–40 |
| Graph size | 100 KB as JSON, with each non-ASCII character counted as its 6-byte escape |
| Node `name` | 80 characters |
| Node `prompt` | 12,000 characters |
| `greeting` | 1,500 characters |
| Extraction variables | 12 per node; `prompt` 2,000 characters |
| Transition `condition` | 2,000 characters |
| Node IDs, labels, variable names | 64 characters: a letter or underscore, then letters, digits or underscores |
| Generation `brief` | 1–20,000 characters; optional when `examples` are sent |
| Generation `examples` | 2–10 call transcripts, each 1–40,000 characters, 200,000 characters together |
| Generation `language` | 32 characters |
| Generation `model` | `gemini-3.8-flash` (default), `gpt-6-sol` or `claude-opus-5.5` |
| Transcription `file` | One recording per request: 25 MB and 15 minutes; mp3, wav, m4a, ogg/opus, webm, flac, aac or amr |
| Transcription `language` | 32 characters |
A flow generation typically takes 15 to 60 seconds, depending on the model; set
a client timeout of a few minutes. When the generator is busy with other
requests, it answers `429 rate_limited` with `Retry-After`, and that refusal
does not count against the hourly limit. A transcription typically takes 5 to
10 seconds for a two-minute call, and the transcriber answers `429` the same way
when it is busy. Each generation and transcription is charged to your wallet at
the provider's reported cost plus 20%; see
[AI flow drafts](/general/tiers#ai-flow-drafts).
### Offline transcription
[Offline transcription](/v2/transcriptions) checks the job count and your
wallet before it reads the recording, and nothing is charged for a refused
request.
| Thing | Limit |
| :- | :- |
| Uploaded file | 45 MB. Over it, `413 request_too_large`. Send a larger file by link. |
| Recording sent by `url` | 200 MB. Over it, `413 request_too_large`. |
| Length of a recording | 120 minutes. A longer recording is accepted, then the job fails with `audio_too_long`, free. |
| `context` | 2,000 characters |
| `vocabulary` | 200 terms of up to 100 characters each |
| Time to finish | 24 hours after the job was accepted. After that, the job fails with `timed_out`, free. |
## 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** | Applies to both create and `POST /contacts`. For a longer list, call it repeatedly; 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 | Also bounded 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 add latency well before they reach a size limit. The stack is
tuned for prompts in the hundreds of tokens, not thousands.
## Timeouts
| Stage | Behaviour |
| :- | :- |
| API request | If there is no response within 30 seconds, treat the request as failed and retry with the same `Idempotency-Key`. |
| Ring / answer | \~60 seconds. If no media arrives by then, the result is `no_answer`. |
| Call duration | Ends at `max_duration_secs` with `status: timeout`. |
| Browser connect | 5 minutes after the call is created. If no audio connects, the call ends `failed` with `ended_reason: "no-media"` and is not billed. |
| Flow generation | Typically 15–20 s on Gemini 3.8 Flash, 20–60 s on GPT-6 Sol and 25–35 s on Claude Opus 5.5; up to a minute from example calls. Set a client timeout of a few minutes. |
| Recording transcription | Typically 5–10 s for a two-minute call, plus the upload. Set a client timeout of a few minutes. |
| Final results | `call.processed` follows the terminal event once the transcript settles (within about 2 minutes of the call ending) and any analysis and post-call tools finish. |
| Webhook delivery | Your endpoint should answer in well under a second. We treat slow responses as failures and [retry](/v2/webhooks#retries) them. |
## 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 |
| Offline transcription audio | Not kept. An upload passes through a temporary file that is deleted when the request ends, a file sent by link is fetched once, and the transcription service deletes the recording once it has decoded it. |
| Offline transcripts | Kept by the transcription service so that [`GET /v2/transcriptions/{id}`](/v2/transcriptions#get-a-transcription) can return them, with no published retention period. There is no endpoint to delete one yet. |
You can fetch recordings and transcripts through the API with
[`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. To store no audio, create calls with
`recording_enabled: false`, or ask us to turn recording off for your whole
workspace.
Save your own copy of each offline transcript when its job completes. If a
transcript is no longer available, its job still reads `completed` with its
cost, but without the text. To have a transcript removed, write to
[help@miraiminds.co](mailto:help@miraiminds.co).
***
## India calling rules
Under Indian telecom regulation you are the sender: the obligations for
commercial voice calls sit with the business placing them, not with the platform
carrying them. This page is not legal advice, and it does not replace your own
compliance review.
### Calling window
Commercial and promotional voice calls are restricted to daytime hours. The
window applied in practice is **09:00 to 21:00 IST**, and several enterprise
programmes tighten it further to 10:00 to 19:00.
**On a [campaign](/v2/campaigns), the platform enforces the window you declare.**
The platform evaluates `timezone`, `start_date`/`end_date` and 1 to 4 `slots` in
local time on every dialling tick. Outside the window the campaign stays in
`play` but rings nobody. Declaring `10:00` to `19:00 Asia/Kolkata` is enough,
and 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. Enforce the window for those calls in your
own 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.** Consent should be explicit, recorded and
revocable. Keep the record, because you will need to produce it when a
complaint arrives.
* **Push your opt-outs to [`POST /v2/dnc`](/v2/dnc).** We enforce that list:
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`, because a single call
is an explicit instruction.
* **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.
* **Classify each campaign as transactional or promotional.** A delivery
confirmation to an existing customer is treated differently from a marketing
pitch to a cold list. Do not put promotional content in a call you present as
transactional.
* **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 they are talking to an AI. Put the disclosure in the agent's
`first_message` or its opening turn:
```json theme={null}
{
"first_message": "नमस्ते {{customer_name}}, मैं Acme की AI असिस्टेंट Priya बोल रही हूँ।"
}
```
Regulation worldwide is moving in this direction, and the disclosure costs you
one clause. In practice it also *reduces* early hang-ups, because callers who
feel misled are more likely to hang up 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. If
you do not need the audio, do not store it: `recording_enabled: false` keeps no
recording for that call, and a workspace with recording turned off refuses
`recording_enabled: true` with `403 recording_disabled`.
### 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` |
| TTS requests in flight | ✅ `429 at_capacity` past 10 per workspace over HTTP; a stream sentence waits up to 3 seconds first |
| Queue depth | ✅ `429 rate_limited` past 500 outstanding |
| Maximum call duration | ✅ `status: timeout` |
| Recording off — per call or per workspace | ✅ `recording_enabled: false`; `403 recording_disabled` on a workspace with recording off |
| 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 |
If complaints against a workspace persist, we suspend its keys. This is our own
policy rather than a regulatory one: 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
How each v1 endpoint and field maps to the v2 API, 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. In v1, webhooks are the only way to learn a call's status.
* 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.
You can migrate function calling to [v2 tools](/v2/tools), including background
on-call requests and post-call actions. Recreate each tool definition on your
v2 agent; v1 tool IDs do not transfer. Review each remaining capability before
cutover, and keep your v1 integration active until its replacement is tested.
## 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 really are named `POST /v2/call/initiate`. That `/v2/` is a
path segment inside the v1 product and has nothing to do with this API. Check
the **host** to tell the two apart.
## Authentication
| v1 | v2 |
| :- | :- |
| `x-public-key: pk_…` | — |
| `x-private-key: sk_…` | `Authorization: Bearer sk_live_…` |
| `workspace: 6690a1b2c3d4e5f600000002` | — (the key is workspace-scoped) |
v2 replaces the three headers with one. The key carries the workspace, so you
cannot reach the wrong workspace by accident with valid 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`, with partial updates. |
| — | [`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` | [`POST /v2/calls`](/v2/web-calls#create-the-call) with `channel: "web"` | Your backend creates the call and gives the page an audio link and a live event link. See [Browser calls](/v2/web-calls). |
| `GET /v1/number-pool/*` | `/v2/phone-numbers` and `/v2/telephony/*` | Different resources and IDs. Follow the [inbound setup recipe](/cookbooks/inbound-calling); importing does not change routing until activation. |
| `/v1/admin/tool/api` (all methods) | [`/v2/agents/{id}/tools/{name}`](/v2/tools#api-routes) | Tools belong to an agent; recreate the definition instead of copying a v1 ID. |
| `/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. This is the v2 equivalent of v1's `recordingUrl`. |
| `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 now have a resource of their own. |
| — | [`POST /v2/dnc`](/v2/dnc#add-a-number) | **New.** A workspace suppression list that we check 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` | [`tools`](/v2/tools) (full definitions, not v1 tool IDs) |
| `telephony.inbound` / `telephony.outbound` | Assign `agent_id` on a v2 phone number for inbound; set `phone_number_id` when creating an outgoing call. See [inbound calling](/cookbooks/inbound-calling). |
| `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` | — (post-call analysis is part of every tier; its v2 surface is on the [roadmap](/general/roadmap)) |
### 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": 5
+ }
+ }
}
```
Watch for these differences when you port a webhook handler:
* **`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.** You fetch the audio with a separate,
authenticated request to
[`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 would
let anybody who ever saw that body fetch the recording.
* **`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 (an AI summary
and a QA score on every call) is [part of every tier](/general/tiers#one-stack-on-every-tier),
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, because 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 two 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. Recreate and test your [tools](/v2/tools) on the v2 agent, then migrate the
remaining calls. Check support for other capabilities before moving them.
# Node-based agents
Source: https://docs.miraiminds.co/v2/node-agents
Build an agent as a graph of steps, each with its own instructions, and let the model move between them. This page covers drafting a flow from a brief or from call recordings, the graph schema, a worked example, editing, versions, limits and errors.
A **node-based agent** (a *flow*) runs a conversation as a graph. Each node is
one step of the call with its own instructions. Each transition between nodes
is a condition, written in plain language, that the model checks as the caller
speaks. When a condition is met, the call moves to the next step and that
step's instructions take over.
Node-based agents are available on every [tier](/general/tiers). They take
phone calls, browser calls and campaigns, and their calls are billed like any
other call. A flow is one field on the [agent](/v2/agents), `flow`, so you
create, edit, version and call it with the same API key and the same routes.
Base URL `https://sandbox.voice.miraiminds.co`. All endpoints require
`Authorization: Bearer sk_live_…`.
## When to use a flow
| Use a single prompt when | Use a flow when |
| :- | :- |
| The call does one job, start to finish. | The call has distinct phases, each with its own rules: confirm who you are speaking to, then explain, then agree a next step. |
| The script is linear. | The call branches: wrong person, reschedule, refusal, a question you must hand off. |
| The instructions fit in 8,000 characters. | Each step needs long instructions. Every node takes up to 12,000 characters, and only the current node's are in play. |
| You need to fetch or send data mid-call with [on-call tools](/v2/tools). | You want to know which step each call reached, and what the caller said at each step. |
A flow can run [tools](/v2/tools) before, during, and after a call. Each start
or conversation node lists the on-call tools it offers in `data.tools`. The
model sees those tools alongside the node's outgoing transitions. For a full
editor example, follow the [Node.js cookbook](/cookbooks/node-flow-builder).
## How a flow runs
1. **The call opens on the start node.** Its `greeting` is spoken exactly as
written, with `{{variables}}` filled in. With no greeting, the model opens
the call from the start node's prompt.
2. **The model follows the current node.** Its instructions are the global
node's prompt (when the node's `add_global_prompt` is `true`), then the
current node's prompt.
3. **Transitions are functions.** Each transition out of the current node is
offered to the model as a function with no arguments: `label` is its name
and `condition` is its description. When the model decides the condition is
met, it calls the function and the call moves to the target node.
4. **The conversation carries across nodes.** Moving to a node replaces the
instructions and keeps the history, so the model still knows everything said
so far. It then replies as the new node.
5. **Speech plays before the move.** If the model speaks and calls a transition
in the same reply, the move happens after that speech has played.
6. **An end node ends the call.** The agent speaks its reply to the end node's
prompt, then hangs up. The call ends `completed` with
`ended_reason: "assistant-ended-call"`.
7. **Extraction runs as the call leaves a node.** A node with
`extraction_enabled` has its `extraction_variables` extracted from the
conversation in the background when the call moves on. The node the call
ends on is extracted as the call ends.
Interruptions and silence follow the same rules as every agent:
* **Barge-in.** While the agent speaks, meaningful speech from the caller cuts
it off. Backchannels such as "हाँ", "जी" or "ok" do not. On a node with
`allow_interrupt: false` the agent finishes its line, then answers what the
caller said. Once a goodbye is playing, nothing the caller says reopens the
call.
* **Silence.** After about 10 seconds without the caller speaking, the agent
checks in once. If the caller stays silent, the agent says a short goodbye
and the call ends with `ended_reason: "idle-timed-out"`. A call created with
[`timing.user_silence_close_secs`](/v2/web-calls#timing-policy) uses that
timer instead.
***
## Generate a flow from a brief
Describe the agent in plain language and the API drafts the graph for you.
Start here, then read and adjust the draft with the [schema](#the-graph) below.
If you already have recordings of good calls, you can also [draft the flow from
them](#draft-a-flow-from-call-recordings).
```http theme={null}
POST /v2/agents/flow/generate
```
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `brief` | string | yes, unless you send `examples` | 1–20,000 characters. What the agent does: who it talks to, what it says and asks, how each answer is handled, what to record, and the language. You can also send a structured specification written with another assistant. |
| `examples` | array | no | 2–10 example calls to learn the flow from, each `{"transcript": "…"}`. See [draft a flow from call recordings](#draft-a-flow-from-call-recordings). |
| `language` | string | no | Up to 32 characters. A language code or name for the spoken lines, such as `hi`, `en` or `Hinglish`. Without it, the brief decides. |
| `model` | string | no | The model that writes the draft: `gemini-3.8-flash` (default), `gpt-6-sol` or `claude-opus-5.5`. See [choose a model](#choose-a-model). Any other value is refused with `400 invalid_request`. |
| `flow` | object | no | A graph to revise, as `GET /v2/agents/{id}/flow` returns it. It must itself be valid. See [revise a flow](#revise-a-flow). |
Generating a flow saves nothing. The response is a draft for you to review, test
and save. Each generation is [charged to your wallet](#what-a-draft-costs) at
what the model's provider reports it cost, plus 20%.
```json title="200 OK" theme={null}
{
"id": "fgen_01K8D3G5J7L9N1Q3S5U7W9Y1A3",
"object": "flow_draft",
"model": "gemini-3.8-flash",
"flow": { "nodes": [ … ], "edges": [ … ] },
"variables": [
{ "name": "customer_name", "type": "string", "description": "The customer's name, used to confirm who answered." },
{ "name": "order_id", "type": "string", "description": "The order number." },
{ "name": "delivery_date", "type": "string", "description": "The scheduled delivery date." },
{ "name": "delivery_window", "type": "string", "description": "The delivery time window, such as 10 AM and 1 PM." }
],
"input_schema": {
"type": "object",
"properties": {
"customer_name": { "type": "string", "description": "The customer's name, used to confirm who answered." },
"order_id": { "type": "string", "description": "The order number." },
"delivery_date": { "type": "string", "description": "The scheduled delivery date." },
"delivery_window": { "type": "string", "description": "The delivery time window, such as 10 AM and 1 PM." }
},
"required": ["customer_name", "order_id", "delivery_date", "delivery_window"]
},
"flow_revision": "5b1e0c9a7d2f4e8b3a6c",
"rounds": 1,
"usage": { "prompt_tokens": 1840, "completion_tokens": 2610 },
"cost": { "amount_inr": 2.36 }
}
```
| Field | Description |
| :- | :- |
| `id` | The generation's ID, `fgen_…`. The wallet row for its charge carries the same ID. |
| `model` | The model that wrote the draft. |
| `flow` | The draft graph, with every default filled in. It passes every rule `PUT /v2/agents/{id}/flow` applies. |
| `variables` | The call variables the draft reads as `{{name}}`: facts you know before the call, such as a name or an order number. What the caller says is captured by the nodes' [extraction variables](#extraction) instead. |
| `input_schema` | The same variables as the agent's `input_schema`, every one required. Save it together with the flow. |
| `flow_revision` | The draft's [fingerprint](#step-2-validate-it). If you save the graph unchanged, it keeps this value. |
| `rounds` | How many attempts the draft took, from 1 to 3. Each attempt is checked against the graph rules, and any problems are sent back for another attempt. |
| `usage` | Tokens the model used, summed over every attempt: `prompt_tokens` in, `completion_tokens` out. |
| `cost` | What was charged to your wallet, `amount_inr`: the provider's reported cost plus 20%. |
**Review and test every draft before it takes a real call.** Read each node's
prompt and each transition's condition, and check that the draft asks, records
and refuses what you intended. Then run a [browser test with live
events](#step-4-test-it-in-the-browser) and take every branch at least once.
### Choose a model
| Model | `model` | Typical draft from a brief | Typical time |
| :- | :- | :- | :- |
| Gemini 3.8 Flash (default) | `gemini-3.8-flash` | ₹2–3 | 15–20 s |
| GPT-6 Sol | `gpt-6-sol` | ₹3–4 | 20–60 s |
| Claude Opus 5.5 | `claude-opus-5.5` | ₹6–8 | 25–35 s |
Start with the default. Try another model when a draft misses branches or rules
your brief asked for. A draft from two example calls costs more and takes
longer: about ₹8 to ₹12, and up to a minute on Claude Opus 5.5. With any model,
set your client's timeout to a few minutes. The examples below use 5.
### What a draft costs
Each generation is charged to your [wallet](/v2/wallet) at what the model's
provider reports it cost, plus 20%. The response's `cost` says what was
charged. If the provider reports no cost for an attempt, we charge that
attempt's tokens at the chosen model's published rates per million input and
output tokens. You can find those rates on
[Billing & tiers](/general/tiers#ai-flow-drafts).
* **Every attempt counts.** We add up the cost of every attempt and round the
total up to the paisa once.
* **Failures are charged too.** A `422 flow_generation_failed`, and a `503`
after the model had already answered, are charged for what the model did.
Both responses carry `id`, `model`, `usage` and `cost`. Nothing is charged
when the model never answered.
* **The wallet must hold a positive balance.** If it is empty, the request is
refused with `402 insufficient_balance` before the model runs. A charge never
takes the wallet below zero.
* **Where it shows.** Each charge is one wallet transaction of kind
[`flow_generation`](/v2/wallet#a-flow-generation-row), and the billing
statement lists the charges as **AI flow drafts**.
### Generate a draft
```bash theme={null}
jq -n --arg brief "Call customers of Acme Retail to confirm the delivery slot for their order. Speak everyday Hindi and say you are an AI assistant. First confirm you are speaking to the customer by name; if it is someone else, apologise and end the call without mentioning the order. Then give the order number, delivery date and time window, and ask whether someone will be at home. If yes, thank them and end. If not, ask which day in the next seven days suits them, read it back, and end once they confirm. Record whether they confirmed who they are, whether they accepted the slot, and the new day if they chose one." \
'{brief: $brief, language: "hi", model: "gemini-3.8-flash"}' |
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents/flow/generate \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--max-time 300 \
--data-binary @- > draft.json
jq '{id, model, cost}' draft.json
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
BRIEF = (
"Call customers of Acme Retail to confirm the delivery slot for their order. "
"Speak everyday Hindi and say you are an AI assistant. "
"First confirm you are speaking to the customer by name; if it is someone else, "
"apologise and end the call without mentioning the order. "
"Then give the order number, delivery date and time window, and ask whether "
"someone will be at home. If yes, thank them and end. If not, ask which day in "
"the next seven days suits them, read it back, and end once they confirm. "
"Record whether they confirmed who they are, whether they accepted the slot, "
"and the new day if they chose one."
)
r = httpx.post(
f"{API}/v2/agents/flow/generate",
headers=auth,
json={"brief": BRIEF, "language": "hi", "model": "gemini-3.8-flash"},
timeout=300, # allow a few minutes
)
if r.status_code in (429, 503):
raise RuntimeError(f"retry in {r.headers.get('Retry-After')} s")
if r.status_code == 422: # charged: the body carries id, usage and cost
for problem in r.json()["details"]:
print(problem["path"], "-", problem["message"])
draft = r.raise_for_status().json()
print(draft["id"], draft["model"], draft["cost"]["amount_inr"])
print([v["name"] for v in draft["variables"]])
```
```javascript theme={null}
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const BRIEF = [
"Call customers of Acme Retail to confirm the delivery slot for their order.",
"Speak everyday Hindi and say you are an AI assistant.",
"First confirm you are speaking to the customer by name; if it is someone else,",
"apologise and end the call without mentioning the order.",
"Then give the order number, delivery date and time window, and ask whether",
"someone will be at home. If yes, thank them and end. If not, ask which day in",
"the next seven days suits them, read it back, and end once they confirm.",
"Record whether they confirmed who they are, whether they accepted the slot,",
"and the new day if they chose one.",
].join(" ");
const res = await fetch(`${API}/v2/agents/flow/generate`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ brief: BRIEF, language: "hi", model: "gemini-3.8-flash" }),
signal: AbortSignal.timeout(300_000), // allow a few minutes
});
const draft = await res.json();
if (!res.ok) {
if (res.status === 429 || res.status === 503) {
throw new Error(`retry in ${res.headers.get("Retry-After")} s`);
}
for (const { path, message } of draft.details ?? []) console.error(path, "-", message);
throw new Error(draft.error?.code ?? draft.error);
}
console.log(draft.id, draft.model, draft.cost.amount_inr);
console.log(draft.variables.map((v) => v.name));
```
### Save it on an agent
Create the agent with the draft's `flow` and its `input_schema`. Save them
together: when an agent has an `input_schema`, every variable a node reads
must be declared in it.
```bash theme={null}
jq '{
name: "Delivery slot confirmation",
voice: { voice_id: "neha", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 240,
flow: .flow,
input_schema: .input_schema
}' draft.json |
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-
```
```python theme={null}
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "Delivery slot confirmation",
"voice": {"voice_id": "neha", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 240,
"flow": draft["flow"],
"input_schema": draft["input_schema"],
},
timeout=30,
).raise_for_status().json()
print(agent["id"], agent["revision"])
```
```javascript theme={null}
const created = await fetch(`${API}/v2/agents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Delivery slot confirmation",
voice: { voice_id: "neha", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 240,
flow: draft.flow,
input_schema: draft.input_schema,
}),
});
const agent = await created.json();
if (!created.ok) throw new Error(JSON.stringify(agent));
console.log(agent.id, agent.revision);
```
With an `input_schema`, each call must send every declared variable in
`variables`, and nothing else: a missing or undeclared variable is refused with
`400 invalid_request`. Send `number` and `boolean` variables as JSON numbers
and booleans. Then [test the agent in the browser](#step-4-test-it-in-the-browser)
before you dial anyone.
### Revise a flow
To change a flow, send the current graph as `flow` and describe only the
change in `brief`. The draft keeps what the brief does not ask to change,
including node IDs, transition labels and the editor layout. Save the result
with `PATCH`, together with its `input_schema` (the change may read a new
variable), and `if_revision` so you never overwrite someone else's edit.
```bash theme={null}
AGENT=agt_01K8C4D6F8H0J2K4M6N8P0Q2RS
curl -s "https://sandbox.voice.miraiminds.co/v2/agents/$AGENT/flow" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" > current.json
jq --arg brief "If the customer is busy right now, offer to call back later and end politely." \
'{brief: $brief, language: "hi", flow: .flow}' current.json |
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents/flow/generate \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--max-time 300 \
--data-binary @- > revised.json
jq --argjson rev "$(jq .agent_revision current.json)" \
'{if_revision: $rev, flow: .flow, input_schema: .input_schema}' revised.json |
curl -X PATCH "https://sandbox.voice.miraiminds.co/v2/agents/$AGENT" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-
```
```python theme={null}
agent_id = "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS"
current = httpx.get(
f"{API}/v2/agents/{agent_id}/flow", headers=auth, timeout=30
).raise_for_status().json()
revised = httpx.post(
f"{API}/v2/agents/flow/generate",
headers=auth,
json={
"brief": "If the customer is busy right now, offer to call back later and end politely.",
"language": "hi",
"flow": current["flow"],
},
timeout=300,
).raise_for_status().json()
r = httpx.patch(
f"{API}/v2/agents/{agent_id}",
headers=auth,
json={
"if_revision": current["agent_revision"],
"flow": revised["flow"],
"input_schema": revised["input_schema"],
},
timeout=30,
)
if r.status_code == 409:
raise RuntimeError("the agent changed since it was read; read the flow again")
print(r.raise_for_status().json()["revision"])
```
```javascript theme={null}
const agentId = "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS";
const current = await fetch(`${API}/v2/agents/${agentId}/flow`, { headers: auth })
.then((r) => r.json());
const gen = await fetch(`${API}/v2/agents/flow/generate`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
brief: "If the customer is busy right now, offer to call back later and end politely.",
language: "hi",
flow: current.flow,
}),
signal: AbortSignal.timeout(300_000),
});
const revised = await gen.json();
if (!gen.ok) throw new Error(JSON.stringify(revised));
const res = await fetch(`${API}/v2/agents/${agentId}`, {
method: "PATCH",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
if_revision: current.agent_revision,
flow: revised.flow,
input_schema: revised.input_schema,
}),
});
if (res.status === 409) throw new Error("the agent changed since it was read; read the flow again");
const agent = await res.json();
if (!res.ok) throw new Error(JSON.stringify(agent));
console.log(agent.revision);
```
Test the revised agent again before it takes real calls. A revision is a new
agent revision like any other save, so you can [roll it back](#versions).
### Generation errors
| Status | `error` | When | Charged | What to do |
| :- | :- | :- | :- | :- |
| `400` | `invalid_request` | Neither `brief` nor `examples` was sent, `brief` is blank or over 20,000 characters, `examples` has fewer than 2 or more than 10 calls, a transcript is blank or over 40,000 characters, the transcripts are over 200,000 characters together, `language` is over 32 characters, `model` is not one of the three, or the body has an unknown field. | No | Fix the request. |
| `400` | `invalid_flow` | The `flow` you sent to revise breaks a rule, or your tier's call model cannot run flows. Same shape as [flow errors](#errors). | No | Fix the listed paths. |
| `402` | `insufficient_balance` | Your wallet is empty. | No | [Top up](/v2/wallet#top-up), then retry. |
| `422` | `flow_generation_failed` | No valid graph after three attempts. `details` lists what was still wrong. Nothing was saved. | Yes | Rephrase the brief: name each step and each branch explicitly, or shorten it. Try another model, or fix the graph by hand. |
| `429` | `rate_limited` | Your workspace has used its 30 generations for the hour, or the generator is busy with other requests. | No | Wait for `Retry-After` seconds, then retry. |
| `503` | `upstream_unavailable` | The model is unavailable, or stopped answering part way. | Only if the model had already answered | Wait for `Retry-After` seconds, then retry. |
`422` uses the same shape as `invalid_flow`, plus the charge:
```json title="422 Unprocessable Entity" theme={null}
{
"error": "flow_generation_failed",
"details": [
{ "path": "flow.nodes[4]", "message": "node \"reschedule\" has no path to an endCall node" }
],
"id": "fgen_01K8D4H6K8M0P2R4T6V8X0Z2B4",
"model": "gemini-3.8-flash",
"usage": { "prompt_tokens": 6120, "completion_tokens": 7480 },
"cost": { "amount_inr": 6.89 }
}
```
The other errors use the [standard envelope](/v2/errors). A charged `503` also
carries `id`, `model`, `usage` and `cost` beside `error`. A `503`, a `402`, and
a `429` sent because the generator is busy do not count against the hourly
limit.
***
## Draft a flow from call recordings
If you already have calls that go the way you want, from your current agent or
your team, the API can learn the flow from them. Transcribe 2 to 10 recordings,
then send the transcripts to
[`POST /v2/agents/flow/generate`](#generate-a-flow-from-a-brief) as `examples`.
The model learns the goal of the call, the steps and their order, the branches,
the objections and how each is handled, the language mix and tone, what is
collected, and how calls end. It then designs the flow for what happened in the
calls and their obvious variants.
In **Agent Studio**, choose **Create agent**, then **Node-based**. Describe the
calls, add 2 to 10 recordings, or both, then choose **Draft flow**. To write the
description with ChatGPT or Claude, choose **Copy prompt**, paste the prompt
there, and paste the answer back as the description. Agent Studio drafts with
Claude Opus 5.5 and creates the agent, ready to test and edit in the builder.
### Transcribe a recording
```http theme={null}
POST /v2/agents/flow/transcribe
```
Send one recording per request, as `multipart/form-data`:
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `file` | file | yes | The call recording: at most 25 MB and 15 minutes, in a common audio format (mp3, wav, m4a, ogg/opus, webm, flac, aac or amr). |
| `language` | string | no | Up to 32 characters. A hint of the call's language, such as `hi`, `en` or `Hinglish`. |
Any other field, or a second file, is refused with `400 invalid_request`.
```json title="200 OK" theme={null}
{
"object": "flow_transcript",
"id": "ftr_01K8E2F4H6K8M0P2R4T6V8X0Z2",
"transcript": "AGENT: नमस्ते, मैं [NAME] बोल रही हूँ, Acme Retail से।\nCALLER: जी, बोलिए।\nAGENT: आपका order कल deliver होना है। क्या कल घर पर कोई होगा?\nCALLER: कल नहीं, परसों ठीक रहेगा।\n…",
"duration_secs": 100.8,
"model": "gemini-3.8-flash",
"usage": { "prompt_tokens": 2717, "completion_tokens": 1917 },
"cost": { "amount_inr": 1.95 }
}
```
| Field | Description |
| :- | :- |
| `id` | The transcription's ID, `ftr_…`. The wallet row for its charge carries the same ID. |
| `transcript` | One turn per line. Each line starts with `AGENT:` (your side of the call) or `CALLER:`. |
| `duration_secs` | The recording's length, to a tenth of a second. |
| `model` | The model that transcribed it, `gemini-3.8-flash`. |
| `usage` | Tokens the model used. The audio counts as `prompt_tokens`. |
| `cost` | What was charged to your wallet, `amount_inr`. See [what transcription costs](#what-transcription-costs). |
The transcript keeps the language and script as spoken: Hindi in Devanagari,
English words in Latin script, and numbers written as they were said. Personal
data is replaced with placeholders: `[NAME]`, `[PHONE]`, `[ADDRESS]` and
`[NUMBER]`. When you draft from the transcripts, a placeholder for something you
know before the call, such as the customer's name, becomes a call variable.
**The audio is transcribed and not stored.** It is deleted when the request
ends, and it is never logged. The transcript is not stored either: it exists
only in the response, so keep it if you want to draft again.
A two-minute call takes about 5 to 10 seconds to transcribe. Set a client timeout
of a few minutes to allow for the upload. Transcribe one or two recordings at a
time: while the transcriber is busy with other requests, it answers
`429 rate_limited` with `Retry-After`.
### Generate from the transcripts
Send the transcripts as `examples` to `POST /v2/agents/flow/generate`, with the
same `language` and `model` fields as a brief:
| Field | Limit |
| :- | :- |
| `examples` | 2–10 calls, each `{"transcript": "…"}` |
| Each `transcript` | 1–40,000 characters, one `AGENT:` or `CALLER:` turn per line |
| All transcripts together | 200,000 characters |
| `brief` | Optional with `examples`. Up to 20,000 characters. |
One example is refused: the model would copy it rather than learn from it. A
`brief` sent with the examples steers the draft, and wins where it disagrees
with them. You can also write the transcripts yourself, in the same format. The
model never copies a caller's personal data or one-off facts into the flow. A
draft from example calls can have up to 20 nodes, and takes longer than a draft
from a short brief. The examples below use Claude Opus 5.5, as Agent Studio does.
```bash theme={null}
# 1. Transcribe each recording, one per request
for call in delivered rescheduled; do
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents/flow/transcribe \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-F "file=@calls/$call.mp3" \
-F "language=hi" \
--max-time 300 > "$call.json"
done
jq '{id, duration_secs, cost}' delivered.json rescheduled.json
# 2. Draft the flow from the transcripts
jq -s '{examples: map({transcript}), language: "hi", model: "claude-opus-5.5"}' \
delivered.json rescheduled.json |
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents/flow/generate \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--max-time 300 \
--data-binary @- > draft.json
jq '{id, model, cost, nodes: (.flow.nodes | length)}' draft.json
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
def transcribe(path, language="hi"):
with open(path, "rb") as audio:
r = httpx.post(
f"{API}/v2/agents/flow/transcribe",
headers=auth,
files={"file": (os.path.basename(path), audio)},
data={"language": language},
timeout=300, # allow for the upload
)
if r.status_code == 422: # charged: no conversation was heard
raise RuntimeError(f"{path}: {r.json()['error']['message']}")
return r.raise_for_status().json()
transcripts = [transcribe(p) for p in ["calls/delivered.mp3", "calls/rescheduled.mp3"]]
for t in transcripts:
print(t["id"], t["duration_secs"], t["cost"]["amount_inr"])
r = httpx.post(
f"{API}/v2/agents/flow/generate",
headers=auth,
json={
"examples": [{"transcript": t["transcript"]} for t in transcripts],
"language": "hi",
"model": "claude-opus-5.5",
},
timeout=300,
)
draft = r.raise_for_status().json()
print(draft["id"], len(draft["flow"]["nodes"]), "nodes", draft["cost"]["amount_inr"])
print([v["name"] for v in draft["variables"]])
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
async function transcribe(path, language = "hi") {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), basename(path));
form.append("language", language);
const res = await fetch(`${API}/v2/agents/flow/transcribe`, {
method: "POST",
headers: auth, // fetch sets the multipart Content-Type
body: form,
signal: AbortSignal.timeout(300_000),
});
const body = await res.json();
if (!res.ok) throw new Error(`${path}: ${body.error.code} ${body.error.message}`);
return body;
}
const transcripts = [];
for (const path of ["calls/delivered.mp3", "calls/rescheduled.mp3"]) {
transcripts.push(await transcribe(path));
}
const res = await fetch(`${API}/v2/agents/flow/generate`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
examples: transcripts.map((t) => ({ transcript: t.transcript })),
language: "hi",
model: "claude-opus-5.5",
}),
signal: AbortSignal.timeout(300_000),
});
const draft = await res.json();
if (!res.ok) throw new Error(JSON.stringify(draft));
console.log(draft.id, draft.flow.nodes.length, "nodes", draft.cost.amount_inr);
```
The response is the same [draft](#generate-a-flow-from-a-brief) a brief returns.
[Save it on an agent](#save-it-on-an-agent) with its `input_schema`, then
[test it in the browser](#step-4-test-it-in-the-browser) and take every branch
the calls took before it calls anyone.
### What transcription costs
Each transcription is charged to your [wallet](/v2/wallet) at what the
provider reports it cost, plus 20%, like a draft. The provider prices audio
higher than text, so the charge does not follow a token rate. It comes to about
₹1 per minute of audio. The response's `cost` says what was charged.
* **A transcript is charged, and so is a recording with no conversation in it**
(`422 transcription_failed`): the model listened to the audio either way.
* **Nothing else is charged.** A request refused before the model runs (`400`,
`402`, `429`) and a model that is unavailable (`503`) cost nothing.
* **The wallet must hold a positive balance.** If it is empty, the request is
refused with `402 insufficient_balance` before the recording is decoded.
* **Where it shows.** Each charge is one wallet transaction of kind
[`flow_generation`](/v2/wallet#a-recording-transcription-row), and the
billing statement lists it with the **AI flow drafts**.
### Transcription errors
| Status | `error.code` | When | Charged | What to do |
| :- | :- | :- | :- | :- |
| `400` | `invalid_request` | The body is not `multipart/form-data`; `file` is missing or empty; there is a second file or another field; the recording is over 15 minutes; the file is not audio that can be decoded; or `language` is over 32 characters. | No | Fix the request. Split a long recording, or convert it to mp3 or wav. |
| `400` or `413` | `invalid_request` or `request_too_large` | The file is over 25 MB. | No | Compress it: a mono mp3 at 32 kbps holds 15 minutes in under 4 MB. |
| `402` | `insufficient_balance` | Your wallet is empty. | No | [Top up](/v2/wallet#top-up), then retry. |
| `422` | `transcription_failed` | No conversation was heard in the recording. | Yes | Check that it is a phone call with speech. |
| `429` | `rate_limited` | Your workspace has used its 60 transcriptions for the hour, or the transcriber is busy with other requests. | No | Wait for `Retry-After` seconds, then retry. |
| `503` | `upstream_unavailable` | The transcription model is unavailable. | No | Wait for `Retry-After` seconds, then retry. |
`422` uses the [standard envelope](/v2/errors), plus the charge:
```json title="422 Unprocessable Entity" theme={null}
{
"error": {
"code": "transcription_failed",
"message": "no conversation could be transcribed from this recording; check that it is a phone call with speech"
},
"id": "ftr_01K8E3G5J7L9N1Q3S5U7W9Y1A3",
"model": "gemini-3.8-flash",
"duration_secs": 12.4,
"usage": { "prompt_tokens": 410, "completion_tokens": 96 },
"cost": { "amount_inr": 0.21 }
}
```
A recording that cannot be decoded, a `402`, a `503` and a `429` sent because
the transcriber is busy do not count against the hourly limit.
***
## The graph
A flow is a JSON object with `nodes`, `edges` and, optionally, `viewport`. Any
other key is refused.
```json theme={null}
{
"nodes": [ { "id": "…", "type": "startCall", "data": { … } } ],
"edges": [ { "id": "…", "source": "…", "target": "…", "data": { "label": "…", "condition": "…" } } ],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
```
`viewport`, a node's `position`, and an edge's `type` and `animated` are for
visual editors. They accept any JSON value and are returned exactly as sent.
The call ignores them.
### Nodes
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `id` | string | yes | Unique in the graph. A letter or underscore, then letters, digits or underscores, at most 64 characters. |
| `type` | string | yes | `startCall`, `agentNode`, `endCall` or `globalNode`. See below. |
| `data` | object | yes | What the node does. See [node data](#node-data). |
| `position` | any | no | Editor layout. Returned as sent. |
| `type` | How many | What it is |
| :- | :- | :- |
| `startCall` | Exactly one | The first step. Its `greeting` opens the call. |
| `agentNode` | Any number | A step of the conversation. |
| `endCall` | At least one | The last step. The agent speaks one reply, then hangs up. No transitions leave it. |
| `globalNode` | At most one | Shared instructions, such as persona, language and rules. Added before the prompt of every node whose `add_global_prompt` is `true`. It is never the current node, and no transition enters or leaves it. |
### Node data
| Field | Type | Default | Description |
| :- | :- | :- | :- |
| `name` | string | required | 1–80 characters. A human label, sent in [live events](#step-4-test-it-in-the-browser). |
| `prompt` | string | required | 1–12,000 characters. The node's instructions: what to do here, and when to call each outgoing transition. `{{variables}}` are [filled in per call](#variables-in-node-text). |
| `greeting` | string \| null | `null` | Up to 1,500 characters. On the start node, the exact opening line. Only the start node's greeting is spoken; put what another node should say in its `prompt`. |
| `allow_interrupt` | boolean | `true` | `false` stops the caller from cutting the agent off while it speaks on this node. What the caller says is answered after the line finishes. |
| `add_global_prompt` | boolean | `true` | Whether the global node's prompt comes before this node's. |
| `extraction_enabled` | boolean | `false` | `true` extracts this node's `extraction_variables`. |
| `extraction_variables` | array | `[]` | Up to 12 values to extract. See [extraction](#extraction). |
| `tools` | string array | `[]` | Up to 10 distinct names of this agent's `on_call` tools, offered on a `startCall` or `agentNode`. Disabled tools are not offered. Names must not collide with any transition label. End and global nodes cannot offer tools. |
| `is_start` | boolean | from `type` | Set from `type` when you save. Any value you send is replaced. |
Every default is filled in when you save, so a graph you read back states
exactly how each node behaves.
### Transitions
Each entry in `edges` is one transition.
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `id` | string | yes | Unique in the graph. Any non-empty string. |
| `source` | string | yes | The `id` of the node the transition leaves. Not an `endCall` or the `globalNode`. |
| `target` | string | yes | The `id` of the node it enters. Not the `globalNode`. |
| `data.label` | string | yes | The function name the model calls to take this transition. A letter or underscore, then letters, digits or underscores, at most 64 characters. Unique among the transitions leaving the same node. |
| `data.condition` | string | yes | 1–2,000 characters, not blank. When to take the transition. The model receives it word for word as the function's description; `{{variables}}` are **not** filled in here. |
| `type`, `animated` | any | no | Editor fields. Returned as sent. |
Write labels in lowercase `snake_case`, such as `confirmed_identity`. The model
sees each label in lowercase, and a readable name helps it choose.
### Extraction
An extraction variable is a value the model reads out of the conversation.
| Field | Type | Description |
| :- | :- | :- |
| `name` | string | A letter or underscore, then letters, digits or underscores, at most 64 characters. Unique within the node. |
| `type` | string | `string`, `number` or `boolean`. |
| `prompt` | string | 1–2,000 characters. What to extract and how to decide. `{{variables}}` are filled in. |
Extraction reads the whole conversation up to that point. It runs in the
background when the call leaves the node, so it never delays the agent's
reply. The node the call ends on is extracted as the call ends. If two nodes
extract the same `name`, the later value wins: a correction step can overwrite
what an earlier step heard.
### Variables in node text
Node prompts (the global node's too), greetings and extraction prompts are
filled in from the call's [`variables`](/v2/calls#variables).
| Placeholder | Becomes |
| :- | :- |
| `{{customer_name}}` | The call's `customer_name`. Names match loosely, as on every agent: `{{Customer Name}}` reads the same variable. A variable the call does not send becomes empty text. |
| `{{customer_name \| there}}` | The variable, or `there` when it is missing or empty. The default cannot contain `:`. |
| `{{account.due_date}}` | A field inside an object value. |
| `{{current_time_Asia/Kolkata}}` | The current time in that IANA zone as the node starts, such as `2026-09-28 14:30:05 IST`. |
| `{{current_weekday_Asia/Kolkata}}` | The current weekday in that zone, such as `Monday`. Always include the zone: without one, the time is UTC. |
Transition conditions and labels are never filled in. Write them without
placeholders.
If your agent declares an `input_schema`, every variable a node reads must be
declared in it. For a dotted path, its first part must be declared. The time
placeholders need no declaration. An undeclared variable is refused when you
save, with the path of the text that reads it.
### Rules
A graph is refused unless all of these hold:
* There is exactly one `startCall` node, at least one `endCall` node, and at
most one `globalNode`.
* Node IDs are unique. Transition IDs are unique. Labels are unique among the
transitions leaving one node. Extraction variable names are unique within a
node.
* No transition leaves an `endCall` node or the `globalNode`, and none enters
the `globalNode`.
* Every node except the global node can be reached from the start node, and has
a path to an `endCall` node.
* The graph stays within the [limits](#limits).
* Each name in a node's `data.tools` refers to an `on_call` tool defined on the
agent. A node offers up to 10 distinct tool names; none may match a transition
label anywhere in the graph. End and global nodes cannot offer tools.
* The `end_call` tool is disabled or absent. A flow ends at its end nodes.
* Your tier's model makes native tool calls, because every transition is a tool
call. The standard model on every tier does.
Each problem is reported with its path. See [errors](#errors).
***
## Build one: a delivery confirmation
This example confirms a delivery slot with a customer, in Hindi. It checks who
answered before it mentions the order, offers the slot, finds a new day if
needed, and closes with a line the caller cannot cut off.
```mermaid theme={null}
flowchart LR
persona[/"Persona and rules (global)"/]
identity(["Confirm the customer"]) -->|confirmed_identity| slot["Offer the delivery slot"]
identity -->|wrong_person| not_customer(["Wrong person"])
slot -->|slot_confirmed| goodbye(["Close"])
slot -->|ask_new_day| reschedule["Find a new day"]
reschedule -->|new_day_agreed| goodbye
```
You need your `sk_live_` key, a phone number you are allowed to call, a public
HTTPS URL for [webhooks](/v2/webhooks), and `jq` for the cURL examples.
### Step 1: Write the graph
Save this as `delivery-flow.json`.
```json title="delivery-flow.json" theme={null}
{
"nodes": [
{
"id": "persona",
"type": "globalNode",
"data": {
"name": "Persona and rules",
"prompt": "You are Priya, a polite voice agent calling from Acme Retail about order {{order_id}}.\nSpeak everyday Hindi: Hindi words in Devanagari, common English words such as order, delivery and slot in Roman script.\nSay one or two short sentences, then listen.\nNever promise a refund, a discount or a delivery time that this call has not offered.\nWhen the condition of one of your functions is met, call it at once without saying anything first. The next step does the talking."
}
},
{
"id": "identity",
"type": "startCall",
"data": {
"name": "Confirm the customer",
"greeting": "नमस्ते, मैं Acme Retail से Priya बोल रही हूँ, एक AI assistant. क्या मेरी बात {{customer_name}} जी से हो रही है?",
"prompt": "You have just asked whether you are speaking with {{customer_name}}. Wait for the answer.\nIf they confirm, call confirmed_identity.\nIf it is someone else or a wrong number, or they ask not to be called, call wrong_person.\nIf the answer is unclear, ask once more. Do not mention the order before they confirm who they are.",
"extraction_enabled": true,
"extraction_variables": [
{
"name": "identity_confirmed",
"type": "boolean",
"prompt": "True only if the caller clearly confirmed they are {{customer_name}}."
}
]
}
},
{
"id": "slot",
"type": "agentNode",
"data": {
"name": "Offer the delivery slot",
"prompt": "Tell the customer that order {{order_id}} will be delivered on {{delivery_date}}, between {{delivery_window}}. Ask whether someone will be at home to receive it.\nIf they say yes, call slot_confirmed.\nIf they cannot receive it then, or ask for another day, call ask_new_day.",
"extraction_enabled": true,
"extraction_variables": [
{
"name": "slot_accepted",
"type": "boolean",
"prompt": "True if the customer accepted the offered delivery slot."
}
]
}
},
{
"id": "reschedule",
"type": "agentNode",
"data": {
"name": "Find a new day",
"prompt": "Ask which day in the next seven days suits the customer. Do not offer a time.\nWhen they name a day, read it back once and ask them to confirm.\nWhen they confirm, call new_day_agreed.",
"extraction_enabled": true,
"extraction_variables": [
{
"name": "preferred_day",
"type": "string",
"prompt": "The delivery day the customer confirmed, in their words, for example Saturday or 14 October."
}
]
}
},
{
"id": "goodbye",
"type": "endCall",
"data": {
"name": "Close",
"prompt": "In one sentence, repeat what was agreed: the offered slot, or the new day the customer chose. Thank them and say goodbye. Do not ask anything else.",
"allow_interrupt": false
}
},
{
"id": "not_customer",
"type": "endCall",
"data": {
"name": "Wrong person",
"prompt": "Apologise for the trouble and say goodbye. Do not mention the order or anything about it."
}
}
],
"edges": [
{
"id": "e1",
"source": "identity",
"target": "slot",
"data": {
"label": "confirmed_identity",
"condition": "The caller confirmed they are the customer you asked for."
}
},
{
"id": "e2",
"source": "identity",
"target": "not_customer",
"data": {
"label": "wrong_person",
"condition": "The caller is someone else, says it is a wrong number, or asks not to be called."
}
},
{
"id": "e3",
"source": "slot",
"target": "goodbye",
"data": {
"label": "slot_confirmed",
"condition": "The customer said someone will be at home to receive the delivery in the offered slot."
}
},
{
"id": "e4",
"source": "slot",
"target": "reschedule",
"data": {
"label": "ask_new_day",
"condition": "The customer cannot receive the delivery in the offered slot, or asks for another day."
}
},
{
"id": "e5",
"source": "reschedule",
"target": "goodbye",
"data": {
"label": "new_day_agreed",
"condition": "The customer confirmed a new delivery day after you read it back."
}
}
]
}
```
What each part does:
* **`persona`** holds what every step shares: who the agent is, the language,
reply length, and the rule to move on silently. That keeps each step's own
prompt short.
* **`identity`** discloses that the caller is speaking with an AI, and says
nothing about the order until the right person has confirmed.
* **`slot`** and **`reschedule`** each name the transitions they may call, and
when. Each transition's condition repeats that decision in plain words.
* **`goodbye`** sets `allow_interrupt: false`, so the summary of what was
agreed is always heard in full.
### Step 2: Validate it
```http theme={null}
POST /v2/agents/flow/validate
```
The body is the graph. The dry run applies every graph rule and checks that
your tier's model can run flows. It saves nothing and calls no one.
Undeclared `input_schema` variables and names that do not resolve to an agent's
on-call tools are reported when you save the graph on that agent. A bare graph
has neither the input schema nor the agent's tool definitions.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents/flow/validate \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @delivery-flow.json
```
```python theme={null}
import json, os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
with open("delivery-flow.json", encoding="utf-8") as f:
flow = json.load(f)
r = httpx.post(f"{API}/v2/agents/flow/validate", headers=auth, json=flow, timeout=30)
if r.status_code == 400 and r.json().get("error") == "invalid_flow":
for problem in r.json()["details"]:
print(problem["path"], "-", problem["message"])
r.raise_for_status()
print(r.json()) # {'ok': True, 'flow_revision': 'ffe29d96cc606b2c53ba', 'nodes': 6, 'edges': 5}
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const flow = JSON.parse(await readFile("delivery-flow.json", "utf8"));
const res = await fetch(`${API}/v2/agents/flow/validate`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(flow),
});
const out = await res.json();
if (!res.ok) {
for (const { path, message } of out.details ?? []) console.error(path, "-", message);
throw new Error(out.error?.code ?? out.error);
}
console.log(out); // { ok: true, flow_revision: "ffe29d96cc606b2c53ba", nodes: 6, edges: 5 }
```
```json title="200 OK" theme={null}
{ "ok": true, "flow_revision": "ffe29d96cc606b2c53ba", "nodes": 6, "edges": 5 }
```
`flow_revision` is a fingerprint of the graph: the same graph always has the
same value, and any change produces a new one.
A graph with problems returns every one of them, each with its path:
```json title="400 Bad Request" theme={null}
{
"error": "invalid_flow",
"details": [
{
"path": "flow.edges[3].data.label",
"message": "must be a function name: a letter or underscore, then letters, digits or underscores (at most 64)"
},
{
"path": "flow.edges[4].data.condition",
"message": "is required: describe when the model should take this transition"
}
]
}
```
Paths start at `flow` on every route, so the same paths work whether you send
the graph to this dry run, to `PUT /v2/agents/{id}/flow`, or inside an agent.
Reachability (a node that cannot be reached, or has no path to an end) is
checked once the rest of the graph is valid.
### Step 3: Create the agent
Send the graph as the agent's `flow`. With a flow, `system_prompt` and
`first_message` are optional and unused: node prompts give the instructions and
the start node's greeting opens the call.
```bash theme={null}
jq '{
name: "Delivery slot confirmation",
voice: { voice_id: "neha", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 240,
voicemail: { action: "hangup" },
flow: .
}' delivery-flow.json |
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-
```
```python theme={null}
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "Delivery slot confirmation",
"voice": {"voice_id": "neha", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 240,
"voicemail": {"action": "hangup"},
"flow": flow,
},
timeout=30,
).raise_for_status().json()
print(agent["id"], agent["revision"])
```
```javascript theme={null}
const created = await fetch(`${API}/v2/agents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Delivery slot confirmation",
voice: { voice_id: "neha", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 240,
voicemail: { action: "hangup" },
flow,
}),
});
const agent = await created.json();
if (!created.ok) throw new Error(JSON.stringify(agent));
console.log(agent.id, agent.revision);
```
**`201 Created`**: the [agent object](/v2/agents#the-agent-object), with
`revision` and the stored `flow`. Every node comes back with its defaults filled
in:
```json title="201 Created (excerpt)" theme={null}
{
"id": "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS",
"object": "agent",
"name": "Delivery slot confirmation",
"revision": 1,
"flow": {
"nodes": [
{
"id": "persona",
"type": "globalNode",
"data": {
"name": "Persona and rules",
"prompt": "You are Priya, a polite voice agent calling from Acme Retail about order {{order_id}}.\n…",
"is_start": false,
"allow_interrupt": true,
"add_global_prompt": true,
"extraction_enabled": false,
"extraction_variables": []
}
}
]
}
}
```
This is how the other agent fields behave on a node-based agent:
| Field | On a node-based agent |
| :- | :- |
| `system_prompt`, `first_message` | Optional and unused. |
| `voice`, `language`, `max_duration_secs`, `voicemail`, `background_sound` | Unchanged. State the spoken language in your global node as well: the node prompts are the model's only instructions. |
| `end_call` | The model is not offered an `end_call` tool. A flow ends when it reaches an end node. |
| `tools` | Pre-call and post-call tools run normally. On-call tools are offered by the nodes that list their names in `data.tools`. The `end_call` tool must stay disabled or absent. |
### Step 4: Test it in the browser
A [browser call](/v2/web-calls) is the quickest way to test: you speak to the
agent from a web page and watch it move through the graph. Create the call from
your backend with `channel: "web"` and the variables the nodes read.
```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: delivery-flow-test-1" \
-d '{
"agent_id": "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS",
"channel": "web",
"variables": {
"customer_name": "Rahul",
"order_id": "AC-88213",
"delivery_date": "Friday, 3 October",
"delivery_window": "10 AM and 1 PM"
},
"final_results": true,
"webhook_url": "https://example.com/mirai/webhook"
}'
```
```python theme={null}
test_vars = {
"customer_name": "Rahul",
"order_id": "AC-88213",
"delivery_date": "Friday, 3 October",
"delivery_window": "10 AM and 1 PM",
}
call = httpx.post(
f"{API}/v2/calls",
headers={**auth, "Idempotency-Key": "delivery-flow-test-1"},
json={
"agent_id": agent["id"],
"channel": "web",
"variables": test_vars,
"final_results": True,
"webhook_url": "https://example.com/mirai/webhook",
},
timeout=30,
).raise_for_status().json()
# Hand only these two links to the page.
links = {"ws_url": call["ws_url"], "events_url": call["events_url"]}
```
```javascript theme={null}
const testVars = {
customer_name: "Rahul",
order_id: "AC-88213",
delivery_date: "Friday, 3 October",
delivery_window: "10 AM and 1 PM",
};
const res = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": "delivery-flow-test-1",
},
body: JSON.stringify({
agent_id: agent.id,
channel: "web",
variables: testVars,
final_results: true,
webhook_url: "https://example.com/mirai/webhook",
}),
});
const call = await res.json();
if (!res.ok) throw new Error(`${call.error.code}: ${call.error.message}`);
// Hand only these two links to the page.
const links = { ws_url: call.ws_url, events_url: call.events_url };
```
Open the two links from a page. The [browser example](/v2/web-calls#browser-example)
is a complete page; add two listeners to show the current step and what each
step heard:
```javascript theme={null}
events.addEventListener("flow.node.entered", (e) => {
const { node_id, name, previous_id } = JSON.parse(e.data).data;
$("state").textContent = `Step: ${name}`;
console.log(`${previous_id ?? "(start)"} → ${node_id}`);
});
events.addEventListener("flow.variables.extracted", (e) => {
const { node_id, variables } = JSON.parse(e.data).data;
console.log(`extracted at ${node_id}`, variables);
});
```
Your backend can read the same stream with the API key. Here is an abridged
test in which the customer asks for another day:
```bash theme={null}
curl -N https://sandbox.voice.miraiminds.co/v2/calls/call_01K8C5F7H9K1M3P5R7T9V1X3Z5/events \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```text theme={null}
event: flow.node.entered
data: {"v":1,"seq":1,"type":"flow.node.entered","call_id":"call_01K8C5F7H9K1M3P5R7T9V1X3Z5","at":"2026-09-28T10:00:02.104Z","data":{"node_id":"identity","name":"Confirm the customer","previous_id":null}}
event: transcript.turn
data: {"v":1,"seq":3,"type":"transcript.turn","call_id":"call_01K8C5F7H9K1M3P5R7T9V1X3Z5","at":"2026-09-28T10:00:07.880Z","data":{"role":"user","text":"हाँ, बोल रहा हूँ","turn":2,"interrupted":false}}
event: flow.node.entered
data: {"v":1,"seq":5,"type":"flow.node.entered","call_id":"call_01K8C5F7H9K1M3P5R7T9V1X3Z5","at":"2026-09-28T10:00:08.910Z","data":{"node_id":"slot","name":"Offer the delivery slot","previous_id":"identity"}}
event: flow.variables.extracted
data: {"v":1,"seq":7,"type":"flow.variables.extracted","call_id":"call_01K8C5F7H9K1M3P5R7T9V1X3Z5","at":"2026-09-28T10:00:09.640Z","data":{"node_id":"identity","variables":{"identity_confirmed":true}}}
event: flow.node.entered
data: {"v":1,"seq":12,"type":"flow.node.entered","call_id":"call_01K8C5F7H9K1M3P5R7T9V1X3Z5","at":"2026-09-28T10:00:21.300Z","data":{"node_id":"reschedule","name":"Find a new day","previous_id":"slot"}}
```
(The `id:` line of each event is left out above.)
| Event | Data | When |
| :- | :- | :- |
| `flow.node.entered` | `node_id`, `name`, `previous_id` | The call entered a node. The start node sends one too, with `previous_id: null`. It arrives before the reply that node produces. |
| `flow.variables.extracted` | `node_id`, `variables` | A node's extraction saved values: the names that node asked for, with what was extracted. Sent only when something was extracted, shortly after the call leaves the node. |
The full event reference is in [Browser calls](/v2/web-calls#live-events).
Phone calls have no live event stream.
Try each branch before you dial anyone:
| Say | Expect |
| :- | :- |
| "हाँ" to the greeting, then "हाँ, घर पर कोई होगा" | `identity` → `slot` → `goodbye`. The close repeats the offered slot. |
| "उस दिन नहीं हो पाएगा", then a day, then "हाँ" to the read-back | `slot` → `reschedule` → `goodbye`, with `preferred_day` extracted. |
| "Wrong number" | `identity` → `not_customer`. The order is never mentioned. |
| Nothing at all | One check-in after about 10 seconds, then a goodbye, and the call ends `idle-timed-out`. |
| Talk over the closing line | The line finishes. The call still ends. |
### Step 5: Call a phone
The same agent takes phone calls. Send `to` instead of `channel: "web"`.
```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-AC-88213-slot-1" \
-d '{
"agent_id": "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS",
"to": "+919876543210",
"variables": {
"customer_name": "Rahul",
"order_id": "AC-88213",
"delivery_date": "Friday, 3 October",
"delivery_window": "10 AM and 1 PM"
},
"metadata": { "order_id": "AC-88213" },
"final_results": true,
"webhook_url": "https://example.com/mirai/webhook"
}'
```
```python theme={null}
call = httpx.post(
f"{API}/v2/calls",
headers={**auth, "Idempotency-Key": "order-AC-88213-slot-1"},
json={
"agent_id": agent["id"],
"to": "+919876543210",
"variables": test_vars,
"metadata": {"order_id": "AC-88213"},
"final_results": True,
"webhook_url": "https://example.com/mirai/webhook",
},
timeout=30,
).raise_for_status().json()
print(call["id"], call["status"]) # call_… queued
```
```javascript theme={null}
const dial = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": "order-AC-88213-slot-1",
},
body: JSON.stringify({
agent_id: agent.id,
to: "+919876543210",
variables: testVars,
metadata: { order_id: "AC-88213" },
final_results: true,
webhook_url: "https://example.com/mirai/webhook",
}),
});
const placed = await dial.json();
if (!dial.ok) throw new Error(`${placed.error.code}: ${placed.error.message}`);
console.log(placed.id, placed.status); // call_… queued
```
**`202 Accepted`**, and the call follows the usual
[lifecycle](/v2/calls#statuses) and [webhooks](/v2/webhooks). To run the same
agent over a list, create a [campaign](/v2/campaigns) with its `agent_id`.
### Step 6: Read the outcome
A node-based agent's call carries a `flow` block: where the call finished, the
steps it went through, and every extracted value. It is on
[`GET /v2/calls/{id}`](/v2/calls#flow-outcome), on each call in
`GET /v2/calls`, and at `data.call.flow` in
[`call.processed`](/v2/webhooks#final-results).
```json theme={null}
{
"flow": {
"final_node": "goodbye",
"nodes_visited": ["Confirm the customer", "Offer the delivery slot", "Find a new day", "Close"],
"variables": {
"identity_confirmed": true,
"slot_accepted": false,
"preferred_day": "Saturday"
},
"status": "end_call",
"tags": ["end_call"]
}
}
```
| Field | Type | Description |
| :- | :- | :- |
| `final_node` | string | The `id` of the node the call ended on. Omitted when unknown. |
| `nodes_visited` | array | The `name` of each node the call entered, in the order it first entered them. A node entered twice is listed once. Always present; `[]` when no node was entered. |
| `variables` | object | Every extracted value, by variable name. When two nodes extract the same name, the later value is kept. Always present; `{}` when nothing was extracted. |
| `status` | string | How the flow ended. See below. Omitted when unknown. |
| `tags` | array | Labels recorded for the call, such as how it ended. Omitted when unknown. |
| `status` | Meaning |
| :- | :- |
| `end_call` | The call reached an end node. |
| `user_hangup` | The caller hung up. |
| `user_idle_max_duration_exceeded` | The caller stayed silent until the agent said goodbye, or a silence timer closed the call. |
| `call_duration_exceeded` | The call reached its time limit, or a timed close ended it. |
| `voicemail_detected` | An answering machine picked up. |
| `pipeline_error` | The call failed on our side. |
| `system_cancelled` | The call was ended from outside the flow, for example by a [`close` control](/v2/web-calls#live-control). |
Match on `status` for your own logic, and keep a default branch: new values can
be added. Values extracted as the call ends are included, so the block is the
complete record of the flow.
**When it arrives.** The block is absent until the call's outcome is recorded,
usually within seconds of the call ending, so it can be missing from the
terminal event. A node-based agent's call always sends `call.processed` when
the call has a `webhook_url` (unless the agent sets
`emit_processed_webhook: false`). That event waits up to 2 minutes for the outcome,
and is sent without `flow` if it has not arrived by then; read
`GET /v2/calls/{id}` later in that case.
***
## Edit the graph
Use these three routes to change a saved graph:
| Route | Use it to |
| :- | :- |
| `GET /v2/agents/{id}/flow` | Read the graph with the revision numbers you need to save it back. |
| `PUT /v2/agents/{id}/flow?if_revision=N` | Replace the graph without resending the rest of the agent. It also turns a single-prompt agent into a node-based one. |
| `PATCH /v2/agents/{id}` with `flow` | Change the graph together with other fields. `flow` is replaced whole, like every nested object. |
`GET` returns the graph with its agent ID and revisions:
```json title="200 OK" theme={null}
{
"object": "flow",
"agent_id": "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS",
"agent_revision": 1,
"flow_revision": "ffe29d96cc606b2c53ba",
"flow": { "nodes": [ … ], "edges": [ … ] }
}
```
A single-prompt agent has no graph, so `GET` returns `404 not_found`.
To edit safely, read the graph, change it, and save it with
`if_revision` set to the `agent_revision` you read. If someone saved the agent
in between, the save is refused with `409 conflict` and nothing changes. Read
again and reapply your edit.
```bash theme={null}
AGENT=agt_01K8C4D6F8H0J2K4M6N8P0Q2RS
curl -s "https://sandbox.voice.miraiminds.co/v2/agents/$AGENT/flow" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" > current.json
# Let the caller interrupt the closing line after all.
jq '.flow | (.nodes[] | select(.id == "goodbye") | .data.allow_interrupt) = true' \
current.json > delivery-flow.json
curl -X PUT "https://sandbox.voice.miraiminds.co/v2/agents/$AGENT/flow?if_revision=$(jq .agent_revision current.json)" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @delivery-flow.json
```
```python theme={null}
agent_id = "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS"
current = httpx.get(
f"{API}/v2/agents/{agent_id}/flow", headers=auth, timeout=30
).raise_for_status().json()
flow = current["flow"]
for node in flow["nodes"]:
if node["id"] == "goodbye":
node["data"]["allow_interrupt"] = True # let the caller interrupt the close
r = httpx.put(
f"{API}/v2/agents/{agent_id}/flow",
params={"if_revision": current["agent_revision"]},
headers=auth,
json=flow,
timeout=30,
)
if r.status_code == 409:
raise RuntimeError("the agent changed since it was read; read the flow again")
agent = r.raise_for_status().json()
print(agent["revision"]) # 2
```
```javascript theme={null}
const agentId = "agt_01K8C4D6F8H0J2K4M6N8P0Q2RS";
const current = await fetch(`${API}/v2/agents/${agentId}/flow`, { headers: auth })
.then((r) => r.json());
const flow = current.flow;
const close = flow.nodes.find((n) => n.id === "goodbye");
close.data.allow_interrupt = true; // let the caller interrupt the close
const res = await fetch(
`${API}/v2/agents/${agentId}/flow?if_revision=${current.agent_revision}`,
{
method: "PUT",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(flow),
}
);
if (res.status === 409) throw new Error("the agent changed since it was read; read the flow again");
const agent = await res.json();
if (!res.ok) throw new Error(JSON.stringify(agent));
console.log(agent.revision); // 2
```
**`200 OK`**: the updated agent object. `PUT` applies the same checks as
creating an agent, and reports problems as [`invalid_flow`](#errors). On
`PATCH`, send `if_revision` in the body instead.
To turn a node-based agent back into a single-prompt agent, send
`"flow": null`. A single-prompt agent needs a prompt and an opening line, so
send `system_prompt` and `first_message` in the same `PATCH` if the agent has
none:
```bash theme={null}
curl -X PATCH https://sandbox.voice.miraiminds.co/v2/agents/agt_01K8C4D6F8H0J2K4M6N8P0Q2RS \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"flow": null,
"system_prompt": "You are Priya from Acme Retail. Confirm the delivery slot for order {{order_id}} with {{customer_name}}.",
"first_message": "नमस्ते, मैं Acme Retail से Priya बोल रही हूँ। क्या मेरी बात {{customer_name}} जी से हो रही है?"
}'
```
An edit takes effect on the next call. Calls already queued or running keep the
graph they started with.
## Versions
The graph is part of the agent, so it is versioned with the agent. There is no
separate history for graphs.
* **Every save creates a revision.** Creating the agent, a `PATCH`, and a
`PUT /flow` each save a new, immutable agent revision. `revision` on the
agent is the current one.
* **Read the history.** `GET /v2/agents/{id}/revisions` lists revisions newest
first; `GET /v2/agents/{id}/revisions/{revision}` returns one, graph
included.
* **Pin a call.** `agent_revision` on `POST /v2/calls` runs that saved
revision, graph and all, instead of the current one.
* **Campaigns stay put.** A campaign keeps the agent revision it was created
with. Editing the graph does not change a campaign that is already running.
* **Roll back.** `POST /v2/agents/{id}/rollback` with
`{"if_revision": , "revision": }` copies an old revision into a
new **draft**. A draft takes no ordinary calls. Publish it with
`POST /v2/agents/{id}/publish` and `{"if_revision": }` to take calls
again.
Publishing and rolling back check the graph again, including your tier's
model.
## Calls with a node-based agent
| | With a node-based agent |
| :- | :- |
| Phone calls | `POST /v2/calls` with `to`, as for any agent. |
| Browser calls | `POST /v2/calls` with `channel: "web"`. The event stream adds the [flow events](#step-4-test-it-in-the-browser). |
| Campaigns | Use the agent's ID as the campaign's `agent_id`. |
| Text chat | Not supported. A call with `channel: "text"` is refused with `409 flow_agent_voice_only`. |
| `variables` | Fill the node prompts, greetings and extraction prompts. See [variables in node text](#variables-in-node-text). |
| `first_message` | Refused with `409 flow_first_message_unsupported`. The start node's greeting opens the call; personalise it with `variables`. |
| `agent_revision` | Runs that saved revision's graph. |
| `timing`, live control | Work as on any call. See [Browser calls](/v2/web-calls#timing-policy). |
Placing a call, and creating, starting or resuming a campaign, check again
that your tier's model can run flows. If it cannot, the request is refused
with `400 invalid_flow` and nothing is dialled.
## Limits
| Thing | Limit |
| :- | :- |
| Nodes | 2–20: exactly one `startCall`, at least one `endCall`, at most one `globalNode` |
| Transitions | 1–40 |
| Graph size | 100 KB, measured as JSON with every non-ASCII character escaped. A Devanagari character counts as 6 bytes, so a graph written in Hindi reaches the limit sooner. |
| Node `name` | 1–80 characters |
| Node `prompt` | 1–12,000 characters |
| `greeting` | 1,500 characters |
| Extraction variables | 12 per node |
| Extraction `prompt` | 1–2,000 characters |
| Transition `condition` | 1–2,000 characters, not blank |
| Node IDs, labels, variable names | A letter or underscore, then letters, digits or underscores; at most 64 characters |
| Request body | 1 MB |
Character limits count characters, not bytes. The size limit is checked on the
graph you send.
## Errors
Graph problems use their own shape, like [tool validation](/v2/tools#validation-and-runtime-errors):
`error` is the string `invalid_flow` and `details` lists every problem.
```json title="400 Bad Request" theme={null}
{
"error": "invalid_flow",
"details": [
{
"path": "tools[1]",
"message": "end_call cannot be enabled on a flow agent: a flow ends when it reaches an endCall node; disable or remove end_call, and add an endCall node where the call should end"
},
{
"path": "flow.nodes[2].data.prompt",
"message": "variable \"delivery_slot\" is not declared in input_schema"
}
]
}
```
Problems are found in stages: first the graph's own rules, then the agent's
(declared variables and on-call tools), then your tier's model. Each stage
reports all of its problems at once. Fix them and save again to see the next
stage.
| Path | Means |
| :- | :- |
| `flow…` | A problem in the graph. The path points at the field, such as `flow.edges[2].data.label`. `flow` alone means the whole graph, including a tier model that cannot run flows. |
| `tools[i]` | An enabled `end_call` tool. Disable or remove it and use an `endCall` node. |
| `flow.nodes[i].data.tools` | An unknown tool name, a tool from the wrong phase, a duplicate, or a collision with a transition label. |
Everything else uses the [standard error envelope](/v2/errors):
| Status | `error.code` | When |
| :- | :- | :- |
| `404` | `not_found` | `GET /v2/agents/{id}/flow` on a single-prompt agent, or an unknown agent. |
| `409` | `conflict` | `if_revision` is not the agent's current revision. Read the agent again. |
| `409` | `flow_agent_voice_only` | A text chat call with a node-based agent. Flows run on phone and browser calls. |
| `409` | `flow_first_message_unsupported` | A per-call `first_message` for a node-based agent. Change the start node's `greeting` instead. |
## Design tips
* **One job per node.** A node that confirms identity should not also explain
the account. Short, single-purpose nodes move more reliably.
* **Name the transitions in the node prompt.** Say which function to call, and
when: "If they confirm, call `confirmed_identity`." Then write the
`condition` to match.
* **Move silently.** Tell the model to call a transition without speaking
first. The next node's prompt decides what is said next, so nothing is said
twice.
* **Make conditions exclusive.** Two transitions from one node that could both
be true make the model guess. Say what distinguishes them.
* **Put shared rules in the global node.** Write the persona, language, reply
length and what never to promise there once, instead of repeating them in
every node.
* **Keep end nodes to one line.** An end node gets one reply before the call
ends, so don't ask a question there.
* **Extract only what you use.** Every extraction variable is stored with the
call. Do not extract personal data you do not need.
## Privacy and compliance
* **Say it is an AI.** Put the disclosure in the start node's `greeting`, as the
example does. See [Limits & compliance](/v2/limits#disclosure-that-it-is-an-ai).
* **Confirm the person first.** Give nothing away until the right person has
confirmed, and give a wrong-person branch its own end node.
* **Mind what you extract.** Extracted values are stored with the call and
delivered to your webhook. Treat them like the transcript.
* **Check your rights to a recording.** Transcribe only calls you have the right
to process, with the notice or consent your calling rules require. The audio
is not stored and personal data is replaced with placeholders, but a
transcript is still your customers' conversation: keep it like a call
transcript.
* **Calling rules still apply.** Calling windows, consent and do-not-call rules
are the same for every agent. See [India calling rules](/v2/limits#india-calling-rules).
## Troubleshooting
| Symptom | Cause | Fix |
| :- | :- | :- |
| The agent never moves on | The condition is vague, or the node prompt does not say when to call the transition | Name the function in the node prompt and make the condition concrete: "The caller confirmed a new day after you read it back." |
| The agent moves on too early | The condition is loose | Require explicit confirmation in the condition, and say what does not count ("hmm", "okay" alone). |
| The agent says the next step's line, then the next step says it again | The node spoke before calling the transition | Tell the model to call transitions without saying anything first. |
| The agent reads a function name aloud, or describes moving on | The node prompt invites it to narrate | Add "Never say function names" to the global node, and "call it at once without saying anything". |
| A variable is spoken as nothing | The call did not send it, or sent it under another name | Send it in `variables`, or give a default: `{{customer_name \| there}}`. |
| A placeholder in a condition is not filled in | Conditions are passed to the model word for word | Remove placeholders from conditions. Put per-call values in the node prompt. |
| A non-start node's greeting is never spoken | Only the start node's greeting is spoken | Write the line into that node's prompt. |
| The caller cannot cut off a line | The node has `allow_interrupt: false` | Set it to `true` on that node. The caller is still answered after the line ends. |
| The call ends after a short silence | The silence policy: one check-in after about 10 seconds, then a goodbye | Expected. Use [`timing`](/v2/web-calls#timing-policy) for a different silence rule on a call. |
| `invalid_flow` at `tools[i]` naming `end_call` | Agents created in Agent Studio include an on-call **End call** tool | Remove it or set `enabled: false`. A flow ends at its end nodes. |
| `invalid_flow`: `has no path to an endCall node` | A node leads only to nodes that never end | Add a transition from it, or from a node after it, to an end node. |
| `invalid_flow`: `cannot be reached from the start node` | No transition enters the node | Add a transition into it, or remove it. |
| `invalid_flow` at `flow`, naming the model | Your tier's model writes tool calls as text | Contact support. Flows need a model with native tool calls. |
| `409 flow_first_message_unsupported` | The call sent `first_message` | Remove it. Personalise the start node's `greeting` with `variables`. |
| `409 conflict` on `PUT /flow` | The agent was saved since you read it | `GET /v2/agents/{id}/flow` again, reapply your edit, and save with the new `agent_revision`. |
| `404 not_found` on `GET /flow` | The agent is a single-prompt agent | `PUT /v2/agents/{id}/flow` to add a graph. |
## Related
Every agent field, including `flow`.
Audio, live events and control for browser calls.
Place calls, read status, transcripts and recordings.
Pre-call, per-node on-call, and post-call tools for node-based agents.
# API overview
Source: https://docs.miraiminds.co/v2/overview
A REST API for placing AI voice calls, with one auth header, predictable response envelopes and IDs that the server mints.
Build with agents, calls, campaigns, your do-not-call list, and [tools](/v2/tools)
using one workspace API key.
```bash theme={null}
https://sandbox.voice.miraiminds.co
```
Create a key, an agent, a call and a webhook in five minutes. [Start here](/v2/quickstart).
The reusable configuration a call runs. [Reference](/v2/agents).
Connect your APIs before, during, and after calls, with synchronous or background execution. [Guide](/v2/tools).
Place, inspect and abort outbound calls; read transcripts and recordings. [Reference](/v2/calls).
Dial a whole list with calling windows, retries, budgets and reports. [Reference](/v2/campaigns).
Your suppression list. We check it at dial time. [Reference](/v2/dnc).
Signed lifecycle events. [Guide](/v2/webhooks).
Point an OpenAI Realtime client, such as Pipecat, at Mirai and keep your pipeline. [Guide](/v2/realtime).
## 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. A key created after your company goes live carries your plan's
[tier](/general/tiers): T5 (pay as you go) unless you have committed to T3 or
T1. You 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 we cannot recover a lost key. Rotate
it instead. 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 minted by the server** and prefixed by resource type. Treat them as
opaque strings and don't parse them.
| Resource | Format | Example |
| :- | :- | :- |
| Agent | `agt_` | `agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ` |
| Call | `call_` | `call_01K7Q9B4M8N3P6R2S5T7V9W1YA` |
| Campaign | `cmp_` | `cmp_01K7QAC5N9P4R7S3T6V8W2X4YB` |
| Offline transcription | `trn_` | `trn_01M3XRSFXW77RHEQ85979AJXAF` |
| Webhook event | `evt_` | `evt_01K7QC5N9P4R7S3T6V8W2X4YB` |
**Errors** normally use the envelope below. [Tool validation](/v2/tools#validation-and-runtime-errors)
returns a list of field errors. 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 in INR. `cost_inr`, `balance_inr` and `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). No
object mixes the two units.
**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. If you reuse a key, you get
the original response back instead of a second phone call. We hold keys for 24
hours.
```bash theme={null}
-H "Idempotency-Key: order-8842-reminder-1"
```
Derive the key from your own domain object, such as an order ID or a job ID,
instead of generating a random UUID for each attempt. That way a retry sends
the same key. See [Calls](/v2/calls#idempotency).
## Incoming calls and phone numbers
To receive inbound calls, **buy a dedicated number through Mirai** from
**Phone numbers → Add a number**, or **connect your own supported telco number**.
Then assign a published agent and activate incoming calls. Buying through Mirai
does not require your own Plivo account. BYO currently supports Plivo; both
options support Indian +91 numbers. The purchase flow shows available rentals,
business verification requirements, and renewal terms. Follow the
[inbound calling cookbook](/cookbooks/inbound-calling) for console and API setup.
## Not built yet
These are the known gaps, so you can check them before you start building:
* **Knowledge base / RAG**. For function calling, use [v2 tools](/v2/tools).
* **Wallet top-up over the API.** Add credit from the **Billing** page of the
console instead.
* **OAuth or user-scoped tokens.** Secret keys only.
If one of these blocks you, tell us. Several are on the
[roadmap](/general/roadmap), and the order is not fixed.
# Pipecat
Source: https://docs.miraiminds.co/v2/pipecat
Add Mirai voices to a Pipecat bot with one package, stream each call over one WebSocket, keep phone audio smooth under load, or run the whole turn on Mirai with the Realtime API.
If your voice bot runs on [Pipecat](https://github.com/pipecat-ai/pipecat), install
our integration and add one service to your pipeline.
```bash theme={null}
uv add pipecat-mirai
```
`pip install -U pipecat-mirai` works too. This page describes version 0.5.0.
You need Pipecat 1.8.1 or newer and Python 3.11 or newer.
## Add it to your pipeline
Use `MiraiTTSService`. It opens one WebSocket to Mirai when the
pipeline starts and carries the whole call over it:
```python theme={null}
import os
from pipecat_mirai import MiraiTTSService
tts = MiraiTTSService(
api_key=os.environ["MIRA_API_KEY"],
settings=MiraiTTSService.Settings(voice="shruti"),
)
pipeline = Pipeline([
transport.input(),
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
])
```
Pick `ashu`, `neha`, `shruti` or `sameer` as the voice (you can
[hear them here](/v2/voices)). Each sentence of the LLM's reply goes to Mirai
as soon as Pipecat has it, and Mirai synthesises the next sentence while the
current one plays, so there is no pause between them. The service also:
* asks Mirai for audio at your pipeline's sample rate, so a phone pipeline at
8 kHz downloads a sixth of the data that 48 kHz would need, and nothing is
resampled on your side;
* cancels the reply on Mirai the moment the caller interrupts, drops any of
its audio still arriving, and you are not charged for the cut sentence;
* waits and resends once if Mirai is briefly at capacity and says when to try
again (up to 5 seconds);
* reopens a dropped socket at once and resends a reply the drop cut short,
from the first sentence the caller had not started hearing;
* keeps a quiet socket open through long pauses in the call;
* holds 150 ms of audio before each sentence starts, so a short first chunk
cannot start playback and then stall.
To send every LLM token as it arrives and let Mirai find the sentence ends,
pass `text_aggregation_mode=TextAggregationMode.TOKEN` (from
`pipecat.services.tts_service`). Mirai knows the danda (।), abbreviations like
"Rs." and "Dr.", and numbers like 3.5.
Billing is per character, as on the HTTP endpoint, and each sentence Mirai
delivers shows up in your [request log](/v2/tts-quickstart#your-request-log).
The [WebSocket page](/v2/tts-websocket) has the protocol underneath, if you
want to know what the service sends.
### Streaming from the edge
The service streams from Mirai's edge, a server next to
the speech GPUs, instead of through the API region. You don't change anything.
The socket opens on the edge with your API key, the same way it would on the
API, and the key is sent in the header, never in a URL. In our production test
from India (10 calls at once on the default settings), the first audio of a
reply came \~100 ms sooner at the median and up to \~500 ms sooner at p95.
If the edge can't be reached or doesn't answer within 3 seconds, the socket
opens on the API (`url`) as before and the edge is left alone for a minute. If that
keeps happening, the wait doubles, up to 16 minutes. A rejected key, an empty
wallet or a rate limit always comes back with the API's own error message.
`edge=False` (or `MIRAI_TTS_EDGE=off` in the environment) keeps every socket on
the API. `tts.connected_url` shows where a call is streaming from.
## Recommended phone setup
For a voice agent that answers phone calls through a websocket transport (Twilio,
Plivo, Exotel or Telnyx), run the pipeline at 8 kHz and use
`MiraiTTSService` with its defaults. That's all: the service sets the output
lead on your transport itself, shares warm connections across every call in the
process, and races a second connect when one stalls.
```python theme={null}
import os
from pipecat.pipeline.task import PipelineParams
from pipecat_mirai import MiraiTTSService
transport = FastAPIWebsocketTransport(websocket, FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
add_wav_header=False,
serializer=serializer, # TwilioFrameSerializer, PlivoFrameSerializer, ...
))
tts = MiraiTTSService(
api_key=os.environ["MIRA_API_KEY"],
settings=MiraiTTSService.Settings(voice="shruti"),
)
# Run the pipeline at the phone line's rate, so Mirai sends 8 kHz audio.
params = PipelineParams(audio_in_sample_rate=8000, audio_out_sample_rate=8000)
```
Each part fixes a different cause of broken audio:
* With the pipeline at 8 kHz, Mirai sends 128 kbit/s per call instead of
768 kbit/s at 48 kHz. At 48 kHz, six calls on an ordinary link already
arrive slower than real time, and callers hear gaps.
* Pipecat normally sends audio to the phone line at exactly real time, so the
provider never holds more than about 40 ms of it. If your server stalls for
longer (a blocking call, a heavy parse, many calls on one CPU), the line
runs dry and the voice breaks up. The Mirai services let Pipecat send up to
0.4 seconds ahead (`output_lead_secs`, applied automatically when the
pipeline starts), so short stalls never reach the caller. When the caller
interrupts, Pipecat still tells the provider to clear its queue, so at most
0.4 seconds of sent audio is thrown away. With another TTS vendor, call
`apply_output_lead(transport)` yourself.
* Calls in the same process share connections that are already open, so after
the first call a greeting doesn't wait for a TCP and TLS handshake. When many
calls start at once, a connect that stalls (a lost packet costs a full
second) is raced by a second attempt after 300 ms.
* No sentence waits for a new connection, and a sentence waits briefly for
capacity instead of failing.
With only these defaults, we started 10 phone calls at the same instant in one
Pipecat process against our production API, stalled the bot's event loop for
150–300 ms every 2 seconds, and interrupted a fifth of the replies: about 100
turns per run, no audible gaps, no errors, nothing played after an interruption
and every sentence in order.
Earlier, with the same setup, we ran 12 and then 16 simultaneous phone calls through our API
to the speech engine: 235 and 262 conversational turns, about a fifth of them
interrupted by the caller. Every call played with no audible gaps and no
errors, and every sentence arrived in order. The
[test harness](https://github.com/MiraiMinds/pipecat-mirai/tree/main/benchmarks/customer-e2e)
is in the repository.
We also measured the output lead on its own, with stalls of 50 to 250 ms every
3 seconds or so on each call. At 10 calls on one server, breaks stretched the
bot's speech by 29% with stock Pipecat and by 0.01% with `apply_output_lead`.
The lead has one side effect: Pipecat's "bot stopped speaking" event fires up
to 0.4 seconds before the caller actually stops hearing the bot.
If callers still hear gaps, see
[Fixing choppy or broken audio](/v2/tts-quickstart#fixing-choppy-or-broken-audio).
## Limits
Two limits apply per workspace, shared by every process that uses your key
(the [limits page](/v2/limits#text-to-speech) has the full table):
* **Sentences in flight: 10.** A call holds a slot while one of its sentences
is streaming, which is most of the time its bot is speaking, and none while
the caller talks. Ten slots carry ten bots speaking at the same moment. A
sentence that starts when all ten are taken waits for one (up to 3 seconds),
which shows up as a longer time to first byte; past that the service reports
`at_capacity`.
* **Open sockets.** One per live call, plus the few each worker process keeps
waiting (two, or its recent peak of calls plus two).
Before a load test with more than 10 bots speaking at once, tell us the number
and we raise your workspace's limits. Nothing changes in your code.
## HTTP only
You shouldn't need it: if WebSockets can't get through to Mirai at all (a proxy
that only allows plain HTTPS, say), `MiraiTTSService` speaks the call over
Mirai's HTTP endpoint by itself and logs it once. If you want HTTP and nothing
else, `MiraiHttpTTSService` makes one HTTPS request per sentence over shared,
kept-warm connections, with the same voices and settings.
## Settings
| Argument | Default | What it does |
| :- | :- | :- |
| `api_key` | `$MIRAI_API_KEY`, then `$MIRA_API_KEY` | Your Mirai API key. |
| `settings` | `voice="neha"`, `model="mira-tts"` | `MiraiTTSService.Settings(...)`. A new voice applies from the next sentence. |
| `sample_rate` | The pipeline's `audio_out_sample_rate` | The rate the service outputs. |
| `server_sample_rate` | `"auto"` | The rate to ask Mirai for. `"auto"` asks for the output rate. `None` asks for nothing, so Mirai sends 48 kHz and the service resamples. |
| `prebuffer_secs` | `0.15` | Audio held before a sentence starts playing. `0` plays from the first byte. |
| `keepalive_secs` | `30` | How often a quiet socket is kept open. `None` turns it off; Mirai closes a socket after 120 seconds of quiet. |
| `text_aggregation_mode` | sentence | `TextAggregationMode.TOKEN` sends every token as it arrives. |
| `edge` | `"auto"` | Stream from Mirai's edge when it can. `False` (or `MIRAI_TTS_EDGE=off`) always uses `url`. |
| `url` | `wss://sandbox.voice.miraiminds.co/v1/audio/speech/stream` | Mirai's streaming endpoint. |
Code written for the HTTP service of 0.4 and earlier still runs:
`base_url`, `http_client`, `warm_connection` and `keep_warm_secs` are accepted.
## Upgrading
Run `uv add "pipecat-mirai>=0.5.0"` or `pip install -U pipecat-mirai`. Your code
keeps working as it is:
* **`MiraiTTSService`** now streams each call over a WebSocket from the edge
(before 0.5.0 it made one HTTPS request per sentence). Nothing to change.
* **`MiraiWebsocketTTSService`** is another name for `MiraiTTSService`, the
same class. Nothing to change.
* Any `apply_output_lead(transport)` call can go: the service applies the lead
itself (calling it as well is harmless).
Coming from 0.2, two more things you may notice: Mirai now sends audio at your
pipeline's rate instead of 48 kHz (`server_sample_rate=None` brings back the old
behaviour), and the first audio of each sentence waits until 150 ms is in hand
(`prebuffer_secs=0` plays from the first byte).
## Check that it works
```bash theme={null}
export MIRA_API_KEY='your_api_key'
uv run https://raw.githubusercontent.com/MiraiMinds/pipecat-mirai/main/examples/foundational/01-say-hello.py --voice shruti
```
This speaks one line through a real Pipecat pipeline and saves it as
`hello.wav`. It uses a few paise of credit.
[`03-websocket-say-hello.py`](https://github.com/MiraiMinds/pipecat-mirai/blob/main/examples/foundational/03-websocket-say-hello.py)
does the same over the WebSocket, feeding the reply in a few words at a time
the way an LLM would.
## Run the whole turn on Mirai
The services above replace only your TTS. You can also hand us the whole turn:
speech recognition, turn detection, the model and the voice. Use
`MiraiRealtimeLLMService` from the same package. It connects to our
[Realtime API](/v2/realtime) and replaces your STT, LLM and TTS services:
```python theme={null}
from pipecat_mirai import MiraiRealtimeLLMService
llm = MiraiRealtimeLLMService(voice="shruti", language="hi")
pipeline = Pipeline([transport.input(), ctx.user(), llm, transport.output(), ctx.assistant()])
```
Every turn makes one round trip to your server instead of three. A session is
billed per minute like a browser call, not per character or token.
To start from one of your agents, pass `agent_id` (and `variables` for its
placeholders). Each reply's timing on our side arrives through an event
handler:
```python theme={null}
llm = MiraiRealtimeLLMService(agent_id="agt_...", variables={"customer_name": "Rahul"})
@llm.event_handler("on_turn_metrics")
async def on_turn_metrics(service, m):
print(f"{m.v2v_ms} ms voice to voice")
```
It connects to the sandbox by default. Once your company has gone live, pass
`base_url=PRODUCTION_REALTIME_URL` (also from `pipecat_mirai`). On a phone line the pipeline can stay at
8 kHz: the service converts the caller's audio for you.
Pipecat's own `OpenAIRealtimeLLMService` works too, with
`base_url="wss://sandbox.voice.miraiminds.co/v2/realtime"`. The package version
adds our settings (language, temperature, tool timeout), the per-turn timing and
the agent parameters that the stock service can't send. The
[Realtime API page](/v2/realtime) has the protocol details.
## Moving from OpenAITTSService
If you set Mirai up through Pipecat's `OpenAITTSService` with the
`VALID_VOICES` workaround, replace it with `MiraiTTSService`. You can
delete the voice-list patch, and the 24 kHz warning goes away. Your key, voices
and billing stay the same.
Source, a Twilio phone-bot example and the phone-call benchmarks.
The WebSocket protocol the service speaks.
# Prompting guide
Source: https://docs.miraiminds.co/v2/prompting-guide
How to write the system prompt and opening line for a voice agent that sounds like a person on an Indian phone call. Covers structure, language rules, numbers, endings and the test loop.
A voice prompt is a script for a two-minute phone call, not a chatbot persona.
The call has one job, one caller and one ending. Everything the model writes is
spoken aloud, at speaking speed, to someone holding a phone to their ear. Every
extra word costs the caller time, an unclear line can end in a hang-up, and a
paragraph turns into a monologue.
The method is the same every time: design the call as a structure, test it
against a caller who says no, and refine it one line at a time. This page covers
the mechanics. The thinking behind them is in
[The Zen of Voice Agents](/v2/zen-of-voice-agents).
## Why voice is not chat
Everything else on this page follows from three constraints.
**The caller cannot scroll, and cannot re-read.** A sentence they missed is
gone. Anything that matters should be said once, briefly, and then 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 question it answered. Ask one thing,
then stop.
## What the engine already does
The engine already handles a lot of what people write into voice prompts.
Repeating it in the prompt only spends turns and tokens.
* **The opening line runs before the model does.** The engine speaks
`first_message` the moment the callee answers, so it sets 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. It says one farewell sentence
and calls `end_call`. A second refusal gets the same treatment.
* **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.
* **Voicemail is detected** by the engine and handled by the agent's
[`voicemail`](/v2/agents#voicemail) setting.
* **Per-call data is substituted for you.** `{{placeholders}}` in both
`system_prompt` and `first_message` are filled from the call's `variables`.
None of this belongs in your prompt. Use the prompt for the job the call has to
do.
**The agent can only do what its tools do.**
Besides ending the call, an agent acts only through the [tools](/v2/tools)
you give it: HTTP requests to your own systems before, during or after the
call, and built-ins such as scheduling a callback. Transfer to a human is not
available yet, so a prompt must never promise "I'll transfer you". It must not
promise "I'll send you the link" or "I'm booking it in the system" either,
unless a tool on that agent does exactly that. Without one, the honest line is
"the team will call you back" or "आपको team से call आएगा". For branching
conversations, use [node-based agents](/v2/node-agents).
## The structure
Every house template uses the same six blocks, in this order, and 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 with the name, the company and the reason for the
call. "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** sets the boundary of what the agent may say: amounts, dates, policy
and what it cannot do. If a claim is not in FACTS, the agent does not make it
and says the team will call back instead. 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** allows one nudge, then acceptance. The engine already
stops a hard refusal, so 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, so two
good turns do more than a paragraph about being warm and professional.
**LANGUAGE RULES + CONVERSATION STYLE** is the fixed block shown further down
this page. Paste it verbatim at the end of every prompt.
## The opening line
`first_message` is spoken before the model runs, so all of it adds to
first-audio latency. Keep it to the agent's name, the brand and a check that
you are speaking to the right person.
```text theme={null}
नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?
```
Leave the pitch out of the opener. The model already knows what was said and
carries on from there.
Hindi verbs are gendered, and the verb has to match the voice. This catches
everybody at first.
| `voice_id` | Voice | Say |
| :- | :- | :- |
| `ashu` | male | मैं … बोल रहा हूं |
| `aishe` | female | मैं … बोल रही हूं |
The console renders this for you when you pick a template. The API does not, so
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 use Hindi grammar with English nouns mixed in. An agent
that sticks to one pure language sounds like a recording. The console appends
this block 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 instead of a person.
* **Roman script for English words.** Nobody on a phone call says भुगतान; they
say payment. Writing the English word in Roman script matches how the
sentence is actually spoken.
* **No shuddh Hindi.** Formal Hindi belongs in a government notice. On a phone
call it sounds like 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, and thanking four times in ninety
seconds is the clearest sign that nobody is there.
**For English calls** (`language: "en-IN"`) the shape is the same: 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, so write numbers the way
they should sound.
| 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, and a quantity can sit inline in a
Hindi sentence ("दो items"). Identifiers (pincode, phone, order id, vehicle
number, challan number) are always read digit by digit and 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, and the agent must push
back instead of accepting it:
```text theme={null}
मुझे सिर्फ three digits मिले, दोबारा बोलिए
```
**The read-back protocol** has three moves, and it belongs in every flow that
touches a number. The caller states the number, the agent reads it back digit by
digit, and then the agent asks "सही है?". Nothing gets written down until the
caller confirms.
## Facts, and the customer
The agent may say what is in FACTS and nothing more. Anything outside it, such
as a policy question, an exception or a price you did not list, gets the answer
"the team will call you back". Never invent policy, waive a penalty or quote a
number that is not in FACTS.
**Per-call data goes in `variables`, not in the prompt.** One agent runs
thousands of calls, and the customer's name, amount and date change every time.
Hard-coding them means managing one agent per customer.
```json theme={null}
{ "variables": { "customer_name": "राहुल", "emi_amount": "five thousand two hundred", "due_date": "five July" } }
```
Those values are already in spoken form on purpose. The model copies a
variable's value verbatim into speech, so format it for the ear when you build
the map, not in the prompt. `five thousand two hundred` comes out right every
time, and `5200` might not.
How variables behave:
* Names are matched loosely. `{{Customer Name}}`, `{{customer_name}}` and
`{{CustomerName}}` are the same variable, because matching uses only letters
and digits and ignores case.
* 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, because they count as 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" }}.
```
If the script mentions a time window, include the time as well:
```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 should only give backchannels ("जी", "हम्म") and then do one read-back
at the end. If it interrupts to confirm line one, you lose the rest of the
address.
* **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 without moving
the call on.
## Pushback and endings
Allow one light nudge, then accept the answer. The engine already enforces the
refusal ending, so your job is the success ending. 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, because nothing tells it the job is done.
A "no" is a valid outcome. Capture it and close cleanly; a refusal that ends in
twenty seconds is a good call.
The close has three moves: a one-line summary of what was agreed, a goodbye,
then `end_call`. Say धन्यवाद once, in that goodbye. If you leave
[`end_call.message`](/v2/agents#ending-a-call) empty (the default), 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 mid-sentence with `ended_reason: exceeded-max-duration`. For a one-job
call, 180 to 300 seconds is right. The default is 300, and the allowed range is
30 to 1800.
## Sounding like a person
* **Lead word up front.** Starting a turn with "जी।", "अच्छा—" or "ठीक है।"
makes the voice start sooner, and the agent sounds like somebody who was
listening.
* **Punctuation is your only expressiveness control.** Use interjections and
emphasis at the moments that need them, such as an apology or a pushback, and
keep everything else flat. When every line is expressive, none of it stands
out.
* **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" | The adjectives have little effect on a real call, and 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" | No tool on the agent does it, so nobody keeps the promise. | "the team will call you back" / "आपको team से call आएगा" |
| Customer data hard-coded: "Rahul's EMI is 5200" | You need a separate agent for every customer | `{{customer_name}}`, `{{emi_amount}}` in `variables` |
| A long list of forbidden phrases | Naming a phrase primes the model to use it, so a list of what not to say teaches it those phrases. | The positive rule: "one short sentence, maximum two" |
| "Wait for the user to finish speaking" | The engine owns turn-taking, so the prompt only controls what is said. | 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 and
Hospitality · Booking confirmation. All six already follow the structure above.
If none of them is close, use **Describe the call and we draft it → Generate
script**. You write a one-line brief and get back 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. You can
catch most prompt bugs here, and it costs nothing.
```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, without translation. See
[Get the transcript](/v2/calls#get-the-transcript). Find where it went wrong,
change one line and run it again. If you change three lines at once, you cannot
tell which one worked.
A phone line sounds very different from a browser mic, with carrier codecs,
background noise and callers who are walking. Prompts often pass on the web
phone and fail on a real handset, and numbers are the first thing to break.
Start with twenty to fifty contacts. Read the transcripts and the campaign
report, and fix what you find before you scale up. See
[Campaigns](/v2/campaigns).
Judge a call by its outcome. Did it reach a defined end state (confirmed,
refused or callback)? "It sounded nice" is not a metric, and a warm agent that
never gets a commitment has still failed the call.
## A worked example
Here is 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 कर लिया है. धन्यवाद, आपका दिन शुभ हो!
```
Replace the last line with the fixed block from
[Language rules](#language-rules-for-hinglish), 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 that
will change often, so 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
As before, the variable values are already in spoken form.
```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 in the angle brackets and 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.
```
***
If a prompt still is not working and the structure is right, the problem is
usually a belief about what a call is.
[The Zen of Voice Agents](/v2/zen-of-voice-agents) covers that.
# 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.
In five minutes you send four requests and place one real phone call. Calls are
billed at your plan's [tier](/general/tiers), and the example call below ran on
T3.
If you are a partner, [**Start building**](/v2/start-building) is the longer
version of this page. It has 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 | `₹200` | pays for call minutes at ₹5 a minute (T5) |
**The key is shown once.** We store only its SHA-256, so we cannot recover a
lost key; you rotate it instead. Keep both secrets in your secret manager and
out of git.
You can rotate and revoke keys on the same page. Rotating issues the
replacement before it revokes the old key, so there is never a moment when
neither key works. Deploy the new key, then revoke the old one.
Check that 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: its prompt, opening line,
voice, language and limits. You create it once and use it for thousands of
calls.
```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": "ashu", "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": "ashu", "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: "ashu", 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": "ashu", "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). It covers prompt 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",
"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",
"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",
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 the call is accepted but not yet connected, so the phone has not
rung. 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": "t5",
"ended_reason": "assistant-ended-call",
"started_at": "2026-07-26T09:15:02Z",
"ended_at": "2026-07-26T09:16:38Z",
"duration_secs": 96,
"cost_inr": 8.34
}
}
}
```
96 seconds is billed as 10 blocks of 10 seconds, which is ₹8.34 on T5, the sandbox rate.
[Blocks are whole and rounded up](/general/tiers#metering), with a one-block
minimum.
## Next
* In 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 and replay protection.
* [Calls](/v2/calls): statuses, `ended_reason`, transcripts, recordings and
abort.
* [Billing & tiers](/general/tiers): the price ladder and what a minute costs.
* [Limits](/v2/limits): rate limits, concurrency and 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 (₹5 on T5) | Check `GET /v2/wallet`; add credit from the console's **Billing** page, or ask your Mirai contact before you go live |
| `400 invalid_request` on `voice_id` | The voice is not in the catalogue | Use `ashu`, `neha`, `shruti` or `sameer`, which work on every tier; 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) |
# Realtime API
Source: https://docs.miraiminds.co/v2/realtime
Point an OpenAI Realtime client, such as Pipecat's OpenAIRealtimeLLMService, at Mirai. Speech recognition, turn detection, the model and speech run together on our side, billed per minute.
The Realtime API speaks the OpenAI Realtime protocol. If your bot already uses
Pipecat's `OpenAIRealtimeLLMService`, or any other client for that protocol, you
change the API key and the URL and keep everything else:
```python theme={null}
llm = OpenAIRealtimeLLMService(
api_key=os.environ["MIRA_API_KEY"],
base_url="wss://prod.voice.miraiminds.co/v2/realtime",
settings=OpenAIRealtimeLLMService.Settings(session_properties=session),
)
pipeline = Pipeline([transport.input(), ctx.user(), llm, transport.output(), ctx.assistant()])
```
Each session runs Mirai's speech recognition, turn detection, language model and
speech synthesis on one voice worker, next to each other. Your process sends the
caller's audio up and plays the agent's audio back. Compared with calling our
speech-to-text, LLM and text-to-speech APIs one by one, a turn makes one round
trip to your server instead of three.
A session is a call. It has a call ID, it shows up in your call list, it sends
[webhooks](/v2/webhooks) and it is [billed per minute](#billing), like a
[browser call](/v2/web-calls).
## Connect
```text theme={null}
wss://prod.voice.miraiminds.co/v2/realtime?model=&agent_id=
```
Use the API address shown under **Developers** in the console: `prod.voice` once
your company has gone live, `sandbox.voice.miraiminds.co` with a sandbox key.
| Query parameter | Required | What it does |
| :- | :- | :- |
| `model` | no | Accepted and ignored. Every session runs Mirai's stack at your workspace's tier, and reports its model as `mira-realtime`. Pipecat adds this parameter itself. |
| `agent_id` | no | Start from one of your [agents](/v2/agents): its prompt, tools, voice and language. Without it, the session starts empty and your `session.update` supplies the instructions, tools and voice. Node-based agents are not supported. |
| `variables` | no | A JSON object, URL-encoded: the values for the agent's `{{placeholders}}`, as for a [call](/v2/calls#variables). Send them when your agent's prompt expects inputs. |
| `webhook_url` | no | Where this session's [webhooks](/v2/webhooks) go (`call.started`, `call.completed` and the rest), as for a [call](/v2/calls#create-a-call). URL-encode it. |
| `max_duration_secs` | no | Ends the session after this many seconds, from 30 to 1800. See [limits](#limits) for the default. |
| `metadata` | no | A JSON object, URL-encoded. It is echoed on the call and in every webhook, and never given to the model. |
| `mirai_events` | no | `1` or `true` turns on the [Mirai events](#mirai-events), `mirai.session.applied` and `mirai.turn.metrics`. Off by default. Do not set it with stock Pipecat. |
A malformed parameter fails the upgrade with `400 invalid_request`, and
`error.param` names the parameter.
Pipecat builds its URL as `base_url + "?model=…"`. A `base_url` that already
carries a query, such as `…/v2/realtime?agent_id=agt_…`, therefore reaches us as
`?agent_id=agt_…?model=…`. That is fine: we read your parameters correctly.
### Authentication
Send your workspace API key in the `Authorization` header, as every OpenAI
Realtime client does:
```text theme={null}
Authorization: Bearer sk_live_…
```
Clients that cannot set headers can send the key as a WebSocket subprotocol
instead, `openai-insecure-api-key.sk_live_…`, alongside `realtime`. We answer with
the `realtime` subprotocol.
The Realtime API is for servers. Never put your API key in a web page or a mobile
app: anyone who opens the page can read it. Short-lived browser credentials are
not available yet.
## With Pipecat
The easiest way is `MiraiRealtimeLLMService` from our
[Pipecat package](/v2/pipecat) (`uv add pipecat-mirai`). It takes `agent_id`,
`variables` and the other session parameters as arguments, sends the
[`mirai` block](#the-mirai-block), handles [Mirai events](#mirai-events) and
reports each turn's timing:
```python theme={null}
from pipecat_mirai import MiraiRealtimeLLMService
llm = MiraiRealtimeLLMService(voice="neha", language="hi")
```
Stock Pipecat 1.8.1 works unchanged too. Here is a complete microphone-and-speaker
agent. Save it as `pipecat_realtime.py`, put on headphones and run it with
[uv](https://docs.astral.sh/uv/), which installs Pipecat for you. The microphone
needs PortAudio (`brew install portaudio` on macOS).
```bash theme={null}
export MIRA_API_KEY=sk_live_…
uv run pipecat_realtime.py
```
```python theme={null}
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pipecat-ai[local,openai]==1.8.1",
# ]
# ///
"""Talk to a Mirai Realtime session from your microphone, with stock Pipecat.
This is Pipecat's own `OpenAIRealtimeLLMService`, unchanged. The only
Mirai-specific lines are the API key and `base_url`: speech recognition,
turn detection, the language model and speech synthesis all run on Mirai's
side, next to each other, and your process only moves audio.
export MIRA_API_KEY=sk_live_…
uv run pipecat_realtime.py
Optional:
MIRA_AGENT_ID start from one of your agents (prompt, tools, voice)
MIRA_VOICE a Mirai voice: neha (default), ashu, shruti, sameer
MIRA_LANGUAGE the caller's language, e.g. hi, en (default: auto)
MIRA_REALTIME_URL another endpoint (default: production); a sandbox key
needs wss://sandbox.voice.miraiminds.co/v2/realtime
Use headphones: the local audio transport has no echo cancellation, so on
speakers the agent hears itself and answers its own voice.
"""
import asyncio
import os
from pipecat.frames.frames import LLMRunFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
from pipecat.services.openai.realtime.events import (
AudioConfiguration,
AudioInput,
AudioOutput,
InputAudioTranscription,
SessionProperties,
TurnDetection,
)
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService
from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams
BASE_URL = os.getenv("MIRA_REALTIME_URL", "wss://prod.voice.miraiminds.co/v2/realtime")
AGENT_ID = os.getenv("MIRA_AGENT_ID", "")
if AGENT_ID:
# Pipecat appends "?model=…" to whatever base_url it is given. Mirai reads
# agent_id correctly either way and ignores the model name.
BASE_URL += f"?agent_id={AGENT_ID}"
INSTRUCTIONS = (
"You are Mira, a friendly voice assistant. Keep every answer to one or two "
"short sentences, because it will be spoken. No markdown, lists or emoji. "
"Reply in the language the caller uses."
)
# Pipecat's Realtime service sends and expects 16-bit PCM at 24 kHz, the
# protocol's default format.
SAMPLE_RATE = 24000
async def main():
transport = LocalAudioTransport(
LocalAudioTransportParams(audio_in_enabled=True, audio_out_enabled=True)
)
session = SessionProperties(
instructions=None if AGENT_ID else INSTRUCTIONS,
audio=AudioConfiguration(
input=AudioInput(
transcription=InputAudioTranscription(language=os.getenv("MIRA_LANGUAGE")),
# Mirai's own voice activity detection and turn model decide
# when you have finished speaking.
turn_detection=TurnDetection(),
),
output=AudioOutput(voice=os.getenv("MIRA_VOICE", "neha")),
),
)
llm = OpenAIRealtimeLLMService(
api_key=os.environ["MIRA_API_KEY"],
base_url=BASE_URL,
settings=OpenAIRealtimeLLMService.Settings(session_properties=session),
)
# The first context runs once the session is configured: the agent speaks
# first. Mirai never greets on its own in a Realtime session.
context = LLMContext([{"role": "user", "content": "Say hello in one short sentence."}])
aggregators = LLMContextAggregatorPair(context)
pipeline = Pipeline(
[
transport.input(),
aggregators.user(),
llm,
transport.output(),
aggregators.assistant(),
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
audio_in_sample_rate=SAMPLE_RATE,
audio_out_sample_rate=SAMPLE_RATE,
enable_metrics=True,
),
)
await task.queue_frames([LLMRunFrame()])
await PipelineRunner(handle_sigint=True).run(task)
if __name__ == "__main__":
asyncio.run(main())
```
Two things to set in your own pipeline:
* Send and play 16-bit PCM at 24 kHz. Set `audio_in_sample_rate=24000` and
`audio_out_sample_rate=24000` in `PipelineParams`.
* Remove your own STT, LLM and TTS services. The Realtime service replaces all
three, and your transport and context aggregators stay as they are.
The agent never greets on its own in a Realtime session. Queue an `LLMRunFrame`
(or send `response.create`) when you want it to speak first.
## Sessions
1. You open the socket. We check your key and balance and start a call on the
`realtime` channel.
2. We send `session.created`.
3. You send `session.update` with your settings. We apply them and answer
`session.updated`. We wait up to 1.5 seconds after `session.created` for this
first update. If it does not arrive in time, the session starts with the
agent's settings (or the defaults), and your update is applied when it comes,
apart from the language and audio formats.
4. You stream audio with `input_audio_buffer.append`. We detect when the caller
has finished, transcribe, run the model and stream the reply as audio. Audio
you send before the session is ready is kept, up to 2 seconds of it.
5. You close the socket, or the session reaches its
[maximum duration](#limits). The call ends and is billed.
You can send `session.update` again at any point. Most changes apply straight
away; [session settings](#session-settings) lists the exceptions.
Every event travels as a JSON text frame, audio included (base64). Binary
frames are ignored.
## Events
The events are the OpenAI Realtime GA events. These are the ones we handle.
**You send**
| Event | Notes |
| :- | :- |
| `session.update` | See [session settings](#session-settings). |
| `input_audio_buffer.append` | Caller audio, base64, in the input format. |
| `input_audio_buffer.commit` | Ends the caller's turn yourself. Only needed with `turn_detection: null`. |
| `input_audio_buffer.clear` | Drops audio not yet committed. |
| `conversation.item.create` | Add a `message` (role `user`, `assistant` or `system`, text only), a `function_call`, or a tool result (`function_call_output`). |
| `conversation.item.truncate` | Tell us how much of the agent's reply the caller heard before interrupting. See [interruptions](#interruptions). |
| `conversation.item.retrieve` | Read an item back. |
| `conversation.item.delete` | Remove an item from the conversation. |
| `response.create` | Ask the agent to respond. Settings inside `response` (such as per-response `instructions` or `tools`) are ignored; use `session.update`. |
| `response.cancel` | Stop the reply in progress. |
`output_audio_buffer.clear` and `transcription_session.update` are accepted and
ignored. Any other event type returns an `error` with code `invalid_event`.
**We send**
| Event | Notes |
| :- | :- |
| `session.created`, `session.updated` | On connect, and after each `session.update`. |
| `input_audio_buffer.speech_started`, `input_audio_buffer.speech_stopped` | Our turn detection heard the caller start or stop. Stop playback when speech starts. Not sent with `turn_detection: null`. |
| `input_audio_buffer.committed`, `input_audio_buffer.cleared` | The caller's turn was committed, or the buffer cleared. |
| `conversation.item.added`, `conversation.item.done` | An item joined the conversation, and its content is final. |
| `conversation.item.retrieved`, `conversation.item.truncated`, `conversation.item.deleted` | Answers to `retrieve`, `truncate` and `delete`. |
| `conversation.item.input_audio_transcription.delta` | What the caller said, a final segment at a time. |
| `conversation.item.input_audio_transcription.completed` | The caller's whole turn. |
| `response.created` | A reply has started. |
| `response.output_item.added`, `response.output_item.done` | An item of the reply (a message or a tool call) starts and ends. |
| `response.content_part.added`, `response.content_part.done` | The reply's audio part starts and ends. |
| `response.output_audio.delta` | The agent's audio, base64, in the output format. |
| `response.output_audio_transcript.delta` | What the agent is saying, in step with its audio. |
| `response.output_audio.done`, `response.output_audio_transcript.done` | The reply's audio and its transcript are complete. |
| `response.function_call_arguments.delta`, `response.function_call_arguments.done` | The model called one of your tools. See [tools](#tools). |
| `response.done` | The reply is finished, with its [turn metrics](#turn-metrics) in `response.metadata`. |
| `error` | See [errors](#errors). |
| `mirai.session.applied`, `mirai.turn.metrics` | Only with `mirai_events=1`. See [Mirai events](#mirai-events). |
**Not supported yet**
* `rate_limits.updated`.
* Interim transcripts. Transcription deltas carry final segments only, so a
client that joins the deltas never prints a word twice.
* Text-only replies. Every reply is spoken, so there is no
`response.output_text.delta`.
* Per-response settings in `response.create`.
* Image and audio content in `conversation.item.create`. Only text parts are
read.
## Session settings
| Field | Applied | Notes |
| :- | :- | :- |
| `instructions` | Yes, at any time | The system prompt. Replaces the agent's prompt when you passed `agent_id`. Used exactly as written: `{{placeholders}}` in it are not filled in. If neither the agent nor you supply one, the session uses a short default prompt. |
| `tools`, `tool_choice` | Yes, at any time | Function tools only. The Chat Completions shape (`{"type": "function", "function": {…}}`) is accepted too. `tool_choice` is `auto`, `none`, `required` or `{"type": "function", "name": "…"}`. |
| `audio.output.voice` | Yes, at any time | A Mirai voice: `neha`, `ashu`, `shruti` or `sameer` ([listen](/v2/voices)). An unknown voice keeps the agent's or workspace's voice. |
| `audio.input.turn_detection` | Yes, at any time | `server_vad` and `semantic_vad` both use Mirai's turn detection. `create_response: false` makes us wait for your `response.create` after each turn. `interrupt_response: false` stops the caller's speech from cutting the agent off. `null` means you decide when the caller has finished: send `input_audio_buffer.commit` and `response.create` yourself. |
| `max_output_tokens` | Yes, at any time | 1 to 4096, or `"inf"`. Caps the length of each reply. |
| `audio.input.transcription.language` | First update only | The caller's language, such as `hi` or `en`. Leave it out to detect it. |
| `audio.input.format`, `audio.output.format` | First update only | `audio/pcm` at 24 kHz (the default), or `audio/pcmu` / `audio/pcma` at 8 kHz for telephony audio, which saves you two resamples. Any other rate is an error. |
| `audio.output.speed` | No | Mirai voices speak at their tuned rate. |
| `threshold`, `prefix_padding_ms`, `silence_duration_ms`, `eagerness`, `idle_timeout_ms` (in `turn_detection`) | No | Mirai's turn detection decides when the caller has finished. |
| `model`, `audio.input.transcription.model`, `audio.input.transcription.prompt`, `audio.input.noise_reduction`, `tracing`, `prompt`, `include`, `truncation`, `reasoning` | No | Every session runs Mirai's stack, with our own noise suppression. |
| `output_modalities` without `"audio"` | No | Replies are always spoken. |
"First update only" means the `session.update` that arrives within 1.5 seconds
of `session.created`. A later change to the language or an audio format is
ignored, because both are fixed once the session has started.
Fields we do not know are ignored. Beta-shaped fields are accepted too:
`input_audio_format` and `output_audio_format` (`pcm16`, `g711_ulaw`,
`g711_alaw`), `input_audio_transcription`, `turn_detection`, `voice`, `speed`,
`modalities`, `max_response_output_tokens` and a top-level `temperature`.
An ignored field never causes an error. With [Mirai events](#mirai-events) on,
`mirai.session.applied` lists each ignored field and the reason. A value that is
wrong, such as a string where a number belongs or a number out of range, rejects
the whole update: you get an `error` event whose `param` names the field, and
nothing in that update is applied.
### The `mirai` block
`session.mirai` carries settings that the OpenAI protocol has no field for. We
validate it strictly: an unknown key returns an `error` with code
`unknown_parameter`, so a typo is never silently ignored.
```json theme={null}
{
"type": "session.update",
"session": {
"instructions": "You are Mira. Keep answers short.",
"mirai": { "temperature": 0.3, "language": "hi", "tool_timeout_ms": 15000 }
}
}
```
| Key | Type | Range | What it sets |
| :- | :- | :- | :- |
| `temperature` | number | 0 to 2 | Model sampling temperature. |
| `top_p` | number | 0 to 1 | Model nucleus sampling. |
| `max_tokens` | integer | 1 to 4096 | Reply length cap, like `max_output_tokens`. |
| `frequency_penalty`, `presence_penalty` | number | -2 to 2 | Model repetition penalties. |
| `seed` | integer | 32-bit | Model sampling seed. |
| `llm` | object | | Any of the six model keys above, nested: `{"llm": {"temperature": 0.3}}`. |
| `language` | string | | The caller's language and the agent's speaking language together. First update only. |
| `stt` | object | | `{"language": "…"}`: the caller's language only. First update only. |
| `tts` | object | | `language` (the agent's speaking language, first update only), `voice` (same as `audio.output.voice`) and `speed` (0.5 to 2, ignored like `audio.output.speed`). |
| `tool_timeout_ms` | integer | 1000 to 30000 | How long we wait for your [tool](#tools) results. Default 10000. |
| `events` | boolean | | `mirai.*` events on or off. Sending a `mirai` block turns them on unless you set `"events": false`. |
Pipecat 1.8.1's `SessionProperties` drops fields it does not know, so stock
Pipecat cannot send the `mirai` block. `MiraiRealtimeLLMService` from
[`pipecat-mirai`](/v2/pipecat) sends it: pass `language=` and `mirai={...}`.
## Tools
You can declare function tools in `session.update`. When the model calls one,
you receive `response.output_item.added` with a `function_call` item, then
`response.function_call_arguments.delta` and `.done` with the call ID, the tool
name and its arguments. Run the tool, then send:
1. `conversation.item.create` with an item of type `function_call_output`, the
same `call_id` and your result in `output`.
2. `response.create`, so the agent continues. The agent also continues on its
own once the result arrives, so a `response.create` sent right after the
result does not start a second reply.
Pipecat does both for functions registered with `llm.register_function`.
We wait 10 seconds for a result by default (`mirai.tool_timeout_ms` changes it,
from 1 to 30 seconds). After that, the model gets a `tool_timeout` result that
asks it to tell the caller the request could not be completed, so the caller is
never left in silence.
With `agent_id`, the agent's own [tools](/v2/tools) keep running on our side,
as on any call. A tool you declare with the same name as one of the agent's
replaces it for the session.
## Interruptions
When our turn detection hears the caller start speaking over the agent, you
receive `input_audio_buffer.speech_started`. Every reply still in progress ends
with `response.done` and status `cancelled` (`status_details.reason` is
`turn_detected`). Stop playback, then send `conversation.item.truncate` with
`audio_end_ms`, how much of the reply the caller actually heard. We cut the
agent's line in the conversation to those words, so the model does not believe
it said things the caller never heard. Pipecat does this for you.
`response.cancel` stops a reply the same way, with reason `client_cancelled`.
## Turn metrics
Every `response.done` carries the turn's numbers in `response.metadata`. The
values are strings, as the OpenAI protocol requires, so any Realtime client
accepts the event:
```json theme={null}
{
"type": "response.done",
"response": {
"id": "resp_…",
"status": "completed",
"metadata": {
"mirai_v2v_ms": "1200.5",
"mirai_stt_ms": "300.0",
"mirai_stt_provider": "sarvam_realtime",
"mirai_llm_provider": "mira",
"mirai_tts_provider": "sarvam",
"mirai_fallback": "true"
}
}
}
```
| Key | Meaning |
| :- | :- |
| `mirai_v2v_ms` | Voice to voice on our side: from the end of the caller's speech to the first audio of the reply. Add your own network time to get what the caller hears. |
| `mirai_stt_ms` | Time for speech recognition to return its first result for the turn. |
| `mirai_llm_ms` | Time for the model to produce its first output. |
| `mirai_tts_ms` | Time for speech synthesis to produce its first audio. |
| `mirai_stt_provider`, `mirai_llm_provider`, `mirai_tts_provider` | Which provider served each step: `mira` for our own, otherwise the [fallback](#fallback) that took over, such as `sarvam_realtime` or `sarvam`. |
| `mirai_fallback` | `"true"` when any step of the turn ran on a fallback provider. |
A time that was not measured for a turn is left out. In the example the model's
time is missing, and speech recognition and speech synthesis both ran on their
fallbacks.
Stock Pipecat 1.8.1 does not keep `response.metadata` when it parses
`response.done`. `MiraiRealtimeLLMService` from [`pipecat-mirai`](/v2/pipecat)
reads these numbers for you and passes them to its `on_turn_metrics` handler.
## Mirai events
Two events are ours rather than OpenAI's. We send them only when you ask, by
adding `mirai_events=1` to the session URL, or by sending a
[`mirai` block](#the-mirai-block) in `session.update`:
```text theme={null}
wss://prod.voice.miraiminds.co/v2/realtime?mirai_events=1
```
Do not set `mirai_events` with stock Pipecat. Pipecat 1.8.1's Realtime service
raises an error on any event type it does not know, and that stops it reading
the socket, so the session goes silent. `MiraiRealtimeLLMService` from
[`pipecat-mirai`](/v2/pipecat) handles them, and turns them on for you.
**`mirai.session.applied`** is sent when the session starts and after every
`session.update`. It shows what is actually running: the voice, the formats,
the transcription language, `turn_detection` (`"mirai"` or `"client"`),
`create_response`, `interrupt_response`, `max_output_tokens`, the model settings
in `llm`, `tool_timeout_ms`, your tools and the agent's (`server_tools`), the
providers serving each step, and the length of the prompt in
`instructions_chars`. Beside it, `changed` lists the settings that update
changed (`["*"]` when the session starts), and `ignored` lists each field we did
not apply, with the reason. Abridged:
```json theme={null}
{
"type": "mirai.session.applied",
"session": { "model": "mira-realtime", "voice": "neha", "turn_detection": "mirai" },
"changed": ["instructions", "voice"],
"ignored": [
{ "field": "session.audio.output.speed", "reason": "Mira TTS speaks at its tuned rate" }
]
}
```
**`mirai.turn.metrics`** is sent just before each `response.done`, with the same
numbers as its metadata as JSON numbers, plus the switches between providers
during the turn:
```json theme={null}
{
"type": "mirai.turn.metrics",
"response_id": "resp_…",
"item_id": "item_…",
"stt_ms": 210.0,
"llm_ms": 95.0,
"tts_ms": 160.0,
"v2v_ms": 742.6,
"providers": { "stt": "sarvam_realtime", "llm": "mira", "tts": "mira" },
"fallback": true,
"failovers": []
}
```
A time that was not measured is `null`. Each entry in `failovers` is
`{"kind", "from", "to", "reason"}`, for example
`{"kind": "tts", "from": "mira", "to": "sarvam", "reason": "no_audio"}`.
## Fallback
Every session runs Mirai's own speech recognition, model and speech synthesis.
If one of them is unavailable during a session, that step moves to a
third-party fallback and the conversation continues:
* Speech recognition and speech synthesis fall back to Sarvam.
* The language model falls back to third-party models.
You pay the same per-minute rate either way. The providers that served each
turn are in [`response.done`](#turn-metrics), so you can tell turns that used a
fallback apart and compare their latency.
## Billing
A session is billed per minute at your workspace's [tier](/general/tiers) rate,
exactly like a browser call:
* Billing starts when the session's audio goes live and stops when the socket
closes.
* Minutes are billed in 10-second blocks, rounded up, with a one-block minimum.
* There is no charge per token, per request or per tool call.
* A session is a call on the `realtime` channel: it appears in your call list
and is charged to your wallet like any other call.
A session needs enough balance to start, and ends if your balance runs out
while it is open.
## Errors
If a session cannot start, the WebSocket upgrade fails with an HTTP status and
an OpenAI-style error body:
```json title="429 Too Many Requests" theme={null}
{
"error": {
"type": "rate_limit_error",
"code": "concurrency_limit",
"message": "…",
"param": null
}
}
```
| Status | `code` | What to do |
| :- | :- | :- |
| `400` | `invalid_request` | A URL parameter is malformed or out of range, such as `max_duration_secs` outside 30 to 1800. The error names the parameter. |
| `401` | `missing_api_key`, `unauthorized` | Send a valid key. |
| `402` | `insufficient_balance` | Top up your wallet. |
| `403` | `forbidden`, `production_workspace_required`, `contract_unavailable` | The key is revoked, it is a sandbox key on the production address, or your plan does not cover the session. |
| `404` | `not_found` | The `agent_id` is not in your workspace. |
| `409` | `flow_agent_realtime_unsupported` | Node-based agents cannot run as Realtime sessions. Use a single-prompt agent, or leave out `agent_id`. |
| `429` | `concurrency_limit` | Your workspace already has as many calls and sessions open as it is allowed. Retry after `Retry-After` seconds (5). |
| `429` | `rate_limited` | Too many requests. Retry after `Retry-After` seconds. |
| `503` | `at_capacity`, `worker_unavailable`, `fleet_offline`, `upstream_unavailable` | No capacity right now. Retry after `Retry-After` seconds. |
| `504` | `start_timeout` | The session could not be set up in time. Try again. |
Clients that authenticate with the subprotocol cannot read an HTTP status, so
they get the upgrade, one `error` event with the same `code`, and a close code of
`4000` plus the status (for example `4429`).
During a session, errors arrive as `error` events:
| `code` | Meaning |
| :- | :- |
| `invalid_type`, `invalid_value`, `unknown_parameter` | A `session.update` (or another event) had a wrong value. `param` names the field. Nothing in that event was applied. |
| `invalid_event`, `invalid_json` | An event type we do not handle, or a frame that is not a JSON object. |
| `conversation_already_has_active_response` | `response.create` while a reply is still running. |
| `response_cancel_not_active` | `response.cancel` with no reply running. |
| `item_retrieve_invalid_item_id` | `conversation.item.retrieve` for an item we do not have. |
| `session_expired` | The session reached its maximum duration. The socket closes after it. |
| `session_interrupted` | The voice worker serving the session went away. The socket closes with `1011`. Start a new session. |
Stock Pipecat 1.8.1 treats every `error` event as fatal and stops reading the
socket, except `conversation_already_has_active_response`,
`response_cancel_not_active` and `item_retrieve_invalid_item_id`. Check the
values you send in `session.update` before you go live: a rejected update
ends a Pipecat session.
## Limits
* **Maximum duration.** A session without `agent_id` runs for up to 300 seconds.
With `agent_id`, the agent's own limit applies. Set `max_duration_secs` in the
URL to change it, from 30 to 1800 seconds. At the limit you get an `error`
with code `session_expired`, and the socket closes.
* **Concurrency.** Sessions count towards your workspace's concurrent calls
(see [limits](/v2/limits)). A session over the limit is refused with
`429 concurrency_limit` and `Retry-After: 5`. It does not queue.
* **Setup time.** If setting up a session takes longer than about 8 seconds,
for example because an agent's pre-call tools are slow, the upgrade fails with
`504 start_timeout`.
* **First update.** We wait 1.5 seconds after `session.created` for your first
`session.update`.
* **Message size.** A single WebSocket message can be up to 16 MB.
* **Idle connections.** We ping your client every 30 seconds. A client that stops
answering pings is disconnected, and the session ends.
* **Servers only.** Browser and mobile clients need short-lived credentials,
which are not available yet.
Use Mirai voices in a Pipecat pipeline that keeps its own STT and LLM.
Run one of your agents in a user's browser, without your own server in the audio path.
# Start building
Source: https://docs.miraiminds.co/v2/start-building
Go from a secret key to a running campaign in ten minutes, with every request ready to copy and paste.
This page is the partner on-ramp, and it takes ten minutes from start to
finish. You check your key, make one agent, ring one phone, run one campaign
and read the report. You can paste every request below as it is; the only
things to change are the key and the phone number.
**You need:** your `sk_live_` key, a phone number you are allowed to call, and
(for step 5) a public HTTPS URL.
```bash theme={null}
https://sandbox.voice.miraiminds.co
```
Your Mirai contact sends your key in the same message as this link. You can
also create one 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 only **once**. We store only its SHA-256, so we
cannot recover a lost key; you rotate it instead. Put it in your secret manager
now, before you go further.
You also get a webhook signing secret, `whsec_…`. It belongs to the workspace
rather than the key, so rotating a key does not change it and 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 pays for the calls below. We bill
**in 10-second blocks, rounded up, with a one-block minimum**, at your plan's
[tier](/general/tiers). The examples below ran on T3, which costs ₹3 a minute
or ₹0.50 a block.
Download both files:
| File | What it is |
| :- | :- |
| [mirai-voice-sandbox.postman\_collection.json](/files/mirai-voice-sandbox.postman_collection.json) | The collection, with every endpoint and worked example responses. |
| [sandbox.postman\_environment.json](/files/sandbox.postman_environment.json) | The environment, which holds the base URL, your key and your test number. |
In Postman, click **Import** and drop in both files. Select the
**Voice Infra — Sandbox** environment and fill in `api_key` (your `sk_live_…`)
and `test_phone` (an E.164 number **you own**). Then open **0-Quickstart** and
run it from top to bottom. Each step saves the id that the next one needs, so
you don't have to copy and paste anything.
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: its prompt, opening line,
voice, language and limits. You make it once and use it for thousands of calls.
```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": "ashu", "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": "ashu", "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**, which each
call fills from its `variables`. They are not shell or Postman variables, so
leave them exactly as they are.
`voice_id` is `ashu`, `neha`, `shruti` or `sameer`. The catalogue is the same
on every tier. 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",
"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 the call is **accepted but not yet connected**, so the phone has
not rung. Read the outcome when the call 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": "t5",
"cost_inr": 8.34
}
```
96 seconds rounds up to 10 blocks of 10 seconds, which costs ₹8.34 on T5. 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.
You can poll for the outcome, but subscribe to webhooks if you can. We `POST` a
signed JSON event at each lifecycle transition. **Verify the signature before
you trust the body.**
```
X-Mirai-Signature: t=1785057398,v1=5f3c…
```
`v1` is `HMAC-SHA256(whsec, "{t}.{raw_body}")`, hex-encoded. Sign the **raw**
bytes. If your framework parsed the JSON and serialized it again, the string is
different and the signature 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 are `call.started`, `call.completed`, `call.voicemail`, `call.failed`
and `call.aborted`, plus `campaign.started` / `paused` / `resumed` /
`completed` / `stopped`. Dedupe on `id`, because a retried delivery repeats it.
[Webhooks](/v2/webhooks) has the full reference and a test vector.
One call proves that the plumbing works. Campaigns are the main product: you
upload a list, set a window and a budget, and the platform dials the list,
handling pacing, retries and do-not-call suppression.
```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",
"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": []
}
```
The response is the campaign object itself, with the upload result as top-level
keys. Bad rows come back by index, and they do not fail the rest of the 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 the `10:00` to `19:00` window it
waits in `play` until the window opens. That is expected, and the campaign is
not stuck. [Campaigns](/v2/campaigns) has the full semantics.
```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
```
You have now run the whole loop. The rest of this page covers 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`,
since retries repeat it. Don't order your state machine by arrival order,
because a retried `call.started` can land after `call.completed`. Order by
`data.call.status` instead.
[Reference](/v2/webhooks#signature-verification).
Every "stop calling me", whether it comes on a call, over WhatsApp or by email,
should become a `POST /v2/dnc` in the same minute. The request is idempotent, so
you don't need a read-modify-write. We check suppression at dial time, so it
also takes effect on campaigns that are already running. [Reference](/v2/dnc).
`budget_paise` limits the cost of a mistake in the *list*, such as a duplicated
CSV or a column shifted by one. Code review cannot catch that kind of failure.
When the campaign hits the cap, it pauses with
`pause_reason: "budget_exhausted"`, keeps its place, and continues when you raise
the cap. Alert on `campaign.paused`.
Check `GET /v2/wallet` and alert at a threshold that covers a day of traffic.
A `402` in the middle of a campaign is an expensive way to learn that your
wallet is empty. An underfunded campaign still starts, dials what it can, and
then pauses itself on `insufficient_balance`.
Once your company is live, top up from the **Billing** page of the console.
| 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` |
The concurrency limit paces your calls: extra calls wait instead of failing.
Raising a ceiling is a provisioning change and needs no code change, so ask for
it before the campaign starts. See [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 and honour `Retry-After`. `message` says which ceiling you hit. |
| `duplicate_call` | `409` | This is not an error. The same `Idempotency-Key` is still in flight, and we place 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. |
| `contract_unavailable` | `403` | The call's `tier` is not your plan's, or the company has not finished going live. Leave `tier` out. |
| `campaigns_unavailable` | `503` | Campaigns are not enabled on your workspace. Ask us, because retrying will not clear it. |
Branch on status first, then on `error.code`. [Errors](/v2/errors) has the full
table and a retry wrapper in two languages.
The business placing the calls is responsible for the calling window, consent
records, national DND scrubbing and telling people they are speaking to an AI.
Campaigns enforce the window and the suppression list that you set, but they do
not make your calls compliant. 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).
The price ladder and what a minute costs. [Reference](/general/tiers).
If you are stuck on something this page does not cover, email
[help@miraiminds.co](mailto:help@miraiminds.co) with 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. The URL still works as an alias for /v1/audio/speech; the old mira_ keys are retired.
This page documents the **original** speech endpoint. Start new integrations at
the [TTS quickstart](/v2/tts-quickstart): 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.
## What `/tts/v1` does now
**The URL keeps working.** `POST /tts/v1/audio/speech` is an alias for
[`POST /v1/audio/speech`](/v2/tts-quickstart). It runs the same handler and
returns the same audio, so a caller that changes only its key needs no other
change:
* **Auth is your `sk_live_` API key**, the one you mint in the
[console](https://sandbox.voice.miraiminds.co) under **Developers**. The old
`mira_` speech keys are retired. If you still have one, get an `sk_live_` key
from the [quickstart](/v2/tts-quickstart#1-get-a-key).
* **`model` is `mira-tts`**, and `mira-tts-v51` is accepted as an alias, so an
existing body keeps working unchanged.
* **Everything else follows the current endpoint**: voices, the
[output formats and sample rates](/v2/tts-quickstart#output-formats),
`pcm` streaming, the 2,000-character ceiling, the response headers, the
[price](/v2/tts-quickstart#billing), the wallet it debits and the
[request log](/v2/tts-quickstart#your-request-log). The TTS quickstart
documents all of it.
* **The options below that were unique to this endpoint** (`speech_ready`,
`instructions`, `lexicon` and `numbers`) are no longer applied. The endpoint
ignores them, and sending them does not cause an error.
The rest of this page describes the original endpoint as it behaved, for
callers reading old code.
## The original endpoint
The speech endpoint is the voice our agents use, on its own. You send text and
get a WAV back. It takes the same request shape as the OpenAI audio API, so any
OpenAI SDK works once you swap `base_url` and the key.
The engine speaks Hindi, English and the Hinglish in between, plus Gujarati in
beta. Five more languages (Telugu, Tamil, Marathi, Kannada and Bengali) arrive
over the next two months. See the [language roadmap](/v2/tts-languages).
## Calling the alias
This request sends the old body with the new key:
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \
-H "Authorization: Bearer $MIRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mira-tts-v51",
"input": "नमस्ते, मैं आशु बोल रहा हूं। आपका ऑर्डर तैयार है।",
"voice": "ashu",
"response_format": "wav"
}' \
--output speech.wav
```
The response body is the audio itself, in the format you ask for. Send
`response_format` explicitly: `wav` gives you a file, and `pcm`, `ulaw_8000`
and the other raw formats stream, which is what you want in a live pipeline.
See [Output formats](/v2/tts-quickstart#output-formats).
## Request
Read this table as history. On the alias, `model`, `input`, `voice` and
`response_format` behave as the [TTS quickstart](/v2/tts-quickstart) documents:
`input` goes up to 2,000 characters and `pcm` streams. The four fields below
that are specific to Mirai are ignored.
| 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 | Which language digits are spoken in: `auto` (default), `english` or `native`. |
`model` is a stable public name rather than a build number. It always points at
the current production voice, which we upgrade over time, so a clip you
generate today may sound better than one from last month with no change to your
code.
## The rewrite pass
The rewrite pass is **not** part of the current endpoint. `speech_ready`,
`instructions` and `lexicon` are ignored and no `X-Mira-Speech-Ready` header is
returned. Send text you are happy to hear read aloud.
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, before your text reaches
the engine, a rewrite pass fixes these cases and keeps the meaning the same.
Every response carries a header that says what happened:
| `X-Mira-Speech-Ready` | Meaning |
| :- | :- |
| `rewritten` | The rewrite ran and its output was synthesised. |
| `bypassed` | You sent `"speech_ready": false`, so your text was synthesised 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 did not shape that clip.
```bash title="Skip the rewrite" theme={null}
curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \
-H "Authorization: Bearer $MIRA_API_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_API_KEY"
```
The list names one model, the current one:
```json theme={null}
{
"object": "list",
"data": [
{ "id": "mira-tts", "object": "model", "owned_by": "miraiminds" }
]
}
```
`mira-tts-v51` is still accepted in a request body; it is no longer listed.
## 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 that maps strings to strings. |
| 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"
}
}
```
The alias returns the current endpoint's codes instead:
`invalid_request`, `unauthorized`, `insufficient_balance`, `at_capacity`,
`rate_limited`, `model_not_found`, `upstream_error`. See
[When something fails](/v2/tts-quickstart#when-something-fails).
## Using an OpenAI SDK
The request shape matches, so you can 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_API_KEY, # sk_live_… — the mira_ speech keys are retired
)
client.audio.speech.create(
model="mira-tts-v51",
voice="neha",
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, so send those
fields with a plain HTTP client, or use `extra_body` where your SDK supports
it.
# Tools
Source: https://docs.miraiminds.co/v2/tools
Connect your APIs before, during, and after a call. Mix synchronous and background tools, use conditions and secrets, and inspect every result.
Use tools to look up customer information, check an order, book an appointment,
or send a call outcome to your CRM. You choose when each tool runs and whether
the voice agent waits for its result or keeps the conversation going.
Tools belong to an [agent](/v2/agents). Each call uses a snapshot of that
agent's configuration, so editing a tool affects new calls.
| Phase | When it runs | Use it for |
| - | - | - |
| `pre_call` | Before dialing or opening browser audio. | Fetch context, personalize the greeting, or skip a call. |
| `on_call` | When the voice agent chooses it during the conversation. | Lookups, bookings, and background requests. |
| `post_call` | After the call has settled. | Send outcomes to your CRM or schedule a follow-up. |
Pre-call and post-call tools run in the order you list them. On-call tools use
`description` and `when` to tell the voice agent when to invoke them.
[Node-based agents](/v2/node-agents) support all three tool phases. A start or
conversation node offers only the on-call tools named in its `data.tools`,
alongside its transitions. Define those tools on the agent first, or save the
definitions and graph together. Keep **End call** disabled or absent: a flow
ends at its end nodes. See the [Node.js editor cookbook](/cookbooks/node-flow-builder).
## Configure tools in Agent Studio
Open an agent in **Agent Studio**, then choose **Tools** in the agent navigation.
The main navigation becomes an icon rail while you work. Switch to **Prompt**
to edit the conversation without losing your tool changes.
The first tool opens automatically when you choose a phase. **Test agent** stays
visible while you edit. Type `@` in the prompt or a supported tool field to insert
available context. Variables and tool references are highlighted differently in
the prompt, and pre-call results are grouped under the tool that returned them.
1. Choose **Pre-call**, **On-call**, or **Post-call**, then click **HTTP request**.
2. Enter a unique tool name, an HTTP method, and your HTTPS endpoint. Expand
**Headers**, **Query parameters**, or **JSON body** as needed.
3. For on-call requests, open **Inputs** to add the values the
agent should collect. Each input has a name, type, description, and required
setting. Reference these values in your request with `{{ args.order_id }}`.
4. Open **Behavior** to describe when an on-call tool should run and set
conditions and failure handling. For an on-call
HTTP tool, choose **Wait for result** or **Run in background**. A background
result goes back to the agent when it is ready.
5. Click **Save changes** at the top of the agent workspace. Prompt and tool
edits save together as one agent revision.
6. Open the tool's **Test** tab, supply sample inputs, and click **Run test**.
Inspect the status, response time, returned fields, and full receipt.
**Run test** executes the saved tool against your real endpoint. It does not
place a phone call. Save your edits before testing a new version. If the agent
changed in another editor, reload it before running the test.
In **Response**, you can transform the result with jq. For a pre-call tool,
choose which returned fields become prompt variables. For example, selecting
`name` from `lookup_customer` makes `{{lookup_customer.name}}` available in the
prompt and `{{ vars.lookup_customer.name }}` available to later requests.
Use **Authentication & secrets** to store a credential or select a stored
Bearer token. You can reference any stored secret in a header with
`{{ secrets.crm_api_key }}`. Secret values are never returned to the editor.
Pre-call and post-call tools run independently of the conversation's LLM. Use
**Move up** and **Move down** to arrange their order. An on-call condition is
checked against call inputs and pre-call results before the conversation
starts. During the call, the tool's **When should the agent use it?**
instructions tell the agent when to invoke it.
Supported actions such as ending a call or scheduling a callback appear under
**Add preset** for the phases they support. You can still edit nested
conditions and more complex argument schemas in the advanced JSON fields.
## Default tools
New agents created in Agent Studio include two editable tools. Existing agents
keep their current configuration. To add either tool to an existing agent,
choose its phase and use **Add preset**. Duplicating an agent copies its saved
tools, including any tools you disabled or removed.
| Tool | Phase | What it does |
| - | - | - |
| Greeting & local time | Pre-call | Fetches a time-aware greeting and local date information. Defaults to `Asia/Kolkata`. |
| End call | On-call | Lets the agent finish a conversation, with confirmation enabled. |
### Greeting and local time
The greeting preset is an ordinary HTTP request you can inspect and customize.
It runs without an LLM. Open **Query parameters** to change `timezone` to an
IANA timezone such as `Europe/London` or `America/New_York`.
```bash theme={null}
curl --fail-with-body --get 'https://sandbox.voice.miraiminds.co/tools/greeting' \
--data-urlencode 'timezone=Asia/Kolkata'
```
This public clock endpoint requires no API key and receives no customer data.
`timezone` is optional and defaults to `Asia/Kolkata`. Invalid or empty timezone
names return `400` with `error.code: "invalid_timezone"`. Responses are not cached.
```json title="Example response" theme={null}
{
"greeting": "Good afternoon",
"outro_greeting": "Have a good day",
"date": "2026-09-23",
"time": "14:30:00",
"datetime": "2026-09-23T14:30:00+05:30",
"epoch": 1790154000,
"timezone": "Asia/Kolkata",
"day_of_week": "Wednesday",
"is_weekend": false,
"period": "afternoon"
}
```
Greetings are English: **Good morning** from 05:00, **Good afternoon** from
12:00, **Good evening** from 17:00, and **Hello** from 22:00 until 05:00, all
in the selected timezone. `epoch` is Unix time in seconds; `datetime` includes
the local UTC offset. Daylight-saving changes follow the selected timezone.
The preset selects greeting, farewell, date, time, weekday, weekend, timezone,
and period fields for your prompt. Type `@` in **First message**, choose
**greeting**, then **greeting** to insert `{{ greeting.greeting }}`. For example:
```text theme={null}
{{ greeting.greeting }}! How can I help you today?
```
Later HTTP tools can use `{{ vars.greeting.date }}` and the other selected
fields. The preset has a two-second timeout and continues the call if the
request fails. You can change these settings in **Behavior**. Current-time
values describe when the pre-call request ran; they do not update during the call.
### End call
End call runs directly on the voice worker during a conversation. It does not
make an HTTP request. The default requires confirmation before hanging up and
keeps the agent's **Closing message**. To configure it, choose **On-call**, then
**End call**. Testing the tool by itself does not place or end a phone call.
## Connect your first tool
Use your workspace API key and an existing agent ID. Create an agent with the
[quickstart](/v2/quickstart) if you do not have one yet.
```bash theme={null}
export MIRAI_API_BASE="https://sandbox.voice.miraiminds.co"
export MIRAI_API_KEY="sk_live_YOUR_API_KEY"
export MIRAI_AGENT_ID="agt_YOUR_AGENT_ID"
```
Keep your API key on your server. The agent and tool management routes below use
`Authorization: Bearer YOUR_API_KEY`.
### 1. Define the request
Save this as `order-status.json`. Replace `https://api.example.com/orders/status`
with your public HTTPS endpoint. This example sends `{"order_id":"ORD-123"}`
and expects a JSON response such as `{"status":"shipped","eta":"Friday"}`.
```json title="order-status.json" theme={null}
{
"name": "order_status",
"phase": "on_call",
"execution": "sync",
"description": "Look up the delivery status of an order.",
"when": "Use this when the customer asks where their order is. Ask for the order ID if needed.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The customer's order ID, such as ORD-123."
}
},
"required": ["order_id"]
},
"request": {
"method": "POST",
"url": "https://api.example.com/orders/status",
"body": { "order_id": "{{ args.order_id }}" }
},
"response": { "transform": "{status, eta}" },
"timeout_ms": 8000
}
```
### 2. Save it on your agent
This creates or replaces only `order_status` and leaves the agent's other tools
as they are. The response is `200 OK` with the updated agent, including its
`tools` and `revision`.
```bash cURL theme={null}
curl --fail-with-body -X PUT \
"$MIRAI_API_BASE/v2/agents/$MIRAI_AGENT_ID/tools/order_status" \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @order-status.json
```
```python Python theme={null}
import json
import os
import urllib.request
base = os.environ["MIRAI_API_BASE"]
agent_id = os.environ["MIRAI_AGENT_ID"]
with open("order-status.json", "rb") as file:
definition = file.read()
request = urllib.request.Request(
f"{base}/v2/agents/{agent_id}/tools/order_status",
data=definition,
method="PUT",
headers={
"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
agent = json.load(response)
print(agent["id"], agent["revision"])
```
```javascript Node.js theme={null}
import { readFile } from "node:fs/promises";
const { MIRAI_API_BASE, MIRAI_API_KEY, MIRAI_AGENT_ID } = process.env;
const response = await fetch(
`${MIRAI_API_BASE}/v2/agents/${MIRAI_AGENT_ID}/tools/order_status`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${MIRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: await readFile("order-status.json", "utf8"),
},
);
const agent = await response.json();
if (!response.ok) throw new Error(JSON.stringify(agent));
console.log(agent.id, agent.revision);
```
You can also supply `tools: [ ... ]` when creating or updating an agent.
`PATCH /v2/agents/{id}` with `tools` replaces the **entire list**; use the
single-tool route above to change one entry. Set `tools: []` to remove all tools.
### 3. Test without placing a call
```bash theme={null}
curl --fail-with-body -X POST \
"$MIRAI_API_BASE/v2/agents/$MIRAI_AGENT_ID/tools/order_status/test" \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"args":{"order_id":"ORD-123"}}'
```
A successful response looks like this:
```json theme={null}
{
"tool": "order_status",
"phase": "on_call",
"kind": "http",
"tool_run_id": "trun_EXAMPLE",
"condition": { "result": true, "failed_leaf": "" },
"request": {
"method": "POST",
"url": "https://api.example.com/orders/status",
"headers": { "Content-Type": "application/json" },
"body": { "order_id": "ORD-123" }
},
"response": {
"status": 200,
"ms": 240,
"body_preview": "{\"status\":\"shipped\",\"eta\":\"Friday\"}"
},
"transform": { "output": { "status": "shipped", "eta": "Friday" } },
"promoted": {},
"missing": [],
"would": "run",
"status": "ok"
}
```
The test endpoint makes a **real HTTP request** to your endpoint. Use test data
for tools that book, charge, send messages, or update records. The
`schedule_callback` builtin is an exception: testing it validates the request
and returns `would_schedule` without scheduling a call.
The optional test input fields are `vars`, `args`, `call`, and `analysis`.
The response shows the condition verdict, masked request, response, transformed
result, promoted values, and missing template paths. Check `status` as well as
HTTP status: the test route can return `200` with a failed tool receipt.
Place a new call with this agent to try the conversation. Ask about an order;
the voice agent collects the ID, invokes the tool, and uses its result.
## Synchronous and background tools
Set `execution` separately on each **on-call HTTP tool**. You can mix both modes
on one agent.
| Mode | Conversation behavior | Maximum timeout |
| - | - | - |
| `"sync"` — default | The voice agent waits for the tool result before continuing its response. | 30 seconds |
| `"async"` | The tool runs in the background. The voice agent can keep talking and receives the result when ready. | 300 seconds |
For example, collect another detail while a slow delivery lookup runs:
```json theme={null}
{
"name": "delivery_lookup",
"phase": "on_call",
"execution": "async",
"description": "Check delivery availability for this customer.",
"when": "Start once when the customer asks about delivery. While the lookup runs, ask for their preferred delivery day. Use the returned availability when it arrives.",
"request": {
"url": "https://api.example.com/delivery",
"query": { "customer_id": "{{ vars.customer_id }}" }
},
"timeout_ms": 60000
}
```
Pass `customer_id` in the call's `variables`. The voice agent should acknowledge
that work is pending, keep asking useful questions, and confirm the outcome
only after receiving the final result. Do not ask it to repeat the same request
while the first one is running.
* A user interruption does not cancel an accepted background request.
* If the result arrives during the call, the voice agent can use it when ready.
* If the call ends first, the request continues and its result is available in
[tool receipts](#inspect-tool-results). It cannot be spoken to a caller who has left.
* Late results do not rewrite a `call.completed` event already delivered. Read
receipts for the final result; `call.processed` is not a completion signal for
background on-call tools.
* `execution: "async"` is rejected for builtins and other phases. Pre-call work
must finish before dialing; post-call tools already run after the conversation.
`speak_while` is an optional short line spoken as a tool starts, such as
`"Let me check that for you."`. If you leave it empty, the agent speaks no
canned filler. An async tool still lets the voice agent continue the
conversation.
## Before a call: fetch context or skip dialing
### Personalize the greeting
Add this tool to your agent. Suppose the endpoint returns
`{"name":"Asha","plan":"Premium"}`:
```json theme={null}
{
"name": "customer_lookup",
"phase": "pre_call",
"request": {
"url": "https://api.example.com/customers/{{ vars.customer_id }}"
},
"response": { "select": ["name", "plan"] },
"on_failure": "abort"
}
```
Pass `{"customer_id":"cus_42"}` in the call's `variables`. The selected fields
become `customer_lookup.name` and `customer_lookup.plan`.
Use `{{customer_lookup.name}}` in the agent's `first_message` or `system_prompt`.
Inside another tool's template, use `{{ vars.customer_lookup.name }}` instead.
The pre-call request runs before dialing or opening browser audio.
`on_failure: "abort"` prevents the call if this lookup fails. The default,
`"continue"`, lets the call go ahead, so if you keep it, write the greeting to
handle missing values.
### Skip when a condition holds
```json theme={null}
{
"name": "skip_call",
"phase": "pre_call",
"condition": { "field": "vars.do_not_call", "op": "is_true" },
"builtin": { "reason": "Customer requested no further calls" }
}
```
When the condition matches, nothing is dialed and the call is not charged.
The call ends as `aborted` with a skip reason. A campaign contact becomes
`skipped` and is not retried as a failed attempt. Use it alongside your
[workspace do-not-call list](/v2/dnc).
## Conditions and templates
`condition` decides whether a tool is available or runs. `when` is a
natural-language instruction that tells the voice agent when to choose an
on-call tool.
```json theme={null}
{
"all": [
{ "field": "vars.customer_id", "op": "exists" },
{ "field": "vars.do_not_call", "op": "is_false" }
]
}
```
Combine conditions with `all`, `any`, and `not`. Supported operators are:
| Operators | Meaning |
| - | - |
| `eq`, `neq` | Equality or inequality. |
| `contains`, `not_contains`, `starts_with`, `ends_with` | Case-insensitive text comparisons. |
| `gt`, `gte`, `lt`, `lte` | Numeric comparisons, or lexical comparisons for nonnumeric values. |
| `in`, `not_in` | Membership in the supplied `value` array. |
| `exists`, `not_exists` | Present and nonempty, or absent/empty. |
| `is_true`, `is_false` | Recognizes `true/1/yes/on` and `false/0/no/off`. |
| `matches` | RE2 regular expression, at most 200 characters. |
Conditions support up to eight nested levels and 64 comparisons. Use `exists`
or `not_exists` when checking for a missing value.
Pre-call and post-call conditions are evaluated before each tool runs. The set
of available on-call tools is chosen when the call is prepared, after pre-call
outputs exist, so base an on-call condition on call inputs and pre-call
results. Finishing one on-call tool does not make new tools available, and
model arguments are not available when the initial tool list is chosen.
### Values you can reference
| Namespace | Available values |
| - | - |
| `vars.*` | Call input values and promoted pre-call outputs. |
| `call.*` | Call identifiers, destination, tier, and status; final duration and outcome after the call. |
| `now.*` | Date, time, weekday, greeting, and timezone. Uses the call's timezone, defaulting to `Asia/Kolkata`. |
| `tools..*` | Results from earlier tools in the same pre-call or post-call phase. |
| `args.*` | Model-supplied arguments for an on-call request. |
| `analysis.*` | Post-call `status`, `summary`, `success`, and `data.*` when analysis is enabled. |
| `secrets.*` | Stored workspace secrets, in request templates only. Never in conditions. |
Write `{{ vars.customer_id }}` to insert text. Inside `request.body`, an entire
value such as `"{{= args.quantity }}"` preserves its JSON type instead of
converting it to a string. A missing text value renders empty and is listed
in the receipt; a missing typed value becomes `null`.
Filters are `default:"value"`, `json`, and `urlencode`, for example
`{{ vars.customer_name | default:"there" }}`. Use `request.query` for query
parameters; values are URL-encoded automatically.
Test a condition without calling an external endpoint:
```bash theme={null}
curl --fail-with-body -X POST \
"$MIRAI_API_BASE/v2/agents/$MIRAI_AGENT_ID/conditions/test" \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"condition":{"field":"vars.do_not_call","op":"is_true"},
"scope":{"vars":{"do_not_call":"true"}}
}'
```
## Store credentials as workspace secrets
Store a secret before saving a tool that references it:
```bash theme={null}
curl --fail-with-body -X PUT "$MIRAI_API_BASE/v2/secrets/crm_key" \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"YOUR_CRM_API_KEY"}'
```
Reference its name in your tool's request headers:
```json theme={null}
{
"Authorization": "Bearer {{ secrets.crm_key }}"
}
```
Secrets are scoped to your workspace, encrypted at rest, and write-only.
`GET /v2/secrets` returns names and timestamps, never values. Requests and
results shown in receipts mask resolved secret values. Keep credentials out of
prompts, call variables, and literal tool definitions.
To rotate a credential, `PUT` the new value to the same secret name. To delete
it, remove references from your agents first, then call
`DELETE /v2/secrets/crm_key`; a referenced secret returns `409`.
## After a call: send the outcome to your CRM
```json theme={null}
{
"name": "crm_update",
"phase": "post_call",
"request": {
"method": "POST",
"url": "https://api.example.com/call-outcomes",
"headers": { "Authorization": "Bearer {{ secrets.crm_key }}" },
"body": {
"call_id": "{{ call.id }}",
"status": "{{ call.status }}",
"duration_seconds": "{{= call.duration_secs }}"
}
},
"retry": { "attempts": 2, "backoff_ms": 500 }
}
```
Post-call tools run after settlement. Their failure does not change call status
or billing. If your agent has analysis enabled, a post-call tool can also read
`analysis.summary`, `analysis.success`, and `analysis.data.*`. Use a condition on
`analysis.status` or `analysis.success` when the request requires a successful
analysis result.
### Receive `call.processed`
The terminal event, such as `call.completed`, describes the finished call.
`call.processed` reports the subsequent analysis and post-call tool results. It
is emitted when the agent has post-call tools or analysis enabled, unless you
set `emit_processed_webhook: false` on the agent. That setting turns off only
the event. Analysis and post-call tools still run.
The event uses your call's `webhook_url` and the normal
[webhook signature verification](/v2/webhooks#signature-verification).
Alongside `data.call`, it includes:
```json title="Processing portion of a call.processed event" theme={null}
{
"processing": {
"analysis": "not_enabled",
"post_call_tools": "done",
"version": 1
}
}
```
This object is at `data.processing`. `post_call_tools` is `done`, `partial`,
`failed`, or `none`; `analysis` is `done`, `failed`, `skipped`, or `not_enabled`.
A tool skipped by its condition is not a processing failure. Webhook retries
can change delivery order, so handle events by their type and processing version.
### Rerun post-call tools
```bash theme={null}
curl --fail-with-body -X POST \
"$MIRAI_API_BASE/v2/calls/call_YOUR_CALL_ID/post_call/rerun" \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
```json theme={null}
{ "call_id": "call_YOUR_CALL_ID", "status": "processing", "version": 2 }
```
A rerun uses the call's saved agent configuration, creates new tool-run IDs,
and emits a new `call.processed` version. It does not rewrite the earlier event.
It returns `409` if the call has not finished or processing is already running.
An explicit rerun repeats external actions. For requests that update records or
create bookings, deduplicate retries using the `X-Mirai-Tool-Run-Id` header.
The same logical retry keeps this ID; an explicit rerun gets a new one. Network
failures can leave an external action's outcome uncertain, so do not assume
exactly-once delivery.
## Schedule a callback
Add this minimal builtin to let the voice agent arrange a later call:
```json theme={null}
{ "name": "schedule_callback", "phase": "on_call" }
```
The tool automatically exposes a required `at` argument and optional `note` to
the model. `at` should be a future RFC3339 timestamp with a UTC offset, such as
`2026-10-01T15:00:00+05:30`. Ask the customer to clarify an ambiguous time.
If you supply a custom `description` or `parameters`, we keep them.
The default window is at least 30 minutes and at most 14 days ahead. Change it
with `builtin.min_delay_minutes` and `builtin.max_days`. For a post-call
callback, configure `builtin.at` and optionally `builtin.note` as templates.
The new call targets the same person at the same tier and uses ordinary call
admission and billing. It carries `parent_call_id` and a `callback_note` input.
A scheduled callback can fail if the agent is no longer available or the
workspace cannot place the call at that time.
The tool result includes a `callback_id`. Cancel before dialing:
```bash theme={null}
curl --fail-with-body -X DELETE \
"$MIRAI_API_BASE/v2/callbacks/cbk_YOUR_CALLBACK_ID" \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
Cancellation succeeds only while the callback is pending or scheduled. Once
it has been claimed for dialing, or was already canceled, the route returns
`404`. Schedule callbacks only with the customer's agreement and observe your
[calling-window and consent requirements](/v2/limits#calling-window).
## Inspect tool results
```bash theme={null}
curl --fail-with-body \
"$MIRAI_API_BASE/v2/calls/call_YOUR_CALL_ID/tool_runs" \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
The `data` array contains receipts with the tool name, phase, status,
`tool_run_id`, start/end times, duration in milliseconds, HTTP status, transformed
result, and any error. Receipts can also include the condition verdict, masked
request, missing paths, and warnings. Read these when a tool was skipped,
timed out, returned an unexpected result, or finished after hangup.
Use `GET /v2/tools/catalog` to discover supported tools, available phases,
execution modes, limits, and whether your configured model accepts structured
tool arguments. Builtins currently include:
| Name | Phase | Purpose |
| - | - | - |
| `skip_call` | `pre_call` | Prevent dialing when a condition holds. |
| `end_call` | `on_call` | End the conversation. |
| `schedule_callback` | `on_call`, `post_call` | Arrange a later call. |
| `webhook` | `post_call` | Send the finished call and analysis to an additional HTTPS URL supplied in `builtin.url`. |
An explicit `end_call` tool can use `builtin.enabled`, `builtin.message`, and
`builtin.confirm` to override the agent's [ending settings](/v2/agents#ending-a-call).
Its tool-level `enabled` and `condition` also control whether it is available.
`transfer_call` and `send_dtmf` are listed as unavailable. Saving an unavailable
builtin returns `tool_unavailable`.
## Tool definition reference
| Field | Required | Meaning |
| - | - | - |
| `name` | Yes | Unique per agent; 2–40 characters matching `[a-z][a-z0-9_]{1,39}`. |
| `phase` | Yes | `pre_call`, `on_call`, or `post_call`. |
| `kind` | No | `http` or `builtin`; inferred from the definition. |
| `enabled` | No | Defaults to `true`. Set `false` to keep a tool configured but inactive. |
| `condition` | No | Condition object; omitted or `null` means no condition. |
| `description` | No | What an on-call tool does; up to 500 characters. |
| `when` | No | When the voice agent should invoke an on-call tool; up to 1,000 characters. |
| `parameters` | No | JSON Schema for model arguments; on-call only. Requires a model with native tool calling. |
| `execution` | No | `sync` by default; `async` is available for on-call HTTP tools. |
| `speak_while` | No | Optional on-call filler line, up to 300 characters. Empty by default. |
| `request` | For HTTP | Required `url`; optional `method` (default `GET`), `headers`, `query`, and `body`. |
| `response` | No | Optional jq `transform`, `select` keys to promote, and `strict` (default `false`). |
| `timeout_ms` | No | Per-attempt timeout; at least 100 ms. See phase limits below. |
| `retry` | No | `attempts` and optional `backoff_ms` (default 500; maximum 10,000). |
| `on_failure` | No | `continue` by default. `abort` is accepted only for pre-call tools. |
| `builtin` | Depends on builtin | Builtin configuration, such as a skip reason or callback window. |
Requests support `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. GET and DELETE
requests cannot carry a body. If a POST, PUT, or PATCH tool omits its body,
the model's arguments become the body. Do not set transport-owned headers
`Host`, `Content-Length`, `Transfer-Encoding`, or `Connection`.
Responses with non-2xx status fail. JSON is parsed automatically; non-JSON
becomes `{"text":"..."}` unless `response.strict` is `true`. A jq transform
selects the result returned to the voice agent. `response.select` promotes
fields for use by later lifecycle steps; it does not replace `transform` for
narrowing the result the voice agent receives.
### Limits and retries
| Limit | Pre-call | On-call sync | On-call async | Post-call |
| - | - | - | - | - |
| Default timeout | 5 s | 10 s | 10 s | 15 s |
| Maximum timeout | 15 s | 30 s | 300 s | 60 s |
| Default retries | 0 | 0 | 0 | 2 |
| Maximum retries | 2 | 0 | 0 | 5 |
| Maximum tools | 10 | 20 total across both on-call modes | Shared with sync | 10 |
You can define up to 40 tools per agent. On-call execution is limited to
40 runs per call and four concurrent requests per call. The pre-call phase has
a 30-second total budget; the post-call tool phase has a five-minute budget,
separate from any wait for analysis.
* Endpoints must use public HTTPS on port 443. Private-network destinations and
redirects are refused.
* Request and response bodies are limited to 256 KB; URLs to 2,048 characters;
headers and query entries to 20 each.
* jq transforms have a 100 ms budget and 64 KB output limit. Up to 16 selected
fields can be promoted, each with a maximum value length of 512 characters.
* Retries apply to timeouts, connection failures, and server errors. Client
errors, blocked destinations, and transform failures are not retried.
* Tool executions currently have no separate charge. Calls and callbacks use
the normal [call pricing](/general/tiers); your API provider may charge separately.
### Validation and runtime errors
Invalid tool definitions return `400` with a list of field errors. This shape
differs from the API's usual error envelope:
```json theme={null}
{
"error": "invalid_tools",
"details": [
{
"path": "tools[0].execution",
"message": "async is available for on_call HTTP tools; lifecycle gates and builtins run synchronously"
}
]
}
```
`error` can also be `tool_unavailable` or `tool_style_unsupported`. Fix the
listed fields before retrying. On a node-based agent, invalid node tool
references or an enabled `end_call` tool use the same shape with
`error: "invalid_flow"`; see
[node-based agent errors](/v2/node-agents#errors). Ordinary authentication, lookup, and conflict
errors keep the [standard error shape](/v2/errors).
At runtime, inspect the receipt for errors such as `timeout`, `http_429`,
`http_500`, `egress_denied`, `redirect_refused`, `transform_failed`,
`response_too_large`, `rate_limited`, or `budget_exhausted`. An on-call tool
failure is returned to the voice agent so it can explain the problem and
continue the conversation.
## API routes
| Method and path | Action |
| - | - |
| `GET /v2/tools/catalog` | Discover capabilities and limits. |
| `POST /v2/agents` | Create an agent with a `tools` list. |
| `PATCH /v2/agents/{id}` | Replace the agent's `tools` list. |
| `PUT /v2/agents/{id}/tools/{name}` | Create or replace one tool. Optional `if_revision` in the body prevents overwriting a newer revision. |
| `DELETE /v2/agents/{id}/tools/{name}` | Remove one tool. Optional `?if_revision=N` checks the revision. Returns the updated agent. |
| `POST /v2/agents/{id}/tools/{name}/test` | Test one tool with supplied input values. |
| `POST /v2/agents/{id}/conditions/test` | Test a condition without an external request. |
| `GET /v2/calls/{id}/tool_runs` | Read a call's tool receipts. |
| `GET /v2/secrets` | List secret names and timestamps. |
| `PUT /v2/secrets/{name}` | Store or rotate a secret using `{"value":"..."}`. |
| `DELETE /v2/secrets/{name}` | Delete an unreferenced secret. |
| `POST /v2/calls/{id}/post_call/rerun` | Repeat post-call processing as a new version. |
| `DELETE /v2/callbacks/{id}` | Cancel a callback before dialing. |
# Transcribe recorded calls
Source: https://docs.miraiminds.co/v2/transcribe-recorded-calls
Turn a recorded call into a transcript with speakers and timestamps, in the console or through the API, at ₹18 per hour of audio.
Mira Transcribe turns a recording into text: what was said, who said it and
when. You send a file or a link and get a job ID straight away. A draft is
readable within seconds, and the finished transcript replaces it when it is
ready. A two-minute call usually takes about 30 seconds.
Base URL `https://sandbox.voice.miraiminds.co`. API requests use
`Authorization: Bearer sk_live_…`, the same workspace key as the rest of the
[v2 API](/v2/overview).
## When to use it
| You have | Use |
| :- | :- |
| A recording of a call that has already happened: your team's calls, calls from another dialler, a recorded interview | Offline transcription, on this page |
| A call one of your Mirai voice agents made | That call's own transcript: [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript). You don't need to send its recording here. |
| Live audio that you need as text while people are still talking | Streaming recognition. It runs inside every voice agent; as a standalone API it is in private preview, so ask us for access. |
Turning example calls into a node-based agent has its own transcription step,
priced separately. See
[Draft a flow from call recordings](/v2/node-agents#draft-a-flow-from-call-recordings).
## How it works
Speaker separation, which is on unless you send `diarize: false`, listens to
the voices first to work out who spoke when. It also finds the parts of the
recording that aren't speech: ringing, ring-back and dial tones, a caller tune
before the call is picked up, or hold music. Those parts are silenced before
anything is transcribed, so they don't come back as stray words or as an extra
speaker. Recorded announcements, such as an IVR menu, are speech, so they are
still transcribed.
Several speech recognition passes then listen to the recording. A language
model compares them and writes one transcript, using any context and vocabulary
you sent to settle names and terms.
While the language model works, the job carries a draft: the strongest single
pass, with timestamps and no speakers. In the draft, English is often
written out in Devanagari. The finished transcript writes Hindi in Devanagari
and English in Latin script, the way the speakers switched between them.
Common English loanwords can stay in Devanagari. Nothing is translated.
```text theme={null}
Draft यस टेल मी आई वॉन्टेड टू आस्क अबाउट द बी बी ए कॉर्स फ़ीस
Finished yes tell me I wanted to ask about the BBA course fees
```
A job moves through `queued`, `decoding` and `merging` to `completed` or
`failed`. The [reference](/v2/transcriptions#statuses) describes each status.
## Try it in the console
Sign in to the [console](https://sandbox.voice.miraiminds.co), open
**Speech to text** and choose **Offline**.
Choose one recording or several: WAV, MP3 or M4A, up to 45 MB each. Add a
line of context and any names or terms to spell right, then click
**Transcribe**.
Each recording gets a row with its length, a clock and its stage. Open it as
soon as the draft appears. The finished transcript takes its place in the
player on its own.
The player shows the recording as a timeline with a lane for each speaker. The
transcript scrolls with the audio, and clicking a line plays from there. You
can rename, recolour and merge speakers, move a line to another speaker, fix
words, and split or join lines, with undo. Exports are plain text, SRT and
WebVTT subtitles, and JSON. Edits are kept in your browser, which also keeps a
copy of your last few uploads for up to a week so you can play them back.
**Logs** lists every recording your workspace sent in the last 30 days, from
the console or the API, with what each one cost. **API** has the requests on
this page ready to copy, and can create a key for you. Jobs from the console
are charged to the workspace wallet in the same way as API jobs.
## Use the API
Send the recording as a multipart upload:
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/transcriptions \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Idempotency-Key: call-2026-10-02-0412" \
-F file=@call.wav \
-F context="A call to a university admissions desk about a course and its fees" \
-F vocabulary="Mira University, BBA, Aadhaar"
```
The answer is `202 Accepted` with the job, whose `status` is `queued`. Read it
every few seconds until `status` is `completed` or `failed`:
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/transcriptions/trn_01M3XRSFXW77RHEQ85979AJXAF \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
A recording over 45 MB goes by link instead: send JSON with an `https` `url`
(up to 200 MB), such as a pre-signed link to your storage. The
[Transcriptions reference](/v2/transcriptions) has every field, response and
error, with Python and Node.js. To build a review screen on top of this, follow
the [transcript player cookbook](/cookbooks/transcript-player).
## Speakers and timestamps
A finished job has one segment for each turn:
```json theme={null}
{
"speaker": "Customer",
"speaker_id": "spk1",
"start": 7.21,
"end": 11.32,
"text": "yes tell me I wanted to ask about the BBA course fees"
}
```
`start` and `end` are seconds from the start of the recording.
`speaker_id` is the person: `spk0`, `spk1` and so on. On a phone call, each
person gets one ID. When the roles are known, the agent is `spk0` and the
customer `spk1`, and any other voice comes after them. Group lines by
`speaker_id`.
`speaker` is that person's role: `Agent`, `Customer` or `Other`. Every segment
with the same `speaker_id` has the same role. The role is worked out from what
each person says and can be wrong, so show it as a first label that someone can
change. It is empty when the role could not be told. `diarization.speakers`
lists each speaker once, with its role and how many seconds it talked.
A third voice, such as a second person in the room or a recorded announcement,
keeps its own ID, and its role is usually `Other`. On phone recordings, that
applies to a voice that talks for about ten seconds or more in all. A shorter
extra voice is merged into the main speaker it sounds closest to.
`diarization.non_speech` lists the parts that were left out because they
weren't speech, each as a `start` and `end` in seconds. It is absent when
nothing was left out, or when you sent `diarize: false`.
Send `words: true` for the time of every word. Word timings are added when they
line up with the audio, which today means Hindi recordings; Gujarati and
English recordings come back without them. `diarization.words.applied` tells
you whether they were added. They need speaker separation, which is on unless
you send `diarize: false`.
## Languages
Mira Transcribe is built for phone calls in Hindi and Hinglish. `language`
defaults to `auto`, which detects the main language of the call. You can set it
to `hi`, `gu`, `en`, `mr`, `pa`, `bn` or `ur` when you know the main language
and detection gets it wrong. Speech in another language inside the call is kept
as it was spoken.
When a call is mostly in English with some Hindi, or mostly Hindi with some
English, the parts in the less-used language can come back less accurate than
the rest. A Hindi greeting at the start of an English call is a common case.
`context` and `vocabulary` help with the names and terms in those parts.
## Getting names and terms right
`vocabulary` takes names, brands, products, places and codes, spelled the way
you want them back. Send up to 200 terms of up to 100 characters each, as one
string separated by commas or new lines, or as a JSON array. A term can't
contain a comma, and repeats are dropped.
`context` is a sentence or two about the call: who is calling whom, and why.
It can be up to 2,000 characters.
Both affect spelling and script. We sent the same 23-second test call twice.
With an unrelated context, "Mira University" and "Aadhaar" came back as
"मीरा यूनिवर्सिटी" and "आधार". With the context and vocabulary in the example
above, both came back as written. Use the same context and vocabulary for every
call of one kind.
## Pricing
Transcription costs **₹18 per hour of audio** on every plan. Prices exclude
GST.
* A job is charged by the length of the recording, to the millisecond, rounded
up to the paisa once. A 23-second call costs ₹0.12 and a five-minute call
₹1.50.
* The charge is taken from your [wallet](/v2/wallet) once, when the job
completes. It is taken whether or not you read the result.
* A degraded job is free. So is a job that fails for any reason.
* The rate is fixed on each job when it is accepted, and `cost` on the job
says what it was charged.
* Starting a job needs a wallet balance above zero, and nothing is held while
it runs. With an empty wallet the request is refused with
`402 insufficient_balance`.
Each charge is one wallet transaction with `kind: "transcription"`, the job ID
as `reference` and the billed seconds as `quantity`. The billing statement in
the console shows transcription under **Charges**, with the number of
recordings and minutes of audio.
### Degraded jobs
When the language model step can't write the transcript, the job still
completes with the strongest single recognition pass and `degraded: true`. The
words are there with their timestamps, but the text has had less clean-up and
reads like the draft, with English often in Devanagari. Speaker IDs are usually
still there, while roles can be missing or less reliable. A degraded job is not
charged. If you need the full transcript, send the recording again; the new job
is charged when it completes in full.
A recording longer than about three minutes is written in parts. If the
language model step fails for some parts but not all, those parts fall back to
the single pass and the rest is written as usual. That job is not marked
`degraded`, and it is charged.
## Limits
| Limit | Value |
| :- | :- |
| Uploaded file | 45 MB |
| Recording sent by link | 200 MB |
| Length of a recording | 2 hours |
| Unfinished jobs per workspace | 20 |
| New jobs per workspace | 120 an hour |
Over a limit, the request is refused with `413` or `429` and nothing is
charged. A recording longer than two hours is accepted, then fails with
`audio_too_long`, free. See [limits and errors](/v2/transcriptions#limits) for
every code.
## Privacy and your data
The API does not keep your audio. An upload passes through a temporary file
that is deleted when the request ends, and the transcription service deletes
the recording once it has decoded it. A recording link is fetched once and is
not stored.
The transcript stays with the transcription service, so that
`GET /v2/transcriptions/{id}` can return it. There is no published retention
period for transcripts yet, so copy each finished transcript into your own
storage. If a transcript is no longer available, its job still reads
`completed` with its cost, but without `text` and `segments`. The job record
itself (status, file name, size, length and cost) stays with your workspace and
appears in **Logs**. There is no endpoint to delete a transcript; if you need
one removed, write to [help@miraiminds.co](mailto:help@miraiminds.co).
Recordings and transcripts of calls are personal data under India's Digital
Personal Data Protection Act, and under laws such as the GDPR when they cover
people elsewhere. Tell the people on your calls that they are recorded and
transcribed, and get consent where the law asks for it. Keep your API key on
your server: send recordings from your backend, never from a browser or a
mobile app.
## Troubleshooting
| Symptom | Likely cause | Fix |
| :- | :- | :- |
| `402 insufficient_balance` | The wallet balance is zero or below. | Add credit in **Billing**, then send the request again. |
| `413 request_too_large` | The file is over 45 MB, or the file at `url` is over 200 MB. | Send a large file by link, or compress it to a mono MP3. |
| `400` with "the recording url answered HTTP 403" | The link has expired or is private. | Send a fresh pre-signed link. We fetch it as soon as the request arrives. |
| `429 at_capacity` | Your workspace already has 20 unfinished jobs. | Wait for `Retry-After`, or queue uploads on your side. |
| The job fails with `transcription_failed` | The file is not audio, is damaged, or has no speech. | Check that it plays. WAV and MP3 work everywhere. |
| The job fails with `engine_restarted`, `job_lost`, `timed_out` or `submit_interrupted` | Something went wrong on our side. | Send the recording again. The failed job is free. |
| Names and brands are misspelled | No `vocabulary` or `context` was sent. | Add the names to `vocabulary`, and a line of `context`. |
| Agent and Customer are the wrong way round | Roles are worked out from what each person says, and can be wrong. | Group lines by `speaker_id` and let a person rename speakers. |
| One person appears as two speaker IDs, or two people as one | Speaker separation can split or merge voices on poor audio, crosstalk or similar voices. | Merge or reassign speakers in your review screen. The console player can do both. |
| A brief third voice, such as a short announcement, is shown as the agent or the customer | On phone recordings, an extra voice that talks for less than about ten seconds is merged into the closest main speaker. | Move those lines to another speaker in your review screen. |
| The first segment starts several seconds into the recording | The recording opens with ringing or a caller tune, which is left out. | Nothing to fix. Its times are in `diarization.non_speech`. |
| No `words` on segments | `words` was not sent, `diarize` was `false`, or the recording is not in Hindi. | Send `words: true` with speaker separation on. |
| `completed`, but no `text` | The transcript is no longer kept. | Save transcripts when they complete. |
## Related
Every field, status, response and error code.
Upload, draft, finished transcript, playback, edits and export in your own
web app.
Balance, transactions and top-ups.
Transcripts of calls your voice agents made.
# Transcriptions
Source: https://docs.miraiminds.co/v2/transcriptions
Send a recording, read the job until it finishes, and list past jobs. Every field, status, response and error code of the offline transcription API.
A **transcription** is one job that turns a recording into a transcript with
speakers and timestamps. Creating one is asynchronous: you get `202 Accepted`
with the job at once, then read it with
[`GET /v2/transcriptions/{id}`](#get-a-transcription) until its `status` is
`completed` or `failed`. For an introduction, see
[Transcribe recorded calls](/v2/transcribe-recorded-calls).
Base URL `https://sandbox.voice.miraiminds.co`. Every request carries
`Authorization: Bearer sk_live_…`, your workspace API key. A job belongs to the
workspace whose key created it.
## The transcription object
A finished job, as `GET /v2/transcriptions/{id}` returns it:
```json theme={null}
{
"id": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"object": "transcription",
"status": "completed",
"created_at": "2026-10-02T07:38:20Z",
"finished_at": "2026-10-02T07:38:26Z",
"duration_s": 23.14,
"degraded": false,
"model": "mira-transcribe-offline-1",
"source": "upload",
"file_name": "synthetic_call.wav",
"bytes": 370308,
"language": null,
"diarize": true,
"words": false,
"text": "नमस्ते मैं Mira University के admissions desk से बात कर रही हूँ क्या आपके पास दो मिनट हैं yes tell me I wanted to ask about the BBA course fees जी ज़रूर BBA की fees एक लाख बीस हज़ार रुपये प्रति वर्ष है आप Aadhaar के साथ online apply कर सकते हैं okay thank you I will apply this week",
"segments": [
{
"speaker": "Agent",
"speaker_id": "spk0",
"start": 0,
"end": 6.57,
"text": "नमस्ते मैं Mira University के admissions desk से बात कर रही हूँ क्या आपके पास दो मिनट हैं"
},
{
"speaker": "Customer",
"speaker_id": "spk1",
"start": 7.21,
"end": 11.32,
"text": "yes tell me I wanted to ask about the BBA course fees"
},
{
"speaker": "Agent",
"speaker_id": "spk0",
"start": 11.98,
"end": 19.56,
"text": "जी ज़रूर BBA की fees एक लाख बीस हज़ार रुपये प्रति वर्ष है आप Aadhaar के साथ online apply कर सकते हैं"
},
{
"speaker": "Customer",
"speaker_id": "spk1",
"start": 20.21,
"end": 23.05,
"text": "okay thank you I will apply this week"
}
],
"diarization": {
"enabled": true,
"applied": true,
"speakers": [
{ "id": "spk0", "role": "Agent", "talk_s": 14.1 },
{ "id": "spk1", "role": "Customer", "talk_s": 7 }
]
},
"error": null,
"cost": { "status": "charged", "amount_inr": 0.12, "rate_inr_per_hour": 18 }
}
```
| Field | Type | Description |
| :- | :- | :- |
| `id` | string | `trn_` and 26 characters. It is also the `reference` of the job's [wallet transaction](#billing). |
| `object` | string | Always `transcription`. |
| `status` | enum | `queued`, `decoding`, `merging`, `completed` or `failed`. See [statuses](#statuses). |
| `created_at` | string | RFC 3339, UTC. When the job was accepted. |
| `finished_at` | string \| null | When the job completed or failed. `null` before that. |
| `duration_s` | number \| null | Length of the recording in seconds. `null` until the recording has been decoded. |
| `degraded` | boolean \| null | `true` when the language model step could not write the transcript, so the text is the strongest single recognition pass with less clean-up, and roles can be missing. A degraded job is free. `null` until the job completes. See [degraded jobs](/v2/transcribe-recorded-calls#degraded-jobs). |
| `model` | string \| null | The model that wrote the transcript, once the job completes: `mira-transcribe-offline-1`. |
| `source` | enum | `upload` for a multipart file, `url` for a link we fetched. |
| `file_name` | string | The uploaded file's name, or the last part of the link's path. |
| `bytes` | integer | Size of the recording. |
| `language` | string \| null | The language you set, or `null` when it was detected. |
| `diarize` | boolean | Whether speaker separation was requested. |
| `words` | boolean | Whether word timings were requested. |
| `text` | string | The whole transcript as one string. Completed jobs only. |
| `segments` | array | One entry per turn. See [segments](#segments). Completed jobs only. |
| `draft` | array | While the job is `merging`: the strongest single recognition pass, in the same shape as `segments`, with an empty `speaker` and no `speaker_id`. Gone once the job completes. |
| `diarization` | object | Who spoke and for how long. See [diarization](#diarization). Completed jobs only. |
| `error` | object \| null | For a failed job, `code` and `message`. See [why a job failed](#why-a-job-failed). `null` otherwise. |
| `cost` | object | `status` is `pending` until the job finishes, then `charged` or `free`. `amount_inr` is `null` while pending, then what the job was charged (`0` when free). `rate_inr_per_hour` is the rate fixed on the job when it was accepted. |
`text`, `segments`, `draft` and `diarization` appear only on
`GET /v2/transcriptions/{id}`. The create response and
[the list](#list-transcriptions) carry every other field.
### Segments
| Field | Description |
| :- | :- |
| `speaker` | The role: `Agent`, `Customer` or `Other`. Every segment with the same `speaker_id` has the same role. It is worked out from what each person says and can be wrong. It is empty when it could not be told. |
| `speaker_id` | The person: `spk0`, `spk1` and so on. Agent voices are numbered first, then Customer voices, then any others, each in the order they first speak. On a call between an agent and a customer, the agent is `spk0` and the customer `spk1`. Present when speaker separation was applied. Group lines by this field. |
| `start`, `end` | Seconds from the start of the recording. |
| `text` | What was said, as it was spoken: Hindi in Devanagari and English in Latin script, though common English loanwords can stay in Devanagari. Nothing is translated. |
| `words` | Word timings, present when you sent `words: true` and the words lined up with the audio. |
With `words: true`, each segment also carries its words:
```json theme={null}
{
"speaker": "Customer",
"speaker_id": "spk1",
"start": 7.21,
"end": 11.32,
"text": "yes tell me I wanted to ask about the BBA course fees",
"words": [
{ "w": "yes", "start": 7.26, "end": 7.5 },
{ "w": "tell", "start": 7.74, "end": 7.98 },
{ "w": "me", "start": 8.06, "end": 8.14 },
{ "w": "I", "start": 8.38, "end": 8.46 },
{ "w": "wanted", "start": 8.62, "end": 9.02 },
{ "w": "to", "start": 9.1, "end": 9.18 },
{ "w": "ask", "start": 9.26, "end": 9.58 },
{ "w": "about", "start": 9.66, "end": 9.9 },
{ "w": "the", "start": 9.98, "end": 10.06 },
{ "w": "BBA", "start": 10.06, "end": 10.46 },
{ "w": "course", "start": 10.62, "end": 10.86 },
{ "w": "fees", "start": 11.02, "end": 11.18 }
]
}
```
### Diarization
| Field | Description |
| :- | :- |
| `enabled` | Whether speaker separation ran for this job. `false` when you sent `diarize: false`. |
| `applied` | Whether its result was used. When it is `false`, segments keep the roles the language model gave them and have no `speaker_id`. |
| `speakers` | One entry per speaker, in `speaker_id` order: `id` (the `speaker_id`), `role` and `talk_s`, the seconds that speaker talked. |
| `non_speech` | The parts of the recording that were left out because they were not speech, such as ringing, a caller tune or hold music. Each entry has a `start` and an `end` in seconds. Absent when nothing was left out. |
| `words.applied` | Present when you sent `words: true` and speaker separation was applied. `true` when word timings were added. Today they are added for Hindi recordings; Gujarati and English recordings get none. |
Speaker separation also finds what is not speech, so `non_speech` appears only
on jobs with `diarize` on. The parts it lists were silenced before the
recording was transcribed. For a call that opens with a caller tune, it looks
like this:
```json theme={null}
"non_speech": [
{ "start": 1.66, "end": 9.35 }
]
```
## Statuses
| Status | Final | Meaning |
| :- | :-: | :- |
| `queued` | | Accepted and waiting for its turn. |
| `decoding` | | The recognition passes are listening to the recording. |
| `merging` | | The language model is writing the transcript and the speakers. `duration_s` is set, and `draft` holds the strongest single pass. |
| `completed` | Yes | `text`, `segments` and `diarization` are ready. Charged, unless `degraded` is `true`. |
| `failed` | Yes | Not transcribed and not charged. `error` says why. |
```mermaid theme={null}
stateDiagram-v2
[*] --> queued: POST /v2/transcriptions
queued --> decoding
decoding --> merging: draft available
merging --> completed
queued --> failed
decoding --> failed
merging --> failed
completed --> [*]
failed --> [*]
```
Statuses only move forward. There is no progress percentage. A job usually
completes in about 10 seconds plus 15% of the recording's length, and speaker
separation adds about a second. A draft is readable a few seconds after
decoding ends. A job that has not finished 24 hours after it was accepted fails
with `timed_out`.
### Why a job failed
| `error.code` | `error.message` | What to do |
| :- | :- | :- |
| `transcription_failed` | the recording could not be transcribed; check that it is an audio file with speech (WAV or MP3 work everywhere) | Check that the file plays and has speech in it. |
| `audio_too_long` | the recording is longer than 120 minutes, the most one job takes | Split the recording. |
| `engine_restarted` | transcription restarted while working on this recording; submit it again | Send it again. |
| `job_lost` | this job is no longer available on the transcription service; submit the recording again | Send it again. |
| `timed_out` | this job did not finish in time; submit the recording again | Send it again. |
| `submit_interrupted` | the recording was not handed to transcription; submit it again | Send it again. |
A failed job is never charged. Branch on `error.code`, allow for codes not
listed here, and show `error.message` if you need a sentence for a person.
```json title="A failed job" theme={null}
{
"id": "trn_01M3XS0Q2B7K9D4F6H8J1M3N5P",
"object": "transcription",
"status": "failed",
"error": {
"code": "audio_too_long",
"message": "the recording is longer than 120 minutes, the most one job takes"
},
"cost": { "status": "free", "amount_inr": 0, "rate_inr_per_hour": 18 }
}
```
The example shows only the fields that change; a real answer carries every
field of [the object](#the-transcription-object).
***
## Create a transcription
```http theme={null}
POST /v2/transcriptions
```
Send the recording in one of two ways:
* As `multipart/form-data` with the file in `file`. Up to 45 MB.
* As `application/json` with an `https` `url` that we fetch. Up to 200 MB.
Recordings can be up to two hours long, in any common audio format (WAV, MP3,
M4A, OGG, FLAC, WebM, AMR and others).
### Upload a file
| Field | Required | Description |
| :- | :- | :- |
| `file` | yes | The recording. One file per request. |
| `context` | no | What the call is about: who is calling whom and why. Up to 2,000 characters. Helps the transcript spell names and terms. |
| `vocabulary` | no | Names, brands and terms to spell right, separated by commas or new lines. Up to 200 terms of up to 100 characters each. Repeats are dropped. |
| `language` | no | `auto` (default) detects the main language. Or `hi`, `gu`, `en`, `mr`, `pa`, `bn`, `ur`. |
| `diarize` | no | `true` (default) or `false`. Speaker separation: a `speaker_id` per voice. |
| `words` | no | `false` (default) or `true`. Word timings on each segment. |
Any other form field is refused with `400 invalid_request`.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/transcriptions \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Idempotency-Key: call-2026-10-02-0412" \
-F file=@call.wav \
-F context="A call to a university admissions desk about a course and its fees" \
-F vocabulary="Mira University, BBA, Aadhaar"
```
```python theme={null}
import os
import httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
with open("call.wav", "rb") as f:
r = httpx.post(
f"{API}/v2/transcriptions",
headers={**auth, "Idempotency-Key": "call-2026-10-02-0412"},
files={"file": ("call.wav", f, "audio/wav")},
data={
"context": "A call to a university admissions desk about a course and its fees",
"vocabulary": "Mira University, BBA, Aadhaar",
},
timeout=600, # the answer comes after the whole file is uploaded
)
job = r.json()
if r.status_code != 202:
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
print(job["id"], job["status"]) # trn_01M3XRSFXW77RHEQ85979AJXAF queued
```
```javascript theme={null}
import { readFile } from "node:fs/promises";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const form = new FormData();
form.append("file", new Blob([await readFile("call.wav")], { type: "audio/wav" }), "call.wav");
form.append("context", "A call to a university admissions desk about a course and its fees");
form.append("vocabulary", "Mira University, BBA, Aadhaar");
const res = await fetch(`${API}/v2/transcriptions`, {
method: "POST",
headers: { ...auth, "Idempotency-Key": "call-2026-10-02-0412" },
body: form,
});
const job = await res.json();
if (res.status !== 202) throw new Error(`${job.error.code}: ${job.error.message}`);
console.log(job.id, job.status); // trn_01M3XRSFXW77RHEQ85979AJXAF queued
```
### Send a link
The same options as JSON, with `url` in place of the file. `diarize` and
`words` are JSON booleans, and `vocabulary` can also be an array of strings.
| Field | Required | Description |
| :- | :- | :- |
| `url` | yes | An `https` link to the recording, up to 2,048 characters, such as a pre-signed link to your storage. |
| `context`, `vocabulary`, `language`, `diarize`, `words` | no | As for an upload. |
We fetch the link once, before we answer, and do not store it. It must point
to a public address on the default `https` port, with no user name or password
in it, and answer `200`. Redirects are followed only to `https` addresses, and
only a few of them. The download has to finish within 5 minutes, so give a
pre-signed link at least that long to live.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/transcriptions \
-H "Authorization: Bearer $MIRAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: call-2026-10-02-0413" \
-d '{
"url": "https://storage.example.com/calls/0413.mp3?X-Amz-Expires=900&X-Amz-Signature=…",
"context": "A call to a university admissions desk about a course and its fees",
"vocabulary": ["Mira University", "BBA", "Aadhaar"],
"language": "hi",
"words": true
}'
```
```python theme={null}
r = httpx.post(
f"{API}/v2/transcriptions",
headers={**auth, "Idempotency-Key": "call-2026-10-02-0413"},
json={
"url": presigned_url, # from your storage, valid for a few minutes
"context": "A call to a university admissions desk about a course and its fees",
"vocabulary": ["Mira University", "BBA", "Aadhaar"],
"language": "hi",
"words": True,
},
timeout=600, # we download the recording before answering
)
job = r.json()
if r.status_code != 202:
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
```
```javascript theme={null}
const res = await fetch(`${API}/v2/transcriptions`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": "call-2026-10-02-0413" },
body: JSON.stringify({
url: presignedUrl, // from your storage, valid for a few minutes
context: "A call to a university admissions desk about a course and its fees",
vocabulary: ["Mira University", "BBA", "Aadhaar"],
language: "hi",
words: true,
}),
});
const job = await res.json();
if (res.status !== 202) throw new Error(`${job.error.code}: ${job.error.message}`);
```
### Response
`202 Accepted`, with a `Location` header and the job:
```http theme={null}
Location: /v2/transcriptions/trn_01M3XRSFXW77RHEQ85979AJXAF
```
```json title="202 Accepted" theme={null}
{
"id": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"object": "transcription",
"status": "queued",
"created_at": "2026-10-02T07:38:20Z",
"finished_at": null,
"duration_s": null,
"degraded": null,
"model": null,
"source": "upload",
"file_name": "synthetic_call.wav",
"bytes": 370308,
"language": null,
"diarize": true,
"words": false,
"error": null,
"cost": { "status": "pending", "amount_inr": null, "rate_inr_per_hour": 18 }
}
```
### Idempotency
Send an `Idempotency-Key` header (up to 255 characters) with a value from your
own records, such as your call ID. A request with a key your workspace has
already used returns the job that key created, with `202` and the job's current
status, and makes no second job and no second charge. That makes a request that
timed out safe to retry.
Keys don't expire, and a reused key returns the original job even if you send
a different file. If the transcription service refused the recording, the job
is not created and the key can be used again.
### Errors
Capacity and the wallet are checked before the recording is read.
| Status | `error.code` | Cause |
| :- | :- | :- |
| `400` | `invalid_request` | The body is neither multipart nor JSON, has no `file` or `url`, has more than one file or an unknown field, or a field is out of range. Also a `url` that is not `https`, not public, or that could not be fetched (the message gives the reason, such as "the recording url answered HTTP 403"), and a recording the transcription service could not accept. `error.message` names the problem. |
| `401` | `unauthorized` | Missing or unknown API key. |
| `402` | `insufficient_balance` | The wallet balance is zero or below. Nothing was read or charged. [Top up](/v2/wallet#top-up) and retry with the same `Idempotency-Key`. |
| `403` | `forbidden` | The key was revoked. |
| `413` | `request_too_large` | The upload is over 45 MB, or the file at `url` is over 200 MB. |
| `429` | `at_capacity` | Your workspace already has 20 unfinished jobs. `Retry-After: 30`. |
| `429` | `rate_limited` | Your workspace has started 120 jobs in the past hour, or uploads are busy for a moment, or the [request rate](/v2/limits#request-rate) for your key was exceeded. `Retry-After` says when to try again. |
| `500` | `internal` | Our fault. No job was made that will be charged. Retry with a new `Idempotency-Key`. |
| `503` | `upstream_unavailable` | The transcription service is unavailable right now. Nothing was charged. `Retry-After: 30`. |
| `503` | `transcription_unavailable` | Offline transcription is not available on this deployment. |
No refusal is charged, and a refused request does not count towards the
hourly limit.
***
## Get a transcription
```http theme={null}
GET /v2/transcriptions/{id}
```
Read a job until its `status` is `completed` or `failed`. Start quickly, since
short recordings often finish in seconds, then slow down to every few seconds.
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/transcriptions/trn_01M3XRSFXW77RHEQ85979AJXAF \
-H "Authorization: Bearer $MIRAI_API_KEY"
```
```python theme={null}
import time
def wait_for_transcript(transcription_id: str, timeout_secs: int = 3600) -> dict:
deadline = time.monotonic() + timeout_secs
delay = 1.0
while time.monotonic() < deadline:
r = httpx.get(f"{API}/v2/transcriptions/{transcription_id}", headers=auth, timeout=30)
if r.status_code in (429, 503):
time.sleep(float(r.headers.get("Retry-After", "5")))
continue
job = r.raise_for_status().json()
if job["status"] in ("completed", "failed"):
return job
time.sleep(delay)
delay = min(delay * 1.5, 5) # quick at first, then every 5 seconds
raise TimeoutError(transcription_id)
job = wait_for_transcript("trn_01M3XRSFXW77RHEQ85979AJXAF")
if job["status"] == "failed":
print(job["error"]["code"], job["error"]["message"])
else:
for seg in job.get("segments", []):
print(f"{seg['start']:7.2f} {seg.get('speaker_id', '-')} {seg['speaker']}: {seg['text']}")
```
```javascript theme={null}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForTranscript(id, timeoutMs = 3_600_000) {
const deadline = Date.now() + timeoutMs;
let delay = 1000;
while (Date.now() < deadline) {
const res = await fetch(`${API}/v2/transcriptions/${id}`, { headers: auth });
if (res.status === 429 || res.status === 503) {
await sleep(Number(res.headers.get("retry-after") ?? 5) * 1000);
continue;
}
const job = await res.json();
if (!res.ok) throw new Error(`${job.error.code}: ${job.error.message}`);
if (job.status === "completed" || job.status === "failed") return job;
await sleep(delay);
delay = Math.min(delay * 1.5, 5000); // quick at first, then every 5 seconds
}
throw new Error(`timed out waiting for ${id}`);
}
const job = await waitForTranscript("trn_01M3XRSFXW77RHEQ85979AJXAF");
if (job.status === "failed") console.log(job.error.code, job.error.message);
else for (const seg of job.segments ?? []) console.log(seg.start, seg.speaker_id, seg.speaker, seg.text);
```
**`200 OK`**: [the transcription object](#the-transcription-object). While the
job is `merging`, it carries `draft`:
```json title="200 OK (merging)" theme={null}
{
"id": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"object": "transcription",
"status": "merging",
"created_at": "2026-10-02T07:38:20Z",
"finished_at": null,
"duration_s": 23.14,
"degraded": null,
"model": null,
"source": "upload",
"file_name": "synthetic_call.wav",
"bytes": 370308,
"language": null,
"diarize": true,
"words": false,
"draft": [
{
"speaker": "",
"start": 0,
"end": 23.14,
"text": "नमस्ते मैं मीरा यूनिवर्सिटी के एडमिशंस डेस्क से बात कर रही हूँ क्या आपके पास दो मिनट हैं यस टेल मी आई वॉन्टेड टू आस्क अबाउट द बी बी ए कॉर्स फ़ीस जी ज़रूर बी बी ए की फ़ीस एक लाख बीस हज़ार रुपये प्रति वर्ष है आप आधार के साथ ऑनलाइन अप्लाई कर सकते हैं ओके थैंक यू आई विल अप्लाई दिस वीक"
}
],
"error": null,
"cost": { "status": "pending", "amount_inr": null, "rate_inr_per_hour": 18 }
}
```
The draft can be one long segment, as here, or several. Show it as provisional
and replace it with `segments` when the job completes.
A completed job is charged whether or not anyone reads it, so you can stop
polling at any time. If a finished transcript is no longer kept, the job still
answers `completed` with its `cost`, but without `text`, `segments` and
`diarization`. Copy transcripts into your own storage when they complete.
| Status | `error.code` | Cause |
| :- | :- | :- |
| `404` | `not_found` | No such job in your workspace. A job that belongs to another workspace answers the same way. |
| `429` | `rate_limited` | The [request rate](/v2/limits#request-rate) for your key. Polling counts towards it. Honour `Retry-After`. |
| `503` | `upstream_unavailable` | The transcription service did not answer. `Retry-After: 10`. The job is unaffected. |
***
## List transcriptions
```http theme={null}
GET /v2/transcriptions?limit=&cursor=&status=
```
Your workspace's jobs, newest first, from the console and the API. List items
carry every field except `text`, `segments`, `draft` and `diarization`; read a
job by its ID for those.
| Query param | Description |
| :- | :- |
| `limit` | 1 to 100, default 20. |
| `cursor` | `next_cursor` from the previous page. |
| `status` | Only jobs in this [status](#statuses): `queued`, `decoding`, `merging`, `completed` or `failed`. |
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/transcriptions \
-H "Authorization: Bearer $MIRAI_API_KEY" \
--data-urlencode "status=completed" \
--data-urlencode "limit=2"
```
```python theme={null}
def all_transcriptions(status: str | None = None):
params = {"limit": 100, **({"status": status} if status else {})}
while True:
page = httpx.get(f"{API}/v2/transcriptions", headers=auth, params=params, timeout=30)
page = page.raise_for_status().json()
yield from page["data"]
if not page["has_more"]:
return
params["cursor"] = page["next_cursor"]
spent = sum(job["cost"]["amount_inr"] for job in all_transcriptions("completed"))
```
```javascript theme={null}
async function* allTranscriptions(status) {
const params = new URLSearchParams({ limit: "100", ...(status ? { status } : {}) });
while (true) {
const res = await fetch(`${API}/v2/transcriptions?${params}`, { headers: auth });
const page = await res.json();
if (!res.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
yield* page.data;
if (!page.has_more) return;
params.set("cursor", page.next_cursor);
}
}
let spent = 0;
for await (const job of allTranscriptions("completed")) spent += job.cost.amount_inr;
```
```json title="200 OK" theme={null}
{
"data": [
{
"id": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"object": "transcription",
"status": "completed",
"created_at": "2026-10-02T07:38:20Z",
"finished_at": "2026-10-02T07:38:26Z",
"duration_s": 23.14,
"degraded": false,
"model": "mira-transcribe-offline-1",
"source": "upload",
"file_name": "synthetic_call.wav",
"bytes": 370308,
"language": null,
"diarize": true,
"words": false,
"error": null,
"cost": { "status": "charged", "amount_inr": 0.12, "rate_inr_per_hour": 18 }
},
{
"id": "trn_01M3XRCR5F4BYY0SSEQCCT01BX",
"object": "transcription",
"status": "completed",
"created_at": "2026-10-02T07:31:22Z",
"finished_at": "2026-10-02T07:31:28Z",
"duration_s": 23.14,
"degraded": false,
"model": "mira-transcribe-offline-1",
"source": "upload",
"file_name": "synthetic_call.wav",
"bytes": 370308,
"language": null,
"diarize": true,
"words": true,
"error": null,
"cost": { "status": "charged", "amount_inr": 0.12, "rate_inr_per_hour": 18 }
}
],
"has_more": true,
"next_cursor": "trn_01M3XE3N8V7VRNTGESH40AMMM0"
}
```
`next_cursor` is `null` on the last page. A `limit` out of range, a `cursor`
that is not a transcription ID, or an unknown `status` returns
`400 invalid_request`. See [pagination](/v2/overview#pagination).
***
## Billing
Transcription costs ₹18 per hour of audio, charged once when a job completes.
A degraded or failed job is free. The charge is the length of the recording in
milliseconds times the rate, rounded up to the paisa:
```python theme={null}
def cost_paise(duration_ms: int, rate_inr_per_hour: float = 18) -> int:
paise_per_hour = round(rate_inr_per_hour * 100)
return -(-duration_ms * paise_per_hour // 3_600_000) # rounded up to the paisa
cost_paise(23_140) # 12 paise, ₹0.12
```
Use the formula to forecast. `cost.amount_inr` on the job and the wallet
transaction are what was charged:
```json title="GET /v2/wallet/transactions" theme={null}
{
"id": "txn_01M3XRSP44KR3116AZ578NC2F1",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 0.12,
"balance_after_inr": 8762.06,
"call_id": null,
"description": "Transcription · 23s of audio · mira-transcribe-offline-1",
"created_at": "2026-10-02T07:38:26Z",
"kind": "transcription",
"reference": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"quantity": 24
}
```
| Field | Meaning |
| :- | :- |
| `kind` | `transcription`. Classify rows by `kind`, never by `description`. |
| `reference` | The job's `id`. |
| `quantity` | The billed length in whole seconds, rounded up. |
Starting a job needs a wallet balance above zero. Nothing is held while it
runs. See [pricing](/v2/transcribe-recorded-calls#pricing) for the rest of the
rules.
## Limits
| Limit | Value | Over the limit |
| :- | :- | :- |
| Uploaded file | 45 MB | `413 request_too_large` |
| Recording sent by link | 200 MB | `413 request_too_large` |
| Length of a recording | 2 hours | The job fails with `audio_too_long`, free. |
| `context` | 2,000 characters | `400 invalid_request` |
| `vocabulary` | 200 terms of up to 100 characters, no commas inside a term | `400 invalid_request` |
| `Idempotency-Key` | 255 characters | `400 invalid_request` |
| Unfinished jobs | 20 per workspace | `429 at_capacity`, `Retry-After: 30` |
| New jobs | 120 an hour per workspace | `429 rate_limited` with `Retry-After` |
| Requests | Your key's [request rate](/v2/limits#request-rate), shared with every `/v2` call | `429 rate_limited` with `Retry-After` |
| Time to finish | 24 hours after the job was accepted | The job fails with `timed_out`, free. |
# Language roadmap
Source: https://docs.miraiminds.co/v2/tts-languages
Gujarati is accepted in beta today; Telugu, Tamil, Marathi, Kannada and Bengali join the voice engine over the next two months.
Today the engine speaks **Hindi, English and the Hinglish** that most real
calls are in, plus **Gujarati in beta**. Five more follow over the next two
months: **Telugu, Tamil, Marathi, Kannada and Bengali**.
We announce each language's date as it clears evaluation. Everything on the
[roadmap](/general/roadmap) follows the same rule: dates are targets, and if a
date and quality conflict, quality always wins. A language ships when it sounds
like a person, even if that is later than the calendar says.
## Gujarati, in beta
**Available now.** Send Gujarati text and you get Gujarati audio from the same
[speech endpoint](/v2/tts-quickstart), with the same voices and at the same
price. You don't add a language parameter or any other field, and you don't
change any code, because the model reads the script you send.
* **Send Gujarati in Gujarati script** (ગુજરાતી), since that is what the model
reads.
* **English words stay in Latin script**, mixed in mid-sentence, exactly the
way they already work in Hindi.
* **Quality is still being tuned.** Beta means it is good enough to build
against and not yet at the bar our Hindi voices hold. Listen before you put
it in front of customers, and tell us what you hear. We tune the voices on
beta feedback.
## Why these six
Together these languages are the mother tongue of **429 million people**,
roughly every third Indian. For most of them, Hindi is no substitute. The
census records how many speakers of each language also speak Hindi, and for
four of the six the answer is *almost none*.
| 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 · **beta** | 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**, so a voice agent that
speaks only Hindi cannot reach them. Only 1.5% of Tamil speakers report
speaking Hindi; for Telugu it is 5.7%, for Kannada 4.7% and for Bengali 8.6%.
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.
* **Native voices.** Each language ships with its own voices in the
[voice gallery](/v2/voices), evaluated by native speakers. We do not use an
accented Hindi voice to read 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 work follows the voice, so each language lands on the speech
endpoint 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. We allocate alpha slots in the order customers
ask, and we tune the final voices on alpha customers' text.
# TTS quickstart
Source: https://docs.miraiminds.co/v2/tts-quickstart
Create a key and get Hindi audio from one curl request, pick a format and sample rate (8 kHz μ-law for phone lines), and fix choppy audio on live calls.
This is the voice our agents use, offered 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 can already call it. You change the
base URL and the model name, and the rest of your code stays as it is.
## 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,
and the console shows it only once. We store only its hash, so we cannot
recover a lost key; you rotate it instead. The same key works for TTS and the
rest of the [v2 API](/v2/overview), and draws on the same [wallet](/v2/wallet).
## 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 itself. Keep `--output`: without it, curl
prints 300 KB of binary into your terminal and you have to reset it. If
`speech.wav` plays, the integration works, and the sections below refine it.
### Output formats
Pick the format and sample rate your player or phone line uses, and we convert
on our side as the audio streams:
| `response_format` | You get | Streams? |
| :- | :- | :- |
| `pcm` | Raw signed 16-bit little-endian mono, no header. | Yes. The first bytes arrive while the rest is still being synthesised. |
| `mulaw` (also `ulaw`, `pcm_mulaw`) | Raw G.711 μ-law, 1 byte per sample. What most phone lines carry. | Yes |
| `alaw` (also `pcm_alaw`) | Raw G.711 A-law, 1 byte per sample. | Yes |
| `wav` | A playable file: 16-bit mono PCM with a WAV header. | No. It arrives in one piece once synthesis finishes. |
Send the rate in `sample_rate`, or use a value with the rate built in:
| Field | Values | Default |
| :- | :- | :- |
| `sample_rate` | `8000`, `16000`, `22050`, `24000`, `44100` or `48000`. μ-law and A-law come at `8000` or `16000` only. | `48000` for `pcm` and `wav`, `8000` for μ-law and A-law |
| `response_format` with a rate | `pcm_8000`, `pcm_16000`, `pcm_22050`, `pcm_24000`, `pcm_44100`, `pcm_48000`, `ulaw_8000` (or `mulaw_8000`), `ulaw_16000` (or `mulaw_16000`), `alaw_8000`, `alaw_16000` | |
If `response_format` includes a rate, leave `sample_rate` out or send the same
value; a different one returns a 400. For a WAV at another rate, send `wav`
with `sample_rate`, for example `"sample_rate": 16000`. Values are not case
sensitive.
Any other value, including OpenAI's default `mp3`, returns a 400 whose message
lists the values we accept. Always send `response_format` explicitly. Without
it you get `pcm` at 48 kHz.
```bash title="Phone-ready audio: 8 kHz μ-law" 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": "neha",
"response_format": "ulaw_8000",
"input": "नमस्ते, आपका ऑर्डर कल तक पहुँच जाएगा।"
}' \
--output speech.ulaw
```
Play it with `ffplay -f mulaw -ar 8000 speech.ulaw`.
`sample_rate` used to be ignored. If your code already sends it, check the
value: a supported rate such as `16000` now gets you audio at that rate
instead of 48 kHz, and an unsupported one such as `32000` returns a 400 that
lists the allowed rates.
**One model name.** `model` is `mira-tts`. The old build-numbered name,
`mira-tts-v51`, is still accepted as an alias, so existing callers keep
working. Any other value returns a `404`.
The same endpoint is also mounted at `POST /v2/tts` if you would rather keep
every URL under `/v2`. It runs the same handler and also accepts `text` as an
alias of `input`. Auth, fields, headers and billing are identical, so switching
between the two changes only the URL.
## 3. Pick a voice
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/voices \
-H "Authorization: Bearer $MIRA_API_KEY"
```
The voices today are `ashu`, `neha`, `shruti` and `sameer`. All four are Hindi
voices that code-switch into English the way real Indian speech does. `neha` is
the default if you omit `voice`. Each entry gives the `voice_id`, the voice's
name and its language. To hear a voice before you choose, use the voice picker
in the console's Agent Studio. An unknown voice returns a 400 that names the
allowed set; we never swap in another voice silently. Gujarati is accepted in
beta, and five 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
```
`sample_rate` is not an OpenAI parameter. To get another rate through the SDK,
pass it in `extra_body={"sample_rate": 16000}`.
For a live conversation, the [WebSocket endpoint](/v2/tts-websocket) is a
better fit than one request per sentence. You keep one connection for the
whole call, send text as your model writes it, and stop a reply with one
message when the caller interrupts.
## 5. Drop it into your orchestrator
If you build on Pipecat, use our integration. Install it with
`uv add pipecat-mirai` and add `MiraiTTSService` to your pipeline:
```python theme={null}
import os
from pipecat_mirai import MiraiTTSService
tts = MiraiTTSService(
api_key=os.environ["MIRA_API_KEY"],
settings=MiraiTTSService.Settings(voice="ashu"),
)
```
It asks for audio at your pipeline's sample rate, stops the reply when the
caller interrupts, and keeps one connection open for the call. See
[Pipecat](/v2/pipecat) for the recommended phone setup.
Other frameworks with an OpenAI TTS integration can call the endpoint directly.
Point them at `https://sandbox.voice.miraiminds.co/v1` with model `mira-tts`
and `response_format="pcm"`. The audio is 48 kHz unless you ask for another
rate, and `X-Sample-Rate` always tells you which rate you got.
## What comes back
Every `200` carries its own accounting in headers, so you can meter spend and
pin a build without a second request:
| Header | Meaning |
| :- | :- |
| `X-Request-Id` | This request's id (`tts_…`). It identifies the row in your [request log](#your-request-log) and is the `reference` on the [ledger row](#billing). |
| `X-Chars-Billed` | Characters counted for billing. |
| `X-Cost-Paise` | What this request costs, in paise, once it completes. |
| `X-Sample-Rate` | The sample rate of the audio you received, for example `8000` or `48000`. |
| `X-Audio-Encoding` | How the samples are encoded: `pcm_s16le` (for `pcm` and `wav`), `mulaw` or `alaw`. |
| `Content-Type` | `audio/pcm` for `pcm` at any rate, `audio/wav`, `audio/PCMU;rate=8000` for μ-law or `audio/PCMA;rate=8000` for A-law (with `16000` at 16 kHz). |
| `X-Mira-Model` | The build that produced the audio, for example `miraiminds/mira-tts-v53`. Pin it when you compare quality across releases. |
Read `X-Sample-Rate` and `X-Audio-Encoding` before you play the audio. Raw
audio has no header, so playing μ-law as PCM, or 8 kHz audio as 48 kHz, gives
you noise or audio at the wrong speed.
Every error after authentication also carries `X-Request-Id`, so you can trace
a failure the same way as a success. Quote the id when you ask us about a
failed request.
## For phone calls
Four things keep a phone call's audio smooth.
### Ask for the line's format
Phone audio is 8 kHz. Request it from us and forward each chunk to your
provider as it arrives, base64-encoded into the provider's media message, with
no decoding or resampling on your side:
| Where the audio goes | `response_format` |
| :- | :- |
| Twilio Media Streams | `ulaw_8000` |
| Plivo audio streams | `ulaw_8000` |
| Exotel voice streaming | `pcm_8000` (16-bit PCM) |
| Your own SIP or RTP leg | `ulaw_8000` for PCMU, `alaw_8000` for PCMA, whichever codec the call negotiated |
| A wideband (16 kHz) leg | `pcm_16000` |
### Keep one connection open
Opening a new HTTPS connection for every sentence adds a TCP and TLS handshake
before that sentence can start: 0.4 to 1 second from India, and more when a
connection attempt has to be retried. Use the [WebSocket
endpoint](/v2/tts-websocket), which carries the whole call on one socket. If
you stay on HTTP, reuse one keep-alive client (one `httpx.AsyncClient` or
`requests.Session`) for every request in the call.
### Keep the provider 0.4 seconds ahead
If you send audio to the phone line at exactly real time, the provider holds
only the latest chunk, about 40 ms of audio. Any stall on your server longer
than that (a blocking call, a long JSON parse, many calls on one CPU) leaves
the line silent and the caller hears the voice break up. Send audio as it
arrives until the provider holds about 0.4 seconds more than the caller has
heard, then pace it. When the caller interrupts, tell the provider to drop what
it holds (Twilio `clear`, Plivo `clearAudio`, Exotel `clear`). The [phone
bridge example](/v2/tts-websocket#bridge-to-a-phone-call) shows the pattern for
Twilio. On Pipecat, pipecat-mirai (0.3.1 and later) does it for you
([recommended phone setup](/v2/pipecat#recommended-phone-setup)).
### Buffer the start of each sentence
Hold about 150 ms of audio before a sentence starts playing. Its first chunk
can be short and followed by a brief pause. If playback starts on it, the
caller hears a sliver of a word, a gap, then the rest.
### Why the format matters
Each stream needs its audio to arrive at least as fast as it plays. The
bandwidth one stream needs, before network overhead:
| Format | One stream | 12 streams |
| :- | :- | :- |
| `pcm` at 48 kHz (the default) | 768 kbit/s | 9.2 Mbit/s |
| `pcm_24000` | 384 kbit/s | 4.6 Mbit/s |
| `pcm_16000` | 256 kbit/s | 3.1 Mbit/s |
| `pcm_8000` | 128 kbit/s | 1.5 Mbit/s |
| `ulaw_8000` or `alaw_8000` | 64 kbit/s | 0.8 Mbit/s |
At 48 kHz, six calls already need a steady 4.6 Mbit/s of download. On an
ordinary server link that can fall behind real time, and the calls start to
break up. 8 kHz μ-law needs a twelfth of that, and a narrowband phone line
carries no more than 8 kHz anyway.
With all four in place, using pipecat-mirai's `MiraiTTSService`, we ran 12 and
then 16 simultaneous phone calls through our API to the speech engine: 235 and
262 conversational turns, about a fifth of them interrupted by the caller.
Every call played with no audible gaps and no errors, and every sentence
arrived in order. We then repeated the 12-call run against this production
API from a client in Europe: 194 turns, again no gaps, no errors and every
sentence in order, with first audio at the phone (from the LLM's first token)
at 622 ms median and 737 ms at the 95th percentile. The [test
harness](https://github.com/MiraiMinds/pipecat-mirai/tree/main/benchmarks/customer-e2e)
is public, so you can run it yourself.
## Fixing choppy or broken audio
If callers hear gaps, clicks or a voice that breaks up, find the symptom below.
| What you hear | Likely cause | Fix |
| :- | :- | :- |
| Fine on one call, breaking up once several calls run at once | 48 kHz PCM over a link that cannot carry every stream in real time | Ask for `ulaw_8000`, or the rate your player uses. See [Why the format matters](#why-the-format-matters). |
| A pause before some sentences, sometimes a second or more | A new HTTPS connection for each sentence | Keep one connection for the call: use the [WebSocket endpoint](/v2/tts-websocket), or one keep-alive HTTP client. |
| Short breaks in the middle of sentences, worse when your server is busy | Audio sent to the phone line at exactly real time, with nothing in hand when your server stalls | Keep the provider about 0.4 seconds ahead. See [For phone calls](#for-phone-calls). |
| A sliver of a word, a gap, then the rest of the sentence | Playback starts on the first, very short chunk | Hold about 150 ms of audio before each sentence starts playing. |
| A sentence goes missing under load, or you see `429 at_capacity` | More sentences in flight than your workspace limit (10 by default) | On the WebSocket, sentences wait up to 3 seconds for a slot. On HTTP, honour `Retry-After`. Ask us to raise the limit if you run many calls at once. |
| Static or noise, or a voice that is too fast, too slow or too high | The audio is decoded with the wrong encoding or rate | Read `X-Sample-Rate` and `X-Audio-Encoding` (on the WebSocket, `audio.start`) and set your player to match. |
To find out whether a gap starts on our side or yours, look the request up in
your [request log](#your-request-log). `first_byte_ms` is how long we took to
send the first audio, and `status` says whether all of it was delivered. If
those look normal and the caller still hears gaps, the audio is being held up
between our server and the caller: on your link, in your server's event loop,
or in how it is paced to the phone line. The fixes above cover each of those.
If you are on Pipecat, the [recommended phone setup](/v2/pipecat#recommended-phone-setup)
takes care of all of this except the size of your limit.
## Limits
| Limit | Default | Over the limit |
| :- | :- | :- |
| Requests in flight | Your workspace's limit, **10** unless it has been set otherwise | `429 at_capacity` with a `Retry-After` header. |
| `input` length | **2,000 characters** | `400 invalid_request`. Split longer text. |
| Request rate | Per key (see [Limits](/v2/limits)) | `429 rate_limited` with a `Retry-After` header. |
We refuse an HTTP request over the limit instead of queueing it. Honour
`Retry-After` and send it again. A refused request does not hold a slot and
costs nothing. `at_capacity` can also come from a deployment-wide ceiling when
the speech node is saturated. Handle it the same way.
Sentences on the [WebSocket endpoint](/v2/tts-websocket) draw on the same
limit, but a sentence that finds it full waits up to 3 seconds for a slot
before it fails. A live conversation rides out a short burst there without a
retry loop of your own.
The limit is set per workspace, and a workspace starts at 10. The plans under
[Billing](#billing) list the concurrency each plan includes. If your
workspace's limit doesn't match your plan, or you need more, ask your Mirai
contact. Changing the limit is a provisioning change and needs no change to
your code.
## Billing
**₹1.80 per 1,000 characters.** Top-ups use the same rate.
| Plan | Monthly commitment | Concurrent requests | Top-ups |
| - | - | - | - |
| Pay as you go | None | 5 | Top up anytime at the same rate: ₹1.80 / 1,000 characters |
| Business | ₹50,000 | 25 | Top up anytime at the same rate: ₹1.80 / 1,000 characters |
Shared endpoint. No contractual SLA. Monthly commitments are credited to your wallet. Unused credit never expires. All prices exclude GST.
**Enterprise:** Custom volume pricing, concurrency, dedicated endpoint and SLA. Top up anytime at the same rate.
Each request is rounded up to the paisa, and we charge only for completed
audio. If the engine fails, the stream stalls or your client disconnects
part-way, nothing is charged and no ledger row is written. The request still
appears in your log, with a status that says why. The output format and sample
rate do not change the price.
On the [WebSocket endpoint](/v2/tts-websocket#billing), each sentence is
charged on its own, and fractions of a paisa carry over between sentences, so a
streamed reply never costs more than the same text sent in one request.
The charge is one debit row on the same [wallet](/v2/wallet) your calls draw
from, and it appears on the console billing statement under **Text to speech**:
```json title="GET /v2/wallet/transactions" theme={null}
{
"id": "txn_01K4N7Q2R5S8T4V7W9X1Y3ZB6C",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 0.12,
"balance_after_inr": 498.32,
"call_id": null,
"kind": "tts",
"reference": "tts_01K4N7Q0V8ZR3M6D9F2H5J7K1P",
"quantity": 62,
"description": "Text to speech · 62 characters · shruti",
"created_at": "2026-09-09T09:16:39Z"
}
```
| Field | Meaning |
| :- | :- |
| `kind` | `tts` here, `call` on call rows. Classify rows by this field, never by reading `description`. |
| `reference` | The `X-Request-Id` of the request that produced the audio. |
| `quantity` | Characters billed. This is the same number as `X-Chars-Billed`. |
If your wallet cannot cover the request, you get a `402` before anything is
synthesised. See [Wallet](/v2/wallet#list-transactions).
## Your request log
We log every request, whether it completed, failed or was rejected, and you can
read the log yourself.
```bash theme={null}
curl "https://sandbox.voice.miraiminds.co/v2/tts/requests?limit=50" \
-H "Authorization: Bearer $MIRA_API_KEY"
```
```json title="200 OK" theme={null}
{
"id": "tts_01K4N7Q0V8ZR3M6D9F2H5J7K1P",
"object": "tts_request",
"created_at": "2026-09-09T09:16:39Z",
"source": "api",
"voice": "shruti",
"model": "mira-tts",
"format": "pcm",
"chars": 62,
"status": "completed",
"cost_inr": 0.12,
"first_byte_ms": 93,
"total_ms": 3210,
"bytes": 437760,
"preview": "नमस्ते, मैं शालिनी बोल रही हूँ …",
"error_code": null,
"transport": "http"
}
```
Rows come newest first, and only from your own workspace:
| Query | Meaning |
| :- | :- |
| `limit` | Up to 200, default 50. |
| `cursor` | Opaque, from the response's `next_cursor`. |
| `from`, `to` | RFC3339 timestamps. |
| `status` | `completed`, `failed`, `truncated` or `rejected`. |
| `source` | `api` or `playground`. |
`GET /v2/tts/requests/{id}` returns a single row. The id is the
`X-Request-Id` you got back from the request.
| Field | Meaning |
| :- | :- |
| `status` | `completed` (audio relayed in full), `failed` (nothing usable), `truncated` (the stream stopped part-way), `rejected` (refused before synthesis because of a bad field, no balance or no free slot). |
| `source` | `api` for a request made with your key. `playground` for audio from the console's **Hear it** button. Playground requests are logged like any other but never charged, so `cost_inr` is `0`. |
| `transport` | `http` for a request to this endpoint, `websocket` for one sentence on a [streaming socket](/v2/tts-websocket). |
| `format` | What you were sent. `pcm` and `wav` mean 48 kHz. Any other format or rate carries its rate, for example `mulaw_8000`, `pcm_16000` or `wav_8000`. |
| `cost_inr` | What it cost. `0` unless `completed`. |
| `first_byte_ms` | Time to the first audio byte. In a live pipeline, this is the number to watch. |
| `total_ms`, `bytes` | Wall time and audio size for the whole request. |
| `error_code` | The `error.code` that was returned, or `null`. A sentence you cancelled on a socket reads `truncated` with `error_code` `cancelled`. |
**We do not keep the text you send.** `preview` is the first 48 characters of
`input`, so a row is recognisable in a list. The rest is never stored.
The same table is in the console under **TTS → Requests**, if you would rather
check it there than poll the API.
## When something fails
| Status | `code` | Cause |
| :- | :- | :- |
| 400 | `invalid_request` | Empty `input`, more than 2,000 characters, an unknown voice, an unsupported `response_format` or `sample_rate`, or a `sample_rate` that conflicts with the rate in `response_format`. The message names the valid values. |
| 401 | `unauthorized` | Missing or unknown key. Check the `Authorization: Bearer sk_live_…` header. |
| 402 | `insufficient_balance` | The wallet cannot cover this request. Nothing was synthesised and nothing was charged. |
| 404 | `model_not_found` | `model` is neither `mira-tts` nor the `mira-tts-v51` alias. |
| 429 | `at_capacity` | You already have as many requests in flight as your workspace allows (10 unless it has been set otherwise), or the deployment is saturated. Honour `Retry-After`. |
| 429 | `rate_limited` | Per-key request rate. Honour `Retry-After`, and see [Limits](/v2/limits). |
| 502 | `upstream_error` | The engine refused, returned no audio, or the stream stalled past its timeout. Nothing was charged; the message says which. Retry. |
| 503 | `fleet_offline` | No speech capacity is online. Nothing was charged. Honour `Retry-After`. |
Errors use the same envelope as the rest of the API. See
[Errors](/v2/errors). If you are stuck on something this page does not cover,
reply on your onboarding thread, where a person reads it.
# Streaming text to speech (WebSocket)
Source: https://docs.miraiminds.co/v2/tts-websocket
Keep one WebSocket open for a whole call, send text as your LLM writes it, and get audio back sentence by sentence, with barge-in and phone-ready formats.
The streaming endpoint carries a whole conversation on one connection. You
send text as your language model writes it, a token or a sentence at a time.
We cut it into sentences, synthesise them in order and send the audio back on
the same socket. Voices, output formats and the price are the same as on
[`POST /v1/audio/speech`](/v2/tts-quickstart).
## When to use it
Use the WebSocket for live conversations: voice agents on the phone or in a
browser, anything where a model writes the reply while someone waits for it.
Compared with one HTTP request per sentence, it changes four things:
* You pay for the connection once per call. A new HTTPS connection needs a TCP
and TLS handshake, which takes 0.4 to 1 second from India and sometimes
longer when a connection attempt has to be retried.
* You can send tokens as they arrive. We find the sentence ends.
* The next sentence is synthesised while the current one plays, so there is no
pause between sentences.
* Barge-in is one message. The sentence that was playing stops at once and is
not charged.
When your workspace is at its concurrency limit, a sentence on a socket waits
up to 3 seconds for a free slot. An HTTP request gets a `429` straight away.
For one-off audio, such as a file, a voicemail or a notification, the
[HTTP endpoint](/v2/tts-quickstart) is simpler.
If your voice agent runs on Pipecat, `MiraiTTSService` from our
[Pipecat package](/v2/pipecat) speaks this protocol for you, including
barge-in, capacity retries and reconnects.
## Connect
```text theme={null}
wss://sandbox.voice.miraiminds.co/v1/audio/speech/stream
```
`wss://sandbox.voice.miraiminds.co/v2/tts/stream` is the same endpoint, if you
keep every URL under `/v2`. Open one socket per call and keep it until the
call ends.
### From your server
Send your API key in the `Authorization` header, as on every other request:
```text theme={null}
Authorization: Bearer sk_live_…
```
We refuse an API key in the URL (`?api_key=` returns `401`), because URLs end
up in proxy and server logs.
### From a browser
A browser cannot set headers on a WebSocket, and your API key must never reach
a web page. Your backend mints a short-lived stream token with its key and
hands the page only the token.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/tts/stream/tokens \
-H "Authorization: Bearer $MIRA_API_KEY"
```
```python theme={null}
import os
import httpx
r = httpx.post(
"https://sandbox.voice.miraiminds.co/v2/tts/stream/tokens",
headers={"Authorization": f"Bearer {os.environ['MIRA_API_KEY']}"},
)
r.raise_for_status()
stream_url = "wss://sandbox.voice.miraiminds.co" + r.json()["url"]
```
```js theme={null}
const r = await fetch("https://sandbox.voice.miraiminds.co/v2/tts/stream/tokens", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MIRA_API_KEY}` },
});
if (!r.ok) throw new Error(`token mint failed: ${r.status}`);
const streamUrl = "wss://sandbox.voice.miraiminds.co" + (await r.json()).url;
```
```json title="201 Created" theme={null}
{
"object": "tts_stream_token",
"token": "Hq3x9Zt0bL7wKc2Vn5RdQe8yUf1aJm4sPo6iTg0hBkE",
"expires_at": "2026-10-07T05:31:00Z",
"url": "/v1/audio/speech/stream?token=Hq3x9Zt0bL7wKc2Vn5RdQe8yUf1aJm4sPo6iTg0hBkE"
}
```
`url` is a path. The page connects to your API host followed by that path, as
in the [browser example](#browser-example). A token:
* lasts 60 seconds and opens one socket;
* bills the workspace of the key that minted it, under that key's rate limit;
* stops working if that key is revoked.
A socket opened with a token can synthesise speech on your wallet until it
closes, for up to an hour. Mint tokens only for users you have signed in, one
per connection, right before the page connects, and rate-limit the route that
mints them.
### When the connection is refused
We check everything we can before the WebSocket upgrade. A refused connection
gets an ordinary HTTP response with the usual [error envelope](/v2/errors):
| Status | `code` | Why | What to do |
| :- | :- | :- | :- |
| 401 | `unauthorized` | No credentials, an unknown key, or an API key in the URL. | Send the key in the `Authorization` header, or use a stream token. |
| 401 | `invalid_token` | The stream token is unknown, expired or already used. | Mint a new token. Each one opens one socket. |
| 402 | `insufficient_balance` | Your wallet is empty. | [Top up](/v2/wallet#top-up). |
| 403 | `forbidden` | The key, or the key that minted the token, was revoked. | Use a current key. |
| 426 | `upgrade_required` | A plain HTTP request reached the endpoint. | Connect with a WebSocket client (`wss://`). |
| 429 | `at_capacity` | Your workspace already has 20 sockets open, or the service is at its own ceiling. `Retry-After: 5`. | Reuse one socket per call and close the ones you are done with. |
| 429 | `rate_limited` | Your key's request rate. | Honour `Retry-After`. |
| 503 | `unavailable` | The server is restarting. `Retry-After: 2`. | Reconnect in a moment. |
A successful upgrade carries `X-Session-Id: ttsws_…`. Quote it when you ask us
about a socket.
## How a call flows
```mermaid theme={null}
sequenceDiagram
participant You as Your voice agent
participant Mirai
You->>Mirai: connect (Authorization header or ?token=)
Mirai-->>You: session.ready
You->>Mirai: session.update (voice, ulaw_8000)
Mirai-->>You: session.updated
You->>Mirai: text (context_id "turn-1"), token by token
You->>Mirai: flush (context_id "turn-1")
Mirai-->>You: audio.start (sentence 1)
Mirai-->>You: audio frames
Mirai-->>You: audio.done (sentence 1)
Mirai-->>You: audio.start, audio frames, audio.done (sentence 2)
Mirai-->>You: context.done (turn-1)
Note over You,Mirai: next turn: a new context_id on the same socket
```
1. Connect. The first message you receive is `session.ready`, with the
settings in force and your limits.
2. Send `session.update` to pick a voice and an output format. You can skip it
if the defaults suit you.
3. For each turn of the conversation, pick a new `context_id` and send the
reply's text in `text` messages as your model produces it.
4. When the reply is complete, send `flush`.
5. Each sentence arrives as `audio.start`, then its audio, then `audio.done`.
After the last sentence of the turn you get `context.done`.
6. If the caller interrupts, send `cancel` for that context. See
[Barge-in](#barge-in).
7. Keep the socket for the next turn, and close it when the call ends.
## Messages you send
Every message is a JSON text frame with a `type`. A message we cannot accept is
answered with an [`error` event](#errors-on-an-open-socket) and changes
nothing. The socket stays open.
| `type` | Fields | What it does |
| :- | :- | :- |
| `session.update` | `voice`, `response_format`, `sample_rate`, `audio_transport`, `model`, all optional | Changes the settings for sentences cut after it. Answered with `session.updated`. |
| `text` | `context_id`, `text`, optional `flush` | Adds text to a context. Every sentence it completes is queued for synthesis. `"flush": true` also flushes. |
| `flush` | `context_id` | Sends whatever text the context still holds, however short. You get `context.done` once everything sent so far for that context has been spoken. |
| `cancel` | `context_id` | Stops the context now. See [Barge-in](#barge-in). Answered with `context.cancelled`. |
| `close` | none | Flushes every context, finishes speaking, then closes the socket. To stop at once, send `cancel` first or just close the socket. |
### `session.update`
| Field | Values | Default |
| :- | :- | :- |
| `voice` | `ashu`, `neha`, `shruti` or `sameer`. See [Voices](/v2/voices). | `neha` |
| `response_format` | `pcm`, `mulaw` (also `ulaw`, `pcm_mulaw`), `alaw` (also `pcm_alaw`), or a value with the rate built in: `pcm_8000`, `pcm_16000`, `pcm_22050`, `pcm_24000`, `pcm_44100`, `pcm_48000`, `ulaw_8000`, `ulaw_16000`, `alaw_8000`, `alaw_16000`. `wav` is not available on a stream. | `pcm` |
| `sample_rate` | `8000`, `16000`, `22050`, `24000`, `44100` or `48000`. μ-law and A-law come at `8000` or `16000` only. | `48000` for `pcm`, `8000` for μ-law and A-law |
| `audio_transport` | `binary` sends audio as binary frames. `base64` sends it inside `audio.chunk` events. | `binary` |
| `model` | `mira-tts` | `mira-tts` |
```json theme={null}
{ "type": "session.update", "voice": "shruti", "response_format": "ulaw_8000" }
```
We check every field before applying any of them, so a bad value leaves all
your settings as they were. If you change `response_format` without
`sample_rate`, you get that format's default rate. If `response_format`
includes a rate, leave `sample_rate` out or send the same value. Audio for
sentences already cut keeps the settings it was cut with, and every
`audio.start` tells you what its audio is.
`wav` is refused because a WAV header has to state the length of audio that
does not exist yet. Ask for `pcm` at the rate you need.
### `text`
```json theme={null}
{ "type": "text", "context_id": "turn-3", "text": "आपका order कल " }
```
A context is one reply, usually one turn of the conversation. `context_id` is
any string of 1 to 128 characters that you choose. Send the text exactly as
your model streams it, spaces included: we join the pieces, so a token without
its leading space runs into the word before it.
## Events you receive
| `type` | Fields | When |
| :- | :- | :- |
| `session.ready` | `session_id`, `model`, `voice`, `response_format`, `sample_rate`, `encoding`, `audio_transport`, `limits` | First message after you connect. |
| `session.updated` | Same as `session.ready` | After a valid `session.update`. |
| `audio.start` | `context_id`, `request_id`, `text`, `response_format`, `sample_rate`, `encoding` | Before a sentence's first audio. `text` is the sentence. |
| binary frame | Raw audio | Each piece of audio, with `audio_transport: binary`. |
| `audio.chunk` | `context_id`, `request_id`, `data` (base64) | Each piece of audio, with `audio_transport: base64`. |
| `audio.done` | `context_id`, `request_id`, `chars_billed`, `cost_paise`, `bytes`, `first_byte_ms` | The sentence was delivered in full. This is when it is charged. |
| `context.done` | `context_id` | A flush completed: everything sent for the context up to that flush has been spoken or has failed. |
| `context.cancelled` | `context_id` | Your `cancel` took effect. Nothing more for this context follows. |
| `error` | `code`, `message`, optional `context_id`, `request_id`, `retry_after_secs` | A sentence failed or a message was refused. See [Errors](#errors-on-an-open-socket). |
| `session.closed` | `reason` | We are about to close the socket. See [Closing](#closing). |
```json title="session.ready" theme={null}
{
"type": "session.ready",
"session_id": "ttsws_01K6Z8M3Q4R7T9V2X5Y8A1B4C7",
"model": "mira-tts",
"voice": "neha",
"response_format": "pcm",
"sample_rate": 48000,
"encoding": "pcm_s16le",
"audio_transport": "binary",
"limits": {
"max_segment_chars": 250,
"max_queued_chars": 10000,
"max_contexts": 64,
"idle_timeout_secs": 120,
"max_session_secs": 3600
}
}
```
```json title="audio.done" theme={null}
{
"type": "audio.done",
"context_id": "turn-1",
"request_id": "tts_01K6Z8N0B2D4F6H8K0M2P4R6T8",
"chars_billed": 41,
"cost_paise": 8,
"bytes": 19840,
"first_byte_ms": 212
}
```
### Audio
Binary frames carry raw audio with no header, always in whole samples:
* `pcm` is signed 16-bit little-endian mono (`encoding: pcm_s16le`), 2 bytes
per sample.
* μ-law and A-law are G.711, 1 byte per sample. At 8 kHz that is 8,000 bytes a
second, so 160 bytes is 20 ms.
A binary frame always belongs to the most recent `audio.start`. Use
`audio_transport: base64` only if your client cannot handle binary frames: the
same audio then arrives in `audio.chunk` events and takes about a third more
bytes.
## How text becomes sentences
We hold each context's text until one of these happens:
* A sentence ends: `.`, `?`, `!`, `।`, `॥` or `…`, optionally followed by a
closing quote or bracket, and then whitespace.
* A new line arrives.
* The text passes 250 characters (`limits.max_segment_chars`) with no sentence
end. We cut at the last `,` `;` `:` `—` `–`, or else at the last space.
* You send `flush`.
The rule needs whitespace after the stop, so `3.5` and `example.com` are never
split, and a stop at the very end of what you have sent waits for your next
token or a flush. A full stop after an abbreviation such as `Rs.`, `Dr.`,
`Mr.` or `e.g.`, or after a single initial, does not end a sentence. A sentence
shorter than two words joins the next one, so "Okay." is not sent on its own.
Lengths are counted in Unicode code points, which is also how characters are
billed.
## Ordering
These hold on every socket:
* Sentences are spoken one at a time, in the order they were cut, across all
contexts.
* Each `audio.start` is closed by exactly one `audio.done` or `error` with the
same `request_id`, or by `context.cancelled` for its context. The next
`audio.start` comes after that.
* So every binary frame belongs to the most recent `audio.start`.
* A context's `context.done` comes after its last sentence's `audio.done` or
`error`.
* Nothing for a context is sent after its `context.cancelled`.
## Barge-in
When the caller starts talking over your voice agent:
1. Send `cancel` with the `context_id` that is speaking.
2. Drop any audio you still receive for that context. Some may already be on
the wire. Track the speaking context from the latest `audio.start`, and
start playing again at the next `audio.start` for a different context.
3. Clear the audio you have already handed to your player or phone provider.
On Twilio, send a `clear` event; on Plivo, `clearAudio`; on Exotel, `clear`.
4. Use a new `context_id` for the next reply.
On our side, `cancel` discards the context's unsent text and its queued
sentences, and stops the sentence being synthesised straight away. None of it
is charged. You always get `context.cancelled`, even if the context had already
finished, so you never need to know whether you were too late.
## When your workspace is busy
Each sentence takes one synthesis slot from your workspace's
[concurrency limit](/v2/limits#text-to-speech), the same pool HTTP requests
use, while it is synthesised. An open socket that is not speaking holds no
slot.
When a sentence's turn comes and the workspace is full, it waits for a slot in
a queue shared by all your sockets, for up to 3 seconds. If no slot frees in
that time, you get an `error` with code `at_capacity` and `retry_after_secs`
for that sentence, and the socket moves on to the next one. Send that text
again if you still need it. pipecat-mirai does this once for you.
If you run many calls at once, ask your Mirai contact for a higher limit. Ask
for a little more than your peak number of simultaneous calls, because each
call starts its next sentence shortly before the current one finishes.
## Billing
The socket costs the same as the [HTTP endpoint](/v2/tts-quickstart#billing),
per character of text spoken:
* A sentence is charged when its `audio.done` is sent, and `cost_paise` in that
event is exactly what was debited.
* A sentence that is cancelled, fails, or is still undelivered when the socket
closes is not charged.
* Fractions of a paisa carry over from one sentence to the next, so the total
for a socket is its characters times the rate, rounded up once. A streamed
reply never costs more than sending the same text in one HTTP request, and
the spaces between sentences are not billed.
* Because of that carry, some sentences cost 0 paise. They appear in your
request log but get no wallet row.
Every sentence has its own row in your
[request log](/v2/tts-quickstart#your-request-log), with `transport` set to
`websocket`. Its `request_id` is the row's `id` and the `reference` on its
[wallet row](/v2/wallet#a-text-to-speech-row). Before each sentence we check
that your wallet can cover it. If it cannot, that sentence fails with
`insufficient_balance`.
## Limits
| Limit | Value | Over the limit |
| :- | :- | :- |
| Open sockets | **200 per workspace** | `429 at_capacity` before the upgrade, with `Retry-After: 5`. |
| Sentences being synthesised | Your workspace's TTS limit, **10** unless it has been set otherwise, shared with HTTP requests | The sentence waits up to 3 seconds, then `error` `at_capacity`. |
| One message | 64 KB | The socket is closed. |
| Text waiting to be spoken | 10,000 characters per socket | `error` `queue_full`. |
| Open contexts | 64 per socket | `error` `too_many_contexts`. |
| `context_id` | 1 to 128 characters | `error` `invalid_request`. |
| One sentence | 250 characters | Cut, as described in [How text becomes sentences](#how-text-becomes-sentences). |
| Idle socket | 120 seconds with no message from you and nothing playing | `session.closed` with `idle_timeout`. |
| Socket lifetime | 1 hour | `session.closed` with `max_duration`. |
| Stream token | 60 seconds, one socket | `401 invalid_token`. |
We send a WebSocket ping every 20 seconds and drop a client that has not
answered for 60 seconds. Most WebSocket libraries, and every browser, answer
pings for you. Pings do not count as activity for the idle limit: to keep a
quiet socket open, send `{"type": "session.update"}` with no fields every 30
seconds or so. It changes nothing.
If your client stops reading, we stop reading your messages until it catches
up, and a client that stays stuck is disconnected. Read the socket all the
time, and do slow work such as pacing audio to a phone line on another task.
## Errors on an open socket
An `error` event affects one message or one sentence. The socket stays open and
the next sentence goes ahead.
| `code` | What happened | What to do |
| :- | :- | :- |
| `invalid_request` | A message was malformed, or a field was invalid. `message` names it. Nothing changed. | Fix the message. |
| `model_not_found` | `session.update` named a model other than `mira-tts`. | Leave `model` out. |
| `queue_full` | The socket already holds 10,000 characters that have not been spoken. | Wait for `audio.done` events, or cancel a context. |
| `too_many_contexts` | 64 contexts are open. | Flush or cancel the ones you are done with. |
| `session_closing` | You sent text after `close`. | Open a new socket. |
| `at_capacity` | No synthesis slot freed within 3 seconds. Carries `retry_after_secs`. | Send the text again after that delay. |
| `insufficient_balance` | Your wallet cannot cover this sentence. | [Top up](/v2/wallet#top-up). |
| `upstream_error`, `first_byte_timeout`, `total_timeout` | The speech engine failed or stalled on this sentence. It was not charged. | Send the text again. |
| `fleet_offline` | No speech capacity is online. | Retry after `retry_after_secs`. |
## Closing
Close the socket yourself when the call ends. If you send `close` first, we
finish speaking what you have sent before closing. When we close a socket, we
send `session.closed` with a reason, then close it:
| `reason` | Close code | Meaning |
| :- | :- | :- |
| `client_close` | 1000 | You sent `close` and everything has been spoken. |
| `idle_timeout` | 1000 | Nothing from you and nothing playing for 120 seconds. |
| `max_duration` | 1000 | The socket reached its 1-hour limit. |
| `server_shutdown` | 1001 | We are restarting. Reconnect, and send again any text whose `audio.done` you had not received. |
A sentence that was not delivered when a socket closed is not charged.
## Python example
This script streams a model's reply into the socket token by token and saves
8 kHz μ-law, the format a phone line takes. Replace `llm_tokens` with your
model's stream.
```python stream_tts.py theme={null}
# pip install "websockets>=14"
"""Stream a reply into Mirai and save 8 kHz μ-law audio, sentence by sentence.
export MIRA_API_KEY=sk_live_...
python stream_tts.py
ffplay -f mulaw -ar 8000 reply.ulaw # listen to the result
"""
import asyncio
import json
import os
import websockets
URL = "wss://sandbox.voice.miraiminds.co/v1/audio/speech/stream"
async def llm_tokens():
"""Stand-in for your LLM's streamed reply."""
reply = "नमस्ते! आपका order कल शाम तक पहुँच जाएगा। Delivery से पहले हमारा agent आपको call करेगा।"
for word in reply.split(" "):
yield word + " "
await asyncio.sleep(0.03)
async def main():
headers = {"Authorization": f"Bearer {os.environ['MIRA_API_KEY']}"}
async with websockets.connect(URL, additional_headers=headers) as ws:
await ws.send(json.dumps(
{"type": "session.update", "voice": "neha", "response_format": "ulaw_8000"}))
async def send_reply(context_id):
async for token in llm_tokens():
await ws.send(json.dumps({"type": "text", "context_id": context_id, "text": token}))
await ws.send(json.dumps({"type": "flush", "context_id": context_id}))
sender = asyncio.create_task(send_reply("turn-1"))
with open("reply.ulaw", "wb") as out:
async for message in ws:
if isinstance(message, bytes):
out.write(message) # raw 8 kHz μ-law, ready for a phone line
continue
event = json.loads(message)
if event["type"] == "audio.start":
print("speaking:", event["text"])
elif event["type"] == "audio.done":
print(f" delivered: {event['chars_billed']} characters, {event['cost_paise']} paise")
elif event["type"] == "error":
print("error:", event["code"], event["message"])
elif event["type"] == "context.done" and event["context_id"] == "turn-1":
break
await sender
asyncio.run(main())
```
With `websockets` older than 14, pass `extra_headers=` instead of
`additional_headers=`.
### Bridge to a phone call
To play the audio on a Twilio call, forward each binary frame to the Media
Stream instead of writing it to a file. The class below does that for one call.
It keeps Twilio at most 0.4 seconds ahead of what the caller hears, which
absorbs short stalls on your server without making barge-in slow, and it
handles barge-in. Plivo and Exotel work the same way with their own message
names, and Exotel takes `pcm_8000` instead of μ-law (see
[For phone calls](/v2/tts-quickstart#for-phone-calls)).
```python phone_leg.py theme={null}
import asyncio
import base64
import json
import time
LEAD_SECS = 0.4 # how far ahead of the caller the provider may be
CHUNK = 160 # 20 ms of 8 kHz μ-law
class PhoneLeg:
"""Plays Mirai's ulaw_8000 audio on one Twilio Media Stream."""
def __init__(self, twilio_ws, stream_sid):
self.twilio_ws = twilio_ws # the Media Stream WebSocket in your FastAPI app
self.stream_sid = stream_sid
self.until = 0.0 # when the audio sent so far finishes playing
self.speaking = None # context_id of the latest audio.start
self.cancelled = set()
async def on_mirai_message(self, message):
"""Call this for every message you read from the Mirai socket."""
if isinstance(message, str):
event = json.loads(message)
if event["type"] == "audio.start":
self.speaking = event["context_id"]
return
for i in range(0, len(message), CHUNK):
chunk = message[i:i + CHUNK]
await self._pace(len(chunk) / 8000) # μ-law: 8,000 bytes per second
if self.speaking in self.cancelled:
return # the caller interrupted: drop the rest
await self.twilio_ws.send_text(json.dumps({
"event": "media",
"streamSid": self.stream_sid,
"media": {"payload": base64.b64encode(chunk).decode()},
}))
async def barge_in(self, mirai_ws, context_id):
"""Call this when your speech detection hears the caller talk over the bot."""
self.cancelled.add(context_id)
await mirai_ws.send(json.dumps({"type": "cancel", "context_id": context_id}))
await self.twilio_ws.send_text(json.dumps({"event": "clear", "streamSid": self.stream_sid}))
self.until = 0.0
async def _pace(self, secs):
now = time.monotonic()
self.until = max(self.until, now)
await asyncio.sleep(max(0.0, self.until - LEAD_SECS - now))
self.until += secs
```
Read the Mirai socket in its own task and pass each message to
`on_mirai_message`. The pacing then waits in that task while the rest of your
voice agent keeps running. The same pacing is what `apply_output_lead` does in
[Pipecat](/v2/pipecat#recommended-phone-setup).
## Browser example
A page that speaks typed text, with a **Stop** button for barge-in. Your
backend mints the token, so the API key stays on your server. `requireSignedIn`
stands in for your own sign-in check.
```js server.js theme={null}
// Your backend (Express). The API key never leaves it.
app.post("/api/tts-token", requireSignedIn, async (req, res) => {
const r = await fetch("https://sandbox.voice.miraiminds.co/v2/tts/stream/tokens", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MIRA_API_KEY}` },
});
if (!r.ok) return res.status(502).json({ error: "could not mint a stream token" });
const { url } = await r.json();
res.json({ url: "wss://sandbox.voice.miraiminds.co" + url });
});
```
The page plays 24 kHz PCM through an `AudioWorklet` and holds 150 ms of audio
before each sentence starts, so a short first chunk cannot start playback and
then stall.
```html index.html theme={null}
```
A token opens one socket, so `connect` mints a new one each time it needs to
reconnect, for example after the socket has been idle for two minutes.
## Related
The HTTP endpoint, output formats, billing and the request log.
`MiraiTTSService` and the recommended phone setup.
Symptoms, causes and fixes for gaps and breaks.
Concurrency, sockets and rate limits.
# Voices
Source: https://docs.miraiminds.co/v2/voices
How to choose a voice_id for your agent. The catalogue is the same on every tier, and the language field has one caveat.
Every agent speaks in one voice, set by `voice.voice_id`. Every
[tier](/general/tiers) uses the same Mira voices, so the catalogue does not
change with your plan.
## The catalogue
| `voice_id` | Voice |
| :- | :- |
| `ashu` | Male |
| `neha` | Female |
| `shruti` | Female |
| `sameer` | Male |
All four are Hindi voices that code-switch into English the way real Indian
speech does. **`voice` is required** when you create an agent. A voice outside
the catalogue is rejected when you create the agent, with an error that names
the allowed set:
```json title="400 Bad Request" theme={null}
{
"error": {
"code": "invalid_request",
"message": "unknown voice_id \"priya\" — voice.voice_id must be one of ashu, neha, sameer, shruti"
}
}
```
Because the catalogue is the same on every tier, an agent's voice never depends
on your plan. Moving to a different plan changes what your calls cost and
leaves the way they sound unchanged.
## Hearing them first
`GET /v2/voices`, with your API key, returns the live catalogue: each voice's
`voice_id`, its name and its language. It has no audio samples, so open Agent
Studio in the [console](https://sandbox.voice.miraiminds.co) to hear each voice
before you commit to one. The name in the picker is the `voice_id`. The
catalogue grows monthly, so if this page and the API disagree, trust the API.
```json title="GET /v2/voices" theme={null}
{
"data": [
{ "voice_id": "ashu", "name": "आशु", "language": "hi" },
{ "voice_id": "neha", "name": "नेहा", "language": "hi" },
{ "voice_id": "shruti", "name": "श्रुति", "language": "hi" },
{ "voice_id": "sameer", "name": "समीर", "language": "hi" }
]
}
```
**Listen before you pick.** Voice choice affects 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": "ashu", "language": "hi-IN" },
"language": "hi-IN"
}
```
| Field | Type | Description |
| :- | :- | :- |
| `voice_id` | string | **Required.** A voice from [the catalogue](#the-catalogue), which is the same on every tier. An unknown voice returns `400`. |
| `language` | string | Rendering language. Omit it to inherit the agent's top-level `language`. **Accepted and stored, but 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": "neha", "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**
We validate it, store it, echo it back and carry it with the call payload, but
the voice renders in the agent's top-level `language`. If the two match, you
get what you expect. If they differ, 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, [text-to-speech](/v2/tts-quickstart) requests debit it when they
complete, [AI flow drafts](/v2/node-agents#what-a-draft-costs) and
[recording transcriptions](/v2/node-agents#what-transcription-costs) debit it
when they finish, [offline transcriptions](/v2/transcribe-recorded-calls#pricing)
debit it when a job completes, and top-ups credit it. If the wallet cannot
cover a minute, `POST /v2/calls` returns [`402`](#402-insufficient-balance) and no call
is placed.
For rates and how call time is counted — 10-second blocks, rounded up — 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": 5,
"balance_after_inr": 495,
"call_id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA",
"kind": "call",
"description": "call 96s @ t3",
"billed_seconds": 100,
"per_min_inr": 3,
"billing_block_secs": 10,
"pricing": {
"voice_rate_paise": 300,
"insights_rate_paise": 0,
"telco_rate_paise": 0,
"telco_block_seconds": 0,
"rate_paise": 300,
"block_seconds": 10,
"insights_enabled": false
},
"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. |
| `kind` | string | The transaction category: `call`, `tts`, `flow_generation`, `transcription`, `number_rental`, `promotion` or `monthly_minimum`. **Branch on this**, never on `description`, and tolerate new categories. |
| `reference` | string | The id of the thing billed: the `tts_…` request id on a `tts` row; the `fgen_…` generation id, or the `ftr_…` transcription id, on a `flow_generation` row; the `trn_…` job id on a `transcription` row. |
| `quantity` | number | How much was billed: characters on a `tts` row, tokens (input plus output) on a `flow_generation` row, seconds of audio (rounded up) on a `transcription` row. |
| `description` | string | Human-readable. Do not parse it. |
| `promotional_inr` | number | Promotional credit granted on a `promotion` row, or the portion of a debit paid with promotional credit. Omitted when zero. Promotional credit does not count towards a monthly minimum. |
| `pricing` | object | The rates snapshotted when the call was admitted: voice AI, optional T1 insights, and any managed provider rate and billing block. Absent on historical rows. |
The call amount combines voice and insights at the voice billing block, rounds
that charge up to a paisa, then adds the separately rounded provider charge.
BYO telco,
WhatsApp and browser calls have no Mirai telco component.
**`kind`, `reference` and `quantity` are additive.** Rows written before they
existed carry no `kind` — read a missing `kind` as `call`. Nothing else about
the shape changed, so a reader that ignores all three keeps working.
Reconcile against `call_id`: every billable call produces exactly one debit row.
### A text-to-speech row
[Speech requests](/v2/tts-quickstart#billing) debit the same wallet, at
₹1.80 per 1,000 characters, and only when the audio was delivered in full:
```json theme={null}
{
"id": "txn_01K4N7Q2R5S8T4V7W9X1Y3ZB6C",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 0.12,
"balance_after_inr": 494.88,
"call_id": null,
"kind": "tts",
"reference": "tts_01K4N7Q0V8ZR3M6D9F2H5J7K1P",
"quantity": 62,
"description": "Text to speech · 62 characters · shruti",
"created_at": "2026-09-09T09:16:39Z"
}
```
`reference` is the `X-Request-Id` the speech request returned, so a row
reconciles against your [request log](/v2/tts-quickstart#your-request-log) the
way a call row reconciles against `call_id`.
On a [WebSocket stream](/v2/tts-websocket#billing), each spoken sentence is its
own row, and `reference` is the `request_id` from that sentence's `audio.done`
event. Fractions of a paisa carry over between sentences on a socket, so a
sentence whose share rounds to nothing has a request-log row but no wallet
row.
### A flow generation row
Each [AI flow draft](/v2/node-agents#what-a-draft-costs) is one debit, at the
provider's reported cost plus 20% (the chosen model's
[token rates](/general/tiers#ai-flow-drafts) when the provider reports none):
```json theme={null}
{
"id": "txn_01K8D5J7L9N1Q3S5U7W9Y1A3C5",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 7.02,
"balance_after_inr": 487.86,
"call_id": null,
"kind": "flow_generation",
"reference": "fgen_01K8D5H2K4M6P8R0T2V4X6Z8B0",
"quantity": 4532,
"description": "Flow generation · claude-opus-5.5 · 1,512 in / 3,020 out tokens",
"created_at": "2026-09-28T10:22:07Z"
}
```
`reference` is the `id` the generation returned, and `quantity` is its input
and output tokens together. A generation that failed or was interrupted is
charged the same way, and its description says so, for example
`Flow generation · gpt-6-sol · failed · 1,200 in / 2,400 out tokens`.
### A recording transcription row
Each [recording transcription](/v2/node-agents#what-transcription-costs) is one
debit of the same kind, at the provider's reported cost plus 20%:
```json theme={null}
{
"id": "txn_01K8E2G6J8L0N2Q4S6U8W0Y2A4",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 1.95,
"balance_after_inr": 485.91,
"call_id": null,
"kind": "flow_generation",
"reference": "ftr_01K8E2F4H6K8M0P2R4T6V8X0Z2",
"quantity": 4634,
"description": "Recording transcription · gemini-3.8-flash · 1m41s · 2,717 in / 1,917 out tokens",
"created_at": "2026-09-29T11:04:52Z"
}
```
`reference` is the `id` the transcription returned, and `quantity` is its input
and output tokens together; the description gives the recording's length. A
recording in which no conversation was heard is charged the same way, and its
description says so, for example
`Recording transcription · gemini-3.8-flash · failed · 12s · 410 in / 96 out tokens`.
Tell a transcription from a draft by its `reference` prefix, `ftr_` or `fgen_`.
### An offline transcription row
Each completed [offline transcription](/v2/transcribe-recorded-calls#pricing)
is one debit with `kind: "transcription"`, at ₹18 per hour of audio. Degraded
and failed jobs write no row.
```json theme={null}
{
"id": "txn_01M3XRSP44KR3116AZ578NC2F1",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 0.12,
"balance_after_inr": 8762.06,
"call_id": null,
"kind": "transcription",
"reference": "trn_01M3XRSFXW77RHEQ85979AJXAF",
"quantity": 24,
"description": "Transcription · 23s of audio · mira-transcribe-offline-1",
"created_at": "2026-10-02T07:38:26Z"
}
```
`reference` is the job's `id`, and `quantity` is the billed length in whole
seconds, rounded up. These rows are separate from the recording transcriptions
above, which turn calls into a flow and are billed as `flow_generation`.
## Top up
Once your company has gone live, add credit yourself from the **Billing** page
of the [console](https://sandbox.voice.miraiminds.co), whenever you need it.
Credit is spent at your workspace's agreed rates. New T3 and T1 agreements
fund their monthly minimum upfront into this wallet. Paid usage counts towards
the minimum; any unused minimum is debited at the end of the billing month as a
`monthly_minimum` transaction. The minimum is not an extra fee on top of usage.
Top-ups above it remain available. Existing agreements retain their recorded
funding terms. Before you go live, your Mirai contact credits the workspace.
Promotional offers appear as `promotion` credits and are used before paid
credit. They do not replace upfront monthly funding or satisfy the minimum.
The **Billing** page shows your promotional balance, billing period and
minimum-spend progress.
A credit appears as a `credit` row and takes effect immediately. Your live
balance is always `GET /v2/wallet`, and on the **Developers** page of the
console. There is no top-up endpoint in the API.
## `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());
}
```
# Browser calls
Source: https://docs.miraiminds.co/v2/web-calls
Run a voice conversation in your user's browser. You create the call on your server, stream audio and live text in the page, steer or close the call, and receive the final transcript.
A **browser call** runs an [agent](/v2/agents) with your user's microphone and
speakers instead of a phone line. Otherwise it is the same as a phone call: it
uses the same agent, [tier](/general/tiers) and [webhooks](/v2/webhooks), and it
is billed the same way from the moment audio goes live. You create it with
`channel: "web"` and no `to`.
Your backend keeps the API key. It creates the call and hands the page two
short-lived links, one for audio and one for live events. The page never sees
a secret key.
If you are building a timed, spoken session such as an interview, the
[timed voice interviews cookbook](/cookbooks/browser-voice-interviews) puts
this page together end to end, with a backend, a page and scoring.
The base URL is `https://sandbox.voice.miraiminds.co`. API requests use
`Authorization: Bearer sk_live_…`.
## How it fits together
1. Your page asks your backend to start a session.
2. Your backend calls [`POST /v2/calls`](#create-the-call) with
`channel: "web"`, stores the returned call `id` beside its own session, and
returns `ws_url` and `events_url` to the page.
3. The page opens the [audio socket](#audio-socket) at `ws_url` and the
[event stream](#live-events) at `events_url`. The call moves from `queued` to
`in_progress`, and `call.started` is sent to your webhook.
4. While the call runs, the page shows each turn and the agent's state. Your
backend can [send an instruction or close the call](#live-control).
5. The call ends. The page receives `call.ended` on the event stream. Your
webhook receives one terminal event and then, if you opted in,
`call.processed` with the [final transcript](#ending-and-results).
```mermaid theme={null}
sequenceDiagram
participant Page as Your page
participant Backend as Your backend
participant API as Mirai Voice
Page->>Backend: start a session
Backend->>API: POST /v2/calls (channel web)
API-->>Backend: 202 {id, ws_url, events_url}
Backend-->>Page: ws_url, events_url
Page->>API: open ws_url (audio)
Page->>API: open events_url (live events)
API-->>Backend: call.started
Note over Page,API: conversation, live text, agent state
Backend->>API: POST /v2/calls/{id}/control (optional)
API-->>Page: call.ended
API-->>Backend: call.completed (or call.failed / call.aborted)
API-->>Backend: call.processed (final results)
```
A browser call never rings anything, so it skips `dialing`: it stays `queued`
until the page connects its audio, then goes straight to `in_progress`.
***
## Create the call
```http theme={null}
POST /v2/calls
```
Create the call from your backend, with the same request as a
[phone call](/v2/calls#create-a-call), plus `channel: "web"` and without `to`.
Sending `to` with `channel: "web"` returns `400 invalid_request`, because no
number is ever dialled.
The fields below matter most for browser calls. The full list, with limits,
is in [Calls](/v2/calls#request).
| Field | Description |
| :- | :- |
| `channel` | `"web"`. |
| `metadata` | Your own identifiers, such as `session_id` and `user_id`. Returned unchanged on the call object and in every webhook for this call. Never shown to the agent. See [metadata](/v2/calls#metadata). |
| `first_message` | The opening line for this call only, up to 500 characters. Replaces the agent's stored `first_message`; `{{variable}}` placeholders work the same way. Not accepted for a [node-based agent](/v2/node-agents): its start node opens the call. |
| `max_duration_secs` | Hard cap for this call. `600` is a 10-minute call. |
| `timing` | Wrap up, close and silence timers. See [timing policy](#timing-policy). |
| `recording_enabled` | `false` stores no audio for this call. The transcript and live text still work. |
| `final_results` | `true` sends one `call.processed` webhook with the finished transcript. See [ending and results](#ending-and-results). |
| `webhook_url` | Where this call's events go. Applies to this call only. |
Send an `Idempotency-Key` derived from your own session, so a retried request
never creates a second call.
```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: voice-session-sess_8f2c41" \
-d '{
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"channel": "web",
"variables": { "first_name": "Asha" },
"metadata": { "session_id": "sess_8f2c41", "user_id": "usr_1042" },
"first_message": "Hi {{first_name}}, thanks for joining. Shall we start?",
"max_duration_secs": 600,
"timing": {
"wrap_up_secs_before_end": 120,
"wrap_up_instruction": "Time is nearly up. Ask no new questions; summarise and wrap up.",
"close_secs_before_end": 30,
"close_message": "That is all we have time for. Thank you, and goodbye!",
"user_silence_close_secs": 60,
"user_silence_message": "It seems you have stepped away, so I will end here. Goodbye!"
},
"recording_enabled": false,
"final_results": true,
"webhook_url": "https://example.com/mirai/webhook"
}'
```
```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": "voice-session-sess_8f2c41"},
json={
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"channel": "web",
"variables": {"first_name": "Asha"},
"metadata": {"session_id": "sess_8f2c41", "user_id": "usr_1042"},
"first_message": "Hi {{first_name}}, thanks for joining. Shall we start?",
"max_duration_secs": 600,
"timing": {
"wrap_up_secs_before_end": 120,
"wrap_up_instruction": "Time is nearly up. Ask no new questions; summarise and wrap up.",
"close_secs_before_end": 30,
"close_message": "That is all we have time for. Thank you, and goodbye!",
"user_silence_close_secs": 60,
"user_silence_message": "It seems you have stepped away, so I will end here. Goodbye!",
},
"recording_enabled": False,
"final_results": True,
"webhook_url": "https://example.com/mirai/webhook",
},
timeout=30,
)
call = r.raise_for_status().json()
# Hand only these two links to the page.
links = {"ws_url": call["ws_url"], "events_url": call["events_url"]}
```
```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": "voice-session-sess_8f2c41",
},
body: JSON.stringify({
agent_id: "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
channel: "web",
variables: { first_name: "Asha" },
metadata: { session_id: "sess_8f2c41", user_id: "usr_1042" },
first_message: "Hi {{first_name}}, thanks for joining. Shall we start?",
max_duration_secs: 600,
timing: {
wrap_up_secs_before_end: 120,
wrap_up_instruction: "Time is nearly up. Ask no new questions; summarise and wrap up.",
close_secs_before_end: 30,
close_message: "That is all we have time for. Thank you, and goodbye!",
user_silence_close_secs: 60,
user_silence_message: "It seems you have stepped away, so I will end here. Goodbye!",
},
recording_enabled: false,
final_results: true,
webhook_url: "https://example.com/mirai/webhook",
}),
});
const call = await res.json();
if (!res.ok) throw new Error(`${call.error.code}: ${call.error.message}`);
// Hand only these two links to the page.
const links = { ws_url: call.ws_url, events_url: call.events_url };
```
```json title="202 Accepted" theme={null}
{
"id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"status": "queued",
"transport": "web",
"ws_url": "wss://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/media?token=Qm9vdHN0cmFwLW1lZGlhLXRva2Vu",
"ws_url_expires_at": "2026-09-27T10:05:00Z",
"events_url": "https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/events?token=ZXZlbnRzLXRva2VuLWV4YW1wbGU"
}
```
| Field | Description |
| :- | :- |
| `id` | The call ID. Store it beside your session. It is the `data.call.id` of every webhook for this call. |
| `status` | `queued` until the page connects its audio. |
| `transport` | `web`. |
| `ws_url` | The [audio socket](#audio-socket). **Single use.** Connect within 5 minutes. |
| `ws_url_expires_at` | When an unused `ws_url` stops working. RFC 3339, UTC. |
| `events_url` | The [live event stream](#live-events). Reusable for the whole call and for one hour after it ends. |
Both URLs are absolute and ready to use. Pass them to the page as they are,
and do not build them yourself.
**The two links are credentials for this one call.** Anyone holding `ws_url`
can speak to your agent; anyone holding `events_url` can read the conversation.
Send them only to the page running this call, over HTTPS, and keep them out of
logs and analytics. Never send your `sk_live_` key to a browser.
How the links behave in these cases:
* **The page never connects.** If no audio connects before `ws_url` expires,
the call ends as `failed` with `ended_reason: "no-media"` and is not billed.
* **You retry the create request.** With the same `Idempotency-Key`, you get the
same call. The replay includes `ws_url` only while that link is still unused.
* **The audio drops mid-call.** `ws_url` cannot be used twice, so a dropped audio
socket cannot be reconnected. Create a new call to continue.
***
## Audio socket
Open `ws_url` as a WebSocket from the page. Audio travels as binary messages in
both directions; the server also sends one kind of text message.
| Direction | Message | Content |
| :- | :- | :- |
| Browser → Mirai | Binary | Microphone audio: raw PCM, 16-bit signed little-endian, mono, **48,000 Hz**, with no header or container. |
| Mirai → browser | Binary | The agent's audio, in the same format: PCM16, mono, 48,000 Hz. Play it in the order it arrives. |
| Mirai → browser | Text `{"event":"clear"}` | The user interrupted the agent. Discard all agent audio you have queued but not yet played. |
* **Send small frames, continuously.** 20 ms per message (960 samples, 1,920
bytes) works well.
* **To mute, send silence.** Keep sending frames filled with zeros instead of
stopping. The agent then hears a quiet line, not a dropped one.
* **Keep echo cancellation on** (`echoCancellation: true`), so a user on
speakers does not feed the agent its own voice.
* **Ignore anything else.** Text messages you send are ignored. Skip any text
message from the server that you do not recognise.
* **Closing the socket ends the conversation.** Do it when the user leaves the
page.
Browsers allow microphone access only on secure pages: serve the page over
HTTPS (`localhost` is fine while you build). The socket accepts any origin that
presents a valid token, so the page can live on your own domain.
The browser does not tell your code why a WebSocket or EventSource was refused.
If the audio socket closes before it opens, the link has expired or was already
used. Create a new call.
***
## Live events
Open `events_url` with `EventSource`. It is a
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
stream: the page receives the agent's state and each completed turn as the
conversation happens.
```javascript theme={null}
const events = new EventSource(events_url);
events.addEventListener("transcript.turn", (e) => {
const { role, text } = JSON.parse(e.data).data;
});
```
### Framing
Each event is one SSE message with three lines:
```text theme={null}
id: 1790503302114-0
event: transcript.turn
data: {"v":1,"seq":7,"type":"transcript.turn","call_id":"call_01K8B2M4Q6S8T0V2X4Z6A8C0EG","at":"2026-09-27T10:01:42.114Z","data":{"role":"user","text":"I have about three years of experience with that.","turn":4,"interrupted":false}}
```
* `id` is the position in the stream. Treat it as opaque: it is what a
reconnect resumes from.
* `event` is the event type. Because every event is named, listen with
`addEventListener(type, …)`. `onmessage` receives nothing.
* `data` is a JSON envelope:
| Field | Description |
| :- | :- |
| `v` | Schema version. `1`. |
| `seq` | Sequence number of the event within this call, increasing. |
| `type` | `agent.state`, `transcript.turn` or `call.ended`, plus `flow.node.entered` and `flow.variables.extracted` for a [node-based agent](/v2/node-agents). The same value as the SSE `event` line. |
| `call_id` | The call this event belongs to. |
| `at` | When it happened. RFC 3339, UTC. |
| `data` | The type-specific payload below. |
Treat the envelope as additive: new fields and new event types can appear.
Ignore what you do not recognise.
### `agent.state`
```json theme={null}
{
"v": 1, "seq": 5, "type": "agent.state",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"at": "2026-09-27T10:01:43.020Z",
"data": { "state": "speaking" }
}
```
| `state` | Meaning |
| :- | :- |
| `listening` | Waiting for the user. The agent returns here when its audio stops. |
| `processing` | The user finished a turn; the agent is preparing its reply. |
| `speaking` | The agent's audio is playing. This follows the actual audio, not the text being written, so it is safe to drive a "speaking" indicator or to mute the microphone. |
| `ended` | The conversation is over. `call.ended` follows. |
The page's own playback buffer can add a short delay between `speaking` and
the moment sound comes out of the speaker.
### `transcript.turn`
One event per completed turn, for both speakers.
```json theme={null}
{
"v": 1, "seq": 9, "type": "transcript.turn",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"at": "2026-09-27T10:01:51.380Z",
"data": {
"role": "assistant",
"text": "Thanks. Could you tell me about a project where",
"turn": 5,
"interrupted": true
}
}
```
| Field | Description |
| :- | :- |
| `role` | `user` or `assistant`. |
| `text` | What was said in that turn. |
| `turn` | The turn's number within the call. |
| `interrupted` | `true` when the user cut the agent off. `text` is then what the user actually heard, not the full reply the agent had planned. |
Text arrives once per **completed** turn: the user's after they finish
speaking, the agent's after its turn ends. There are no word-by-word captions
yet.
### `flow.node.entered`
Sent only for a [node-based agent](/v2/node-agents): the call entered a node.
The start node sends one too, with `previous_id: null`. It arrives before the
reply that node produces, so a page can show the current step as it changes.
```json theme={null}
{
"v": 1, "seq": 5, "type": "flow.node.entered",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"at": "2026-09-27T10:00:08.910Z",
"data": { "node_id": "slot", "name": "Offer the delivery slot", "previous_id": "identity" }
}
```
| Field | Description |
| :- | :- |
| `node_id` | The `id` of the node the call entered. |
| `name` | That node's `name`. |
| `previous_id` | The `id` of the node the call left. `null` for the start node. |
### `flow.variables.extracted`
Sent only for a node-based agent: a node's extraction saved values. It arrives
shortly after the call leaves a node with `extraction_enabled`, and only when
something was extracted.
```json theme={null}
{
"v": 1, "seq": 7, "type": "flow.variables.extracted",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"at": "2026-09-27T10:00:09.640Z",
"data": { "node_id": "identity", "variables": { "identity_confirmed": true } }
}
```
| Field | Description |
| :- | :- |
| `node_id` | The node whose extraction ran. |
| `variables` | The names that node asked for, with the values extracted: strings, numbers or booleans. |
### `call.ended`
Always the last event. The stream closes after it.
```json theme={null}
{
"v": 1, "seq": 31, "type": "call.ended",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"at": "2026-09-27T10:09:30.412Z",
"data": { "reason": "time-limit-close", "status": "completed" }
}
```
| Field | Description |
| :- | :- |
| `reason` | Why the conversation ended, from the [ended reason](/v2/calls#ended-reasons) vocabulary. |
| `status` | The call's final [status](/v2/calls#statuses), when it is already known. It can be missing. |
`call.ended` is sent even when the conversation ends abnormally. Use it to end
the session in the page. The [webhook](#ending-and-results) is the record your
backend acts on.
### Reconnecting
* **Automatic resume.** `EventSource` reconnects on its own and sends the
`Last-Event-ID` header. The stream continues after that event, so no turn is
missed or repeated.
* **Manual resume.** Add `after=` to `events_url` to start after a given
event, for example after a page reload.
* **One-hour buffer.** Events are kept for one hour after the call ends.
Opening the stream for a finished call replays what happened, then closes.
* **Close it yourself.** Call `events.close()` when you receive `call.ended`.
Otherwise `EventSource` keeps reconnecting to a stream that has nothing left
to send.
* **Keep-alives.** The server sends an SSE comment every 15 seconds.
`EventSource` ignores these; they keep proxies from closing an idle stream.
The stream allows every origin (`Access-Control-Allow-Origin: *`) and uses no
cookies: the token in `events_url` is the credential. Your backend can read the
same stream with `Authorization: Bearer sk_live_…` instead of the token:
```bash theme={null}
curl -N https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/events \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
***
## Live control
```http theme={null}
POST /v2/calls/{id}/control
```
Steer or close a browser call while it is in progress. Send it from your
backend with your API key. If the user starts the action in the page, the page
asks your backend.
| Field | Required | Description |
| :- | :- | :- |
| `type` | yes | `instruction` or `close`. |
| `text` | `instruction` only | 1 to 1,000 characters. The direction to give the agent. |
| `message` | no | `close` only, up to 500 characters. The goodbye line to speak. |
* **`instruction`** adds your text to the agent's context. The agent follows it
from its next reply for the rest of the call. The text is not read aloud.
* **`close`** speaks `message`, then ends the call. Without `message`, the agent
speaks its [`end_call.message`](/v2/agents#ending-a-call), or else a short
goodbye of its own. The call ends even if the agent would not have chosen
to end it. It ends `completed` with `ended_reason: "api-ended-call"`.
```bash theme={null}
# Steer
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/control \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sess_8f2c41-wrap-up" \
-d '{ "type": "instruction", "text": "Stop asking new questions and start wrapping up." }'
# Close with a goodbye
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/control \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sess_8f2c41-close" \
-d '{ "type": "close", "message": "Thank you for your time today. Goodbye!" }'
```
```python theme={null}
def control(call_id: str, body: dict, key: str) -> dict:
r = httpx.post(
f"{API}/v2/calls/{call_id}/control",
headers={**auth, "Idempotency-Key": key},
json=body,
timeout=30,
)
return r.raise_for_status().json()
control(call_id, {"type": "instruction",
"text": "Stop asking new questions and start wrapping up."},
key="sess_8f2c41-wrap-up")
control(call_id, {"type": "close",
"message": "Thank you for your time today. Goodbye!"},
key="sess_8f2c41-close")
```
```javascript theme={null}
async function control(callId, body, key) {
const res = await fetch(`${API}/v2/calls/${callId}/control`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(body),
});
const out = await res.json();
if (!res.ok) throw new Error(`${out.error.code}: ${out.error.message}`);
return out; // out.status: applied | pending | rejected | failed
}
await control(callId, {
type: "instruction",
text: "Stop asking new questions and start wrapping up.",
}, "sess_8f2c41-wrap-up");
```
```json title="200 OK" theme={null}
{
"id": "ctl_01K8B3F7H9K1M3P5R7T9V1X3Z5",
"object": "call_control",
"call_id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"type": "instruction",
"status": "applied",
"created_at": "2026-09-27T10:08:00.120Z",
"applied_at": "2026-09-27T10:08:00.410Z"
}
```
The response is `200` when the live call answered within 3 seconds, with
`applied` or `rejected`. Otherwise it is `202` with `status: "pending"`. Check
a pending control with `GET`:
```http theme={null}
GET /v2/calls/{id}/control/{control_id}
```
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K8B2M4Q6S8T0V2X4Z6A8C0EG/control/ctl_01K8B3F7H9K1M3P5R7T9V1X3Z5 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
| `status` | Meaning |
| :- | :- |
| `pending` | Sent, not yet confirmed by the live call. Check again. |
| `applied` | The live call accepted it. `applied_at` is set. |
| `rejected` | The live call refused it. `reason` says why: `call_ending` (the call is already ending) or `already_closed` (a `close` was already applied). |
| `failed` | It could not be applied. |
**Idempotency.** Send an `Idempotency-Key`. A repeat with the same key returns
the same control record and is never delivered twice, so a retry cannot make
the agent say goodbye twice.
| Status | `error.code` | Cause |
| :- | :- | :- |
| `400` | `invalid_request` | Unknown `type`, `text` missing or longer than 1,000 characters, `message` longer than 500 |
| `400` | `unsupported_channel` | The call is not a browser call. Live control is for `channel: "web"` only. |
| `404` | `not_found` | Unknown call ID, or a call in another workspace |
| `409` | `call_not_active` | The call is not in progress: still `queued` (the page has not connected yet), or already ended |
### Close or abort
Both end a live browser call, but they behave differently.
| | `close` control | [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call) |
| :- | :- | :- |
| What the user hears | A goodbye line, then the call ends | Nothing more. Audio stops at once. |
| Browser audio | Closed after the goodbye has played | Closed immediately |
| Final status | `completed`, `ended_reason: "api-ended-call"` | `aborted`, `ended_reason: "aborted-by-api"` |
| Terminal webhook | `call.completed` | `call.aborted` |
| Billing | Billed like any completed call | Not billed |
Use `close` for a graceful ending the user hears. Use `abort` when the
conversation must stop now. If a `close`, an `abort`, the user leaving, and a
timer all happen at once, the call still ends exactly once: one terminal status,
one terminal webhook, and at most one goodbye.
***
## Timing policy
`timing` on [`POST /v2/calls`](#create-the-call) lets the call end gracefully
instead of being cut off at its hard cap. It works on browser and phone calls.
Every field is optional.
| Field | Range | What happens |
| :- | :- | :- |
| `wrap_up_secs_before_end` | 10 to `max_duration_secs` − 10 | At this many seconds before the cap, `wrap_up_instruction` is given to the agent, like an [`instruction`](#live-control) control. |
| `wrap_up_instruction` | up to 1,000 characters | The direction the agent follows from then on. |
| `close_secs_before_end` | 5 to `max_duration_secs` − 10 | At this many seconds before the cap, the agent speaks `close_message` and the call ends, `completed` with `ended_reason: "time-limit-close"`. Must be smaller than `wrap_up_secs_before_end` when both are set. |
| `close_message` | up to 500 characters | The goodbye line for the timed close. |
| `user_silence_close_secs` | 15–600 | After this many seconds without the user speaking, the agent speaks `user_silence_message` and the call ends, `completed` with `ended_reason: "user-silence-close"`. |
| `user_silence_message` | up to 500 characters | The goodbye line for the silence close. |
`max_duration_secs` here is the call's effective cap: the value on the request,
or else the agent's. Set each timer together with its message.
How the clock works:
* **It starts when audio goes live**: when the page connects, or when the phone
is answered. Time spent `queued` does not count.
* **The hard cap still applies.** If a timed close cannot finish, the call still
ends at `max_duration_secs` (`status: timeout`,
`ended_reason: "exceeded-max-duration"`).
* **Only the user resets the silence timer.** The agent's own speech does not
count as a user response.
* **Placeholders work.** `{{variable}}` placeholders in the messages and the
instruction are filled from the call's `variables`.
For `max_duration_secs: 600` with the example above:
| Time after audio starts | Event |
| :- | :- |
| 0:00 | Conversation starts. |
| 8:00 | The agent receives `wrap_up_instruction` and starts wrapping up. |
| 9:30 | The agent speaks `close_message`; the call ends (`time-limit-close`). |
| 10:00 | Hard cap. Reached only if the timed close did not finish. |
| Any time | 60 seconds without the user speaking: `user_silence_message`, then the call ends (`user-silence-close`). |
Invalid timing returns `400 invalid_request`.
***
## Browser example
This is a complete page. It captures the microphone with an AudioWorklet, sends
PCM16 over the audio socket, plays the agent's audio with support for
interruption, and shows a live transcript for both speakers. It assumes the
backend routes in the [next section](#backend-example).
```html title="index.html" theme={null}
```
Notes on the sample:
* **Barge-in is on by default.** The user can talk over the agent; the server
sends `clear` and the speaker worklet drops what it had queued. Uncomment the
`muted` line to turn barge-in off. The microphone then sends silence while the
agent speaks, so the user cannot interrupt.
* **Microphone processing.** The sample leaves the browser's noise suppression
and auto gain on (the defaults). See [Microphone capture](#microphone-capture).
* **Sample rate.** Most browsers convert the microphone into a 48 kHz
`AudioContext` for you. If one does not, capture at the device's rate and
resample to 48,000 Hz before sending.
* **Playback smoothing.** The speaker worklet plays audio as soon as it arrives.
On unreliable networks, buffer 100 to 200 ms before starting each reply.
### Microphone capture
How you ask the browser for the microphone changes what the agent hears.
| Setup | `getUserMedia` audio constraints | Why |
| :- | :- | :- |
| Laptop or built-in mic, other people nearby | `echoCancellation: true`, `noiseSuppression: true`, `autoGainControl: true` | The browser lowers background noise and distant voices before they reach the agent. Recommended for most web calls. |
| Headset or a mic close to the mouth | `echoCancellation: true`, `noiseSuppression: false`, `autoGainControl: false`, plus about +6 dB of gain | Noise suppression can clip the start of soft speech, and auto gain can dip the level right after the agent speaks. |
Keep `echoCancellation` on in both cases, so the agent does not hear its own
voice from the user's speakers.
If the agent stops talking when someone nearby speaks, check the constraints
above first. Then adjust the agent's [Behavior](/v2/agents#behavior): raise
`interruptions` to `firm`, and keep `adaptive_interruptions` on.
***
## Backend example
This server has three routes, written in Node.js with Express. One starts a
session, one ends it gracefully, and one receives webhooks. `db`, `queue`,
`requireUser`, `verifySignature` and `alreadyProcessed` stand in for your own
code. The last two are on the
[webhooks page](/v2/webhooks#signature-verification).
```javascript title="server.js" theme={null}
import express from "express";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const app = express();
// 1. The page asks for a session. The call is created here, where the key lives.
app.post("/api/voice-sessions", requireUser, async (req, res) => {
const session = await db.sessions.create({ userId: req.user.id });
const r = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": `voice-session-${session.id}`,
},
body: JSON.stringify({
agent_id: process.env.MIRAI_AGENT_ID,
channel: "web",
variables: { first_name: req.user.firstName },
metadata: { session_id: session.id, user_id: req.user.id },
first_message: "Hi {{first_name}}, thanks for joining. Shall we start?",
max_duration_secs: 600,
timing: {
wrap_up_secs_before_end: 120,
wrap_up_instruction: "Time is nearly up. Ask no new questions; summarise and wrap up.",
close_secs_before_end: 30,
close_message: "That is all we have time for. Thank you, and goodbye!",
user_silence_close_secs: 60,
user_silence_message: "It seems you have stepped away, so I will end here. Goodbye!",
},
recording_enabled: false,
final_results: true,
webhook_url: "https://example.com/mirai/webhook",
}),
});
const call = await r.json();
if (!r.ok) return res.status(502).json({ error: call.error.code });
await db.sessions.update(session.id, { miraiCallId: call.id });
// The page gets the two links and your own session ID. Nothing else.
res.json({ session_id: session.id, ws_url: call.ws_url, events_url: call.events_url });
});
// 2. End gracefully: the agent says goodbye, then the call ends.
app.post("/api/voice-sessions/:id/end", requireUser, async (req, res) => {
const session = await db.sessions.get(req.params.id, { userId: req.user.id });
const r = await fetch(`${API}/v2/calls/${session.miraiCallId}/control`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": `voice-session-${session.id}-close`,
},
body: JSON.stringify({ type: "close", message: "Thank you for your time. Goodbye!" }),
});
// 409 call_not_active: the call already ended, or never connected. Nothing to do.
res.status(r.status === 409 ? 200 : r.status).json(await r.json());
});
// 3. Webhooks: "the session is over", then "process the transcript".
app.post("/mirai/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.get("X-Mirai-Signature") ?? "";
if (!verifySignature(req.body, signature, process.env.MIRAI_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
const { call } = event.data;
const sessionId = call.metadata?.session_id;
switch (event.type) {
case "call.completed":
case "call.failed":
case "call.aborted":
await db.sessions.update(sessionId, { ended: true, callStatus: call.status });
break;
case "call.processed":
// Slow work off the request: store turns, score, notify.
await queue.add("process-transcript", {
sessionId,
callId: call.id,
transcript: event.data.transcript,
});
break;
}
res.sendStatus(200);
});
app.listen(3000);
```
***
## Ending and results
A browser call sends the same [webhooks](/v2/webhooks) as a phone call, in this
order:
1. `call.queued`, only if it is enabled for your workspace.
2. `call.started`, when the page connects its audio.
3. Exactly one terminal event:
* `call.completed`: the conversation finished because the user left, the
agent ended it, or a `close`, timed close or silence close ended it.
* `call.failed`: the call never connected (`status: failed`,
`ended_reason: "no-media"`, not billed), it hit the hard cap
(`status: timeout`, billed), or it failed on our side (not billed).
* `call.aborted`: you called [`POST /abort`](/v2/calls#abort-a-call).
4. `call.processed`, when you set `final_results: true` (or the agent has
analysis or post-call tools). With `final_results`, it carries the finished
transcript.
Which event to act on:
| You want to | Use |
| :- | :- |
| Mark the session as over | Any terminal event: `call.completed`, `call.failed` or `call.aborted`. In the page, `call.ended` on the event stream. |
| Process the transcript | `call.processed` with `final_results: true`. Read [`data.transcript`](/v2/webhooks#final-results). |
Do not treat `call.processed` as the "session over" signal. It arrives after
the terminal event: the transcript settles within about 2 minutes of the call
ending, plus the time analysis and post-call tools take when the agent has them.
Mark the session over on the terminal event, and start your transcript
processing on `call.processed`.
`data.transcript.status` is one of:
| Status | What to do |
| :- | :- |
| `ready` | `data.transcript.turns` holds the final turns, `{role, text, start_ms, end_ms}`. Process them. |
| `empty` | Nobody spoke. There is nothing to process. |
| `failed` | The transcript could not be produced. Record it; do not wait for another event. |
| `fetch_required` | The transcript is larger than 512 KB, so it is not inline. Fetch it with [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript). It is never truncated. |
For a [node-based agent](/v2/node-agents), `call.processed` is always sent when
the call has a `webhook_url`, and it carries the flow's outcome at
`data.call.flow`: the node the call ended on, the nodes it visited and every
extracted value. It waits up to 2 minutes for that outcome. See
[flow outcome](/v2/calls#flow-outcome).
Deliveries are at least once, and order is not guaranteed. Deduplicate on the
event `id`, which retries keep, and order your own state by `data.call.status`,
not by arrival. `data.call.metadata` carries your `session_id` on every event,
so you can match an event even before your own create request has finished
writing the call ID.
***
## Privacy and security
* **Keys stay on your server.** The page gets two links scoped to one call.
* **Keep metadata opaque.** `metadata` is stored with the call and sent on every
webhook. Put IDs in it, not secrets or personal answers.
* **Turn off stored audio when you do not need it.** `recording_enabled: false`
keeps no recording. Live audio is still processed during the call, and the
transcript is still kept.
* **Tell users.** Say in the opening line that they are speaking with an AI and
whether the conversation is recorded or transcribed. Collect the consent your
jurisdiction requires (for example under GDPR or India's DPDP Act) before you
start the call.
***
## FAQ
`POST /v2/call/web` is the v1 endpoint. Use [`POST /v2/calls`](#create-the-call)
with `channel: "web"`. You get a call ID, an audio link, a live event link,
[live control](#live-control), [timing](#timing-policy), `metadata` on every
webhook and the [final transcript](#ending-and-results).
An agent's `system_prompt` holds up to **8,000 characters**. There is no
per-call system prompt. Per call, you can set the opening line with
`first_message` (up to 500 characters), pass data into the prompt with
`variables`, and direct the agent mid-call with an
[`instruction`](#live-control).
Yes. Set `max_duration_secs: 600` on the call. The accepted range is on
[Limits](/v2/limits#payload-and-duration-ceilings). Add a
[timing policy](#timing-policy) so the agent wraps up and says goodbye before
the cap.
No. Each call's events go only to the `webhook_url` it was created with. A call
without one sends no webhooks. Every delivery is signed with your workspace's
webhook secret.
Yes. Create the call with `recording_enabled: false`. No audio is stored,
`recording_available` is `false`, and `GET /v2/calls/{id}/recording` returns
`404`. The transcript, live events and billing are unchanged. To turn recording
off for your whole workspace, ask support. After that, a request with
`recording_enabled: true` returns `403 recording_disabled`.
Transcripts follow the [data retention](/v2/limits#data-retention) policy. If you
need a different retention period, ask support.
Not yet. The event stream sends each turn when it is complete. For an
interrupted agent turn, the text is what the user actually heard.
No. `metadata` is for your systems only. Anything the agent should know goes in
`variables`.
# Webhooks
Source: https://docs.miraiminds.co/v2/webhooks
The signed lifecycle events we send, how to verify their signatures, and how retries and idempotency work.
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 the intended way
to learn a call's outcome. Use polling only as a fallback.
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": 5
}
}
}
```
| 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. |
| `data.call.metadata` | The [`metadata`](/v2/calls#metadata) you set when creating the call, unchanged, on every event for that call. Absent when the call has none. |
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. This event is 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.processed` | Analysis and post-call tool processing finished for a call with those features enabled. For a call created with `final_results: true`, it fires when the [final results](#final-results) are ready. | Unchanged terminal status |
| `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), whether it was queued, ringing or mid-conversation. | `aborted` |
Exactly one terminal event fires per call: `call.completed`, `call.voicemail`,
`call.failed` or `call.aborted`.
`call.processed` is a separate processing event, not another terminal event.
It includes `data.processing.version`, analysis status, and post-call tool status.
Explicit reruns produce a new version. Background on-call tools can finish
later; read their receipts separately. See [Tools: call.processed](/v2/tools#receive-call-processed).
For the finished transcript in the same event, create the call with
`final_results: true`. See [final results](#final-results).
A call run by a [node-based agent](/v2/node-agents) always sends
`call.processed` when it has a `webhook_url`, with the flow's outcome at
`data.call.flow`, unless the agent sets `emit_processed_webhook: false`. See
[final results](#final-results).
[Campaigns](/v2/campaigns) add five more event types. We deliver them to the
campaign's own `webhook_url`, signed with the same workspace secret and in the
same envelope, with `data.campaign` in place of `data.call`.
| 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.
For the payload, see [Webhooks](/v2/campaigns#webhooks) on the Campaigns page.
```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. A `no_answer` is
worth retrying the next day, while a `failed` is worth paging someone about.
```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": 1
}
}
}
```
`status: voicemail` always comes with `ended_reason: "voicemail"`. That reason
is how we identify the answering machine in the first place, so you will only
ever see the two together.
Voicemail calls are billed, because media was live. The 18-second call above
bills as two 10-second blocks: ₹1.00 on T3. 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.
***
## Final results
Create a call with `final_results: true` to receive one `call.processed` event
with everything about the finished call: the call object, the transcript, and
the status of analysis and post-call tools. Start processing a conversation
when this event arrives.
```json title="call.processed with final_results: true" theme={null}
{
"id": "evt_01K8B4R2T4V6X8Z0B2D4F6H8K0",
"type": "call.processed",
"created_at": "2026-09-27T10:10:41Z",
"data": {
"call": {
"id": "call_01K8B2M4Q6S8T0V2X4Z6A8C0EG",
"object": "call",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"status": "completed",
"tier": "t3",
"ended_reason": "time-limit-close",
"started_at": "2026-09-27T10:00:04Z",
"ended_at": "2026-09-27T10:09:34Z",
"duration_secs": 570,
"cost_inr": 28.5,
"recording_available": false,
"transcript_available": true,
"transcript_status": "ready",
"metadata": { "session_id": "sess_8f2c41", "user_id": "usr_1042" },
"analysis": null
},
"processing": {
"transcript": "ready",
"analysis": "not_enabled",
"post_call_tools": "not_enabled",
"version": 1
},
"transcript": {
"status": "ready",
"turns": [
{
"role": "assistant",
"text": "Hi Asha, thanks for joining. Shall we start?",
"start_ms": 380,
"end_ms": 3120
},
{ "role": "user", "text": "Yes, let's start.", "start_ms": 3900, "end_ms": 5010 }
]
}
}
}
```
| Field | Description |
| :- | :- |
| `data.call` | The [call object](/v2/calls#the-call-object) in its final state, with your `metadata` and the `analysis` result. |
| `data.transcript.status` | `ready`, `empty`, `failed` or `fetch_required`. See below. |
| `data.transcript.turns` | Present when `ready`: the final user and agent turns, in exactly the same shape as [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript) (`role`, `text`, `start_ms`, `end_ms`). |
| `data.processing` | One status per part: `transcript`, `analysis` and `post_call_tools`, plus `version`. A part that is off for this call reports `not_enabled`. |
| `data.transcript.status` | Meaning |
| :- | :- |
| `ready` | The turns are inline. They are the same turns `GET /v2/calls/{id}/transcript` returns. |
| `empty` | Nobody spoke. There are no turns. |
| `failed` | The transcript could not be produced. |
| `fetch_required` | The transcript is larger than the 512 KB inline limit, so no turns are included. Fetch them with `GET /v2/calls/{id}/transcript`. The transcript is never truncated. |
`analysis` is `done`, `failed`, `skipped` or `not_enabled`. `post_call_tools` is
`done`, `partial` or `failed` when the agent has post-call tools. See
[Tools: call.processed](/v2/tools#receive-call-processed).
**Node-based agents.** For a call run by a [node-based agent](/v2/node-agents),
`data.call.flow` holds the flow's outcome, in the ordinary `call.processed` and
in final results alike:
```json title="data.call.flow" theme={null}
{
"final_node": "goodbye",
"nodes_visited": ["Confirm the customer", "Offer the delivery slot", "Find a new day", "Close"],
"variables": { "identity_confirmed": true, "slot_accepted": false, "preferred_day": "Saturday" },
"status": "end_call",
"tags": ["end_call"]
}
```
The fields are described in [flow outcome](/v2/calls#flow-outcome).
`nodes_visited` and `variables` are always present; `final_node`, `status` and
`tags` are omitted when unknown.
A node-based agent's call sends `call.processed` whenever the call has a
`webhook_url`, even without analysis, post-call tools or `final_results`.
The outcome is usually recorded within seconds of the call ending.
`call.processed` waits up to 2 minutes for it and is then sent without `flow`.
In that case, read [`GET /v2/calls/{id}`](/v2/calls#get-a-call) later.
**When it arrives.** It comes after the terminal event, once the transcript has
settled (within about 2 minutes of the call ending) and any analysis and
post-call tools have finished, which can take longer. Do not wait for it to
decide that a conversation is over.
**One event, stable ID.** With `final_results: true`, this is the call's
`call.processed`. It replaces the ordinary one, so you never get a second.
Retries keep its `id`, so dedupe on it as usual. An explicit
[post-call rerun](/v2/tools#rerun-post-call-tools) produces a new `version`.
Final results are delivered only to the call's `webhook_url`. Set one when you
set `final_results`.
### Which event to act on
| You want to | Use |
| :- | :- |
| Know the conversation ended | The terminal event: `call.completed`, `call.failed`, `call.aborted` (or `call.voicemail` on phone calls). It arrives promptly and is never delayed by processing. |
| Process the transcript now | `call.processed` from a call created with `final_results: true`, when `data.transcript.status` is `ready`. On `fetch_required`, fetch the transcript instead. |
For the whole browser-call flow, see
[Ending and results](/v2/web-calls#ending-and-results) on the Browser calls page.
***
## 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 takes four steps:
1. Parse `t` and `v1` out of the header.
2. Reject the delivery 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 test vector before you try 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 most common integration bug is 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 do not reject deliveries on your behalf. The timestamp is there so that you
can: drop any delivery 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.
Keep it synced with NTP.
### Rotating the secret
Ask ops to rotate it. During rotation, the old and the new secret are both
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`. We retry anything else, including a `4xx`, a
`5xx`, a timeout, a TLS error or a DNS failure. You get **six attempts with
exponential backoff over roughly 36 minutes**. The first fires immediately, and
the gaps widen up to a half-hour cap.
Design for the whole window. The individual delays are an implementation detail
that we tune, so do not build around them. What we guarantee is that a brief
outage on your side will not lose the event, and that an endpoint that is down
for the whole 36 minutes will lose it.
After the sixth attempt, the event goes to a dead-letter queue (DLQ), and we can
replay it from there. A replayed event is byte-identical to the original,
including its `id`. It carries a fresh `t` and `v1`, so it passes your window
check.
Some consequences to plan 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 and do the
work afterwards.
* **A `401` from your signature check is a retryable failure to us.** We do
this on purpose, so that a transient secret-loading bug on your side does not
lose the event.
## Linking an event to your own records
An event describes the call and carries nothing from your own database. Join
the two on `call_id`. You have it **before** any event can arrive, because
`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 and are **not** echoed back on
the event. [`metadata`](/v2/calls#metadata) is echoed back. Set small
identifiers such as your `session_id` when you create the call, and every event
for the call carries them in `data.call.metadata`. This also handles a webhook
that lands before your own `call_id` write has finished. Store anything larger
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;
}
```
You can also make the handler naturally idempotent. A statement such as
`UPDATE orders SET call_status = ?
WHERE id = ?` needs no dedupe table at all.
## Local development
Webhooks need a public URL. To receive them on your laptop, open a tunnel:
```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. To iterate without spending wallet balance, replay a captured
delivery against your handler with `curl`:
```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. It enqueues the work and returns.
# The Zen of Voice Agents
Source: https://docs.miraiminds.co/v2/zen-of-voice-agents
Nineteen short rules for writing prompts that are spoken aloud, modelled on 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.
They hear whatever you write once, at talking speed, on a phone, so write it the
way a person would say it.
**Short is better than complete.** The caller has to wait through every reply.
Keep a reply to one short sentence, two at most, and leave the rest for the
next turn.
**One question is better than two.** Ask two things and the caller answers
neither. Ask one thing, then stop and listen.
**The first word matters more than the last paragraph.** The opening line is
spoken before the model even runs, so it sets your first-audio latency and makes
your first impression. Keep it to a name, the brand and a check that you have
the right person. A lead word at the top of every reply ("जी।", "अच्छा—",
"ठीक है।") also 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, and a
lecture makes them stop listening. Short turns that keep moving fix both.
**Numbers are words. Identifiers are digits, one at a time.** Write amounts as
"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 only three, the
recogniser dropped some, and the agent should say so.
**Read back what changed, not what was already agreed.** Confirm by exception:
give one consolidated read-back at the end instead of a receipt after every
turn.
**When a digit is unclear, ask again. Never guess.** A wrong address means a
failed delivery, and a wrong phone number means a lost customer. Asking
"सिर्फ pincode दोबारा बोलिए" costs three seconds.
**Write in the language you will speak.** A Hindi call driven by an English
prompt code-switches badly. Write Hindi words in Devanagari and everyday English
words in Roman, and use no romanized Hindi anywhere.
**Facts live in the prompt. The customer lives in the variables.** The prompt
holds what the agent may say about the business. Names, amounts, dates and order
IDs arrive with each call, already in spoken form, so one agent can make
thousands of calls.
**What the prompt leaves out, the model will make up.** The model invents any
policy you did not state and quotes prices you did not list. Write the facts
down, and also write what the agent should 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. While a caller dictates an address, the agent should
only say "जी" or "हम्म", then give one read-back when they are done.
**No is an answer. Nudge once, then accept it.** One light nudge is persuasion,
and a second is harassment. Capture the refusal as an outcome and close warmly.
The engine enforces this ending, but the prompt should ask for it as well.
**Never promise what the agent cannot do.** The agent can talk, end the call,
and do what its [tools](/v2/tools) do. It cannot transfer a call yet, and it
can't send a link or book anything in your system unless a tool does that. The
honest line is "The team will call you back".
**Thank them once.** Thank the caller in the goodbye and nowhere else. Models
over-thank, and a धन्यवाद in every turn sounds like a machine.
**Every turn moves the call forward.** No turn should only acknowledge. "जी,
order confirm हो गया — अब address एक बार check कर लेते हैं" acknowledges and
advances the call in one utterance.
**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.** Place test calls to your own
phone. Say no, correct a digit, ask something off-script and go quiet. Then read
the transcript, change one thing and call again. A phone line sounds very
different from a browser mic.
**Hinglish is one honking great idea — let's do more of that.** Real calls mix
the two languages, as in "payment कब तक हो जाएगा", with the Hindi in Devanagari
and the English in Roman. If you write the way people already talk, 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). Like Python's list, this one
has nineteen lines and leaves a twentieth unwritten.