# Create AI Assistant
Source: https://docs.miraiminds.co/api-reference/assistant/create-ai-assistant
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/assistant/create
Creates a new AI assistant with the specified configuration. The `variant.type` determines the assistant's behavior and the required `variant.config` structure.
**Variants**
- `abandoned_cart` — a ready-made flow; provide `variant.config.abandoned_cart` (e.g. payment plan).
The system prompt is generated from the variant template, so `agent.systemPrompt` can be left empty.
- `cod_to_prepaid` — converts Shopify COD orders to prepaid. Provide
`variant.config.cod_to_prepaid.paymentLinkValidity`, `codFee`, and `supportContacts`.
- `address_verification` — verifies or collects Shopify shipping addresses. Provide
`variant.config.address_verification.minDays`, `maxDays`, and `supportContacts`.
- `order_confirmation` — confirms Shopify orders before fulfillment. Optional config:
`variant.config.order_confirmation.supportContacts`.
- `ndr_followup` — follows up on failed delivery / NDR events. Optional config:
`variant.config.ndr_followup.maxRescheduleDays`, `webhookToken`, and `supportContacts`.
- `custom` — you fully author the behavior. Put your prompt in `agent.systemPrompt` and declare any
dynamic variables in `variant.config.inputSchema`.
For preset variants, leave `agent.systemPrompt` empty and pass the required per-call data in `payload` when initiating the call.
**Using variables in `agent.systemPrompt` (custom variant)**
Reference a variable with the `{{variableName}}` placeholder syntax; nested values use dot-paths, e.g. `{{customer.firstName}}`. Each variable you reference should be declared in `variant.config.inputSchema` (name + type, mark `isRequired: true` when mandatory). The actual values are supplied **per call** through the `variableValues` object when you initiate the call — at that point every `{{placeholder}}` is substituted with the matching value. Any placeholder with no matching value is left untouched in the prompt.
# Get AI Assistant
Source: https://docs.miraiminds.co/api-reference/assistant/get-ai-assistant
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/assistant/get/{assistantId}
Fetches a single assistant by ID. Returns the full internal assistant configuration including variant config, agent identity, callSettings, analysisPlan, and knowledgeBase.
# List AI Assistants
Source: https://docs.miraiminds.co/api-reference/assistant/list-ai-assistants
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/assistant/list
Returns all assistants in the current workspace. Each item carries the **same payload** as the get-by-id endpoint (`GET /v1/admin/assistant/get/{assistantId}`), so no field selection is needed. Takes no query parameters or request body.
# Update AI Assistant
Source: https://docs.miraiminds.co/api-reference/assistant/update-ai-assistant
https://api.voice-agents.miraiminds.co/swagger.yaml put /v1/admin/assistant/update/{assistantId}
Updates an existing assistant's configuration. All fields are optional — only the provided fields will be updated. The variant type cannot be changed. Any active campaigns for this assistant will be briefly paused and resumed during the update.
# Abort Call
Source: https://docs.miraiminds.co/api-reference/call/abort-call
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/abort
Aborts a call that is queued or scheduled for retry. This operation is allowed when the call has no status (initial queue) or has a status of: 'busy', 'failed', 'no-answer', 'rescheduled', 'validation-failed'. It is NOT allowed if the call is 'in-progress', 'completed', 'ended', 'timeout', or already 'aborted'.
# Call Events Webhook
Source: https://docs.miraiminds.co/api-reference/call/call-events-webhook
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/webhooks/call-events
Receives call status and action events
# Initiate AI Call
Source: https://docs.miraiminds.co/api-reference/call/initiate-ai-call
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/initiate
# Initiate AI Web Call
Source: https://docs.miraiminds.co/api-reference/call/initiate-ai-web-call
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/call/web
# Receive Shiprocket NDR webhook
Source: https://docs.miraiminds.co/api-reference/call/receive-shiprocket-ndr-webhook
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/shiprocket/ndr-webhook/{assistantId}
Queues an NDR follow-up call for an assistant whose `variant.type` is `ndr_followup`. If `variant.config.ndr_followup.webhookToken` is configured, send the same value in the `x-api-key` header.
# Update Call Payload
Source: https://docs.miraiminds.co/api-reference/call/update-call-payload
https://api.voice-agents.miraiminds.co/swagger.yaml put /v2/call/{callId}
Updates the `payload` of a queued call recipient. Allowed only when the call has not yet been attempted.
# Introduction
Source: https://docs.miraiminds.co/api-reference/introduction
Complete reference for the Voice Agents REST API.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
The Voice Agents API lets you onboard workspaces, create and manage assistants, initiate outbound calls, and handle real-time webhook events programmatically.
## Base URL
All API requests target the production server:
```
https://api.voice-agents.miraiminds.co
```
A staging environment is also available for testing:
```
https://api.stage.voice-agent.miraiminds.co
```
## Authentication
Every request must include two API key headers:
| Header | Description |
| :-------------- | :------------------------------------- |
| `x-public-key` | Your public API key for identification |
| `x-private-key` | Your private API key for authorization |
Most endpoints also require a `workspace` header to scope requests to a specific workspace.
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v2/call/initiate \
-H "x-public-key: pk_your_public_key" \
-H "x-private-key: sk_your_private_key" \
-H "workspace: your_workspace_id" \
-H "Content-Type: application/json" \
-d '{ ... }'
```
Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to obtain your API keys and workspace ID.
## API endpoints
The API is organized into the following groups:
Register a Shopify store and create a workspace with billing configuration.
Create, retrieve, and update voice agent configurations.
Initiate outbound calls and abort queued calls.
Receive real-time call status and action events.
Archive and unarchive organizations and workspaces.
Browse available voice options for your agents.
## Common error codes
| Code | Meaning |
| :---- | :-------------------------------------------------- |
| `200` | Success |
| `201` | Resource created |
| `400` | Bad request — check your request body or parameters |
| `401` | Unauthorized — missing or invalid API keys |
| `403` | Forbidden — insufficient permissions |
| `404` | Not found — resource does not exist |
| `429` | Rate limit exceeded — wait before retrying |
| `500` | Internal server error |
## Rate limits
The API enforces rate limits to ensure stability. If you receive a `429` response, wait before retrying. Assistant updates have a cooldown period to prevent overlapping update operations.
Keep your `x-private-key` secure. Never expose it in client-side code or public repositories.
# Complete Knowledge Base Upload
Source: https://docs.miraiminds.co/api-reference/knowledge-base/complete-knowledge-base-upload
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/complete/{sessionId}
Finalises an upload session and triggers background processing (chunking, indexing, embedding). Returns a `knowledgeBaseId` you can use to poll status.
Processing is asynchronous — poll `GET /v1/knowledge-base/files/{knowledgeBaseId}` until `status` is `ready`, then add the file URL to `knowledgeBase.documents` on your assistant.
# Delete Knowledge Base Collection
Source: https://docs.miraiminds.co/api-reference/knowledge-base/delete-knowledge-base-collection
https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/knowledge-base/collection/{collectionName}
Permanently deletes a knowledge base collection and all its indexed data. This action cannot be undone.
The collection **cannot be deleted** if it is currently assigned to an assistant. Remove it from `knowledgeBase.documents` on the assistant first.
# Get Knowledge Base File
Source: https://docs.miraiminds.co/api-reference/knowledge-base/get-knowledge-base-file
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/knowledge-base/files/{knowledgeBaseId}
Returns a single knowledge base file by ID including its processing status. Poll this endpoint after completing an upload to check when `status` changes to `ready`. Once `ready`, add the file to `knowledgeBase.documents` on the assistant.
# List Knowledge Base Files
Source: https://docs.miraiminds.co/api-reference/knowledge-base/list-knowledge-base-files
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/knowledge-base/files
Returns all uploaded knowledge base files for the current workspace. Use the returned file URL to set in `knowledgeBase.documents` on an assistant. Use the `collectionName` if manually configuring the RAG tool.
# Start Knowledge Base Upload Session
Source: https://docs.miraiminds.co/api-reference/knowledge-base/start-knowledge-base-upload-session
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/start
Creates an upload session for a new knowledge base document. Returns a `sessionId` used for the subsequent chunk upload and complete calls.
**Supported MIME types:** `application/pdf`, `text/plain`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `text/markdown`
**Max file size:** 100 MB | **Max chunk size:** 10 MB
# Upload Knowledge Base Chunk
Source: https://docs.miraiminds.co/api-reference/knowledge-base/upload-knowledge-base-chunk
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/knowledge-base/upload/chunk/{sessionId}
Uploads a single chunk of a file to an existing upload session. Send chunks sequentially starting from `chunkIndex: 0`. Use `multipart/form-data` with a `chunk` binary field and a `chunkIndex` form field.
**Max chunk size:** 10 MB
# Onboard a custom workspace
Source: https://docs.miraiminds.co/api-reference/onboarding/onboard-a-custom-workspace
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/workspace/onboard/custom
Creates a new **custom** workspace under the authenticated organization. Use this where you build assistants from scratch with your own system prompts and variables. The organization is resolved from the public/private key pair. A default outbound telephony number is auto-assigned in production.
# Onboard a Shopify store (Shopify merchants only)
Source: https://docs.miraiminds.co/api-reference/onboarding/onboard-a-shopify-store-shopify-merchants-only
https://api.voice-agents.miraiminds.co/swagger.yaml post /v2/workspace/onboard/shopify
Shopify-specific onboarding. Uploads the store's identity, trust metrics, and policies. Returns the Workspace ID and Billing Configuration.
**For all other integrations, use `POST /v2/workspace/onboard/custom` instead.**
# Archive Organization or Workspace
Source: https://docs.miraiminds.co/api-reference/organization/archive-organization-or-workspace
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/organization/archive
Archives an organization or workspace by ID. When archiving an organization, all associated workspaces, assistants and campaigns are also archived in a cascading manner. When archiving a workspace, all its associated assistants and campaigns are archived. Requires admin or organization_admin role.
# Unarchive Organization or Workspace
Source: https://docs.miraiminds.co/api-reference/organization/unarchive-organization-or-workspace
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/organization/unarchive
Unarchives a previously archived organization or workspace by ID. When unarchiving an organization, all associated workspaces, assistants and campaigns are also unarchived in a cascading manner. When unarchiving a workspace, the parent organization must not be archived; if the parent organization is still archived, the request will fail with a 400 error. Requires admin or organization_admin role.
# platform health status
Source: https://docs.miraiminds.co/api-reference/setting/platform-health-status
https://api.voice-agents.miraiminds.co/swagger.yaml get /health
Returns whether the platform is currently under maintenance. No authentication required.
# Get a single demo flow
Source: https://docs.miraiminds.co/api-reference/showcase/get-a-single-demo-flow
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/flows/{flow_id}
# Get a single real customer call
Source: https://docs.miraiminds.co/api-reference/showcase/get-a-single-real-customer-call
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/calls/{call_id}
# List demo flows
Source: https://docs.miraiminds.co/api-reference/showcase/list-demo-flows
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/flows
Returns the product demo flows shown in the showcase page. These are produced demos of each use case, not real customer recordings.
# List real customer call recordings
Source: https://docs.miraiminds.co/api-reference/showcase/list-real-customer-call-recordings
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/showcase/calls
Returns real call recordings made for live stores. Optionally filter by call category or language.
# Purchase an available phone number
Source: https://docs.miraiminds.co/api-reference/telephony/purchase-an-available-phone-number
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/number-pool/purchase
Purchases a phone number for the organization from the telephony provider. The organization is charged the setup fee and recurring monthly rate; a `402` is returned if the organization has insufficient credit balance.
# Release a purchased phone number
Source: https://docs.miraiminds.co/api-reference/telephony/release-a-purchased-phone-number
https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/number-pool/{telephonyNumberId}
Releases a previously purchased phone number back to the provider and marks the record as `released`. Recurring billing for the number stops.
# Search available phone numbers
Source: https://docs.miraiminds.co/api-reference/telephony/search-available-phone-numbers
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/number-pool/search
Returns a list of phone numbers available to purchase from the configured telephony provider, filtered by country and (optionally) a matching pattern.
# Create an API tool
Source: https://docs.miraiminds.co/api-reference/tools/create-an-api-tool
https://api.voice-agents.miraiminds.co/swagger.yaml post /v1/admin/tool/api
Creates a user-defined API tool the assistant can call. The `name` must be unique within the workspace (a slug is auto-generated from it).
# Delete an API tool
Source: https://docs.miraiminds.co/api-reference/tools/delete-an-api-tool
https://api.voice-agents.miraiminds.co/swagger.yaml delete /v1/admin/tool/api/{toolId}
Soft-deletes an API tool owned by the current workspace.
# List workspace API tools
Source: https://docs.miraiminds.co/api-reference/tools/list-workspace-api-tools
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/tool/api
Lists all user-defined API tools owned by the current workspace.
# Update an API tool
Source: https://docs.miraiminds.co/api-reference/tools/update-an-api-tool
https://api.voice-agents.miraiminds.co/swagger.yaml put /v1/admin/tool/api/{toolId}
Updates an existing API tool. All fields are optional — only provided fields are updated.
# Get Voice Gallery
Source: https://docs.miraiminds.co/api-reference/voice-gallery/get-voice-gallery
https://api.voice-agents.miraiminds.co/swagger.yaml get /v1/admin/voice-gallery
# Assistant Sandbox
Source: https://docs.miraiminds.co/developers/assistant-sandbox
Test and iterate on your voice assistant's system prompt in real-time without affecting your production configuration.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
The **Assistant Sandbox** is a testing environment where you can experiment with your voice assistant's system prompt, make real-time tweaks, and trigger web calls — all without modifying your production assistant. It's the fastest way to fine-tune how your assistant behaves during a call.
**Credits are deducted for sandbox calls.** Sandbox calls are real calls and
will consume credits from your wallet just like production calls.
## Getting Started
Enter your **Public Key**, **Private Key**, and **Workspace** credentials to access the sandbox, and click the **Validate** button.
| Field | Description |
| :------------ | :--------------------------------------------- |
| `Public Key` | Your public API key |
| `Private Key` | Your private/secret API key |
| `Workspace` | The workspace identifier you want to work with |
Once authenticated, the sandbox loads your workspace data and retrieves all assistants configured under it.
After authentication, a **dropdown** appears listing all assistants created in your workspace. Select the assistant you want to test.
Once you select an assistant, the screen splits into **two sections**:
**Left Section — System Prompt & Variables**
* The assistant's **system prompt** is displayed in an editable area, organized by sections like **Context**, **Objective**, and **Personality**
* Below the prompt, you'll see **allowed variables** — these are system-generated variables based on the data you provide (e.g., customer name, order details, discount info)
* You can only use these system-generated variables in your prompt
**Right Section — Configuration**
* **Payload** — A JSON text box where you paste the call payload data (e.g., customer info, cart details)
* **Metadata** — A JSON text box where you paste any additional metadata for the call
* **Change Assistant** — A button in the top-right to switch to a different assistant
1. Populate the **Payload** and **Metadata** fields with your JSON data 2.
Optionally, edit the **system prompt** to test different conversation
behaviors 3. Click the **Test Assistant** button to trigger a web call 4.
You'll be connected to the assistant and can speak with it in real-time
After each call, review how the assistant responded:
* Modify the **system prompt** based on your findings
* Adjust the **Payload** or **Metadata** as needed
* Click **Test Assistant** again to test your changes instantly
Repeat this cycle until the assistant behaves as expected.
To test a different assistant, click the **Change Assistant** button in the top-right corner. The assistant dropdown will reappear, letting you select another assistant from your workspace.
## Important Notes
**Prompt changes are not saved.** Any modifications you make to the system
prompt in the sandbox are temporary. They are not stored or updated in the
database. To apply changes permanently, update the assistant's prompt through
the dashboard or API.
**Use only system-generated variables.** The allowed variables shown in the
sandbox are automatically generated based on the data you provide. Custom or
arbitrary variables will not be resolved during the call.
**No events or actions in sandbox.** Webhook events (such as `call.ended`,
`call.completed`, `end-of-call`, `action`, etc.) and actions (such as
`create-order`, `send-whatsapp`) are **not triggered** during sandbox calls.
The sandbox is purely for testing prompt behavior and conversation flow.
## Typical Workflow
```mermaid theme={null}
flowchart TD
A[Authenticate with Public Key, Private Key & Workspace] --> B[Select Assistant from Dropdown]
B --> C[Review System Prompt & Variables]
C --> D[Enter Payload & Metadata JSON]
D --> E[Click Test Assistant Button]
E --> F[Talk with the Assistant]
F --> G{Satisfied with behavior?}
G -- No --> H[Tweak System Prompt]
H --> E
G -- Yes --> I[Update prompt in Production via Dashboard / API]
F --> J{Want to test another assistant?}
J -- Yes --> K[Click Change Assistant]
K --> B
```
## Best Practices
* **Start with your production prompt** — Select the assistant and test its current behavior before making changes.
* **Change one thing at a time** — Modify a single aspect of the prompt per call so you can clearly identify what impacts the assistant's behavior.
* **Test edge cases** — Vary your payload data (e.g., different product types, price ranges, customer profiles) to ensure the prompt handles diverse scenarios.
* **Note your changes** — Since sandbox edits aren't saved, copy your final working prompt and apply it to your assistant configuration manually.
* **Monitor your credits** — Each sandbox call deducts credits, so plan your testing sessions efficiently.
# Call Lifecycle & Webhooks
Source: https://docs.miraiminds.co/developers/call-lifecycle-events
Understand the lifecycle of a voice call and how to handle webhook events.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Voice Agents uses webhooks to notify your application about the state of every call in real-time. Whether a call connects, fails, or triggers a specific action (like creating an order), your configured **callback URL** receives an event payload.
## Call Lifecycle
Every call progresses through a specific lifecycle. At each stage, an event is fired.
```mermaid theme={null}
stateDiagram-v2
[*] --> Initiate: Call Placed
Initiate --> InProgress: Connected
Initiate --> Failed: Failed/Busy/No Answer
InProgress --> Ended: Disconnected
Ended --> Completed: Processing Done
Completed --> EndOfCall: Analysis Ready
EndOfCall --> LifecycleEnded: All Done
Failed --> LifecycleEnded: All Done
LifecycleEnded --> [*]
```
### Lifecycle Events
These events track the normal progression of a successful call.
| Event | Description |
| :--------------------- | :---------------------------------------------------------------------------------------- |
| `call.initiate` | The call has been queued and is being dialed. |
| `call.in-progress` | The recipient picked up; the AI conversation has started. |
| `call.ended` | The phone connection has dropped. |
| `call.completed` | Call data (recording, transcript, duration) has been processed. |
| `call.timeout` | The call exceeded the maximum allowed duration. |
| `end-of-call` | AI analysis is complete — summary, success evaluation, and structured data are available. |
| `call.lifecycle-ended` | All processing is finished. This is the **final event** for this call. |
### Failure & Retry Events
If a call cannot be established, one of these events will fire. The system may automatically retry based on your campaign settings.
| Event | Description |
| :----------------------- | :------------------------------------------------- |
| `call.failed` | The call could not be connected (general failure). |
| `call.busy` | The recipient's line was busy. |
| `call.no-answer` | The recipient did not pick up. |
| `call.validation-failed` | Pre-call validation failed (e.g., invalid number). |
| `call.skip` | The call was skipped (e.g., DND number). |
### Other Events
| Event | Description |
| :----------------- | :---------------------------------------------------------------------------------------- |
| `call.rescheduled` | A callback has been scheduled for a later time (lifecycle continues to the next attempt). |
| `call.aborted` | The call was manually cancelled via the abort API. |
After all retry attempts are exhausted, `call.lifecycle-ended` is sent to signal that no further attempts will be made for this specific call task.
## Webhook Payload Structure
Every webhook event shares a common envelope structure.
```json theme={null}
{
"metadata": {
"customer_id": "12345",
"order_ref": "ORD-001"
},
"event": {
"type": "call.completed",
"data": {
"call": {
"id": "c_550e8400-e29b",
"status": "completed",
"startedAt": "2023-10-27T10:00:00Z",
"endedAt": "2023-10-27T10:02:30Z",
"durationSeconds": 150,
"recordingUrl": "https://api.voice-agents.com/recordings/...",
"detailUrl": "https://api.voice-agents.com/calls/..."
}
}
}
}
```
### Key Fields
* **`metadata`**: The custom JSON object you passed when initiating the call. This is passed back in **every** event, allowing you to link calls to your internal records (e.g., `userId`, `orderId`).
* **`event.type`**: The specific event name (e.g., `call.in-progress`, `end-of-call`).
* **`event.data`**: The payload specific to the event.
### Special Payloads
Some events contain additional data in `event.data`.
#### `end-of-call`
Contains the AI analysis, summary, and credit usage.
```json theme={null}
{
"analysis": {
"success": true,
"summary": "Customer confirmed the appointment for Tuesday.",
"insights": {
"sentiment": "positive",
"intent": "booking_confirmed"
}
},
"credits": {
"used": 2.5,
"available": 105.0
}
}
```
#### `call.lifecycle-ended`
Contains a report of all attempts.
```json theme={null}
{
"report": {
"reAttemptCount": 1,
"rescheduledCount": 0,
"finalStatus": "completed"
}
}
```
## Actions
Actions are special events triggered by the AI during the conversation when a specific task needs to be performed on your end, such as creating an order or sending a message.
### `create_order`
Fires when the AI determines the customer wants to place an order and has provided all necessary details.
```json theme={null}
{
"event": {
"type": "action",
"data": {
"action": "create_order",
"payload": {
"phone": "+15550109988",
"lineItems": [{ "variantId": "123", "quantity": 1 }],
"shippingAddress": {
"firstName": "Jane",
"lastName": "Doe",
"address1": "123 Main St",
"city": "New York",
"zip": "10001",
"country": "US"
},
"paymentMode": "cod",
"cartTotal": 150.00
}
}
}
}
```
### `send_whatsapp`
Fires when the AI cannot complete a task (like creating an order) due to missing info, and triggers a fallback message via WhatsApp.
| Reason | Meaning |
| :------------------- | :----------------------------------------- |
| `missing_address` | Customer's shipping address is incomplete. |
| `missing_first_name` | Customer's name could not be determined. |
| `invalid_cart_data` | Cart data is malformed or missing. |
```json theme={null}
{
"event": {
"type": "action",
"data": {
"action": "send_whatsapp",
"reason": "missing_address"
}
}
}
```
## Handling Webhooks
Here is an example of how to handle these events in a Node.js Express application.
```javascript theme={null}
app.post('/webhooks/voice-agent', (req, res) => {
const { event, metadata } = req.body;
console.log(`Received event: ${event.type} for Customer: ${metadata.customer_id}`);
switch (event.type) {
case 'call.in-progress':
// Call connected, maybe update UI status
break;
case 'action':
if (event.data.action === 'create_order') {
// Handle order creation logic
createOrder(event.data.payload);
} else if (event.data.action === 'send_whatsapp') {
// Handle WhatsApp fallback logic
sendWhatsAppFallback(event.data.reason);
}
break;
case 'end-of-call':
// Save call summary and analysis
saveCallAnalysis(event.data.call.id, event.data.analysis);
break;
case 'call.rescheduled':
// Update next attempt time in your DB
console.log(`Call rescheduled for later`);
break;
case 'call.failed':
case 'call.busy':
case 'call.no-answer':
// Log failure reason
console.log(`Call failed: ${event.type}`);
break;
default:
console.log('Unhandled event:', event.type);
}
// Always acknowledge the webhook
res.status(200).send('OK');
});
```
## Security
To ensure that the webhooks you receive are genuinely from Voice Agents, you should verify the signature included in the headers.
See [Webhook Signature Verification](/developers/webhook-signature) for implementation details.
## The same thing in v2
Most of the shape above survives — one event per state change, an envelope you
acknowledge with `200`, a signature you verify. The names changed, the retry
and action machinery did not carry over, and the analysis is not built yet.
| v1 event | v2 | Notes |
| :------------------------------------------------------ | :--------------- | :-------------------------------------------------------------------------------- |
| `call.initiate` | `call.queued` | Opt-in in v2; ask to have it enabled. |
| `call.in-progress` | `call.started` | Media is live. |
| `call.ended` + `call.completed` | `call.completed` | One terminal event, not two. |
| `call.timeout` | `call.failed` | Read `data.call.status` — it is `timeout`. |
| `call.failed` / `call.busy` / `call.no-answer` | `call.failed` | One event; the status distinguishes them. |
| `call.aborted` | `call.aborted` | Same meaning. |
| — | `call.voicemail` | New in v2, and it is billed. |
| `call.validation-failed` | — | v2 rejects a bad number at create time, with `400`. |
| `call.rescheduled`, `call.skip`, `call.lifecycle-ended` | — | **v2 does not retry.** One call object, one outcome. Retry from your own dialler. |
| `end-of-call` (summary, sentiment) | — | Post-call analysis is on the [roadmap](/general/roadmap). |
| `action` (`create_order`, `send_whatsapp`) | — | Not in v2. |
**`metadata` works differently.** v1 echoes your object back on every event. v2
hands you the `call_id` in the `202` response to
[`POST /v2/calls`](/v2/calls#place-a-call) — store that against your own record
before the first event can arrive, and correlate on it. `variables` you pass at
create are used in the prompt; they do not come back on the event.
Full reference: [v2 Webhooks](/v2/webhooks).
# Create Assistant
Source: https://docs.miraiminds.co/developers/create-assistant
Create a new voice assistant with specific configuration.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
The `Create Assistant` endpoint allows you to programmatically create a new voice assistant. You can configure its personality, voice, tools, and other settings.
## Endpoint
POST /admin/assistant
## Request Flow
```mermaid theme={null}
sequenceDiagram
participant App as Your App
participant API as Voice API
participant DB as Database
App->>API: POST /admin/assistant
Note right of App: Includes config, name,
and preferences
API->>API: Validate Request
API->>DB: Create Assistant Record
DB-->>API: Assistant Created
API-->>App: 200 OK { assistant_id: "..." }
```
## Request Parameters
### Headers
| Header | Type | Required | Description |
| :-------------- | :----- | :------- | :------------------------------- |
| `workspace` | string | **Yes** | Your unique workspace ID. |
| `organization` | string | **Yes** | Your organization ID. |
| `Authorization` | string | **Yes** | Bearer token for authentication. |
| `Content-Type` | string | **Yes** | Must be `application/json`. |
### Body Parameters
| Parameter | Type | Required | Description |
| :-------- | :----- | :------- | :-------------------------------------- |
| `name` | string | **Yes** | Name of the assistant. |
| `config` | object | **Yes** | Configuration object for the assistant. |
### Config Object
| Parameter | Type | Required | Description |
| :---------------- | :----- | :------- | :---------------------------------------------- |
| `system_prompt` | string | **Yes** | The persona and instructions for the assistant. |
| `end_message` | string | No | Message to speak when ending the call. |
| `tool_config` | array | No | List of tools enabled for the assistant. |
| `model` | object | **Yes** | LLM configuration (provider, model). |
| `transcriber` | object | **Yes** | Transcriber configuration (provider, model). |
| `voice` | object | **Yes** | Voice configuration (provider, voiceId, model). |
| `structured_data` | array | No | Schema for structured data extraction. |
## Code Examples
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant' \
--header 'workspace: 68a4410242a5c31c24ed063b' \
--header 'organization: 68a44064be1aab154e4806ee' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer ' \
--data '{
"name": "test assistant",
"config": {
"system_prompt": "You are a helpful assistant.",
"end_message": "Goodbye!",
"tool_config": [],
"model": {
"provider": "openai",
"model": "gpt-4o-mini"
},
"transcriber": {
"provider": "deepgram",
"model": "nova-3"
},
"voice": {
"provider": "cartesia",
"voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8",
"model": "sonic-2"
},
"structured_data": []
}
}'
```
```javascript Node.js theme={null}
const myHeaders = new Headers();
myHeaders.append("workspace", "68a4410242a5c31c24ed063b");
myHeaders.append("organization", "68a44064be1aab154e4806ee");
myHeaders.append("Content-Type", "application/json");
myHeaders.append("Authorization", "Bearer ");
const raw = JSON.stringify({
"name": "test assistant",
"config": {
"system_prompt": "You are a helpful assistant.",
"end_message": "Goodbye!",
"tool_config": [],
"model": {
"provider": "openai",
"model": "gpt-4o-mini"
},
"transcriber": {
"provider": "deepgram",
"model": "nova-3"
},
"voice": {
"provider": "cartesia",
"voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8",
"model": "sonic-2"
},
"structured_data": []
}
});
const requestOptions = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow"
};
fetch("https://api.voice-agents.miraiminds.co/v1/admin/assistant", requestOptions)
.then((response) => response.json())
.then((result) => console.log(result))
.catch((error) => console.error(error));
```
```python Python theme={null}
import requests
import json
url = "https://api.voice-agents.miraiminds.co/v1/admin/assistant"
payload = json.dumps({
"name": "test assistant",
"config": {
"system_prompt": "You are a helpful assistant.",
"end_message": "Goodbye!",
"tool_config": [],
"model": {
"provider": "openai",
"model": "gpt-4o-mini"
},
"transcriber": {
"provider": "deepgram",
"model": "nova-3"
},
"voice": {
"provider": "cartesia",
"voiceId": "791d5162-d5eb-40f0-8189-f19db44611d8",
"model": "sonic-2"
},
"structured_data": []
}
})
headers = {
'workspace': '68a4410242a5c31c24ed063b',
'organization': '68a44064be1aab154e4806ee',
'Content-Type': 'application/json',
'Authorization': 'Bearer '
}
response = requests.request("POST", url, headers=headers, data=payload)
print(response.text)
```
## Response
Returns a JSON object containing the created assistant details.
### Success Response (`200 OK`)
```json theme={null}
{
"success": true,
"message": "Assistant created successfully",
"data": {
"_id": "6927ec5c9322ed9f9fb55c68",
"name": "test assistant",
"config": { ... },
"createdAt": "2025-11-27T10:00:00.000Z"
}
}
```
# Customer Lifecycle
Source: https://docs.miraiminds.co/developers/customer-lifecycle
End-to-end guide for onboarding a brand, creating an AI agent, initiating calls, aborting them, and handling webhook events.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
This guide walks through the complete lifecycle of a customer on the Voice Agents platform — from initial onboarding to receiving real-time call events via your callback URL.
## Lifecycle Overview
```mermaid theme={null}
flowchart LR
A[Onboard Brand] --> B[Create AI Agent]
B --> C[Initiate Call]
C --> D{Call Outcome}
D -- Abort --> E[Abort Call]
D -- Proceeds --> F[Handle Callback Events]
E --> F
F --> G[Lifecycle Ended]
```
***
## Step 1 — Onboard a Brand
Register your brand (Shopify store) to create a workspace with billing configuration. All subsequent API operations are scoped to this workspace.
POST /v2/workspace/onboard/shopify
### Authentication
| Header | Description |
| :-------------- | :------------------- |
| `x-public-key` | Your public API key |
| `x-private-key` | Your private API key |
### Body Parameters
| Parameter | Type | Required | Description |
| :-------------------------------------- | :------ | :------- | :--------------------------------------------- |
| `name` | string | **Yes** | Your store/brand name |
| `currencyCode` | string | **Yes** | Store currency (e.g., `INR`, `USD`) |
| `timezone` | string | **Yes** | Store timezone (e.g., `Asia/Kolkata`) |
| `supportContacts.phoneNumber` | string | **Yes** | Support phone number in E.164 format |
| `supportContacts.email` | string | No | Support email address |
| `trustSignals.valuePropositionOneLiner` | string | **Yes** | One-line brand value proposition |
| `trustSignals.customersTillDate` | integer | No | Total customers served |
| `trustSignals.totalOrdersFulfilled` | integer | No | Total orders fulfilled |
| `trustSignals.storeRating` | number | No | Store rating (e.g., `4.8`) |
| `policyFramework.returnPolicy` | object | No | Return window, processing fee, refund timeline |
| `policyFramework.shippingPolicy` | object | No | Delivery timeline, free shipping threshold |
| `policyFramework.codPolicy` | object | No | COD availability and additional fee |
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify' \
--header 'x-public-key: pk_your_public_key' \
--header 'x-private-key: sk_your_private_key' \
--header 'Content-Type: application/json' \
--data '{
"name": "Acme Store",
"currencyCode": "INR",
"timezone": "Asia/Kolkata",
"supportContacts": {
"phoneNumber": "+919876543210",
"email": "support@acme.com"
},
"trustSignals": {
"valuePropositionOneLiner": "Handcrafted products using sustainable materials",
"customersTillDate": 15000,
"totalOrdersFulfilled": 42000,
"storeRating": 4.8
},
"policyFramework": {
"returnPolicy": {
"windowDays": 7,
"processingFee": { "amount": 50 },
"refundTimelineDays": 3
},
"shippingPolicy": {
"deliveryTimeline": { "minDays": 3, "maxDays": 5 },
"freeShippingMinOrderValue": 999
},
"codPolicy": {
"enabled": true,
"additionalFee": { "amount": 50 }
}
}
}'
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify',
{
method: 'POST',
headers: {
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Acme Store',
currencyCode: 'INR',
timezone: 'Asia/Kolkata',
supportContacts: {
phoneNumber: '+919876543210',
email: 'support@acme.com',
},
trustSignals: {
valuePropositionOneLiner: 'Handcrafted products using sustainable materials',
customersTillDate: 15000,
totalOrdersFulfilled: 42000,
storeRating: 4.8,
},
policyFramework: {
returnPolicy: { windowDays: 7, processingFee: { amount: 50 }, refundTimelineDays: 3 },
shippingPolicy: { deliveryTimeline: { minDays: 3, maxDays: 5 }, freeShippingMinOrderValue: 999 },
codPolicy: { enabled: true, additionalFee: { amount: 50 } },
},
}),
}
);
const { data } = await response.json();
console.log(data.workspace); // save your workspace ID
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.voice-agents.miraiminds.co/v2/workspace/onboard/shopify',
headers={
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
'Content-Type': 'application/json',
},
json={
'name': 'Acme Store',
'currencyCode': 'INR',
'timezone': 'Asia/Kolkata',
'supportContacts': {
'phoneNumber': '+919876543210',
'email': 'support@acme.com',
},
'trustSignals': {
'valuePropositionOneLiner': 'Handcrafted products using sustainable materials',
'customersTillDate': 15000,
'totalOrdersFulfilled': 42000,
'storeRating': 4.8,
},
'policyFramework': {
'returnPolicy': {'windowDays': 7, 'processingFee': {'amount': 50}, 'refundTimelineDays': 3},
'shippingPolicy': {'deliveryTimeline': {'minDays': 3, 'maxDays': 5}, 'freeShippingMinOrderValue': 999},
'codPolicy': {'enabled': True, 'additionalFee': {'amount': 50}},
},
}
)
workspace_id = response.json()['data']['workspace']
print(workspace_id) # save your workspace ID
```
**Success Response (`200 OK`)**
```json theme={null}
{
"success": true,
"data": {
"workspace": "68d63c242cd956c2bb41cd3a",
"status": "active"
}
}
```
Save your `workspace` ID — it is required as a header in every subsequent API call.
***
## Step 2 — Create an AI Agent
Create a voice assistant configured with a persona, language, voice, call settings, and optionally a knowledge base.
POST /v1/admin/assistant/create
### Headers
| Header | Required | Description |
| :-------------- | :------- | :------------------- |
| `x-public-key` | **Yes** | Your public API key |
| `x-private-key` | **Yes** | Your private API key |
| `workspace` | **Yes** | Your workspace ID |
### Body Parameters
| Parameter | Type | Required | Description |
| :--------------------------------- | :----- | :------- | :------------------------------------------------------------------------- |
| `name` | string | **Yes** | Assistant display name (max 40 chars) |
| `variant.type` | string | **Yes** | Assistant type: `abandoned_cart` or `custom` |
| `variant.config.systemPrompt` | string | No | Custom system prompt (for `custom` variant) |
| `agent.identity.name` | string | **Yes** | Agent's spoken name (e.g., `Priya`) |
| `agent.identity.gender` | string | **Yes** | `male` or `female` |
| `agent.identity.voice` | string | **Yes** | Voice identifier (see [Voice Gallery](/api-reference/introduction)) |
| `icpContext.language` | string | **Yes** | Call language: `hinglish`, `english`, `hindi`, `tamil`, `telugu`, and more |
| `icpContext.targetAgeGroups` | array | No | `gen_z`, `millennials`, `gen_x`, `boomers` |
| `icpContext.locationTiers` | array | No | `metro_urban`, `tier1`, `tier2`, `tier3`, `rural` |
| `callSettings.slots` | array | No | Time windows when calls can be made (e.g., `10:00`–`17:30`) |
| `callSettings.maxCallDuration` | number | No | Maximum call duration in seconds |
| `callSettings.concurrentCallCount` | number | No | Max concurrent calls (up to 10) |
| `callSettings.retryProtocol` | object | No | Retry behavior for no-pick-up and low-engagement calls |
| `analysisPlan` | object | No | Success criteria and summary instructions for post-call AI analysis |
| `knowledgeBase.faq` | array | No | FAQ pairs (`question` + `answer`) |
| `knowledgeBase.documents` | array | No | External documents by URL (`pdf`, `txt`, `docx`, `markdown`) |
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create' \
--header 'x-public-key: pk_your_public_key' \
--header 'x-private-key: sk_your_private_key' \
--header 'workspace: 68d63c242cd956c2bb41cd3a' \
--header 'Content-Type: application/json' \
--data '{
"name": "Acme Abandoned Cart Agent",
"variant": {
"type": "abandoned_cart"
},
"agent": {
"identity": {
"name": "Priya",
"gender": "female",
"voice": "priya"
}
},
"icpContext": {
"language": "hinglish",
"targetAgeGroups": ["millennials", "gen_z"],
"locationTiers": ["metro_urban", "tier1"]
},
"callSettings": {
"slots": [
{ "startTime": "10:00", "endTime": "13:00" },
{ "startTime": "15:00", "endTime": "19:00" }
],
"maxCallDuration": 180,
"concurrentCallCount": 5,
"retryProtocol": {
"maxAttemptsNoPickup": 2,
"maxAttemptsLowEngagement": 1,
"reAttemptPeriod": 300,
"maxRescheduleCount": 1
}
},
"analysisPlan": {
"successCriteriaPlan": "Call is successful if the customer confirmed intent to complete the purchase or provided a reason for abandonment.",
"summaryPlan": "Summarize customer sentiment and whether they intend to buy."
},
"knowledgeBase": {
"faq": [
{
"question": "What is your return policy?",
"answer": "We offer 7-day returns with a ₹50 processing fee."
}
]
}
}'
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create',
{
method: 'POST',
headers: {
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
workspace: '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Acme Abandoned Cart Agent',
variant: { type: 'abandoned_cart' },
agent: {
identity: { name: 'Priya', gender: 'female', voice: 'priya' },
},
icpContext: {
language: 'hinglish',
targetAgeGroups: ['millennials', 'gen_z'],
locationTiers: ['metro_urban', 'tier1'],
},
callSettings: {
slots: [
{ startTime: '10:00', endTime: '13:00' },
{ startTime: '15:00', endTime: '19:00' },
],
maxCallDuration: 180,
concurrentCallCount: 5,
retryProtocol: {
maxAttemptsNoPickup: 2,
maxAttemptsLowEngagement: 1,
reAttemptPeriod: 300,
maxRescheduleCount: 1,
},
},
analysisPlan: {
successCriteriaPlan:
'Call is successful if the customer confirmed intent to complete the purchase.',
summaryPlan: 'Summarize customer sentiment and whether they intend to buy.',
},
knowledgeBase: {
faq: [{ question: 'What is your return policy?', answer: 'We offer 7-day returns.' }],
},
}),
}
);
const { data } = await response.json();
console.log(data.assistantId); // save your assistant ID
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.voice-agents.miraiminds.co/v1/admin/assistant/create',
headers={
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
'workspace': '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
json={
'name': 'Acme Abandoned Cart Agent',
'variant': {'type': 'abandoned_cart'},
'agent': {
'identity': {'name': 'Priya', 'gender': 'female', 'voice': 'priya'},
},
'icpContext': {
'language': 'hinglish',
'targetAgeGroups': ['millennials', 'gen_z'],
'locationTiers': ['metro_urban', 'tier1'],
},
'callSettings': {
'slots': [
{'startTime': '10:00', 'endTime': '13:00'},
{'startTime': '15:00', 'endTime': '19:00'},
],
'maxCallDuration': 180,
'concurrentCallCount': 5,
'retryProtocol': {
'maxAttemptsNoPickup': 2,
'maxAttemptsLowEngagement': 1,
'reAttemptPeriod': 300,
'maxRescheduleCount': 1,
},
},
'analysisPlan': {
'successCriteriaPlan': 'Call is successful if the customer confirmed intent to purchase.',
'summaryPlan': 'Summarize customer sentiment and purchase intent.',
},
'knowledgeBase': {
'faq': [{'question': 'What is your return policy?', 'answer': 'We offer 7-day returns.'}],
},
}
)
assistant_id = response.json()['data']['assistantId']
print(assistant_id) # save your assistant ID
```
**Success Response (`200 OK`)**
```json theme={null}
{
"success": true,
"data": {
"assistantId": "6927ec5c9322ed9f9fb55c68"
}
}
```
Save the `assistantId` — it is required when initiating calls.
`variant.type` is immutable after creation. Choose `abandoned_cart` for cart recovery flows or `custom` for fully flexible prompts.
***
## Step 3 — Initiate a Call
Trigger an outbound call to a customer using the assistant you created.
POST /v2/call/initiate
### Headers
| Header | Required | Description |
| :-------------- | :------- | :------------------- |
| `x-public-key` | **Yes** | Your public API key |
| `x-private-key` | **Yes** | Your private API key |
| `workspace` | **Yes** | Your workspace ID |
### Body Parameters
| Parameter | Type | Required | Description |
| :------------------ | :------ | :------- | :-------------------------------------------------------------------------------------- |
| `phoneNumber` | string | **Yes** | Customer's phone number in E.164 format (e.g., `+919876543210`) |
| `assistant` | string | **Yes** | The `assistantId` from Step 2 |
| `callbackUrl` | string | **Yes** | HTTPS URL to receive webhook events for this call |
| `priority` | boolean | No | Set `true` to jump the call queue |
| `payload` | object | No | Call context data (customer info, cart items, pricing) passed to the AI during the call |
| `metadata` | object | No | Arbitrary key-value pairs echoed back in every webhook event |
| `metadata.discount` | object | No | Discount code to offer during the call (`code`, `value`, `codeType`, `applyAs`) |
#### `payload` Object
The `payload` provides the AI with context about the customer and their cart. All fields are optional but recommended for `abandoned_cart` assistants.
| Field | Type | Description |
| :---------------------------------- | :----- | :----------------------------------------------------------------------------- |
| `customer.firstName` | string | Customer's first name |
| `customer.lastName` | string | Customer's last name |
| `customer.email` | string | Customer's email |
| `customer.phone` | string | Customer's phone |
| `lineItems` | array | Cart items — each with `title`, `quantity`, and `variant.id` + `variant.title` |
| `subtotalPriceSet.shopMoney.amount` | string | Cart subtotal |
| `totalPriceSet.shopMoney.amount` | string | Cart total |
| `abandonedCheckoutUrl` | string | Direct URL to the abandoned checkout |
| `shippingAddress` | object | Customer's shipping address |
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v2/call/initiate' \
--header 'x-public-key: pk_your_public_key' \
--header 'x-private-key: sk_your_private_key' \
--header 'workspace: 68d63c242cd956c2bb41cd3a' \
--header 'Content-Type: application/json' \
--data '{
"phoneNumber": "+919876543210",
"assistant": "6927ec5c9322ed9f9fb55c68",
"callbackUrl": "https://your-app.com/webhooks/voice-agent",
"priority": false,
"payload": {
"id": "gid://shopify/AbandonedCheckout/66509168181329",
"abandonedCheckoutUrl": "https://acme.com/checkout/recover?token=abc123",
"customer": {
"firstName": "Riya",
"lastName": "Shah",
"email": "riya@example.com",
"phone": "+919876543210"
},
"lineItems": [
{
"title": "Handcrafted Tote Bag",
"quantity": 1,
"variant": {
"id": "gid://shopify/ProductVariant/44001234567",
"title": "Brown / Medium"
}
}
],
"subtotalPriceSet": { "shopMoney": { "amount": "1895.0" } },
"totalPriceSet": { "shopMoney": { "amount": "1945.0" } },
"shippingAddress": {
"city": "Mumbai",
"province": "Maharashtra",
"country": "India",
"zip": "400001"
}
},
"metadata": {
"orderId": "ORD-9876",
"customerId": "cust_001",
"discount": {
"code": "SAVE10",
"description": "10% off your cart",
"value": 10,
"codeType": "percentage",
"applyAs": "additional"
}
}
}'
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.voice-agents.miraiminds.co/v2/call/initiate',
{
method: 'POST',
headers: {
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
workspace: '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
body: JSON.stringify({
phoneNumber: '+919876543210',
assistant: '6927ec5c9322ed9f9fb55c68',
callbackUrl: 'https://your-app.com/webhooks/voice-agent',
priority: false,
payload: {
id: 'gid://shopify/AbandonedCheckout/66509168181329',
abandonedCheckoutUrl: 'https://acme.com/checkout/recover?token=abc123',
customer: {
firstName: 'Riya',
lastName: 'Shah',
email: 'riya@example.com',
phone: '+919876543210',
},
lineItems: [
{
title: 'Handcrafted Tote Bag',
quantity: 1,
variant: { id: 'gid://shopify/ProductVariant/44001234567', title: 'Brown / Medium' },
},
],
subtotalPriceSet: { shopMoney: { amount: '1895.0' } },
totalPriceSet: { shopMoney: { amount: '1945.0' } },
shippingAddress: { city: 'Mumbai', province: 'Maharashtra', country: 'India', zip: '400001' },
},
metadata: {
orderId: 'ORD-9876',
customerId: 'cust_001',
discount: { code: 'SAVE10', description: '10% off your cart', value: 10, codeType: 'percentage', applyAs: 'additional' },
},
}),
}
);
const { data } = await response.json();
console.log(data.callId); // save for abort/tracking
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.voice-agents.miraiminds.co/v2/call/initiate',
headers={
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
'workspace': '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
json={
'phoneNumber': '+919876543210',
'assistant': '6927ec5c9322ed9f9fb55c68',
'callbackUrl': 'https://your-app.com/webhooks/voice-agent',
'priority': False,
'payload': {
'id': 'gid://shopify/AbandonedCheckout/66509168181329',
'abandonedCheckoutUrl': 'https://acme.com/checkout/recover?token=abc123',
'customer': {
'firstName': 'Riya',
'lastName': 'Shah',
'email': 'riya@example.com',
'phone': '+919876543210',
},
'lineItems': [
{
'title': 'Handcrafted Tote Bag',
'quantity': 1,
'variant': {'id': 'gid://shopify/ProductVariant/44001234567', 'title': 'Brown / Medium'},
}
],
'subtotalPriceSet': {'shopMoney': {'amount': '1895.0'}},
'totalPriceSet': {'shopMoney': {'amount': '1945.0'}},
'shippingAddress': {'city': 'Mumbai', 'province': 'Maharashtra', 'country': 'India', 'zip': '400001'},
},
'metadata': {
'orderId': 'ORD-9876',
'customerId': 'cust_001',
'discount': {'code': 'SAVE10', 'description': '10% off your cart', 'value': 10, 'codeType': 'percentage', 'applyAs': 'additional'},
},
}
)
call_id = response.json()['data']['callId']
print(call_id) # save for abort/tracking
```
**Success Response (`200 OK`)**
```json theme={null}
{
"success": true,
"data": {
"callId": "c_550e8400-e29b-41d4-a716-446655440000",
"status": "queued"
}
}
```
The call is placed asynchronously. The `callId` is returned immediately — real-time status updates are delivered to your `callbackUrl`.
***
## Step 4 — Abort a Call
Cancel a queued or in-progress call using the `callId` returned during initiation.
POST /v2/call/abort
### Body Parameters
| Parameter | Type | Required | Description |
| :-------- | :----- | :------- | :------------------------------------------------ |
| `callId` | string | **Yes** | The `callId` returned when the call was initiated |
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v2/call/abort' \
--header 'x-public-key: pk_your_public_key' \
--header 'x-private-key: sk_your_private_key' \
--header 'workspace: 68d63c242cd956c2bb41cd3a' \
--header 'Content-Type: application/json' \
--data '{
"callId": "c_550e8400-e29b-41d4-a716-446655440000"
}'
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.voice-agents.miraiminds.co/v2/call/abort',
{
method: 'POST',
headers: {
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
workspace: '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
body: JSON.stringify({
callId: 'c_550e8400-e29b-41d4-a716-446655440000',
}),
}
);
const result = await response.json();
console.log(result.message); // "Call aborted successfully"
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.voice-agents.miraiminds.co/v2/call/abort',
headers={
'x-public-key': 'pk_your_public_key',
'x-private-key': 'sk_your_private_key',
'workspace': '68d63c242cd956c2bb41cd3a',
'Content-Type': 'application/json',
},
json={'callId': 'c_550e8400-e29b-41d4-a716-446655440000'}
)
print(response.json()['message'])
```
**Success Response (`200 OK`)**
```json theme={null}
{
"success": true,
"message": "Call aborted successfully",
"data": {
"callId": "c_550e8400-e29b-41d4-a716-446655440000",
"status": "aborted"
}
}
```
After a successful abort, your `callbackUrl` will receive a `call.aborted` event.
Calls that have already reached `call.completed` or `call.lifecycle-ended` state cannot be aborted.
***
## Step 5 — Handle Callback URL & Lifecycle Events
The `callbackUrl` you provided when initiating the call receives real-time webhook events as the call progresses. Your custom `metadata` is echoed back in every event so you can correlate events to your internal records.
### Call Lifecycle
```mermaid theme={null}
stateDiagram-v2
[*] --> Initiate: Call Placed
Initiate --> InProgress: Connected
Initiate --> Failed: Failed / Busy / No Answer
InProgress --> Ended: Disconnected
Ended --> Completed: Data Processed
Completed --> EndOfCall: AI Analysis Ready
EndOfCall --> LifecycleEnded: All Done
Failed --> LifecycleEnded: All Done
LifecycleEnded --> [*]
```
### Lifecycle Events Reference
| Event | Trigger |
| :--------------------- | :----------------------------------------------------------- |
| `call.initiate` | Call has been queued and is dialing |
| `call.in-progress` | Customer answered — AI conversation started |
| `call.ended` | Phone connection dropped |
| `call.completed` | Recording, transcript, and duration are ready |
| `call.timeout` | Call exceeded maximum allowed duration |
| `end-of-call` | AI analysis complete — summary and structured data available |
| `call.lifecycle-ended` | Final event — all processing finished |
| `call.failed` | Call could not connect |
| `call.busy` | Customer's line was busy |
| `call.no-answer` | Customer did not pick up |
| `call.aborted` | Call was cancelled via the abort API |
| `call.rescheduled` | Retry scheduled for a later time |
### Webhook Payload Structure
Every event uses the same envelope with your `metadata` echoed back:
```json theme={null}
{
"metadata": {
"customerId": "cust_001",
"orderId": "ORD-9876"
},
"event": {
"type": "call.completed",
"data": {
"call": {
"id": "c_550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"startedAt": "2025-11-27T10:00:00Z",
"endedAt": "2025-11-27T10:02:30Z",
"durationSeconds": 150,
"recordingUrl": "https://api.voice-agents.miraiminds.co/recordings/...",
"detailUrl": "https://api.voice-agents.miraiminds.co/calls/..."
}
}
}
}
```
The `end-of-call` event additionally includes AI analysis and credit usage:
```json theme={null}
{
"event": {
"type": "end-of-call",
"data": {
"analysis": {
"success": true,
"summary": "Customer confirmed intent to complete the purchase after receiving the discount code.",
"insights": {
"sentiment": "positive",
"intent": "purchase_confirmed"
}
},
"credits": {
"used": 2.5,
"available": 105.0
}
}
}
}
```
### Handling Webhooks — Example
```javascript theme={null}
app.post('/webhooks/voice-agent', (req, res) => {
// Always respond immediately — do not wait for processing
res.status(200).send('OK');
const { event, metadata } = req.body;
console.log(`[${event.type}] customer=${metadata.customerId}`);
switch (event.type) {
case 'call.initiate':
updateCallStatus(metadata.customerId, 'dialing');
break;
case 'call.in-progress':
updateCallStatus(metadata.customerId, 'in-progress');
break;
case 'call.aborted':
updateCallStatus(metadata.customerId, 'aborted');
break;
case 'call.failed':
case 'call.busy':
case 'call.no-answer':
logCallFailure(metadata.customerId, event.type);
break;
case 'call.completed':
saveRecording(event.data.call.id, event.data.call.recordingUrl);
break;
case 'end-of-call':
saveAnalysis(metadata.customerId, event.data.analysis);
deductCredits(event.data.credits.used);
break;
case 'call.lifecycle-ended':
closeCallRecord(metadata.customerId);
break;
default:
console.log('Unhandled event:', event.type);
}
});
```
### Callback URL Tips
* **HTTPS required** — plain HTTP endpoints are rejected.
* **Respond with `200 OK` immediately** — Voice Agents does not wait for your processing; slow responses may trigger retries.
* **Use `metadata` for correlation** — pass your internal IDs (e.g., `customerId`, `orderId`) when initiating the call; they are echoed in every event.
* **Verify signatures** — validate the webhook signature on every incoming request. See [Webhook Signature Verification](/developers/webhook-signature).
***
## API Summary
| Step | Method | Endpoint | Purpose |
| :------------- | :----- | :------------------------------ | :-------------------------------------- |
| Onboard | `POST` | `/v2/workspace/onboard/shopify` | Register a brand and create a workspace |
| Create Agent | `POST` | `/v1/admin/assistant/create` | Create a voice assistant |
| Initiate Call | `POST` | `/v2/call/initiate` | Trigger an outbound call |
| Abort Call | `POST` | `/v2/call/abort` | Cancel a queued or active call |
| Receive Events | — | your `callbackUrl` | Handle real-time call lifecycle events |
# Developer Introduction
Source: https://docs.miraiminds.co/developers/introduction
Get started with the Voice Agents API to build programmatic voice interactions.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Welcome to the Voice Agents Developer Hub. Our API allows you to programmatically trigger voice calls, manage agents, and integrate voice capabilities directly into your applications.
## Base URL
All API requests should be made to:
```bash theme={null}
https://api.voice-agents.miraiminds.co/v1
```
## Authentication
We use a workspace and organization-based authentication system. Every request to the API must include the following headers to identify your account:
| Header | Description |
| :-------------- | :------------------------------------------------------------------ |
| `workspace` | Your unique workspace identifier (e.g., `68d63c242cd956c2bb41cd3a`) |
| `organization` | Your organization identifier (e.g., `68d63c242cd956c2bb41cd12`) |
| `Authorization` | Your Bearer token (e.g., `Bearer eyJhbGciOiJIUzI1Ni...`) |
To get your Organization ID, Workspace ID, and Authorization Token, please contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co).
## Quick Start
Contact [sneh@miraiminds.co](mailto:sneh@miraiminds.co) to obtain your `workspace`, `organization`, and `Authorization` token.
Identify the `assistant_id` of the voice agent you want to trigger.
Use our [Make a Call](/developers/make-a-call) endpoint to trigger an outbound call immediately.
## Common Error Codes
| Code | Meaning |
| :---- | :---------------------------------------------- |
| `200` | Success |
| `400` | Bad Request (Check your JSON body) |
| `401` | Unauthorized (Check your workspace/org headers) |
| `429` | Rate Limit Exceeded |
| `500` | Internal Server Error |
# Make a Call
Source: https://docs.miraiminds.co/developers/make-a-call
Trigger an outbound call from a specific voice assistant to a target phone number.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
The `Make a Call` endpoint allows you to programmatically trigger an outbound call to any phone number using a specific voice assistant. This is useful for automated notifications, appointment reminders, or immediate lead qualification.
## Endpoint
POST /admin/assistant/call
## Request Flow
Before the call is placed, the system validates your workspace, checks the assistant's availability, and then initiates the telephony connection.
```mermaid theme={null}
sequenceDiagram
participant App as Your App
participant API as Voice API
participant Tel as Telephony Provider
participant User as Customer Phone
App->>API: POST /admin/assistant/call
Note right of App: Includes assistant_id
and to_number
API->>API: Validate Workspace & Org
API->>API: Load Assistant Config
API->>Tel: Initiate Call
Tel-->>User: Ringing...
Tel-->>API: Call Status: Queued
API-->>App: 200 OK { call_id: "..." }
User->>Tel: Answers
Tel->>API: Stream Audio
API->>Tel: AI Response
```
## Request Parameters
### Headers
| Header | Type | Required | Description |
| :-------------- | :----- | :------- | :------------------------------- |
| `workspace` | string | **Yes** | Your unique workspace ID. |
| `organization` | string | **Yes** | Your organization ID. |
| `Content-Type` | string | **Yes** | Must be `application/json`. |
| `Authorization` | string | **Yes** | Bearer token for authentication. |
### Body Parameters
| Parameter | Type | Required | Description |
| :------------- | :----- | :------- | :---------------------------------------------------------------- |
| `assistant_id` | string | **Yes** | The unique identifier of the assistant to place the call. |
| `to_number` | string | **Yes** | The phone number to call in E.164 format (e.g., `+919723067241`). |
## Code Examples
```bash cURL theme={null}
curl --location 'https://api.voice-agents.miraiminds.co/v1/admin/assistant/call' \
--header 'workspace: 68d63c242cd956c2bb41cd3a' \
--header 'organization: 68d63c242cd956c2bb41cd12' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer ' \
--data '{
"assistant_id": "68c128a658cd7d0668bce78d",
"to_number": "+919723067241"
}'
```
```javascript Node.js theme={null}
const myHeaders = new Headers();
myHeaders.append("workspace", "68d63c242cd956c2bb41cd3a");
myHeaders.append("organization", "68d63c242cd956c2bb41cd12");
myHeaders.append("Content-Type", "application/json");
myHeaders.append("Authorization", "Bearer ");
const raw = JSON.stringify({
"assistant_id": "68c128a658cd7d0668bce78d",
"to_number": "+919723067241"
});
const requestOptions = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow"
};
fetch("https://api.voice-agents.miraiminds.co/v1/admin/assistant/call", requestOptions)
.then((response) => response.json())
.then((result) => console.log(result))
.catch((error) => console.error(error));
```
```python Python theme={null}
import requests
import json
url = "https://api.voice-agents.miraiminds.co/v1/admin/assistant/call"
payload = json.dumps({
"assistant_id": "68c128a658cd7d0668bce78d",
"to_number": "+919723067241"
})
headers = {
'workspace': '68d63c242cd956c2bb41cd3a',
'organization': '68d63c242cd956c2bb41cd12',
'Content-Type': 'application/json',
'Authorization': 'Bearer '
}
response = requests.request("POST", url, headers=headers, data=payload)
print(response.text)
```
## Response
Returns a JSON object containing the status of the call request.
### Success Response (`200 OK`)
```json theme={null}
{
"success": true,
"message": "Call initiated successfully",
"data": {
"call_id": "c_1234567890",
"status": "queued",
"assistant_id": "68c128a658cd7d0668bce78d",
"to_number": "+919723067241"
}
}
```
### Common Errors
* `400 Bad Request`: Missing `to_number` or invalid `assistant_id`.
* `401 Unauthorized`: Invalid or missing `workspace`/`organization`/`Authorization` headers.
* `404 Not Found`: Assistant ID does not exist in the specified workspace.
# Webhook Signature Verification
Source: https://docs.miraiminds.co/developers/webhook-signature
Learn how to verify webhook signatures to ensure authenticity and integrity.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
All webhook events emitted by the Voice Agents Backend are cryptographically signed using HMAC-SHA256. The signature is calculated from the payload data, allowing consumers to verify the data's authenticity and integrity.
This approach allows consumers to verify that:
1. **Authenticity**: The webhook originated from our service
2. **Integrity**: The payload has not been tampered with during transmission
After successful signature verification, the consumer can trust and use the payload data included in the request body.
## Keys
Each organization has a unique key pair automatically generated when the organization is created:
* **Public Key** (`publicKey`): Format `pk_<32 hex characters>`
* **Secret Key** (`privateKey`): Format `sk_<64 hex characters>`
These keys are stored at the organization level and can be found in your organization settings. The secret key is used to sign webhooks, while the public key is included in webhook headers for identification purposes.
Keep your secret key secure and never expose it publicly. Only the public key should be shared or used for verification.
## Signature Headers
Every webhook request includes the following headers along with the payload in the request body:
| Header | Description | Example |
| -------------- | -------------------------------------------------- | ------------------------ |
| `x-signature` | HMAC-SHA256 signature of the payload (hex encoded) | `a1b2c3d4e5f6...` |
| `x-public-key` | Organization's public key identifier | `pk_1234567890abcdef...` |
The request body contains the full event payload. The signature is calculated from this payload data. After successful verification, you can trust and use the payload data.
## Verification Process
To verify a webhook signature, follow these steps:
### Step 1: Extract Headers
Extract the following headers from the incoming request:
* `x-signature`: The signature to verify against
* `x-public-key`: Used to identify which organization's secret key to use
### Step 2: Get the Secret Key
Using the `x-public-key` header, retrieve the corresponding secret key for your organization. This should match the secret key stored in your organization settings.
### Step 3: Reconstruct the Signature
The signature is calculated from the raw payload string:
1. Get the raw request body as a UTF-8 string (exactly as received, before any JSON parsing)
2. Create an HMAC-SHA256 hash using your secret key
3. Update the hash with the raw payload string
4. Get the hex digest of the hash
### Step 4: Compare Signatures
Compare the computed signature with the `x-signature` header value. They must match exactly (case-sensitive).
### Step 5: Use the Payload Data
After successful signature verification, you can trust and use the payload data from the request body. The signature proves the data's authenticity and integrity.
## Code Examples
### Node.js
```javascript theme={null}
const crypto = require("crypto");
function verifyWebhookSignature(req, secretKey) {
// Extract headers
const signature = req.headers["x-signature"];
const publicKey = req.headers["x-public-key"];
if (!signature || !publicKey) {
throw new Error("Missing required signature headers");
}
// Get raw body (must be the exact string, not parsed JSON)
const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
// Compute signature from payload
const computedSignature = crypto.createHmac("sha256", secretKey).update(rawBody).digest("hex");
// Compare signatures (use constant-time comparison to prevent timing attacks)
if (signature.length !== computedSignature.length) {
throw new Error("Invalid signature");
}
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computedSignature));
}
// Express.js middleware example
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
try {
const publicKey = req.headers["x-public-key"];
const secretKey = getSecretKeyByPublicKey(publicKey); // Your function to retrieve secret key
if (verifyWebhookSignature(req, secretKey)) {
// Signature is valid, parse and use the payload data
const payload = JSON.parse(req.body.toString());
console.log("Webhook verified:", payload);
// Use the payload data - it's verified and trusted
// Process the event: payload.event.type, payload.event.data, etc.
res.status(200).json({ received: true });
} else {
res.status(401).json({ error: "Invalid signature" });
}
} catch (error) {
console.error("Webhook verification error:", error);
res.status(401).json({ error: error.message });
}
});
```
### Python
```python theme={null}
import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_webhook_signature(request, secret_key):
"""
Verify webhook signature using HMAC-SHA256
Args:
request: Flask request object
secret_key: Organization's secret key (str)
Returns:
bool: True if signature is valid, False otherwise
"""
# Extract headers
signature = request.headers.get('x-signature')
public_key = request.headers.get('x-public-key')
if not all([signature, public_key]):
raise ValueError('Missing required signature headers')
# Get raw body (must be bytes, not parsed JSON)
raw_body = request.get_data()
# Compute signature from payload
computed_signature = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# Compare signatures (use constant-time comparison)
return hmac.compare_digest(signature, computed_signature)
@app.route('/webhook', methods=['POST'])
def webhook_handler():
try:
public_key = request.headers.get('x-public-key')
secret_key = get_secret_key_by_public_key(public_key) # Your function to retrieve secret key
if verify_webhook_signature(request, secret_key):
# Signature is valid, parse and use the payload data
payload = request.get_json()
print(f'Webhook verified: {payload}')
# Use the payload data - it's verified and trusted
# Process the event: payload['event']['type'], payload['event']['data'], etc.
return jsonify({'received': True}), 200
else:
return jsonify({'error': 'Invalid signature'}), 401
except Exception as e:
print(f'Webhook verification error: {e}')
return jsonify({'error': str(e)}), 401
def get_secret_key_by_public_key(public_key):
"""
Retrieve secret key by public key.
Replace this with your actual implementation.
"""
# TODO: Implement your logic to retrieve secret key from database/storage
pass
```
## Important Notes
1. **Raw Body**: Always use the raw, unparsed request body for signature verification. Do not use parsed JSON objects, as JSON serialization may differ between systems.
2. **Constant-Time Comparison**: Use constant-time comparison functions (like `crypto.timingSafeEqual` in Node.js or `hmac.compare_digest` in Python) to prevent timing attacks.
3. **Secret Key Storage**: Store secret keys securely (e.g., environment variables, secure key management systems). Never commit them to version control.
4. **Error Handling**: If signature verification fails, return a 401 Unauthorized status and log the event for security monitoring.
5. **Using Payload Data**: After successful signature verification, you can trust and use the payload data from the request body. The signature proves the data's authenticity and integrity.
## Troubleshooting
### Signature Mismatch
* Ensure you're using the raw request body (before JSON parsing)
* Verify you're using the correct secret key for the public key in the header
* Check that the payload string matches exactly (no extra whitespace, correct encoding)
### Missing Headers
* Verify that your webhook endpoint is receiving all required headers: `x-signature`, `x-public-key`
* Check your reverse proxy or load balancer configuration to ensure headers are not being stripped
# Campaign Management
Source: https://docs.miraiminds.co/general/campaign
Learn how to create and manage voice campaigns with CSV data or API integration
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Voice campaigns allow you to automate outbound calls to multiple contacts. You can create campaigns using CSV data uploads or connect directly through your existing systems via API integration.
Upload contact lists using our CSV template for quick campaign setup and
data management.
Connect your existing systems via webhook to automatically sync contact
data.
Configure precise timing, timezone handling, and operating hours for optimal
contact rates.
Set retry logic, call duration limits, and inactivity periods to maximize
efficiency.
## Setup Guide
### Step 1: Access Campaign Creation
1. **Navigate to Campaigns Page**
Go to the **Campaigns** section in your Voice Agents dashboard.
2. **Create New Campaign**
Click the **"Create Campaign"** button to start the setup process.
### Step 2: Basic Campaign Configuration
3. **Enter Campaign Name**
Provide a descriptive name for your campaign that clearly identifies 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 need to add data from your platform through API. When enabled, CSV upload becomes 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**
Update the downloaded CSV template with your contact information, ensuring all required fields are completed.
8. **Upload CSV File**
Upload your completed CSV file using the file upload interface.
9. **Preview Contact Data**
Review your uploaded data in the right panel preview to verify accuracy before proceeding.
#### Option B: API Integration
When webhook is enabled, your platform can send contact data directly through API calls, eliminating the need for manual CSV uploads.
### Step 4: Schedule Configuration
10. **Set Campaign Dates**
Configure your campaign timeline:
* **Start Date**: Select when the campaign should begin
* **End Date**: Choose when the campaign should conclude
11. **Configure Timezone**
Select the appropriate timezone for your campaign to ensure calls are made at the right local times.
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**: Wait time during no response before ending calls
### Step 6: Launch Campaign
15. **Create**
Once all settings are configured, click **"Create Campaign"** to Create your 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
Any invalid or missing entries will be shown in list format, allowing you to download this data for correction and potential re-upload.
### Step 7: Launch and Manage Campaign
17. **Launch Your Campaign**
After creating your campaign, navigate to the campaign list to launch it. Click the **"Play"** button next to your campaign to start making calls.
18. **Track Campaign Status**
Monitor your campaign's progress by visiting the **Campaign Status** page. Here you can view:
* Call completion rates
* Success/failure statistics
* Real-time campaign progress
* Contact attempt history
19. **Copy Webhook API Endpoint**
If you enabled webhook integration, you can copy the API endpoint for your platform integration. Click **"Copy Webhook"** to get the endpoint URL for sending data via API.
Use this endpoint to programmatically add contacts to your campaign or trigger campaign events from your existing systems.
## What's Next?
Once your campaign is created and launched, you can:
* **Monitor Performance** - Track call success rates and campaign progress
* **Manage Active Campaigns** - Pause, resume, or modify running campaigns
* **Analyze Results** - Review detailed campaign analytics and outcomes
* **Export Reports** - Download campaign performance data
## Best Practices
### Data Preparation
* Test your CSV data with a small sample before uploading large datasets
* Ensure phone numbers are properly formatted for your target regions
* Include all required fields to minimize data validation issues
### Campaign Optimization
* Set reasonable retry limits to avoid overwhelming contacts
* Configure appropriate operating hours to respect contact preferences
* Monitor initial performance and adjust settings as needed
### Compliance Considerations
* Ensure you have proper consent to contact all numbers in your campaign
* Respect do-not-call lists and opt-out requests
* Follow local regulations for automated calling
# Frequently Asked Questions
Source: https://docs.miraiminds.co/general/faq
Answers to all your questions
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
# Voice Agent Overview
Source: https://docs.miraiminds.co/general/overview
Understand what voice agents are, how they work, and how they can transform your customer interactions.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Voice agents are AI-powered conversational systems that automate voice interactions at scale. They combine advanced natural language processing, speech recognition, and intelligent call flow management to handle customer communications without human intervention.
Unlike traditional phone systems or chatbots, voice agents can:
* **Understand natural speech** in real-time conversations
* **Process complex requests** and provide contextual responses
* **Connect to your existing systems** to access data and perform actions
* **Route calls intelligently** between automated handling and human agents
* **Learn and adapt** from each interaction to improve performance
## How Voice Agents Work
Voice agents operate through a sophisticated pipeline that transforms spoken conversations into actionable outcomes:
### 1. Speech Recognition
Advanced speech-to-text technology converts caller audio into structured text, handling various accents, speaking speeds, and background noise.
### 2. Natural Language Understanding
AI models analyze the converted text to understand:
* **Intent** - What the caller wants to accomplish
* **Entities** - Key information like names, dates, product IDs
* **Context** - Previous conversation history and current situation
### 3. Decision Engine
The voice agent determines the appropriate response based on:
* Predefined conversation flows
* Integration with your business systems
* Real-time data lookup and validation
* Escalation rules for complex scenarios
### 4. Response Generation
Dynamic response creation that:
* Provides accurate, contextual information
* Maintains conversational flow
* Adapts tone and style to match your brand
* Handles follow-up questions naturally
### 5. Action Execution
Voice agents can perform real actions like:
* Booking appointments in your calendar system
* Processing orders and payments
* Updating customer records
* Triggering workflows in connected platforms
## Key Benefits
### Cost Efficiency
* **Pay-per-minute pricing** - Only pay for productive conversation time
* **No idle time charges** - Eliminate costs of agents waiting for calls
* **Reduced staffing needs** - Handle high call volumes without hiring
### 24/7 Availability
* **Always-on service** - Never miss a call due to business hours
* **No breaks or vacations** - Consistent availability year-round
* **Instant response** - Eliminate hold times and queue delays
### Unlimited Scalability
* **Handle millions of calls** simultaneously without infrastructure limits
* **Instant scaling** during peak periods or marketing campaigns
* **No training delays** - New capacity available immediately
### Universal Integration
* **Connect to any system** via APIs and webhooks
* **Sync with existing tools** like CRM, scheduling, and e-commerce platforms
* **Maintain data consistency** across all customer touchpoints
## Common Use Cases
Voice agents excel in scenarios that require:
### Customer Support
* Answer frequently asked questions
* Troubleshoot common issues
* Route complex problems to specialists
* Provide order status and tracking information
### Sales and Lead Qualification
* Qualify inbound leads automatically
* Schedule sales appointments
* Provide product information and pricing
* Follow up on abandoned carts or inquiries
### Appointment Scheduling
* Book, reschedule, and cancel appointments
* Send confirmation and reminder notifications
* Handle availability checking across multiple calendars
* Manage waitlists and cancellations
### Order Management
* Process new orders over the phone
* Handle returns and exchanges
* Provide shipping updates
* Manage subscription changes
## Getting Started
Voice agents can be deployed across multiple channels and integrated with your existing business systems. The setup process typically involves:
1. **Define conversation flows** for your specific use cases
2. **Connect integrations** to your business systems and databases
3. **Configure routing rules** for escalation to human agents
4. **Test and refine** the voice agent's responses and actions
5. **Deploy and monitor** performance across your communication channels
Voice agents represent the next evolution in customer communication - combining the personal touch of voice interaction with the efficiency and scalability of AI automation.
## Next Steps
Ready to implement voice agents for your business? Explore our integration guides to connect voice agents with your existing systems:
* [Shopify Integration](/integrations/shopify) - E-commerce order management
* [WhatsApp Integration](/integrations/whatsapp) - Multi-channel messaging
* [Google Calendar Integration](/integrations/google-calendar) - Appointment scheduling
* [Klaviyo Integration](/integrations/klaviyo) - Marketing automation
Or dive into specific use cases to see voice agents in action:
* [Retail Use Cases](/usecases/retail) - Customer support and sales
* [B2B Use Cases](/usecases/b2b) - Lead qualification and scheduling
* [Healthcare Use Cases](/usecases/healthcare) - Appointment management and patient support
# Roadmap
Source: https://docs.miraiminds.co/general/roadmap
What is shipping next — tiers, node graphs, the orchestration platform, model APIs, voices and integrations — with target dates.
What is coming, in the order we expect to ship it. Dates are **targets, not
contracts** — they move when call quality demands it, and quality wins every
time. Before you plan a launch around a row on this page, confirm it with your
account contact.
| What | Target | Status |
| :--------------------------------------------------------------------------------------------------- | :------------------------ | :----------------------------------- |
| [Post-call analytics on `t1`](#post-call-analytics-everywhere) — AI summary + QA score in the v2 API | August 2026 | rolling out |
| [Warm transfer on every tier](#warm-transfer-everywhere) — `ended_reason: transferred` goes live | August 2026 | rolling out |
| [`t3` conversational tier](/general/tiers) — premium voice, always-on path | August 2026 | ✅ **live, and now the default tier** |
| [Campaigns](/v2/campaigns) — server-side dialler with windows, retries, budgets and a report | August 2026 | ✅ **live** |
| [Transcripts + recordings](/v2/calls#get-the-transcript) in the v2 API | August 2026 | rolling out |
| [New voices](#new-voices) — fresh additions to the catalogue | monthly, from August 2026 | ongoing |
| [Model APIs](#model-apis) — ASR, TTS and LLM as standalone endpoints | September 2026 | in development |
| [Six new TTS languages](#new-tts-languages) — Telugu, Tamil, Marathi, Kannada, Gujarati, Bengali | September – October 2026 | in development |
| [Next-generation TTS](#next-generation-tts) — the voice engine behind every tier, upgraded | October 2026 | in development |
| Node graphs + `t5` — author calls as versioned graphs | Q4 2026 | schema preview published |
| [Orchestration platform](#orchestration-platform) — visual node-graph builder for your team | Q4 2026 | design |
| [Email + channel integrations](#channels) — email follow-ups from call outcomes | Q4 2026 | design |
| Self-serve signup and wallet top-up | Q4 2026 | design |
## Post-call analytics everywhere
Every call on every tier gets opened: an AI summary and a QA score, on 100% of
calls, not a sample. The capability is in the
[feature matrix](/general/tiers#feature-matrix) today; what lands in August is
the **v2 API surface** — an analysis object on the call and a webhook event when
it is ready. Until then the summaries exist but are not queryable over v2.
## Warm transfer everywhere
Human handoff is part of every tier — a caller who asks for a person gets one.
The August rollout wires the last mile:
[`ended_reason: transferred`](/v2/calls#ended-reasons) starts emitting, and
transfer targets become configurable per agent. Ask your account contact to
enable it on your workspace as the rollout reaches you.
## Model APIs
The ASR, TTS and LLM that power the call path, exposed as standalone
endpoints — for teams that want the models without the telephony. Streaming
ASR tuned for Indian telephone audio, the TTS voice catalogue, and the
call-tuned LLM, each behind the same `sk_live_` auth as the rest of v2.
## New TTS languages
Six languages join the voice engine over the next two months: **Telugu, Tamil,
Marathi, Kannada, Gujarati and Bengali** — together the mother tongue of 429
million people, most of whom cannot be reached in Hindi at all. Per-language
dates are announced as each clears evaluation. The full story, with the data,
is on the [language roadmap](/v2/tts-languages).
## Next-generation TTS
The next revision of the voice engine: lower first-audio latency and a step up
in naturalness on Hindi and code-switched speech. It replaces the engine under
every tier when it clears our quality bar — no API change, your calls just
start sounding better.
## Orchestration platform
Node graphs get a canvas. Build, test and version call flows visually — the
same graphs `t5` runs from JSON, authored by your ops team instead of your
engineers. Validation runs as you draw, and every published version is
immutable, so a live campaign never runs an unreviewed edit.
## New voices
The catalogue grows monthly from August 2026. New voices appear in the
[voice gallery](/v2/voices) as they clear evaluation — listen to
`sampleAudio` before you switch; voice choice moves answer-through rates more
than prompt wording does.
## Channels
Calls stop being an island: email follow-ups triggered by call outcomes first
("summary of what we agreed", "payment link after a confirmed order"), other
channels after. Designed so a call outcome, not a human, is what pulls the
trigger.
# Billing & tiers
Source: https://docs.miraiminds.co/general/tiers
What a minute costs, how minutes are counted, what each tier can do, and what happens at zero balance.
One number to remember: **₹3 per minute**. That is the `t3` conversational
tier, it is what a new key is issued at, and it is what every example in these
docs uses unless it says otherwise.
| Tier | Rate | Status |
| :-------------------- | :-------------- | :----------------------- |
| `t1` — transactional | ₹1 / minute | ✅ Live |
| `t3` — conversational | **₹3 / minute** | ✅ Live — **the default** |
| `t5` — orchestrated | ₹5 / minute | 🔜 Not live |
`t5` is published so you can plan against it. Requesting it today returns
`501 tier_unavailable` — see [when it arrives](#coming-soon).
**Why `t3` is the default.** It is the always-on path: it speaks through a
premium third-party voice catalogue with no dependency on our own GPU fleet, so
it is the tier that is up whenever the API is up. `t1` runs on the Mira stack at
a third of the price and a smaller voice catalogue — a deliberate trade, and the
right one for high-volume transactional work. Ask for it explicitly:
```json theme={null}
{ "tier": "t1" }
```
## Feature matrix
What the tier buys is not a discount, it is **what the call can do**.
| Capability | `t1` ₹1 | `t3` ₹3 | `t5` ₹5 |
| :------------------------------------------------------ | :------: | :------------: | :------------: |
| Core call (ASR → LLM → TTS, Mira stack) | ✅ | ✅ | ✅ |
| Voicemail detection | ✅ basic | ✅ proper | ✅ |
| Clean call ending | ✅ | ✅ | ✅ |
| Post-call analytics (AI summary + QA score, every call) | ✅ | ✅ | ✅ |
| Human handoff (warm transfer) | ✅ | ✅ | ✅ |
| Premium TTS voice option + fallback | ❌ | ✅ | ✅ |
| Node-graph runtime | ❌ | ✅ (our graph) | ✅ (your graph) |
| IVR detect + navigate | ❌ | ✅ | ✅ |
| Customer-authored node graph | ❌ | ❌ | ✅ |
| Priority capacity | standard | reserved floor | reserved floor |
**Every call is opened.** On every tier, each completed call gets an AI summary
and a QA score — 100% of calls, not a sample. Warm transfer to a human is part
of the same lattice: any tier can hand a caller to a person. See the
[roadmap](/general/roadmap) for rollout status of both on `t1`.
**Read the ❌ as a contract.** A ❌ is not "degraded", it is "absent". On `t1`
there is no node-graph runtime and no IVR navigation — the call is one prompt,
start to finish. That simplicity is what makes ₹1 possible.
### Picking a tier
* **`t3` (default)** — start here. Premium voices, no dependency on our GPU
fleet, and the tier every example in these docs is written against. If you are
not sure, you want this.
* **`t1`** — one job, under two minutes, no branching, at volume. Order
confirmations, delivery windows, appointment reminders, OTP-adjacent
notifications, "are you still interested?". If you can write the whole call as
one prompt, it ends when the customer says yes or no, and the volume makes ₹2
a minute worth optimising for, drop to `t1`. Its voice catalogue is two
voices — `ashu` and `aishe`, see [Voices](/v2/voices).
* **`t5`** — you want to author the flow yourself, node by node, and version it
like code. Not live.
## What a minute costs
Billing is **per-minute blocks, rounded up, with a one-minute minimum.** The
minute is the billing unit, not just the price: a call is not prorated to the
second, and a part-minute is a whole minute.
| Call duration | Billed minutes | `t3` cost | `t1` cost |
| :------------ | :------------- | :-------- | :-------- |
| 8 seconds | 1 | ₹3.00 | ₹1.00 |
| 59 seconds | 1 | ₹3.00 | ₹1.00 |
| 61 seconds | 2 | ₹6.00 | ₹2.00 |
| 96 seconds | 2 | ₹6.00 | ₹2.00 |
| 3 min 01 s | 4 | ₹12.00 | ₹4.00 |
### Metering
`duration_secs` on the [call object](/v2/calls#the-call-object) is the true
media duration in seconds. The charge is that duration rounded **up** to the
next whole minute, never fewer than one, times the tier rate. A call that
never connected is not billed at all — see the table below.
```python theme={null}
import math
def cost_inr(duration_secs: int, per_min_inr: float) -> float:
minutes = max(1, math.ceil(duration_secs / 60))
return minutes * per_min_inr
```
You never have to compute this — `cost_inr` on the call object and the
debit row in the [ledger](/v2/wallet#list-transactions) are authoritative. Use
the formula to forecast, not to reconcile.
### What is and is not billable
| Outcome | `status` | Billed |
| :-------------------------------------------- | :---------- | :----: |
| Conversation completed | `completed` | ✅ |
| Answering machine, message left or hung up on | `voicemail` | ✅ |
| Hit the duration cap | `timeout` | ✅ |
| Rang out | `no_answer` | ❌ |
| Line busy | `busy` | ❌ |
| Could not connect, or our pipeline errored | `failed` | ❌ |
| You cancelled before it connected | `aborted` | ❌ |
The rule underneath: **if media went live, you pay for it.** A voicemail
consumed a real dial, real TTS and real minutes of carrier time, so it is
billed. A call our own pipeline broke is not.
### Forecasting a month
Take your answer rate and your mean answered-call duration:
Round each answered call up to a whole minute first, then multiply. Averaging
the seconds and dividing by 60 will under-forecast, because the rounding
happens per call and never cancels out.
```text theme={null}
monthly ₹ = answered_calls × mean(ceil(secs / 60)) × rate
```
A campaign of 50,000 dials at a 35% answer rate and 80 seconds mean talk time,
on `t3`:
```text theme={null}
50,000 × 0.35 = 17,500 answered
80 seconds -> 2 billed minutes each
17,500 × 2 × ₹3 = ₹105,000 (the same list on t1: ₹35,000)
```
Unanswered dials cost nothing, so the 32,500 that rang out are free.
Because the boundary is the minute, **the lever is which side of a boundary
your calls land on.** Trimming an 80-second call to 55 seconds halves its cost;
trimming it to 70 saves nothing. Look at the distribution of `duration_secs`,
not the mean — a cluster sitting just past 60 or just past 120 is the cheapest
thing you will ever fix, and a prompt that caps reply length is usually what
moves it.
## Wallet
Billing is prepaid against one INR wallet per workspace.
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/wallet \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json theme={null}
{ "balance_inr": 500, "currency": "INR", "updated_at": "2026-07-26T09:12:44Z" }
```
* **Debits happen at call end**, one ledger row per billable call, carrying the
`call_id`.
* **Top-ups are not self-serve yet** — see the
[roadmap](/general/roadmap). Your Mirai contact credits the workspace; it
lands within minutes and applies immediately.
* **The ledger is the source of truth** for reconciliation:
[`GET /v2/wallet/transactions`](/v2/wallet#list-transactions).
See the [Wallet reference](/v2/wallet) for the full API.
## At zero balance
**Before dialling**, we check the wallet can cover at least one minute at the
call's tier. If it cannot:
```json title="402 Payment Required" theme={null}
{
"error": {
"code": "insufficient_balance",
"message": "wallet balance 0.40 INR is below the minimum for one minute at t3"
}
}
```
* No phone rings. No charge. No call object is created.
* The `Idempotency-Key` is **not** consumed — retry with the same key after
topping up.
* **Calls already in flight are not killed.** A call that started with credit
runs to its natural end and then debits. Your balance can therefore dip below
what the pre-dial check implied during a busy minute.
* **Inside a [campaign](/v2/campaigns) this is not an error.** The campaign
pauses itself with `pause_reason: "insufficient_balance"`, keeps every
contact's place, and continues from there once you top up and start it again.
Alert on balance yourself, hourly, at a threshold covering a day of traffic. A
`402` mid-campaign is an expensive way to find out.
```python theme={null}
wallet = httpx.get(f"{API}/v2/wallet", headers=auth).raise_for_status().json()
if wallet["balance_inr"] < DAILY_BURN_INR:
alert_ops(f"mirai wallet at ₹{wallet['balance_inr']}")
```
```javascript theme={null}
const wallet = await fetch(`${API}/v2/wallet`, { headers: auth }).then((r) => r.json());
if (wallet.balance_inr < DAILY_BURN_INR) {
await alertOps(`mirai wallet at ₹${wallet.balance_inr}`);
}
```
## Setting the tier
Your key carries a default tier — `t3` unless you asked for something else.
Every call and every campaign inherits it unless you override:
```json theme={null}
{
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t1"
}
```
Override downward per call when a particular job is simple enough for `t1`.
There is no way to set a tier per *agent*: the same agent can be run at either
rate.
The call object reports the `tier` it ran at alongside `cost_inr`, so you can
always see which rate card a given call was billed under. A
[campaign](/v2/campaigns) sets its tier once, at create time, and every call it
places inherits it.
## Coming soon
`t3` [went live in August 2026](/general/roadmap) and is now the default.
`t5` is in the contract but not in production. Today:
```json title="501 Not Implemented" theme={null}
{
"error": {
"code": "tier_unavailable",
"message": "Tier t5 is not available on this deployment yet."
}
}
```
Design against it if you like — the field name and the values will not change —
but do not ship a code path that depends on it until we tell you it is live.
Current target: **`t5` with node graphs in Q4 2026**. See the
[roadmap](/general/roadmap), and ask your account contact to confirm before you
plan a launch around it.
# Abandon Cart Recovery
Source: https://docs.miraiminds.co/guides/abandon-cart-recovery
Complete guide to setting up automated abandon cart recovery using Voice Agents with GoKwik integration and Shopify coupon automation.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Abandon cart recovery is one of the most powerful applications of Voice Agents in e-commerce, capable of recovering up to 20% of abandoned carts through intelligent, automated outreach. This guide provides complete setup instructions for implementing abandon cart recovery using GoKwik webhook integration and [Shopify coupon automation](/integrations/shopify).
**Estimated Time:** \~30 minutes
**Core Requirements:** GoKwik, [Shopify](/integrations/shopify), and [WhatsApp](/integrations/whatsapp)
integrations.
Instantly detect abandoned carts via GoKwik webhooks and trigger a
personalized AI voice call within minutes.
Automatically generate unique, time-sensitive Shopify discount coupons to
encourage purchase completion.
Combine an AI voice call with an immediate WhatsApp follow-up containing the
checkout link and coupon code.
## How It Works
The recovery system operates through a seamless, automated workflow triggered in real-time:
1. **Cart Abandoned:** A customer abandons their cart on your GoKwik checkout page.
2. **Webhook Trigger:** GoKwik instantly sends a webhook notification to the Voice Agents platform with the cart and customer details.
3. **Coupon Generated:** The system connects to your [Shopify store](/integrations/shopify) and automatically generates a unique, time-limited discount coupon.
4. **AI Voice Call:** Within minutes, an AI Voice Agent places a personalized call to the customer, referencing their cart items and offering the discount.
5. **WhatsApp Follow-up:** Immediately after the call, a [WhatsApp message](/integrations/whatsapp) is sent containing the direct GoKwik checkout link and the unique coupon code, making it easy to complete the purchase.
## Data Access & Permissions
We prioritize your data security and privacy. Our system is designed to be non-intrusive and only accesses the minimal data required to function.
**Strictly Read-Only and Action-Specific**
The Voice Agents system **NEVER** manipulates cart contents, modifies payment information, or alters customer data in GoKwik or Shopify. It only listens for events and performs specific, authorized actions.
* **GoKwik Integration:**
* **Access:** Listens to the Abandon Cart webhook to receive customer contact info, cart contents, and the cart recovery link.
* **Permissions:** Read-only.
* **[Shopify Integration](/integrations/shopify):**
* **Access:** Reads product information (like titles) using the product ID provided by GoKwik.
* **Permissions:** Primarily requires permission to **create and manage discount codes**.
* **[WhatsApp Integration](/integrations/whatsapp):**
* **Access:** No access to your WhatsApp data.
* **Permissions:** Requires permission to **send messages** using your pre-approved templates.
## Setup Guide
Follow these steps to configure your automated abandon cart recovery campaign.
### Step 1: GoKwik Webhook Integration
This is the recommended method for real-time recovery.
1. **Contact the GoKwik Integration Team.**
* Email: `integration@gokwik.co`
* Subject: `Voice Agents Abandon Cart Webhook Setup`
* Body: "Hello, please add the abandon cart webhook integration for our account to send notifications to the Voice Agents platform. Our webhook endpoint is: `[Your Webhook URL]`"
2. **Get Your Webhook Endpoint.**
* You can find this unique URL in your abandon cart campaign page on the Voice Agents platform.
3. **How It Works:**
* Once configured, GoKwik will send a JSON payload with cart details to this endpoint whenever a cart is abandoned. Our platform automatically extracts the relevant information (customer name, phone, cart items) to initiate the recovery call.
#### GoKwik Webhook Data Structure
```json theme={null}
{
"cartId": "688bb9076b9a7a596ca17c30",
"timeInitiated": "Aug 01, 2025 12:12 AM",
"custPhone": "xxxxxxxxxx",
"custName": "John Doe",
"custEmail": "testjohndoe@gmail.com",
"line_items": [
{
"productName": "Travel Fresh Pro (Pack of 3) - Pack of 3",
"productQuantity": 1,
"productVariant": "46338199126323",
"productPrice": 1499.0
}
],
"cartTotal": 1499.0,
"subtotal": 1499.0,
"shippingCharges": 0.0,
"codCharges": 0.0,
"prepaidDiscount": 0.0,
"couponDiscount": 0.0,
"checkoutStage": "ORDER_SCREEN",
"communicationSent": 0,
"NoOfSMSSent": 0,
"NoOfWhatsappSent": 0,
"communicationTime": "",
"channel": "SHOPIFY",
"abandonLink": "https://urturms.com/?cart-resume-id=688bb9076b9a7a596ca17cxx&type=report,fastrr,",
"source": "fastrr",
"address": "",
"recoverStatus": "NOT_RECOVERED",
"recoverFastrrOrderId": "",
"recoverPlatformOrderId": "",
"recoverClientOrderId": "",
"recoverOrderCreatedAt": ""
}
```
### Step 2: Shopify Integration Setup
This is required for automatically generating discount codes. For detailed setup instructions, see our [complete Shopify integration guide](/integrations/shopify).
1. **Connect Your Store:** In the Voice Agents dashboard, navigate to **Integrations** and select **Shopify**.
2. **Authorize:** Enter your Shopify store URL (`your-store.myshopify.com`) and follow the prompts to authorize the connection.
3. **Configure Coupon Settings:**
* **Discount Percentage:** Set a default value (e.g., 10%).
* **Coupon Validity:** Set an expiration time (e.g., 1 hour) to create urgency.
* **Usage Limits:** Configure as single-use per customer.
### Step 3: WhatsApp Integration Setup
This is required for sending the follow-up message with the checkout link.
1. **Connect Your Account:** In the Voice Agents dashboard, navigate to **Integrations** and select **WhatsApp**.
2. **Authorize:** Follow the steps to connect your WhatsApp Business Account (WABA).
3. **Configure Message Template:**
* Select or create an approved message template for abandon cart recovery.
* Ensure it includes placeholders for the customer's name, coupon code, and the GoKwik checkout link.
### Step 4: Tracking Recovery Success
To measure the effectiveness of your campaigns, we automatically add UTM parameters to the checkout link.
* **UTM Parameters:** The `abandonLink` from GoKwik is appended with parameters like `&utm_source=voiceagents&utm_medium=call`.
* **Attribution:** This allows you to see all recovered orders attributed directly to Voice Agents on your GoKwik dashboard, giving you a clear view of your ROI.
## Alternative Method: CSV Upload
**Not Recommended**
Using the CSV method significantly delays outreach, reducing the "heat of the moment" urgency and lowering conversion rates. Only use this if real-time webhook integration is not possible.
1. **Export Data:** Export your abandoned cart data from the GoKwik dashboard.
2. **Format CSV:** Ensure the file includes columns for customer phone, name, cart items, and the checkout URL.
3. **Upload Manually:** Upload the formatted CSV to a new campaign on the Voice Agents platform.
## Best Practices for Optimization
* **Timing is Key:** The AI call should be triggered within 5-15 minutes of abandonment for the highest impact.
* **Smart Discounts:** Start with a 5-10% discount. A/B test different offers to find what works best for your audience.
* **Clear Call to Action:** Both the voice call and WhatsApp message should clearly guide the user to complete their purchase using the provided link and code.
* **Monitor & Refine:** Regularly check your campaign's performance metrics (recovery rate, coupon redemption rate) and refine your call scripts and offers accordingly.
# How Discounts Work
Source: https://docs.miraiminds.co/guides/how-discounts-work
Understanding the intelligent discount system with applied and additional discounts, including how voice agents offer context-aware deals during calls.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
The Voice Agent discount system intelligently manages two types of discounts during customer calls: **applied discounts** (already in the cart) and **additional discounts** (offered during the conversation). The agent adapts its approach based on customer interest, offering additional incentives when needed to close the sale.
## Discount Types
### Applied Discount
An **applied discount** is one that's already active in the customer's cart when the voice agent call begins. This could be:
* A first-time user discount
* A discount from a previous interaction
The voice agent will mention this discount when the customer asks about available deals.
### Additional Discount
An **additional discount** is offered by the voice agent during the call as a strategic incentive. The agent offers this when:
* The customer shows less interest in purchasing
* The customer mentions the price is too high
* The customer has concerns about the product
* The customer explicitly asks for more discount
This creates a personalized sales conversation where the agent can negotiate to close the deal.
## How It Works
The discount system follows an intelligent flow during each call:
1. **Customer Asks About Discounts:** When a customer inquires about available discounts, the voice agent first mentions any **applied discount** already in their cart.
2. **Customer Shows Hesitation:** If the customer indicates they're not ready to buy (due to price, product concerns, or wanting a better deal), the agent offers the **additional discount**.
3. **Discount Application Logic:** The system applies the additional discount based on the `applyAs` setting:
* **additional**: The new discount stacks on top of the existing one
* **override**: The new discount replaces the existing one
4. **Webhook Event:** After the call ends, the `create-order` webhook event includes only the **additional discount** details (code and reason), since the applied discount is already reflected in the cart.
## Discount Configuration
You configure discounts by including a `discount` object in your `initiate-call` API request:
```json theme={null}
{
"discount": {
"code": "NEW_USER",
"amount": "50",
"type": "%tage",
"applyAs": "additional"
}
}
```
### Field Descriptions
* **`code`** (string): The discount code that will be applied (e.g., "NEW\_USER", "SAVE25", "FLASH50")
* **`amount`** (string): The discount value
* For percentage discounts (`%tage`): A number between 0-100
* For fixed discounts (`fixed`): A static amount in your currency
* **`type`** (string): The discount type
* `"%tage"`: Percentage-based discount (e.g., 25% off)
* `"fixed"`: Fixed amount discount (e.g., ₹500 off)
* **`applyAs`** (string): How the discount should be applied
* `"additional"`: Stack this discount on top of any existing applied discount
* `"override"`: Replace any existing applied discount with this one
## The applyAs Logic
Understanding the `applyAs` field is crucial for controlling how discounts combine.
### Additional Mode (`applyAs: "additional"`)
In **additional** mode, the new discount is applied **on top of** the existing applied discount. Both discounts work together.
**Example:**
* Product price: ₹1,000
* Applied discount: 10% (₹100 off) → Cart shows ₹900
* Additional discount offered: 25% (₹250 off the original price)
* **Final price: ₹650** (both discounts apply: ₹1,000 - ₹100 - ₹250)
**When to Use**
Use **additional** mode when you want to reward customers with cumulative savings. This works well for loyalty programs or special promotions where you want customers to feel they're getting an exceptional deal.
### Override Mode (`applyAs: "override"`)
In **override** mode, the new discount **replaces** the existing applied discount. Only the additional discount applies.
**Example:**
* Product price: ₹1,000
* Applied discount: 10% (₹100 off) → Cart shows ₹900
* Additional discount offered: 25% (₹250 off the original price)
* **Final price: ₹750** (only the 25% discount applies: ₹1,000 - ₹250)
**When to Use**
Use **override** mode when you want to offer a better deal than what's currently applied, but don't want discounts to stack excessively. This is ideal for protecting your margins while still giving customers an upgrade.
## Real-World Scenarios
Let's walk through all possible discount scenarios to see how the voice agent handles each case.
### Scenario 1: Both Discounts Available (Additional Mode)
**Conversation Flow:**
> **Customer:** "Do you have any discounts?"\
> **Agent:** "Yes! You already have a 10% welcome discount applied to your cart with code WELCOME10."
>
> **Customer:** "Hmm, the price is still a bit high for me."\
> **Agent:** "I understand! Let me offer you an additional 25% discount with code EXTRA25. This will be applied on top of your current 10% discount, giving you even more savings!"
**Result:** Both discounts apply. Cart total reflects 10% + 25% = 35% total savings.
### Scenario 2: Both Discounts Available (Override Mode)
**Conversation Flow:**
> **Customer:** "Is there any discount on this?"\
> **Agent:** "Absolutely! You currently have a 10% discount applied with code WELCOME10."
>
> **Customer:** "Can I get a better deal?"\
> **Agent:** "I can offer you a special 25% discount with code SPECIAL25. This will replace your current 10% discount, giving you a better overall saving!"
**Result:** The 25% discount replaces the 10% discount. Cart updates to reflect only the 25% saving.
### Scenario 3: Only Applied Discount Available
**Conversation Flow:**
> **Customer:** "Are there any discounts?"\
> **Agent:** "Yes! You have a 15% discount already applied to your cart with code SAVE15."
>
> **Customer:** "Can I get more discount?"\
> **Agent:** "I'm sorry, but the 15% discount with code SAVE15 is the best offer available for this product right now. It's already a great deal!"
**Result:** Only the applied discount is active. No additional discount to offer.
### Scenario 4: Only Additional Discount Available
**Conversation Flow:**
> **Customer:** "Do you have any offers right now?"\
> **Agent:** "Currently, there's no discount applied to your cart."
>
> **Customer:** "That's too expensive. I'm not sure about this."\
> **Agent:** "I completely understand! Let me help you with that. I can offer you an exclusive 50% discount with code FIRST50 to make this purchase more affordable!"
**Result:** The 50% additional discount is applied during the call.
### Scenario 5: No Discounts Available
**Conversation Flow:**
> **Customer:** "Is there any discount on this item?"\
> **Agent:** "I'm sorry, but there are no discounts available for this product at the moment. However, it's a premium quality item that offers great value!"
>
> **Customer:** "Can you give me any discount?"\
> **Agent:** "Unfortunately, I don't have any discount codes to offer right now. But this product is worth the investment for its quality and features!"
**Result:** No discounts available. Agent focuses on value proposition.
## Webhook Event Details
After each call, when an order is created, the `create-order` webhook event is triggered. The discount information in this webhook follows a specific pattern:
### Webhook Payload Structure
```json theme={null}
{
"orderId": "12345",
"customerId": "67890",
"discount": {
"code": "EXTRA25",
"reason": "Customer requested additional discount due to price concerns"
}
// ... other order details
}
```
### Important Notes
**Only Additional Discount Returned**
The webhook payload includes **only the additional discount** that was offered by the voice agent during the call. The applied discount is **not included** because it's already reflected in the cart total.
* **`code`**: The discount code that was offered during the call (only if an additional discount was provided)
* **`reason`**: An optional explanation of why the discount was offered (e.g., "Customer showed hesitation", "Customer requested better deal")
**Why only additional discount?**
The applied discount is already part of the cart state before the call begins, so it's already factored into the order total. The webhook only reports new information—what the voice agent actively did during the conversation.
## Best Practices
### Choosing Additional vs Override
* **Use `additional` when:**
* Running promotional campaigns where stacking is expected
* Rewarding loyal customers with extra benefits
* You want to create a "wow" moment with combined savings
* Your margins can support stacked discounts
* **Use `override` when:**
* You want to control maximum discount limits
* Protecting profit margins is critical
* Offering a better single discount is more attractive than stacking
* Simplifying the customer's understanding (one clear discount)
# Mirai Voice
Source: https://docs.miraiminds.co/index
AI voice agents that make real phone calls — ₹1 a minute, on our own infrastructure in India.
Give an agent a script and a number, and it makes the call. Hindi, English, and
the Hinglish most real calls actually are — on GPUs, models and SIP lines we
own, in India, at **₹1 a minute**.
Key → agent → call → webhook. Five minutes, one real phone call.
Two resources, one auth header, one error envelope.
What a minute costs, and what is and is not billable.
What is shipping, and when.
## Get a key
Create one yourself in the [console](https://sandbox.voice.miraiminds.co) under
**Developers**. It is shown once, and you can rotate or revoke from the same
page.
```bash theme={null}
pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice
```
```python theme={null}
from mirai import Mirai
mirai = Mirai() # reads MIRAI_API_KEY
agent = mirai.agents.create(
name="Order confirmation",
system_prompt="You are आशु from Acme. Confirm the order and ask whether the delivery address is unchanged.",
first_message="नमस्ते, मैं Acme से आशु बोल रहा हूं।",
voice_id="ashu",
language="hi",
)
call = mirai.calls.create(agent_id=agent.id, to="+919876543210")
print(mirai.calls.wait(call.id).status) # 'completed'
```
Prefer plain HTTP? Every page here carries the same thing in cURL, Python and
Node. Start with the [Quickstart](/v2/quickstart).
# Google Calendar
Source: https://docs.miraiminds.co/integrations/google-calendar
Voice agent can access and book your google calendar on call
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
> Work in progress
# Klaviyo Integration
Source: https://docs.miraiminds.co/integrations/klaviyo
Integrate Voice Agent with Klaviyo
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Integrating Voice Agent with Klaviyo enables your brand to add a powerful calling channel to your marketing campaigns. By connecting a Voice Agent webhook node in Klaviyo flows, you can reach customers with personalized voice calls, improving engagement and conversion rates. Voice Agents can deliver campaign messages, collect feedback, and assist customers directly, making your outreach more interactive and effective.
Boost recovery rates by up to 30% with automated voice calls, outperforming traditional email or WhatsApp campaigns.
Send timely voice reminders for replenishable products, ensuring customers never run out of essentials.
Engage customers with calls to gather solid, actionable feedback, improving your products and services.
Use customer data from Klaviyo to personalize voice interactions, increasing relevance and response rates.
## Setup Guide
### Step 1: Add Webhook Node in Klaviyo Flow
1. **Navigate to Your Klaviyo Flow**
Open your existing Klaviyo flow or create a new one where you want to add voice agent integration.
2. **Add Webhook Node**
Click the "+" button to add a new action and select **Webhook** from the available options.
### Step 2: Create Voice Agent Campaign with Webhook Support
3. **Access Voice Agents Dashboard**
Navigate to the **Campaigns** section in your Voice Agents platform and create a new campaign.
4. **Enable Webhook Integration**
When creating your campaign, make sure to enable webhook support. This allows Klaviyo to send contact data directly to your voice agent campaign.
For complete campaign setup instructions, see our [Campaign Management Guide](/general/campaign).
### Step 3: Copy Webhook Details
5. **Copy Webhook Endpoint**
After creating your campaign with webhook support enabled, click the **"Copy Webhook"** button to reveal the webhook configuration modal.
This will display:
* **Webhook URL** - The endpoint where Klaviyo will send data
* **Secret Key** - For secure authentication between platforms
### Step 4: Configure Klaviyo Webhook
6. **Setup Webhook in Klaviyo**
Return to your Klaviyo flow and configure the webhook node with the details from your Voice Agent campaign:
* Paste the **Webhook URL** in the endpoint field
* Add the **Secret Key** for authentication
* Configure any additional payload data you want to send
7. **Test and Activate**
Test your webhook connection to ensure data flows correctly between Klaviyo and Voice Agents, then activate your flow.
## What's Next?
Once your integration is complete, your Klaviyo flows can automatically trigger voice agent calls for:
* **Abandoned Cart Recovery** - Reach customers who left items in their cart
* **Post-Purchase Follow-up** - Collect feedback and encourage repeat purchases
* **Replenishment Reminders** - Notify customers when it's time to reorder
* **Win-back Campaigns** - Re-engage inactive customers with personalized calls
The voice agent will receive customer data from Klaviyo and can personalize conversations based on purchase history, preferences, and behavior patterns.
# Limechat Integration
Source: https://docs.miraiminds.co/integrations/limechat
Connect your Limechat account to enable voice agents for customer support and automated messaging assistance.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Voice Agents seamlessly integrates with Limechat to provide intelligent customer support and automated messaging capabilities. Your customers can interact naturally with voice agents through Limechat's platform for instant support, order inquiries, and personalized assistance.
Handle customer inquiries and support requests automatically through
Limechat's messaging platform.
Voice agents can check order status, process requests, and handle
order-related inquiries seamlessly.
Provide instant responses to common questions and route complex queries
to human agents when needed.
Connect across multiple messaging channels supported by Limechat for
comprehensive customer engagement.
## Setup Guide
To enable your Voice Agent platform to connect with Limechat, you'll need to obtain two crucial pieces of information directly from your Limechat account: your Limechat Account ID and API Access Token.
### Step 0: Access Voice Agents Integration Tab
1. **Navigate to Voice Agents Dashboard**
Log in to your Voice Agents platform and go to the integration tab.
### Step 1: Obtain Your Limechat Account ID
1. **Log in to your Limechat dashboard**
Access your Limechat account through the web interface.
2. **Locate Account ID**
Your Account ID can typically be found directly on the Limechat Dashboard.
### Step 2: Obtain Your Limechat API Access Token
1. **Access Profile Settings**
While logged into your Limechat dashboard, click on your profile picture at the extreme bottom left.
2. **Navigate to Profile Settings**
Select **Profile Settings** from the menu.
3. **Find Access Token**
Scroll down to the bottom of the Profile Settings page.
4. **Copy the Access Token**
Copy the Access Token and keep it secure - you'll need this to connect with Voice Agents.
### Step 3: Connect to Voice Agents Platform
1. **Complete Limechat Connection**
Click the **Connect** button next to Limechat, then:
* Enter your Limechat Account ID from Step 1
* Paste the API Access Token from Step 2
* Click **Connect** to finalize the integration
## What's Next?
Once connected, your voice agent can immediately start handling:
* **Customer inquiries** - "I need help with my order"
* **Support requests** - "How do I track my shipment?"
* **General questions** - "What are your business hours?"
* **Product information** - "Tell me more about this product"
# Shopify Integration
Source: https://docs.miraiminds.co/integrations/shopify
Connect your Shopify store to enable voice agents for customer support, order management, and automated sales assistance.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Voice Agents seamlessly integrates with Shopify to provide intelligent customer support, order tracking, inventory inquiries, and sales assistance. Your customers can interact naturally with voice agents to check order status, find products, apply discounts, and get instant support.
Voice agents can check order status, process returns, and handle shipping
inquiries automatically.
Help customers find products, check inventory, and provide detailed product
information through voice.
Handle common support requests like account issues, discount codes, and
general store policies.
Upsell, cross-sell, and guide customers through the purchase process with
intelligent recommendations.
## Required Permissions
Your voice agent needs specific Shopify API permissions to function effectively:
### Essential Scopes
* **`read_customers`** - Access customer information for personalized support
* **`read_products`** - Provide product details and inventory status
* **`read_orders`** - Check order status and history
* **`write_orders`** - Process order modifications and cancellations
* **`read_discounts`** - Apply available discount codes
* **`write_discounts`** - Create promotional codes for customers
### Optional Scopes (based on your use case)
* **`read_inventory`** - Real-time inventory checking
* **`read_shipping`** - Shipping rate calculations
* **`write_customers`** - Update customer information
* **`read_returns`** - Handle return requests
## Setup Guide
### Part 1: Create Shopify Private App
1. **Access Shopify Admin**
Log in to your Shopify admin dashboard.
2. **Navigate to Settings**
Click the **Settings** option in the bottom-left corner of your admin panel.
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 additional scopes based on your specific use case, 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. **Keep this secure** - you'll need it to connect with 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 connected, your voice agent can immediately start handling:
* **Order inquiries** - "What's the status of my order #1234?"
* **Product questions** - "Do you have this item in stock?"
* **Customer support** - "How do I return an item?"
* **Sales assistance** - "What's your best-selling product?"
# WhatsApp Integration
Source: https://docs.miraiminds.co/integrations/whatsapp
Connect WhatsApp Business API with Voice Agents to enable seamless customer communication through voice and text messaging.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Voice Agents integrates with WhatsApp Business API to provide a unified communication experience. Customers can seamlessly switch between voice calls and WhatsApp messaging, with conversation context maintained across both channels. This creates a more flexible and accessible customer service experience.
Seamlessly transition conversations between voice and WhatsApp messaging while maintaining full context.
Send images, documents, location data, and interactive buttons through WhatsApp integration.
Provide instant responses via WhatsApp even when voice agents are busy with other customers.
Connect with customers worldwide using their preferred messaging platform.
## Prerequisites
Before setting up WhatsApp integration, ensure you have:
* **WhatsApp Business Account** - Verified business account on WhatsApp
* **Meta Business Manager** - Access to Meta Business Manager with admin permissions
* **Phone Number** - Verified business phone number (cannot be used on regular WhatsApp)
* **Voice Agents Account** - Active Voice Agents platform subscription
## Required Information
You'll need to collect these details during setup:
* **App ID** - Your Meta app identifier
* **Phone Number ID** - WhatsApp Business phone number identifier
* **WhatsApp Business Account ID** - Business account identifier
* **Access Token** - Permanent access token with messaging permissions
* **Webhook Verify Token** - Custom token for webhook verification
## Setup Guide
### Step 1: Create or Select Meta App
**Option A: Create New App**
1. Visit [Meta for Developers](https://developers.facebook.com/)
2. Click **"Create App"** in the top-right corner
3. Select **"Business"** as your app type
4. Provide required details:
* App name (e.g., "Your Company Voice Agent")
* Contact email address
* Business Manager account (if applicable)
5. Click **"Create App"** to proceed
**Option B: Use Existing App**
If you already have a Meta app:
1. Access your Meta for Developers dashboard
2. Select your existing app from the **"My Apps"** section
3. Ensure the app has appropriate permissions for WhatsApp Business
### Step 2: Configure WhatsApp Business API
1. **Add WhatsApp Product**
* In your app dashboard, navigate to **"WhatsApp" → "API Setup"**
* If WhatsApp isn't added, click **"Add to App"** next to WhatsApp
2. **Configure Phone Number**
* Under **"Send and receive messages"**, go to **"Step 1: Select phone numbers"**
* In the **"From"** dropdown, you'll see:
* Test numbers (for development only)
* Your verified WhatsApp Business numbers
* To add a new number, click **"➕ Add Phone Number"**
### Step 3: Collect Required IDs
Once your phone number is configured:
1. **Select your verified WhatsApp Business number** from the dropdown
2. **Record these important values:**
* **Phone Number ID** - Found below the selected number
* **WhatsApp Business Account ID** - Located to the right of the Phone Number ID
* **App ID** - Displayed in the top-left corner of the dashboard
Keep these IDs secure and accessible - you'll need them to complete the Voice Agents integration.
### Step 4: Generate Permanent Access Token
1. **Open WhatsApp Business Manager**
Navigate to [Meta Business Manager](https://business.facebook.com/) and access WhatsApp Manager.
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 and securely store the generated access token. This token provides full access to your WhatsApp Business messaging capabilities.
Never share your access token publicly or commit it to version control. Treat it like a password.
### Step 5: Create Message Templates
WhatsApp requires pre-approved templates for business-initiated conversations:
1. **Access Template Manager**
Go to **WhatsApp Manager → Message Templates**.
2. **Create New Template**
Click **"Create Template"** and provide:
* **Template Name**: Descriptive name (e.g., `order_confirmation`)
* **Category**:
* **Marketing** - Promotional content
* **Utility** - Account updates, order status
* **Authentication** - Verification codes, security alerts
* **Language**: Your target language (e.g., `en_US`)
* **Message Content**: Include dynamic variables using `{{1}}`, `{{2}}` syntax
3. **Submit for Review**
Templates typically get reviewed within 24 hours. Only approved templates can be used for business-initiated messages.
4. **Template Best Practices**
* Keep messages concise and relevant
* Use clear variable placeholders
* Follow WhatsApp's messaging policies
* Create templates for common use cases (order updates, appointment reminders, support responses)
### Step 6: Connect to Voice Agents
1. **Access Voice Agents Dashboard**
Log in to your Voice Agents platform and navigate to **Integrations**.
2. **Configure WhatsApp Integration**
Click **"Connect"** next to WhatsApp and provide:
* **App ID** (from Step 3)
* **Phone Number ID** (from Step 3)
* **WhatsApp Business Account ID** (from Step 3)
* **Access Token** (from Step 4)
* **Webhook Verify Token** (create a custom secure string)
3. **Test Connection**
Send a test message to verify the integration is working correctly.
## Use Cases & Capabilities
Once integrated, your Voice Agents can:
### Customer Support
* **Seamless handoffs** between voice and WhatsApp
* **Rich media sharing** like product images, receipts, and documents
* **Quick replies** with predefined response options
* **Interactive buttons** for common actions
### Business Communications
* **Order confirmations** with tracking links
* **Appointment reminders** with rescheduling options
* **Support ticket updates** with status changes
* **Marketing messages** (with proper opt-in consent)
### Advanced Features
* **Location sharing** for store locations or delivery tracking
* **Multi-language support** with localized templates
* **Analytics integration** for message performance tracking
* **CRM synchronization** for unified customer profiles
## Best Practices
### Compliance & Privacy
* Always obtain proper consent before messaging customers
* Respect opt-out requests immediately
* Follow WhatsApp Business Policy guidelines
* Implement proper data retention policies
### Message Optimization
* Use approved templates for business-initiated messages
* Keep response times under 24 hours during business conversations
* Provide clear next steps in every interaction
* Use rich media strategically to enhance communication
### Integration Management
* Monitor webhook delivery and response times
* Implement proper error handling and retry logic
* Set up alerts for API rate limit warnings
* Regularly review and update message templates
# B2B
Source: https://docs.miraiminds.co/usecases/b2b
Voice Agents has deep integration with shopify
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
> Work in progress
# Healthcare
Source: https://docs.miraiminds.co/usecases/healthcare
Voice Agents has deep integration with shopify
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
> Work in progress
# Retail
Source: https://docs.miraiminds.co/usecases/retail
Voice Agents has deep integration with shopify
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
## Overview
Voice Agents revolutionize retail operations by providing intelligent, automated customer service and sales support through AI-powered conversational interfaces. These agents are specifically designed to handle the unique challenges and opportunities in the retail industry.
### How Retail AI Agents Work
Retail AI agents combine a practical knowledge base, automated integrations, and flexible ways to talk to customers — all designed to make running your store easier and more efficient.
**Knowledge base (what the agent knows)**
* **Customer service playbook**: Clear steps and friendly scripts for handling common questions and issues
* **Marketing content**: Up‑to‑date product details, promotions, and brand messaging
* **Sales playbook**: Conversation tips and tactics that help turn interest into purchases
* **Standard operating procedures**: Consistent workflows for orders, returns, and fulfillment
* **Training resources**: Guides and examples that keep the agent improving over time
**Automation and system connections**
* **Shopify**: Sync orders, check stock, and access customer profiles directly from your store
* **WooCommerce**: Same seamless flow for WordPress shops
* **ERP systems**: Tie into back‑office systems for pricing, inventory, and fulfillment data
* **CRM / LMS**: Use customer histories and training records to personalize responses and improve accuracy
* **Calendar**: Book appointments and show availability in real time
* **Email**: Send confirmations, follow‑ups, and promotional messages automatically
**How customers interact**
* **Chat**: Website widgets, social DMs, and WhatsApp for quick text conversations
* **Voice**: Phone or VoIP interactions for hands‑free support and a more human touch
### Why this matters for retail
* Provide friendly, 24/7 support so customers get answers anytime
* Scale support and sales without adding staff — handle many conversations at once
* Keep messaging consistent across channels for a reliable brand experience
* Automate routine tasks like order processing to reduce manual work and mistakes
* Offer personalized product suggestions based on customer data
* Monitor stock in real time and automate reorder alerts to prevent stockouts
You can deploy these agents wherever your customers are — on your website, social platforms, or over the phone — so they fit your business and your customers' preferences.
## Abandon Cart Recovery - A Detailed Use Case
One of the most impactful applications of Voice Agents in retail is automated abandon cart recovery. This sophisticated system can recover up to 20% of abandoned carts through intelligent, personalized outreach.
### How It Works
The abandon cart recovery system operates through a seamless, multi-step process:
1. **Smart Customer Identification** - The system automatically identifies customers who have abandoned their shopping carts and uses sophisticated AI filters to determine the best candidates for outreach calls.
2. **Human-like Voice Engagement** - A natural-sounding AI agent makes personalized phone calls to customers, addressing them by name and referencing their specific abandoned items.
3. **Intelligent Conversation Flow** - The agent engages in natural conversation to:
* Understand why the customer didn't complete their purchase
* Address any concerns or objections they might have
* Offer personalized solutions or incentives when appropriate
4. **Dynamic Incentive Creation** - Based on the conversation, the system can generate personalized discount coupons that are active for a limited time (typically one hour) to create urgency.
5. **Multi-channel Follow-up** - After the call, the system automatically sends a WhatsApp message with a direct checkout link and the personalized coupon code, making it easy for customers to complete their purchase.
**Key Benefits:**
* **20% average cart recovery rate** - Significantly higher than traditional email-based recovery
* **Personalized approach** - Each interaction is tailored to the specific customer and their abandoned items
* **Immediate response** - Customers receive help within hours of abandoning their cart
* **Cost-effective** - Automated system scales without increasing staff costs
* **Data-driven insights** - Learn why customers abandon carts and improve your checkout process
This approach transforms a common e-commerce challenge into a revenue opportunity while providing genuine value to customers who may have encountered obstacles during their shopping journey.
### Getting Started with Retail AI Agents
Bringing Voice Agents into your retail business is easier than you might think. Below is a simple, practical path to get started and some tips to make them work well for your customers.
**Quick setup (4 easy steps)**
1. **Gather your content** — Upload product catalogs, FAQs, return policies, and any service guides the agent should know.
2. **Connect your systems** — Link Shopify, inventory, CRM, or any other tools so the agent can access orders, stock, and customer info.
3. **Choose channels** — Pick where customers will talk to the agent (website chat, social DMs, WhatsApp, phone). Start with one or two and expand later.
4. **Train and test** — Run common scenarios, fine‑tune replies, and have staff test handoffs to humans.
**Practical tips**
* Start by automating the highest-volume, repetitive requests (order status, returns, store hours).
* Make handoffs to human agents smooth and obvious for complex issues.
* Keep product details and pricing up to date so answers stay accurate.
* Track customer satisfaction and common failure points to prioritize improvements.
* Roll out new features gradually based on real customer feedback.
**What to expect (ROI)**
Most retailers see noticeable improvements within weeks: faster response times, fewer routine tickets, and happier customers. Over time, that translates into better conversion rates and lower support costs while your team focuses on higher‑value work.
# Assistants
Source: https://docs.miraiminds.co/v1/assistants
Create, read, list and update v1 assistants — prompts, identity, variants, call settings and analysis plans.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
An **assistant** is v1's agent: prompt, voice identity, telephony bindings, call
settings, knowledge base and post-call analysis plan. All endpoints are
workspace-scoped — send the `workspace` header.
## Variants
| `variant.type` | Use for |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `custom` | **Recommended for everything you author.** You write `agent.systemPrompt` and declare variables in `variant.config.inputSchema`. |
| `abandoned_cart` | A built-in Shopify cart-recovery flow. Configure `variant.config.abandoned_cart`. |
## Create an assistant
```http theme={null}
POST /v1/admin/assistant/create
```
### Core fields
| Field | Type | Required | Notes |
| :-------------------------------- | :-------- | :------- | :---------------------------------------------------------------------------------------------------------------------- |
| `name` | string | yes | Max 40 characters. Unique per workspace. |
| `variant.type` | enum | yes | `custom` \| `abandoned_cart` |
| `variant.config.inputSchema` | array | no | Variables the prompt references. See [variables](#variables). |
| `agent.identity` | object | yes | `{ name, gender, voice }` — `gender` is `male` or `female`, `voice` is a slug from the [voice gallery](#voice-gallery). |
| `agent.systemPrompt` | string | no | The instructions. Supports `{{placeholders}}`. |
| `agent.firstMessage` | string | no | Spoken on connect. If empty, a greeting is generated from the identity and ICP language. |
| `agent.endMessage` | string | no | Spoken just before hanging up. |
| `agent.tools` | string\[] | no | [API tool](/v1/tools) `_id` values the assistant may call. |
| `telephony.inbound` / `.outbound` | string | no | Telephony number `_id`s. |
| `icpContext` | object | no | See [ICP context](#icp-context). |
| `callSettings` | object | no | See [call settings](#call-settings). |
| `analysisPlan` | object | no | See [analysis plan](#analysis-plan). |
| `knowledgeBase` | object | no | See [Knowledge Base](/v1/knowledge-base). |
| `preCall` | object | no | See [pre-call enrichment](#pre-call-enrichment). |
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/assistant/create \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Update Assistant",
"variant": {
"type": "custom",
"config": {
"inputSchema": [
{ "name": "customerName", "type": "string", "isRequired": true },
{ "name": "orderId", "type": "string", "isRequired": true },
{ "name": "deliveryDate", "type": "string", "isRequired": false }
]
}
},
"agent": {
"identity": { "name": "priya", "gender": "female", "voice": "priya" },
"systemPrompt": "You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}. If a delivery date is available, mention it: expected {{deliveryDate}}.",
"firstMessage": "Hi {{customerName}}, this is Priya from Acme.",
"endMessage": "Thank you for your time. Have a great day!"
},
"icpContext": { "language": "hinglish" },
"callSettings": {
"slots": [{ "startTime": "09:00", "endTime": "21:00" }],
"maxCallDuration": 200,
"concurrentCallCount": 5,
"retryProtocol": {
"maxAttemptsNoPickup": 2,
"maxAttemptsLowEngagement": 1,
"reAttemptPeriod": 300,
"maxRescheduleCount": 1
}
}
}'
```
```python theme={null}
import os, httpx
API = "https://api.voice-agents.miraiminds.co"
H = {
"x-public-key": os.environ["MIRAI_PUBLIC_KEY"],
"x-private-key": os.environ["MIRAI_PRIVATE_KEY"],
"workspace": os.environ["MIRAI_WORKSPACE"],
}
assistant = httpx.post(
f"{API}/v1/admin/assistant/create",
headers=H,
json={
"name": "Order Update Assistant",
"variant": {
"type": "custom",
"config": {
"inputSchema": [
{"name": "customerName", "type": "string", "isRequired": True},
{"name": "orderId", "type": "string", "isRequired": True},
]
},
},
"agent": {
"identity": {"name": "priya", "gender": "female", "voice": "priya"},
"systemPrompt": "You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}.",
"firstMessage": "Hi {{customerName}}, this is Priya from Acme.",
},
"icpContext": {"language": "hinglish"},
"callSettings": {
"slots": [{"startTime": "09:00", "endTime": "21:00"}],
"maxCallDuration": 200,
"concurrentCallCount": 5,
"retryProtocol": {"maxAttemptsNoPickup": 2, "reAttemptPeriod": 300},
},
},
timeout=30,
).raise_for_status().json()
print(assistant["data"]["_id"])
```
```javascript theme={null}
const API = "https://api.voice-agents.miraiminds.co";
const H = {
"x-public-key": process.env.MIRAI_PUBLIC_KEY,
"x-private-key": process.env.MIRAI_PRIVATE_KEY,
workspace: process.env.MIRAI_WORKSPACE,
"Content-Type": "application/json",
};
const res = await fetch(`${API}/v1/admin/assistant/create`, {
method: "POST",
headers: H,
body: JSON.stringify({
name: "Order Update Assistant",
variant: {
type: "custom",
config: {
inputSchema: [
{ name: "customerName", type: "string", isRequired: true },
{ name: "orderId", type: "string", isRequired: true },
],
},
},
agent: {
identity: { name: "priya", gender: "female", voice: "priya" },
systemPrompt:
"You are Priya from Acme. Greet {{customerName}} and confirm order {{orderId}}.",
firstMessage: "Hi {{customerName}}, this is Priya from Acme.",
},
icpContext: { language: "hinglish" },
callSettings: {
slots: [{ startTime: "09:00", endTime: "21:00" }],
maxCallDuration: 200,
concurrentCallCount: 5,
retryProtocol: { maxAttemptsNoPickup: 2, reAttemptPeriod: 300 },
},
}),
});
const { data } = await res.json();
```
**`200 OK`** — the created assistant under `data`.
| Status | Cause |
| :----- | :---------------------------------------------------------- |
| `400` | Validation error |
| `409` | An assistant with this name already exists in the workspace |
### Variables
Declare each variable in `variant.config.inputSchema`, reference it in
`agent.systemPrompt` / `agent.firstMessage` as `{{name}}`, and supply its value
per call. Nested values use dot paths: `{{customer.firstName}}`.
```json theme={null}
{
"inputSchema": [
{ "name": "customerName", "type": "string", "isRequired": true },
{ "name": "orderId", "type": "string", "isRequired": true }
]
}
```
`type` is one of `string`, `number`, `boolean`, `object`, `array`; nested shapes
use `fields`. **A placeholder with no matching value is left in the prompt
verbatim** — the assistant will read the braces aloud. Always send every
required variable.
**Documented inconsistency**
The API description text calls the per-call values object `variableValues`,
while the `POST /v2/call/initiate` request schema and all of its examples use
**`payload`**. `payload` is what the endpoint accepts. See
[Calls](/v1/calls#initiate-a-call).
### ICP context
Shapes tone, vocabulary and pace.
| Field | Values |
| :---------------- | :------------------------------------------------------------------------------------------------------------------ |
| `language` | `hinglish`, `english`, `hindi`, `telugu`, `tamil`, `kannada`, `malayalam`, `gujarati`, `punjabi`, `odia`, `marathi` |
| `targetAgeGroups` | `gen_z`, `millennials`, `gen_x`, `boomers` |
| `locationTiers` | `metro_urban`, `tier1`, `tier2`, `tier3`, `rural` |
| `targetAudience` | `male`, `female`, `children` |
### Call settings
| Field | Type | Notes |
| :-------------------- | :----- | :------------------------------------------------------------------------------------------------------------------------------ |
| `slots` | array | **Required.** At least one `{ startTime, endTime }` in `HH:MM`, in the workspace timezone. Calls run only inside these windows. |
| `maxCallDuration` | number | Seconds. |
| `concurrentCallCount` | number | Maximum 10. |
| `retryProtocol` | object | See below. **Required.** |
```json theme={null}
{
"retryProtocol": {
"maxAttemptsNoPickup": 2,
"maxAttemptsLowEngagement": 1,
"reAttemptPeriod": 300,
"maxRescheduleCount": 1
}
}
```
| Field | Default | Meaning |
| :------------------------- | :------ | :---------------------------------------------------- |
| `maxAttemptsNoPickup` | `2` | Rang, nobody answered — retry this many times. |
| `maxAttemptsLowEngagement` | `1` | Answered but cut immediately — retry this many times. |
| `reAttemptPeriod` | `300` | Seconds between attempts. |
| `maxRescheduleCount` | `1` | Callbacks the customer can request. Maximum 5. |
`slots` is v1's calling-window enforcement. Set it to your compliant window —
see [India calling rules](/v2/limits#india-calling-rules).
### Analysis plan
Post-call AI evaluation. Results arrive on the [`end-of-call`](/v1/webhooks#end-of-call)
event.
| Field | What it does |
| :-------------------- | :---------------------------------------------------------------------------------- |
| `successCriteriaPlan` | A prompt that must return `true` or `false`. Write explicit, enumerated conditions. |
| `summaryPlan` | A prompt producing a plain-English summary. Tell it what to cover. |
| `callInsightPlan` | Structured fields extracted from the call. |
```json theme={null}
{
"analysisPlan": {
"successCriteriaPlan": "Return true ONLY if the customer explicitly confirmed the order AND agreed to the total price AND chose a payment method. Return false if they were 'just checking' or asked for more discount after the final price.",
"summaryPlan": "Summarize: (1) the customer issue, (2) the resolution, (3) sentiment, (4) follow-up needed."
}
}
```
Vague success criteria produce vague booleans. Enumerate the conditions and
state the false cases explicitly.
### Pre-call enrichment
If `preCall.apiPlan.url` is set, we call your endpoint before the call connects
(inbound or outbound) and merge the returned data into the call's variables, so
your `{{placeholders}}` can use it — greeting an inbound caller by name, for
example.
We send `number` and `assistantId` as query parameters for `method: get`, or as
a JSON body for `method: post`. Keep the endpoint fast: it sits in front of the
call.
## Get an assistant
```http theme={null}
GET /v1/admin/assistant/get/{assistantId}
```
```bash theme={null}
curl https://api.voice-agents.miraiminds.co/v1/admin/assistant/get/68c128a658cd7d0668bce78d \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002"
```
`200` with the assistant under `data`; `404` if it is not in this workspace.
## List assistants
```http theme={null}
GET /v1/admin/assistant/list
```
Returns every assistant in the workspace under `data`.
## Update an assistant
```http theme={null}
PUT /v1/admin/assistant/update/{assistantId}
```
Send the fields you want to change.
```bash theme={null}
curl -X PUT https://api.voice-agents.miraiminds.co/v1/admin/assistant/update/68c128a658cd7d0668bce78d \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{ "callSettings": { "maxCallDuration": 180, "concurrentCallCount": 3 } }'
```
| Status | Cause |
| :----- | :---------------------------------------------- |
| `400` | The assistant is archived and cannot be updated |
| `404` | Not found |
| `409` | Another assistant already has that name |
There is no delete. [Archive](/v1/workspaces#archive) the workspace or
organization instead.
## Voice gallery
```http theme={null}
GET /v1/admin/voice-gallery
```
The catalogue of available voices. The `slug` is what goes in
`agent.identity.voice` — and in [v2's](/v2/agents#voice) `voice.voice_id`.
```bash theme={null}
curl https://api.voice-agents.miraiminds.co/v1/admin/voice-gallery \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
```
```json title="200 OK" theme={null}
{
"status_code": 200,
"message": "Voices fetched successfully.",
"data": [
{
"_id": "6690a1b2c3d4e5f600000301",
"slug": "priya",
"sampleAudio": "https://cdn.miraiminds.co/voices/priya.wav"
}
]
}
```
Listen to `sampleAudio` before you pick. Voice choice moves answer-through rates
more than prompt wording does.
# Calls
Source: https://docs.miraiminds.co/v1/calls
Initiate outbound calls, start web calls, abort queued calls and update a queued call's payload.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
All call endpoints are workspace-scoped — send the `workspace` header.
These paths are named `/v2/call/…` but they belong to the **v1 API** on
`api.voice-agents.miraiminds.co`. See [hosts, not prefixes](/v1/overview#hosts-not-prefixes).
## Initiate a call
```http theme={null}
POST /v2/call/initiate
```
| Field | Type | Required | Description |
| :------------ | :------ | :------- | :------------------------------------------------------------------------------ |
| `phoneNumber` | string | yes | E.164 recommended: `+919876543210`. |
| `assistant` | string | yes | Assistant `_id`. |
| `payload` | object | no | Values for the assistant's variables. Shape depends on the variant — see below. |
| `callbackUrl` | string | no | HTTPS [webhook](/v1/webhooks) URL. |
| `priority` | boolean | no | Jump the queue. |
| `metadata` | object | no | Arbitrary object echoed back on **every** webhook event. Use it to correlate. |
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v2/call/initiate \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "+919876543210",
"assistant": "68c128a658cd7d0668bce78d",
"callbackUrl": "https://example.com/webhooks/call-events",
"payload": {
"customerName": "Jane Smith",
"orderId": "ORD-12345",
"orderValue": 2500
},
"metadata": { "internalOrderId": "8842" }
}'
```
```python theme={null}
import os, httpx
API = "https://api.voice-agents.miraiminds.co"
H = {
"x-public-key": os.environ["MIRAI_PUBLIC_KEY"],
"x-private-key": os.environ["MIRAI_PRIVATE_KEY"],
"workspace": os.environ["MIRAI_WORKSPACE"],
}
call = httpx.post(
f"{API}/v2/call/initiate",
headers=H,
json={
"phoneNumber": "+919876543210",
"assistant": "68c128a658cd7d0668bce78d",
"callbackUrl": "https://example.com/webhooks/call-events",
"payload": {"customerName": "Jane Smith", "orderId": "ORD-12345"},
"metadata": {"internalOrderId": "8842"},
},
timeout=30,
).raise_for_status().json()
print(call["callId"], call["status"]) # 68f0… initiate
```
```javascript theme={null}
const API = "https://api.voice-agents.miraiminds.co";
const H = {
"x-public-key": process.env.MIRAI_PUBLIC_KEY,
"x-private-key": process.env.MIRAI_PRIVATE_KEY,
workspace: process.env.MIRAI_WORKSPACE,
"Content-Type": "application/json",
};
const call = await fetch(`${API}/v2/call/initiate`, {
method: "POST",
headers: H,
body: JSON.stringify({
phoneNumber: "+919876543210",
assistant: "68c128a658cd7d0668bce78d",
callbackUrl: "https://example.com/webhooks/call-events",
payload: { customerName: "Jane Smith", orderId: "ORD-12345" },
metadata: { internalOrderId: "8842" },
}),
}).then((r) => r.json());
console.log(call.callId, call.status);
```
```json title="200 OK" theme={null}
{ "callId": "68f0a1b2c3d4e5f600000900", "status": "initiate" }
```
This endpoint returns **`200`**, not `201`, and the body is **not** wrapped in
`{ status_code, message, data }`. Read `callId` and `status` off the top level.
| Status | Cause |
| :----- | :------------------ |
| `400` | Validation error |
| `404` | Assistant not found |
### `payload` by variant
**`custom`** — a flat (or nested) object whose keys match the variables you
declared in `variant.config.inputSchema`:
```json theme={null}
{
"payload": {
"orderId": "ORD-12345",
"customerName": "Jane Smith",
"orderValue": 2500,
"status": "pending",
"notes": "Customer requested callback"
}
}
```
**`abandoned_cart`** — the Shopify abandoned-checkout object: `id`,
`abandonedCheckoutUrl`, `customer`, `lineItems`, `totalPriceSet`,
`discountCodes`, `shippingAddress`, and so on. Pass Shopify's payload through
largely unchanged.
There is no `GET` for a call in v1 — outcomes arrive by
[webhook](/v1/webhooks) only. If you need to read call state on demand, that is
[`GET /v2/calls/{id}`](/v2/calls#get-a-call) in v2.
### `metadata`
Whatever you put here comes back on every event for that call:
```json theme={null}
{ "metadata": { "internalOrderId": "8842", "userId": "u_991" } }
```
Use it to join the webhook to your own records without keeping a `callId` table.
v2 dropped `metadata` — correlate on `call.id` there.
## Initiate a web call
```http theme={null}
POST /v2/call/web
```
Starts a browser-based call and returns a join token.
| Field | Type | Required |
| :------------- | :----- | :-------------------------------------------------- |
| `assistant` | string | yes |
| `systemPrompt` | string | no — overrides the assistant's prompt for this call |
| `payload` | object | no |
| `metadata` | object | no |
```json title="201 Created" theme={null}
{ "success": true, "token": "eyJhbGciOiJIUzI1NiIs…", "error": null }
```
Note the `201` here versus `200` on `/v2/call/initiate`.
## Abort a call
```http theme={null}
POST /v2/call/abort
```
The call ID goes in the **body**, not the path.
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v2/call/abort \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{ "callId": "68f0a1b2c3d4e5f600000900" }'
```
```json title="200 OK" theme={null}
{ "message": "Call aborted successfully" }
```
A bare `message` — no `status_code`, no `data`.
**Allowed** when the call has no status yet (initial queue) or is `busy`,
`failed`, `no-answer`, `rescheduled` or `validation-failed`.
**Rejected** with `400` when it is `in-progress`, `completed`, `ended`,
`timeout` or already `aborted`.
| Status | Cause |
| :----- | :----------------------------------------- |
| `400` | Already aborted, in progress, or completed |
| `404` | Call not found |
Aborting a retryable call is how you stop the whole retry chain — otherwise
`retryProtocol` keeps dialling.
## Update a queued call's payload
```http theme={null}
PUT /v2/call/{callId}
```
Replaces the `payload` of a call that has not been attempted yet.
```bash theme={null}
curl -X PUT https://api.voice-agents.miraiminds.co/v2/call/68f0a1b2c3d4e5f600000900 \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{ "payload": { "customer": { "firstName": "Jane", "phone": "+919876543210" }, "orderId": "ORD-12345" } }'
```
```json title="200 OK" theme={null}
{ "status_code": 200, "message": "Call payload updated successfully." }
```
The payload is **replaced**, not merged. Send the whole object.
| Status | Cause |
| :----- | :--------------------------------------------------------------- |
| `400` | Validation error, or the call is already in progress / completed |
| `401` | Unauthorized |
| `404` | Call not found |
## Call statuses
| Status | Meaning |
| :------------------ | :-------------------------------------------------- |
| `initiate` | Queued and being dialled. |
| `in-progress` | Answered; the conversation is running. |
| `ended` | The connection dropped. |
| `completed` | Recording, transcript and duration processed. |
| `timeout` | Exceeded `maxCallDuration`. |
| `failed` | Could not connect. |
| `validation-failed` | Pre-call validation failed, e.g. an invalid number. |
| `busy` | Line busy. |
| `no-answer` | Nobody picked up. |
| `skip` | Skipped, e.g. a DND number. |
| `rescheduled` | A callback is scheduled; the lifecycle continues. |
| `aborted` | Cancelled through the abort API. |
Each status has a matching `call.` [webhook event](/v1/webhooks).
# Knowledge base
Source: https://docs.miraiminds.co/v1/knowledge-base
Upload documents for retrieval-augmented answers — chunked upload, indexing status, collections.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Upload documents to a workspace and the assistant can search them mid-call to
answer policy and product questions.
The knowledge base is a v1 capability. [v2](/v2/overview) has no RAG yet.
**Limits:** 100 MB per file, 10 MB per chunk, 1,000 chunks maximum. Supported
types: `application/pdf`, `text/plain`, `text/markdown`, and `.docx`
(`application/vnd.openxmlformats-officedocument.wordprocessingml.document`).
## Upload a document
Three steps: start a session, push the chunks, complete.
```http theme={null}
POST /v1/knowledge-base/upload/start
```
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/start \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{
"fileName": "product-catalog.pdf",
"totalChunks": 1,
"fileSize": 524288,
"mimeType": "application/pdf"
}'
```
```json title="201 Created" theme={null}
{ "status_code": 201, "message": "Upload session created.", "data": { "sessionId": "sess_abc123xyz" } }
```
`totalChunks` must match what you actually send. Target 10 MB per chunk.
`400` if the file exceeds 100 MB or the MIME type is unsupported.
```http theme={null}
POST /v1/knowledge-base/upload/chunk/{sessionId}
```
`multipart/form-data` with `chunk` (binary) and `chunkIndex` (zero-based).
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/chunk/sess_abc123xyz \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-F "chunk=@part-0.bin" \
-F "chunkIndex=0"
```
`400` for a missing chunk, a negative index, or a chunk over 10 MB.
`404` if the session does not exist.
```http theme={null}
POST /v1/knowledge-base/upload/complete/{sessionId}
```
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/knowledge-base/upload/complete/sess_abc123xyz \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002"
```
```json title="200 OK" theme={null}
{
"message": "Upload completed. Processing has started in the background.",
"sessionId": "sess_abc123xyz",
"knowledgeBaseId": "6701a1b2c3d4e5f600000050",
"knowledgeBaseStatus": "processing"
}
```
`400` if the session is not in the `uploading` state or is already linked to
a knowledge base.
Indexing runs in the background. Poll
`GET /v1/knowledge-base/files/{knowledgeBaseId}` until `status` is `ready`.
### Full upload, in code
```python theme={null}
import os, time, math, httpx
API = "https://api.voice-agents.miraiminds.co"
H = {
"x-public-key": os.environ["MIRAI_PUBLIC_KEY"],
"x-private-key": os.environ["MIRAI_PRIVATE_KEY"],
"workspace": os.environ["MIRAI_WORKSPACE"],
}
CHUNK = 10 * 1024 * 1024
def upload(path: str, mime: str) -> str:
size = os.path.getsize(path)
total = math.ceil(size / CHUNK)
start = httpx.post(
f"{API}/v1/knowledge-base/upload/start",
headers=H,
json={
"fileName": os.path.basename(path),
"totalChunks": total,
"fileSize": size,
"mimeType": mime,
},
timeout=30,
).raise_for_status().json()
session = start["data"]["sessionId"]
with open(path, "rb") as fh:
for i in range(total):
httpx.post(
f"{API}/v1/knowledge-base/upload/chunk/{session}",
headers=H,
files={"chunk": fh.read(CHUNK)},
data={"chunkIndex": i},
timeout=300,
).raise_for_status()
done = httpx.post(
f"{API}/v1/knowledge-base/upload/complete/{session}", headers=H, timeout=60
).raise_for_status().json()
kb_id = done["knowledgeBaseId"]
while True: # indexing is async
f = httpx.get(
f"{API}/v1/knowledge-base/files/{kb_id}", headers=H, timeout=30
).raise_for_status().json()["data"]
if f["status"] in ("ready", "failed"):
if f["status"] == "failed":
raise RuntimeError(f"indexing failed for {kb_id}")
return kb_id
time.sleep(5)
```
```javascript theme={null}
import fs from "node:fs";
import path from "node:path";
const API = "https://api.voice-agents.miraiminds.co";
const H = {
"x-public-key": process.env.MIRAI_PUBLIC_KEY,
"x-private-key": process.env.MIRAI_PRIVATE_KEY,
workspace: process.env.MIRAI_WORKSPACE,
};
const CHUNK = 10 * 1024 * 1024;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function upload(filePath, mimeType) {
const buf = fs.readFileSync(filePath);
const totalChunks = Math.ceil(buf.length / CHUNK);
const start = await fetch(`${API}/v1/knowledge-base/upload/start`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({
fileName: path.basename(filePath),
totalChunks,
fileSize: buf.length,
mimeType,
}),
}).then((r) => r.json());
const sessionId = start.data.sessionId;
for (let i = 0; i < totalChunks; i++) {
const form = new FormData();
form.append("chunk", new Blob([buf.subarray(i * CHUNK, (i + 1) * CHUNK)]));
form.append("chunkIndex", String(i));
await fetch(`${API}/v1/knowledge-base/upload/chunk/${sessionId}`, {
method: "POST",
headers: H,
body: form,
});
}
const done = await fetch(
`${API}/v1/knowledge-base/upload/complete/${sessionId}`,
{ method: "POST", headers: H }
).then((r) => r.json());
for (;;) {
const { data } = await fetch(
`${API}/v1/knowledge-base/files/${done.knowledgeBaseId}`,
{ headers: H }
).then((r) => r.json());
if (data.status === "ready") return done.knowledgeBaseId;
if (data.status === "failed") throw new Error("indexing failed");
await sleep(5000);
}
}
```
## List files
```http theme={null}
GET /v1/knowledge-base/files
```
```json title="200 OK" theme={null}
{
"status_code": 200,
"message": "Knowledge base files retrieved successfully.",
"data": [
{
"_id": "6701a1b2c3d4e5f600000050",
"fileName": "product-catalog.pdf",
"collectionName": "rag_acme_product_catalog_v1",
"type": "application/pdf",
"size": 524288,
"status": "ready",
"processingPercentage": 100,
"workspace": "6690a1b2c3d4e5f600000002",
"createdAt": "2026-06-30T10:00:00.000Z"
}
]
}
```
| `status` | Meaning |
| :----------- | :--------------------------------------------- |
| `processing` | Indexing; read `processingPercentage` (0–100). |
| `ready` | Searchable. |
| `failed` | Indexing failed. Re-upload. |
## Get one file
```http theme={null}
GET /v1/knowledge-base/files/{knowledgeBaseId}
```
Same object under `data`. This is the endpoint you poll after `complete`.
## Delete a collection
```http theme={null}
DELETE /v1/knowledge-base/collection/{collectionName}
```
Deletes by `collectionName`, not by file `_id`.
```bash theme={null}
curl -X DELETE https://api.voice-agents.miraiminds.co/v1/knowledge-base/collection/rag_acme_product_catalog_v1 \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002"
```
| Status | Cause |
| :----- | :--------------------------------------- |
| `404` | No such collection in this workspace |
| `409` | The collection is in use by an assistant |
Detach it from the assistant first.
## Writing documents that retrieve well
* **Short, self-contained sections.** Retrieval returns fragments, not
documents. A fragment that only makes sense with the page around it is a
fragment the assistant will read out wrong.
* **Put the question in the text.** A heading of "Return policy" retrieves worse
than a line reading "How do I return an item?".
* **One fact per paragraph.** Tables and multi-column PDFs chunk badly — flatten
them to prose before uploading.
* **Say numbers explicitly.** "Returns accepted within 7 days of delivery" is
retrievable; "within the standard window" is not.
# v1 API (stable)
Source: https://docs.miraiminds.co/v1/overview
The original Voice Agents API — workspaces, assistants, calls, telephony, tools and knowledge base.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
v1 is the API that runs today's production integrations. It is **stable and
supported** — nothing here is being removed.
```bash theme={null}
https://api.voice-agents.miraiminds.co
```
| Environment | Base URL |
| :---------- | :-------------------------------------------- |
| Production | `https://api.voice-agents.miraiminds.co` |
| Staging | `https://api.stage.voice-agent.miraiminds.co` |
**v1 or v2?**
v1 has the broad surface: inbound calls, phone-number purchase, knowledge
base/RAG, API tools, campaigns, post-call analysis. [v2](/v2/overview) has the clean
one: one auth header, `GET` on calls, a wallet API and idempotency — but only
outbound calls and agents. They address the same workspace and can run side by
side. See the [mapping table](/v2/migration).
## Hosts, not prefixes
**The `/v2/` paths on this host are v1**
v1's call endpoints are named `POST /v2/call/initiate`, `POST /v2/call/abort` and
so on. That `/v2/` is a path segment inside **this** API, unrelated to the
[v2 API](/v2/overview). What distinguishes the two APIs is the **host**:
| API | Host |
| :----------------- | :------------------------------- |
| v1 (this one) | `api.voice-agents.miraiminds.co` |
| [v2](/v2/overview) | `sandbox.voice.miraiminds.co` |
Onboard, archive, health. [Reference](/v1/workspaces).
The v1 equivalent of an agent. [Reference](/v1/assistants).
Initiate, abort, update payload, web calls. [Reference](/v1/calls).
15 event types and the `x-signature` scheme. [Reference](/v1/webhooks).
## Authentication
Every request except `GET /health` carries two key headers. Workspace-scoped
endpoints add a third.
| Header | Required on | Value |
| :-------------- | :------------------------- | :----------------------------------------- |
| `x-public-key` | everything | `pk_` + 32 hex |
| `x-private-key` | everything | `sk_` + 64 hex |
| `workspace` | workspace-scoped endpoints | the workspace `_id` returned at onboarding |
```bash theme={null}
curl https://api.voice-agents.miraiminds.co/v1/admin/assistant/list \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002"
```
A few admin endpoints (organization archive/unarchive) additionally require an
`admin` or `organization_admin` role, carried as a JWT bearer token.
v1 pairs are issued with your account. For a new v2 key you do not need to ask
anyone — create it in the [console](https://sandbox.voice.miraiminds.co) under
**Developers**, and see [Migrating from v1](/v2/migration).
The `x-private-key` is a secret **and** the webhook signing key. Never put it in
a browser, a mobile app, or a repo.
## Entity hierarchy
```text theme={null}
Organization
└── Workspace (holds assistants + telephony numbers)
└── Assistant
├── Telephony (inbound + outbound phone numbers)
├── Knowledge Base (documents + FAQ for RAG)
└── Analysis Plan (post-call AI evaluation)
```
IDs are 24-character Mongo ObjectIds: `6690a1b2c3d4e5f600000002`.
## Response envelopes
v1 wraps most successful responses:
```json theme={null}
{
"status_code": 200,
"message": "Assistants fetched successfully.",
"data": { }
}
```
Errors use a flat shape where `code` is a **number**:
```json theme={null}
{ "code": 400, "message": "Validation error" }
```
**Known inconsistencies — these are contract, not bugs to work around**
* `POST /v2/call/initiate` returns **`200`**, while `POST /v2/call/web`,
`POST /v1/number-pool/purchase`, `POST /v1/admin/tool/api` and
`POST /v1/knowledge-base/upload/start` return **`201`**.
* `POST /v2/call/abort` returns a bare `{ "message": "…" }` with no
`status_code` wrapper. `PUT /v2/call/{callId}` returns
`{ "status_code", "message" }` with no `data`.
* Some error bodies use `code`, some use `status_code`. Read the HTTP status
first and the body second.
Handle these explicitly rather than assuming a uniform envelope. [v2](/v2/overview) is
uniform.
## Status codes
| Code | Meaning |
| :---- | :------------------------------------------------------------------- |
| `200` | Success |
| `201` | Created (see the list above for which endpoints) |
| `400` | Validation error, or the resource is archived |
| `401` | Missing or invalid key headers |
| `402` | Insufficient credit balance |
| `403` | Insufficient role, or the operation is not allowed for this org type |
| `404` | Not found in this workspace |
| `409` | Already exists / already released / in use |
| `429` | Rate limit exceeded |
| `500` | Internal error |
## Health
```http theme={null}
GET /health
```
No authentication.
```bash theme={null}
curl https://api.voice-agents.miraiminds.co/health
```
```json theme={null}
{
"status_code": 200,
"message": "Platform is operational.",
"data": { "underMaintenance": false, "status": "healthy" }
}
```
Check `data.underMaintenance` before starting a large campaign.
## Quickstart
`POST /v2/workspace/onboard/custom`. A default telephony number is assigned
automatically in production. [How](/v1/workspaces#onboard-a-custom-workspace)
`POST /v1/admin/assistant/create` with `variant.type: custom` and your
`agent.systemPrompt`. [How](/v1/assistants#create-an-assistant)
`POST /v2/call/initiate` with `callbackUrl` for webhooks.
[How](/v1/calls#initiate-a-call)
# Telephony
Source: https://docs.miraiminds.co/v1/telephony
Search, purchase and release phone numbers from the number pool.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Numbers belong to the **organization** and are bound to assistants through
`telephony.inbound` / `telephony.outbound`.
Every new workspace gets a default number automatically in production. Check
what you already own before buying.
## Search available numbers
```http theme={null}
GET /v1/number-pool/search
```
| Query param | Required | Description |
| :------------ | :------- | :-------------------------------------------------------- |
| `countryCode` | yes | 2-letter ISO, case-insensitive. `IN`, `US`. |
| `numberType` | no | `local`. |
| `pattern` | no | Digits or letters to match within the number, e.g. `415`. |
| `limit` | no | 1–50, default 20. |
| `provider` | no | Default `miraiminds`. |
```bash theme={null}
curl -G https://api.voice-agents.miraiminds.co/v1/number-pool/search \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
--data-urlencode "countryCode=IN" \
--data-urlencode "pattern=815" \
--data-urlencode "limit=10"
```
```json title="200 OK" theme={null}
{
"status_code": 200,
"message": "Available numbers fetched successfully.",
"data": [
{
"number": "+918155550101",
"countryCode": "IN",
"numberType": "local",
"monthlyRateCents": 100,
"setupFeeCents": 0
}
]
}
```
Rates are in **cents**, not rupees — `monthlyRateCents: 100` is 1.00 of the
provider's billing unit.
## Purchase a number
```http theme={null}
POST /v1/number-pool/purchase
```
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/number-pool/purchase \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{
"number": "+918155550101",
"provider": "miraiminds",
"countryCode": "IN",
"numberType": "local"
}'
```
```json title="201 Created" theme={null}
{
"status_code": 201,
"message": "Phone number purchased successfully.",
"data": {
"_id": "6700a1b2c3d4e5f600000222",
"number": "+918155550101",
"provider": "miraiminds",
"organization": "6690a1b2c3d4e5f600000001",
"workspace": null,
"status": "active",
"providerNumberId": "6690a1b2c3d4e5f600000090",
"monthlyRateCents": 100,
"setupFeeCents": 0,
"countryCode": "IN",
"numberType": "local",
"createdAt": "2026-07-26T10:00:00.000Z",
"updatedAt": "2026-07-26T10:00:00.000Z"
}
}
```
Keep `data._id` — that is what goes in an assistant's `telephony.inbound` or
`telephony.outbound`, not the number itself.
| Status | Cause |
| :----- | :-------------------------- |
| `400` | Validation error |
| `402` | Insufficient credit balance |
| `404` | Organization not found |
Note the `201` here, versus `200` on most other v1 endpoints.
## Release a number
```http theme={null}
DELETE /v1/number-pool/{telephonyNumberId}
```
```bash theme={null}
curl -X DELETE https://api.voice-agents.miraiminds.co/v1/number-pool/6700a1b2c3d4e5f600000222 \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002"
```
```json title="200 OK" theme={null}
{ "status_code": 200, "message": "Phone number released successfully." }
```
The path takes the telephony record `_id`, not the phone number.
| Status | Cause |
| :----- | :----------------------------------- |
| `404` | No such number for this organization |
| `409` | Already released |
Releasing is irreversible — the number returns to the provider pool and you are
unlikely to get it back. Detach it from every assistant first, or their calls
will fail.
# API tools
Source: https://docs.miraiminds.co/v1/tools
Give an assistant HTTP tools it can call mid-conversation.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
An **API tool** lets an assistant call your HTTP endpoint during a call — look
up an order, check stock, book a slot. Create the tool once per workspace, then
list its `_id` in the assistant's `agent.tools`.
API tools are a v1 capability. [v2](/v2/overview) has no equivalent yet.
## Create a tool
```http theme={null}
POST /v1/admin/tool/api
```
| Field | Type | Required | Description |
| :------------ | :------ | :------- | :--------------------------------------------------------------------------- |
| `name` | string | yes | Human name. A unique `slug` is derived from it. |
| `description` | string | yes | **Tells the model when to call this tool.** The single most important field. |
| `url` | string | yes | Your endpoint. |
| `method` | enum | yes | `get`, `post`, `put`, `patch`, `delete`. |
| `headers` | array | no | `[{ key, value }]` — static headers such as auth. |
| `parameters` | object | no | `{ required: [names], properties: [ToolProperty] }`. |
| `isActive` | boolean | no | Default `true`. |
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/tool/api \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{
"name": "Check Order Status",
"description": "Look up the current status of a customer order by its ID. Use this whenever the customer asks where their order is.",
"url": "https://api.example.com/orders/status",
"method": "post",
"headers": [{ "key": "Authorization", "value": "Bearer " }],
"parameters": {
"required": ["orderId"],
"properties": [
{ "name": "orderId", "description": "The order identifier to look up.", "type": "string" }
]
},
"isActive": true
}'
```
```json title="201 Created" theme={null}
{
"status_code": 201,
"message": "API tool created successfully.",
"data": {
"_id": "6710a1b2c3d4e5f600000020",
"name": "Check Order Status",
"slug": "check-order-status",
"workspace": "6690a1b2c3d4e5f600000002",
"isActive": true,
"config": {
"description": "Look up the current status of a customer order by its ID…",
"url": "https://api.example.com/orders/status",
"method": "post"
},
"createdAt": "2026-07-26T10:00:00.000Z"
}
}
```
| Status | Cause |
| :----- | :---------------------------------------------------------- |
| `400` | Validation error |
| `409` | A tool with a similar name already exists in this workspace |
### Parameters
```json theme={null}
{
"parameters": {
"required": ["orderId"],
"properties": [
{
"name": "orderId",
"description": "The order identifier, e.g. ORD-12345.",
"type": "string"
},
{
"name": "channel",
"description": "Where the order was placed.",
"type": "string",
"enum": ["web", "app", "store"]
}
]
}
}
```
`type` is `string`, `number`, `boolean`, `object` or `array`. Nested objects use
`properties` and their own `required` list; arrays use `items`. A property with
a fixed `value` is sent as-is rather than being asked of the model.
### Writing tool descriptions
The `description` is a prompt, not documentation. The model reads it to decide
whether to call the tool, mid-conversation, under latency pressure.
* **Say when, not what.** "Use this whenever the customer asks where their order
is" beats "Returns order status."
* **Name the trigger phrases** your customers actually use.
* **Describe each parameter** in the same terms. `orderId` — "the order
identifier, e.g. ORD-12345" — tells the model what shape to extract.
* **Keep the endpoint fast.** A tool call happens between turns; a slow endpoint
is dead air on a live phone call. Budget a few hundred milliseconds.
## List tools
```http theme={null}
GET /v1/admin/tool/api
```
Returns every API tool in the workspace under `data`.
## Update a tool
```http theme={null}
PUT /v1/admin/tool/api/{toolId}
```
Send only the fields you are changing.
```bash theme={null}
curl -X PUT https://api.voice-agents.miraiminds.co/v1/admin/tool/api/6710a1b2c3d4e5f600000020 \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "workspace: 6690a1b2c3d4e5f600000002" \
-H "Content-Type: application/json" \
-d '{ "description": "Updated guidance for when to use this tool.", "isActive": false }'
```
`isActive: false` is the safe way to take a tool out of service — assistants
stop calling it without you having to edit each one.
| Status | Cause |
| :----- | :------------------------------ |
| `400` | Validation error |
| `404` | Tool not found |
| `409` | Name collides with another tool |
## Delete a tool
```http theme={null}
DELETE /v1/admin/tool/api/{toolId}
```
```json title="200 OK" theme={null}
{ "status_code": 200, "message": "API tool deleted successfully." }
```
Remove the tool `_id` from every assistant's `agent.tools` first.
## Attaching tools to an assistant
```json theme={null}
{
"agent": {
"identity": { "name": "priya", "gender": "female", "voice": "priya" },
"systemPrompt": "…When the customer asks about an order, use the order status tool before answering.",
"tools": ["6710a1b2c3d4e5f600000020"]
}
}
```
Mention the tool's *purpose* in the system prompt as well as in the tool's own
description. Two nudges are more reliable than one.
# Webhooks
Source: https://docs.miraiminds.co/v1/webhooks
v1 lifecycle events, the action events, and the x-signature verification scheme.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
Pass `callbackUrl` when you [initiate a call](/v1/calls#initiate-a-call) and v1
POSTs events there as the call progresses.
**v2 signs differently**
v1 uses `x-signature` (a bare hex HMAC over the raw body) plus `x-public-key`.
[v2](/v2/webhooks) uses `X-Mirai-Signature: t=…,v1=…` with a replay window. If
you run both, use **two separate routes** — the schemes are not interchangeable.
## Envelope
```json theme={null}
{
"metadata": { "internalOrderId": "8842" },
"event": {
"type": "call.completed",
"data": {
"call": {
"id": "68f0a1b2c3d4e5f600000900",
"status": "completed",
"startedAt": "2026-07-26T09:15:02.000Z",
"endedAt": "2026-07-26T09:16:38.000Z",
"durationSeconds": 96,
"recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3",
"detailUrl": "https://app.miraiminds.co/calls/68f0…"
}
}
}
}
```
| Field | Description |
| :----------- | :---------------------------------------------------------------------------------------------- |
| `metadata` | Exactly the object you sent at initiate. Present on **every** event. |
| `event.type` | See below. |
| `event.data` | Event-specific. `CallEventData` for `call.*` and `end-of-call`; `ActionEventData` for `action`. |
## Lifecycle events
```mermaid theme={null}
stateDiagram-v2
[*] --> initiate: call placed
initiate --> in_progress: connected
initiate --> failed: failed / busy / no-answer
in_progress --> ended: disconnected
ended --> completed: processing done
completed --> end_of_call: analysis ready
end_of_call --> lifecycle_ended
failed --> lifecycle_ended
lifecycle_ended --> [*]
```
| Event | Fires when |
| :--------------------- | :--------------------------------------------------------------- |
| `call.initiate` | Queued and being dialled. |
| `call.in-progress` | Answered; the conversation started. |
| `call.ended` | The phone connection dropped. |
| `call.completed` | Recording, transcript and duration processed. |
| `call.timeout` | Exceeded the maximum duration. |
| `end-of-call` | Analysis is complete — summary, success flag, insights, credits. |
| `call.lifecycle-ended` | **Final event.** No further attempts for this call task. |
### Failure and retry events
| Event | Fires when |
| :----------------------- | :--------------------------------------------------------- |
| `call.failed` | General connection failure. |
| `call.busy` | Line busy. |
| `call.no-answer` | Nobody picked up. |
| `call.validation-failed` | Pre-call validation failed. |
| `call.skip` | Skipped, e.g. a DND number. |
| `call.rescheduled` | A callback was scheduled; the lifecycle continues. |
| `call.aborted` | Cancelled through the [abort API](/v1/calls#abort-a-call). |
`retryProtocol` on the assistant decides how many attempts follow a failure.
`call.lifecycle-ended` is what tells you the chain is over — **that** is the
event to close your record on, not `call.failed`.
## `end-of-call`
Carries the [analysis plan](/v1/assistants#analysis-plan) output and credit
usage.
```json theme={null}
{
"metadata": { "internalOrderId": "8842" },
"event": {
"type": "end-of-call",
"data": {
"call": {
"id": "68f0a1b2c3d4e5f600000900",
"status": "completed",
"durationSeconds": 96,
"recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3"
},
"analysis": {
"success": true,
"summary": "Customer confirmed the order and chose cash on delivery.",
"requiredActions": ["create-order", "send-whatsapp"],
"insights": { "interested": true, "callback_requested": false }
},
"credits": { "used": 1.5, "available": 98.5 },
"report": { "reAttemptCount": 0, "rescheduledCount": 0 }
}
}
}
```
| Field | Description |
| :---------------------------- | :---------------------------------------------------- |
| `analysis.success` | The boolean your `successCriteriaPlan` returned. |
| `analysis.summary` | Output of your `summaryPlan`. |
| `analysis.requiredActions` | Post-call actions the analysis asked for. |
| `analysis.insights` | Structured fields from your `callInsightPlan`. |
| `credits.used` / `.available` | Credits consumed by this call, and the balance after. |
| `report` | Attempt counters for this call task. |
## `action` events
Fired when the conversation produced a business action for you to execute.
```json theme={null}
{
"event": {
"type": "action",
"data": {
"action": "create_order",
"call": { "id": "68f0a1b2c3d4e5f600000900" },
"reason": null,
"payload": {
"email": "jane@example.com",
"phone": "+919876543210",
"lineItems": [{ "title": "Sample Product", "quantity": 1 }],
"shippingAddress": {
"firstName": "Jane", "lastName": "Smith",
"address1": "12 MG Road", "city": "Bengaluru",
"province": "Karnataka", "zip": "560001", "country": "India",
"phone": "+919876543210"
},
"cartTotal": 1895,
"codCharge": 50,
"discount": { "code": "SAVE10", "reason": "abandoned cart recovery" },
"note": "Customer confirmed on call",
"abandonedCheckoutId": "gid://shopify/AbandonedCheckout/66509168181329"
}
}
}
}
```
| `action` | Meaning |
| :------------------ | :--------------------------------------------- |
| `create_order` | Place the order described in `payload`. |
| `send_whatsapp` | Send a WhatsApp follow-up to `payload.number`. |
| `mark_prepaid` | The customer chose prepaid. |
| `update_address` | The customer gave a corrected address. |
| `confirmed_address` | The customer confirmed the address on file. |
`reason` is set when the action exists because something was missing:
`missing_address`, `missing_first_name`, `invalid_cart_data`.
## Signature verification
Every delivery carries two headers:
| Header | Value |
| :------------- | :-------------------------------------------------------------------------------------- |
| `x-signature` | Hex HMAC-SHA256 of the **raw request body**, keyed with your organization's private key |
| `x-public-key` | Your organization's public key, so you know which secret to verify with |
There is no timestamp and no replay window in v1. Recompute the HMAC over the
raw bytes and compare in constant time.
```python theme={null}
import hashlib
import hmac
import os
from flask import Flask, request
app = Flask(__name__)
PRIVATE_KEY = os.environ["MIRAI_PRIVATE_KEY"] # sk_…, the same key you send as x-private-key
def verify_v1(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
@app.post("/webhooks/call-events")
def call_events():
if not verify_v1(request.get_data(), request.headers.get("x-signature", ""), PRIVATE_KEY):
return {"error": "invalid signature"}, 401
body = request.get_json()
event_type = body["event"]["type"]
call = body["event"]["data"].get("call", {})
handle(event_type, call, body.get("metadata", {}))
return "", 200
```
```javascript theme={null}
import express from "express";
import crypto from "node:crypto";
const app = express();
const PRIVATE_KEY = process.env.MIRAI_PRIVATE_KEY; // sk_…
function verifyV1(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// raw body — a re-serialised object will not match
app.post(
"/webhooks/call-events",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifyV1(req.body, req.get("x-signature"), PRIVATE_KEY)) {
return res.status(401).json({ error: "invalid signature" });
}
const body = JSON.parse(req.body.toString("utf8"));
handle(body.event.type, body.event.data.call, body.metadata);
res.sendStatus(200);
}
);
app.listen(3000);
```
### Rules
1. **Use the raw body.** Sign the exact bytes received, before any JSON parsing.
Re-serialising changes key order and spacing, and the digest with it.
2. **Constant-time compare.** `hmac.compare_digest` / `crypto.timingSafeEqual`.
3. **Keep the private key out of the browser.** It signs webhooks *and*
authenticates API calls.
4. **Return `200` fast.** Enqueue the work; do not do it inline.
5. **Expect duplicates.** v1 events carry no event ID — dedupe on
`event.type` + `call.id`, and make handlers idempotent.
### Troubleshooting
| Symptom | Cause |
| :---------------------- | :----------------------------------------------------------------------------------------------- |
| Signature never matches | Body was parsed before hashing, or a proxy re-encoded it |
| Headers missing | A reverse proxy or WAF stripped `x-signature` |
| Wrong key | `x-public-key` identifies which organization's secret to use — check it matches the one you have |
# Workspaces & organizations
Source: https://docs.miraiminds.co/v1/workspaces
Onboard a workspace, archive and unarchive organizations and workspaces.
**This documents the v1 product.** It is kept for integrations already running
on it. If you are building something new, start with the
[Quickstart](/v2/quickstart).
A **workspace** holds assistants and telephony numbers and belongs to an
**organization**. The workspace `_id` you get here is the `workspace` header
value for every workspace-scoped call afterwards.
## Onboard a custom workspace
```http theme={null}
POST /v2/workspace/onboard/custom
```
The recommended path for every non-Shopify integration.
| Field | Type | Required | Description |
| :-------------------------------------- | :-------- | :------- | :---------------------------------------------- |
| `name` | string | yes | Display name; a unique slug is derived from it. |
| `currencyCode` | string(3) | yes | ISO 4217, uppercased. `INR`, `USD`. |
| `timezone` | string | yes | IANA identifier, e.g. `Asia/Kolkata`. |
| `supportContacts.phoneNumber` | string | yes | E.164. |
| `supportContacts.email` | string | no | |
| `trustSignals.valuePropositionOneLiner` | string | yes | One line used to ground the assistant. |
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v2/workspace/onboard/custom \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Support Line",
"currencyCode": "INR",
"timezone": "Asia/Kolkata",
"supportContacts": { "phoneNumber": "+919876543210", "email": "support@acme.com" },
"trustSignals": { "valuePropositionOneLiner": "Premium 24x7 support for Acme customers." }
}'
```
```json title="200 OK" theme={null}
{
"message": "Workspace onboarded successfully.",
"data": {
"_id": "6690a1b2c3d4e5f600000002",
"name": "Acme Support Line",
"variant": "custom"
}
}
```
Keep `data._id` — it is your `workspace` header from now on.
**Auto-assigned number**
In production a default telephony number is assigned to every new workspace.
List it through the [Telephony](/v1/telephony) APIs before buying another.
| Status | Cause |
| :----- | :----------------------------------------------------- |
| `400` | Validation error, or the organization is archived |
| `403` | Stand-alone organizations cannot create new workspaces |
| `409` | A workspace with this name already exists |
## Onboard a Shopify store
```http theme={null}
POST /v2/workspace/onboard/shopify
```
Shopify merchants only — creates the workspace and configures billing in one
step. Returns `201` with `workspace`, `status` and a `billing` block:
```json title="201 Created" theme={null}
{
"workspace": "6690a1b2c3d4e5f600000002",
"status": "active",
"billing": {
"type": "prepaid",
"creditBalance": 100,
"negativeCreditAllowance": 100
}
}
```
`negativeCreditAllowance` is an overdraft: calls keep running that far past zero
before the workspace is suspended.
## Archive
```http theme={null}
POST /v1/admin/organization/archive
```
Requires an `admin` or `organization_admin` role.
```bash theme={null}
curl -X POST https://api.voice-agents.miraiminds.co/v1/admin/organization/archive \
-H "x-public-key: pk_1234567890abcdef1234567890abcdef" \
-H "x-private-key: sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" \
-H "Content-Type: application/json" \
-d '{ "type": "workspace", "id": "6690a1b2c3d4e5f600000002" }'
```
| Field | Values |
| :----- | :---------------------------- |
| `type` | `organization` \| `workspace` |
| `id` | ObjectId of the target |
```json title="200 OK" theme={null}
{ "status_code": 200, "message": "Workspace archived successfully.", "data": null }
```
Archiving **cascades**: archiving an organization archives its workspaces,
assistants and campaigns; archiving a workspace archives its assistants and
campaigns. Archived assistants cannot be updated (`400`) and cannot place calls.
## Unarchive
```http theme={null}
POST /v1/admin/organization/unarchive
```
Same body. Unarchiving also cascades. A workspace cannot be unarchived while its
parent organization is still archived:
```json title="400 Bad Request" theme={null}
{ "code": 400, "message": "Cannot unarchive workspace. Parent Organization is archived." }
```
Unarchive the organization first.
# Agents
Source: https://docs.miraiminds.co/v2/agents
Create, read, update and delete the reusable configuration a call runs.
An **agent** is the reusable configuration a call runs: what it says first, how
it behaves, which voice, which language, and when it must stop. Calls reference
an agent by ID; per-call differences go in [`variables`](/v2/calls#variables).
Base URL `https://sandbox.voice.miraiminds.co`. All endpoints require
`Authorization: Bearer sk_live_…`.
## The agent object
```json theme={null}
{
"id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"object": "agent",
"name": "Order Confirmation",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}…",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"end_call": {
"enabled": true,
"message": "धन्यवाद, आपका दिन शुभ हो।",
"confirm": true
},
"voicemail": { "action": "hangup" },
"background_sound": { "sound": "off" },
"created_at": "2026-07-26T09:14:02Z",
"updated_at": "2026-07-26T09:14:02Z"
}
```
### Fields
| Field | Type | Required | Description |
| :------------------ | :--------------- | :------- | :------------------------------------------------------------------------------------------------------------------- |
| `name` | string | yes | Human label. Shown in logs and the console. |
| `system_prompt` | string | yes | The agent's instructions. Supports `{{variable}}` placeholders. |
| `first_message` | string | yes | Spoken the moment the callee answers, before the model runs. Keep it short — it is your first-audio latency. |
| `voice` | object | yes | See [Voice](#voice). |
| `language` | string | yes | BCP-47 conversation language, e.g. `hi-IN`, `en-IN`. Drives ASR and the model's default output language. |
| `max_duration_secs` | integer | no | Hard cap. The call is ended with `ended_reason: exceeded-max-duration`. Minimum `30`, maximum `1800`, default `300`. |
| `end_call` | object | no | See [Ending a call](#ending-a-call). |
| `voicemail` | object | no | See [Voicemail](#voicemail). |
| `background_sound` | string \| object | no | Room ambience under the agent's voice. See [Background sound](#background-sound). Default `off`. |
Server-set and read-only: `id`, `object`, `created_at`, `updated_at`.
### Voice
```json theme={null}
{ "voice": { "voice_id": "ashutosh", "language": "hi-IN" } }
```
| Field | Type | Description |
| :--------- | :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `voice_id` | string | A voice your [tier](/general/tiers) can say. On `t3` (the default) it is a name from the Sarvam catalogue, e.g. `ashutosh`, and it is **required**. On `t1` it is `ashu` (the default) or `aishe`, and nothing else. See [Voices](/v2/voices). |
| `language` | string | Rendering language for that voice. Omit to inherit the agent's `language`. **Accepted and stored, not yet honoured** — see below. |
**`voice.language` is recorded, not yet applied**
The field is validated, stored and echoed back, and it travels with the call
payload — but the voice today renders in the agent's top-level `language`. Set
both to the same value and you get what you expect; set them differently and the
top-level one wins until per-voice rendering ships.
Hear the catalogue and pick a `voice_id` in the
[console](https://sandbox.voice.miraiminds.co). See [Voices](/v2/voices).
### Ending a call
The agent gets an `end_call` tool. When it fires, the agent speaks `message` and
hangs up.
| Field | Type | Default | Description |
| :-------- | :------ | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | Set `false` to remove the tool entirely — the call then ends only on the callee hanging up or `max_duration_secs`. **Accepted and stored; enforcement rolling out.** |
| `message` | string | `""` | Spoken farewell, played **after** the agent's own goodbye when the call ends. Empty by default — the agent's last line is the farewell. Live today. |
| `confirm` | boolean | `true` | Require a second `end_call` within the confirmation window before hanging up. Guards against fast models ending on a bare "ok". **Accepted and stored; enforcement rolling out.** |
**Only `message` is enforced today**
`enabled` and `confirm` are validated, stored, echoed back and carried into the
call payload, but the agent currently reads only `end_call.message`. Until the
worker picks the other two up, assume the tool is available and confirmation
behaves at its default. Set them now — they take effect without any change on
your side.
Leave `confirm: true` unless you are running a one-turn notification. It costs
one extra turn in the rare case and prevents the far more expensive failure of
hanging up on a customer mid-sentence.
### Voicemail
```json theme={null}
{
"voicemail": {
"action": "message",
"message": "Hi, this is Acme calling about your order. We'll try again later."
}
}
```
| Field | Type | Description |
| :-------- | :-------------------- | :-------------------------------------------------------------------- |
| `action` | `hangup` \| `message` | What to do when an answering machine is detected. Default `hangup`. |
| `message` | string | Required when `action` is `message`. Spoken once, then the call ends. |
Either way the call ends with `status: voicemail` and fires a
[`call.voicemail`](/v2/webhooks#callvoicemail) event. Voicemail detection is
available on every tier — see the [feature matrix](/general/tiers#feature-matrix).
### Background sound
An agent answering out of complete silence is the loudest tell that nobody is
there. `background_sound` plays a room under the voice for the whole call.
```json theme={null}
{ "background_sound": "office" }
```
Or, with the level:
```json theme={null}
{ "background_sound": { "sound": "office", "volume": 0.2 } }
```
| Field | Type | Description |
| :------- | :---------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sound` | `off` \| `office` | Which room. Default `off`. |
| `volume` | number | `0`–`1`, default `0.08`. The default sits about 22 dB under the agent's voice — a room you notice when the line goes quiet, never one that competes with the words. Raise it if you want the room forward; a bed at `0.3` is already conversational-level. |
Two things worth knowing before you turn it on:
* The bed is in the **call recording** too, because it is what the callee
heard. If you run your own transcription over recordings, test it first.
* It does not touch what the agent hears. Ambience is mixed into the outbound
audio only, so your callers' speech recognition is unchanged.
***
## Create an agent
```http theme={null}
POST /v2/agents
```
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Confirmation",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"end_call": { "enabled": true, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": true },
"voicemail": { "action": "hangup" }
}'
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "Order Confirmation",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": {"voice_id": "ashutosh", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 300,
"end_call": {"enabled": True, "message": "धन्यवाद, आपका दिन शुभ हो।", "confirm": True},
"voicemail": {"action": "hangup"},
},
timeout=30,
).raise_for_status().json()
print(agent["id"])
```
```javascript theme={null}
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const res = await fetch(`${API}/v2/agents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Order Confirmation",
system_prompt: "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}.",
first_message: "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
voice: { voice_id: "ashutosh", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 300,
end_call: { enabled: true, message: "धन्यवाद, आपका दिन शुभ हो।", confirm: true },
voicemail: { action: "hangup" },
}),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const agent = await res.json();
```
**`201 Created`** — the full [agent object](#the-agent-object).
| Error | `error.code` | Cause |
| :---- | :---------------- | :--------------------------------------------------------------------------- |
| `400` | `invalid_request` | Missing required field, unknown `voice_id`, `max_duration_secs` out of range |
| `401` | `unauthorized` | Bad or missing key |
***
## Get an agent
```http theme={null}
GET /v2/agents/{id}
```
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python theme={null}
agent = httpx.get(
f"{API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ", headers=auth, timeout=30
).raise_for_status().json()
```
```javascript theme={null}
const agent = await fetch(
`${API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ`,
{ headers: auth }
).then((r) => r.json());
```
**`200 OK`** — the agent object. **`404 not_found`** if the agent was deleted, if
the ID does not exist, or if it exists in another workspace — all three answer
identically, so a `404` is never a hint that the ID is real. Never `403`: that
status means your key has been revoked and nothing else. See
[errors](/v2/errors#error-codes).
***
## List agents
```http theme={null}
GET /v2/agents?limit=&cursor=
```
```bash theme={null}
curl "https://sandbox.voice.miraiminds.co/v2/agents?limit=20" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"data": [
{
"id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"object": "agent",
"name": "Order Confirmation",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}}…",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"end_call": {
"enabled": true,
"message": "धन्यवाद, आपका दिन शुभ हो।",
"confirm": true
},
"voicemail": { "action": "hangup" },
"background_sound": { "sound": "off" },
"created_at": "2026-07-26T09:14:02Z",
"updated_at": "2026-07-26T09:14:02Z"
}
],
"has_more": false,
"next_cursor": null
}
```
Each element is the **full** [agent object](#the-agent-object) — the same shape
`GET /v2/agents/{id}` returns, prompts included. There is no trimmed summary
form, so listing a page of agents with long prompts is a large response: page
with `limit` rather than pulling everything at once.
Deleted agents are excluded. See [pagination](/v2/overview#pagination).
***
## Update an agent
```http theme={null}
PATCH /v2/agents/{id}
```
Send only the fields you are changing. Nested objects are replaced wholesale —
to change `end_call.message`, send the whole `end_call` object.
```bash theme={null}
curl -X PATCH https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "max_duration_secs": 180, "voice": { "voice_id": "neha", "language": "hi-IN" } }'
```
```python theme={null}
agent = httpx.patch(
f"{API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
headers=auth,
json={"max_duration_secs": 180, "voice": {"voice_id": "neha", "language": "hi-IN"}},
timeout=30,
).raise_for_status().json()
```
```javascript theme={null}
const agent = await fetch(
`${API}/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ`,
{
method: "PATCH",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
max_duration_secs: 180,
voice: { voice_id: "neha", language: "hi-IN" },
}),
}
).then((r) => r.json());
```
**`200 OK`** — the updated agent object.
An update takes effect on the **next** call. Calls already `queued`, `dialing`
or `in_progress` keep the configuration they were created with.
***
## Delete an agent
```http theme={null}
DELETE /v2/agents/{id}
```
```bash theme={null}
curl -X DELETE https://sandbox.voice.miraiminds.co/v2/agents/agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
**`204 No Content`**.
The delete is soft: the agent stops appearing in `GET /v2/agents` and can no
longer be used for new calls, but historical calls keep resolving their
`agent_id`. Calls already in flight are not affected.
***
## Writing a good prompt
A v2 call is a **single-prompt** call — one job, start to finish, usually in
under two or three minutes. The [prompting guide](/v2/prompting-guide) is the
long version; the short one:
* **Name the job in the first line.** "Confirm order `{{order_id}}`. Nothing else."
* **Cap reply length explicitly.** "One or two short sentences." Long turns are
the single biggest driver of perceived latency.
* **State the ending condition.** "When the customer confirms or refuses, thank
them and call `end_call`." Without this the model keeps talking.
* **Put per-call data in `variables`, not the prompt.** One agent, thousands of
calls, no re-create.
* **Write in the language you will speak.** A Hindi call driven by an English
prompt code-switches badly. Write the Hindi lines in Devanagari.
Prompts that cause trouble: multi-branch scripts ("if they say X, then go
through the following seven questions"), instructions that assume the model
remembers a previous call, and anything that needs a database lookup mid-call —
that needs the node-graph runtime, which is a `t5` feature — see
[tiers](/general/tiers).
# Calls
Source: https://docs.miraiminds.co/v2/calls
Place outbound calls, inspect their status, abort them, and list history.
A **call** is one outbound dial attempt driven by an [agent](/v2/agents).
Creating a call is asynchronous: you get `202 Accepted` immediately and the
outcome arrives by [webhook](/v2/webhooks) (or by polling
[`GET /v2/calls/{id}`](#get-a-call)).
Base URL `https://sandbox.voice.miraiminds.co`.
## The call object
```json theme={null}
{
"id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA",
"object": "call",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"status": "completed",
"tier": "t3",
"ended_reason": "customer-ended-call",
"created_at": "2026-08-10T09:15:00Z",
"started_at": "2026-08-10T09:15:02Z",
"ended_at": "2026-08-10T09:16:38Z",
"duration_secs": 96,
"cost_inr": 6,
"recording_available": true,
"transcript_available": true,
"analysis": null
}
```
| Field | Type | Description |
| :--------------------- | :-------------- | :------------------------------------------------------------------------------------------------------ |
| `id` | string | `call_`. Also the idempotency anchor and the workflow ID. |
| `agent_id` | string | The agent this call ran. |
| `to` | string | E.164 destination. |
| `status` | enum | See [statuses](#statuses). |
| `tier` | string | The rate card this call was billed under — `t3` unless you asked for `t1`. See [tiers](/general/tiers). |
| `ended_reason` | string \| null | Why it ended. See [ended reasons](#ended-reasons). `null` while running. |
| `created_at` | string | RFC 3339, UTC. When we accepted the request. |
| `started_at` | string \| null | RFC 3339, UTC. Set when **media goes live**, not when we dialled. `null` if the call never connected. |
| `ended_at` | string \| null | RFC 3339, UTC. |
| `duration_secs` | integer \| null | Billable seconds of live media. `0` for calls that never connected, `null` while running. |
| `cost_inr` | number \| null | Rupees charged. `null` until the call ends, and `null` for an ending that is not billable. |
| `recording_available` | boolean | Whether [`GET /recording`](#get-the-recording) will return audio. |
| `transcript_available` | boolean | Whether [`GET /transcript`](#get-the-transcript) will return turns. |
| `analysis` | object \| null | Post-call summary, when the agent was created with `analysis.enabled`. `null` otherwise. |
## Statuses
| Status | Terminal | Meaning |
| :------------ | :------: | :--------------------------------------------------------------------------------------------------------------------- |
| `queued` | | Accepted, waiting on fleet capacity. |
| `dialing` | | SIP invite sent, phone is ringing. |
| `in_progress` | | Media live, conversation running. |
| `completed` | ✅ | Connected and finished normally. **Billable.** |
| `voicemail` | ✅ | An answering machine picked up. See [voicemail](/v2/agents#voicemail). **Billable.** |
| `no_answer` | ✅ | Rang out, nobody picked up. Free. |
| `busy` | ✅ | Callee's line was busy. Free. |
| `failed` | ✅ | Could not connect, or the pipeline errored. Free. |
| `timeout` | ✅ | Hit `max_duration_secs`. **Billable.** |
| `aborted` | ✅ | You cancelled it with [`POST /abort`](#abort-a-call) — from the queue, mid-ring, or mid-conversation. Free either way. |
Statuses only move forward. A terminal status never changes.
```mermaid theme={null}
stateDiagram-v2
[*] --> queued: POST /v2/calls
queued --> dialing: capacity acquired
queued --> aborted: POST /abort
dialing --> aborted: POST /abort
in_progress --> aborted: POST /abort
dialing --> in_progress: media live
dialing --> no_answer
dialing --> busy
dialing --> failed
dialing --> voicemail: machine detected
in_progress --> completed
in_progress --> voicemail
in_progress --> timeout: max_duration_secs
in_progress --> failed
completed --> [*]
voicemail --> [*]
no_answer --> [*]
busy --> [*]
failed --> [*]
timeout --> [*]
aborted --> [*]
```
## Ended reasons
`ended_reason` explains *why* a terminal status was reached. Match on `status`
for control flow; use `ended_reason` for analytics and support.
| `ended_reason` | Usual `status` | Meaning |
| :------------------------ | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `assistant-ended-call` | `completed` | The agent called `end_call` — the job finished. |
| `customer-ended-call` | `completed` | The callee hung up. |
| `exceeded-max-duration` | `timeout` | Hit `max_duration_secs`. |
| `customer-did-not-answer` | `no_answer` | Rang out. |
| `customer-busy` | `busy` | Line busy. |
| `voicemail` | `voicemail` | An answering machine answered. Detecting it is what sets the status, so the two always travel together. |
| `silence-timed-out` | `completed` | Media was live but nobody ever spoke. Billable — the agent ran. |
| `idle-timed-out` | `completed` | The agent re-prompted its configured number of times and the caller never came back. Billable. |
| `no-media` | `failed` | The call was placed but audio never flowed. See the note below. Not billed. |
| `aborted-by-api` | `aborted` | You called [`POST /abort`](#abort-a-call). The only reason an abort produces. |
| `assistant-error` | `failed` | Pipeline failure on our side. Not billed. |
| `transferred` | `completed` | The call ended in a completed warm transfer to a human. Handoff is [part of every tier](/general/tiers#feature-matrix); the rollout is completing — **no call emits this yet**, see the [roadmap](/general/roadmap). |
| `balance-exhausted` | `completed` | Reserved for a call ended when the wallet emptied mid-call. **Not wired — no call emits this yet.** |
Treat `ended_reason` as an open vocabulary. New values are added without a
version bump — always have a default branch. The last two rows above are in the
vocabulary but **no call emits them today**; they arrive with the warm-transfer
rollout and the `t3`/`t5` tiers — see the [roadmap](/general/roadmap).
**`no_answer` is under-reported today**
Some calls that genuinely rang out are reported as `failed` with
`ended_reason: "no-media"` rather than as `no_answer`.
Whether we can tell the two apart depends on what the carrier tells us. When the
carrier returns a clear verdict (SIP 480/408 → rang out, 486/600 → busy) you get
`no_answer` / `busy` correctly. When it black-holes the call instead — accepts
the invite and returns nothing — we have no signal to distinguish "rang, nobody
picked up" from "answered into silence", and the call ends `failed` /
`no-media` after the media gate expires.
**What this means for you:** if you are counting unanswered calls for retry
logic, treat `no_answer`, `busy` **and** `failed` + `no-media` as the
did-not-connect bucket. None of the three is billed, so your costs are unaffected
either way. We are working on tightening this; the reported status will get more
specific over time, never less.
***
## Create a call
```http theme={null}
POST /v2/calls
```
### Request
| Field | Type | Required | Description |
| :------------------ | :------ | :------- | :--------------------------------------------------------------------------------------------------------------------------- |
| `agent_id` | string | yes | The agent to run. |
| `to` | string | yes | E.164, e.g. `+919876543210`. |
| `variables` | object | no | Flat string map substituted into the agent's prompt. See [variables](#variables). |
| `webhook_url` | string | no | HTTPS URL for [lifecycle events](/v2/webhooks). |
| `max_duration_secs` | integer | no | Overrides the agent's cap for this call only. |
| `tier` | string | no | `t1` \| `t3`. Defaults to your key's tier — `t3` (₹3/min) on a partner key. `t5` answers `501`. See [tiers](/general/tiers). |
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8842-confirm-1" \
-d '{
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t3",
"variables": { "customer_name": "Rahul", "order_id": "8842" },
"webhook_url": "https://example.com/mirai/webhook",
"max_duration_secs": 240
}'
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
r = httpx.post(
f"{API}/v2/calls",
headers={**auth, "Idempotency-Key": "order-8842-confirm-1"},
json={
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t3",
"variables": {"customer_name": "Rahul", "order_id": "8842"},
"webhook_url": "https://example.com/mirai/webhook",
"max_duration_secs": 240,
},
timeout=30,
)
if r.status_code == 402:
raise RuntimeError(r.json()["error"]["message"]) # top up the wallet
call = r.raise_for_status().json()
print(call["id"], call["status"]) # call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued
```
```javascript theme={null}
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const res = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": "order-8842-confirm-1",
},
body: JSON.stringify({
agent_id: "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
to: "+919876543210",
tier: "t3",
variables: { customer_name: "Rahul", order_id: "8842" },
webhook_url: "https://example.com/mirai/webhook",
max_duration_secs: 240,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.id, body.status); // call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued
```
```json title="202 Accepted" theme={null}
{ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" }
```
`202` means the call is admitted to the queue — **the phone has not rung yet**.
The full call object is available from [`GET /v2/calls/{id}`](#get-a-call).
### Errors
| Status | `error.code` | Cause |
| :----- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400` | `invalid_request` | Missing `agent_id`/`to`, `to` not E.164, `max_duration_secs` out of range |
| `401` | `unauthorized` | Bad or missing key |
| `402` | `insufficient_balance` | Wallet cannot cover the first minute at your tier. **Checked before we dial** — no partial charge. See [Wallet](/v2/wallet#402-insufficient-balance). |
| `404` | `not_found` | `agent_id` does not exist in your workspace — including one that exists in someone else's |
| `409` | `duplicate_call` | A request with this `Idempotency-Key` is still being processed. See [idempotency](#idempotency). |
| `429` | `rate_limited` | Request rate exceeded, **or** your queue is full (500 calls accepted and not yet ended). Concurrency does **not** 429 — over-cap calls queue. `error.message` says which. Honour `Retry-After`. See [Limits](/v2/limits). |
| `429` | `at_capacity` | Every line on the deployment is busy. `Retry-After: 30`. Nothing was charged. |
| `501` | `tier_unavailable` | `tier` is `t5`, which is not live yet |
### Variables
`variables` is a flat map of string keys to string values. Each key replaces the
matching `{{key}}` placeholder in the agent's `system_prompt` and
`first_message`.
```json theme={null}
{ "variables": { "customer_name": "Rahul", "order_id": "8842" } }
```
* Plain `{{key}}` placeholders match loosely: letters and digits only,
case-insensitive, so `{{Customer Name}}`, `{{customer_name}}` and
`{{CustomerName}}` all read the same variable. Only a Liquid expression
with a filter (`{{ key | upcase }}`) needs the exact key.
* A placeholder with no matching variable is substituted with an empty string —
it does not error, and it does not leave `{{braces}}` for the agent to read
aloud. Validate on your side.
* Keep values short. They are part of the prompt, and the prompt is on the
latency path.
* Variables are **static per call**. There is no way to change them mid-call.
### Idempotency
Send `Idempotency-Key` with a value derived from your own domain object:
```bash theme={null}
-H "Idempotency-Key: order-8842-confirm-1"
```
If we have seen that key in the last 24 hours, we replay the original response
byte for byte and place **no second call**. This is the safe way to retry a
request that timed out — you cannot tell from a network timeout whether the call
was placed, and without the key a retry means the customer's phone rings twice.
The key is scoped to your workspace. Reusing a key with a *different* body still
replays the original response — pick keys that are unique per intended call.
There is one window where a reused key does not replay: while the **first**
request with that key is still being processed, there is no stored response to
replay yet, so the second request gets
[`409 duplicate_call`](/v2/errors#error-codes). It means "already in flight, no
second call placed" — retry a moment later and you will get the original
response, or wait for the webhook.
***
## Get a call
```http theme={null}
GET /v2/calls/{id}
```
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/calls/call_01JZQ9B4M8N3P6R2S5T7V9W1YA \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python theme={null}
import time
TERMINAL = {"completed", "voicemail", "no_answer", "busy", "failed", "timeout", "aborted"}
def wait_for_call(call_id: str, timeout_secs: int = 600) -> dict:
"""Prefer webhooks. Poll only when you cannot host an endpoint."""
deadline = time.monotonic() + timeout_secs
while time.monotonic() < deadline:
call = httpx.get(f"{API}/v2/calls/{call_id}", headers=auth, timeout=30) \
.raise_for_status().json()
if call["status"] in TERMINAL:
return call
time.sleep(5)
raise TimeoutError(call_id)
```
```javascript theme={null}
const TERMINAL = new Set([
"completed", "voicemail", "no_answer", "busy", "failed", "timeout", "aborted",
]);
async function waitForCall(callId, timeoutMs = 600_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const call = await fetch(`${API}/v2/calls/${callId}`, { headers: auth })
.then((r) => r.json());
if (TERMINAL.has(call.status)) return call;
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error(`timed out waiting for ${callId}`);
}
```
**`200 OK`** — the [call object](#the-call-object). **`404 not_found`** for an
unknown ID, and for an ID that belongs to another workspace — the two are
deliberately indistinguishable.
Poll no faster than every 5 seconds, and only when you cannot receive webhooks.
Polling counts against your [rate limit](/v2/limits); webhooks do not.
***
## Get the transcript
```http theme={null}
GET /v2/calls/{id}/transcript
```
What was said, as turns, once the call has ended.
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/transcript \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"call_id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA",
"turns": [
{
"role": "assistant",
"text": "नमस्ते Rohit जी, मैं Acme Retail से Priya बोल रही हूँ। एक मिनट बात कर सकती हूँ?",
"start_ms": 420,
"end_ms": 4980
},
{ "role": "user", "text": "हाँ बोलिए", "start_ms": 5640, "end_ms": 6390 },
{
"role": "assistant",
"text": "आपका order AC-88213 kal shaam 6 baje deliver hoga. Ye slot theek hai?",
"start_ms": 7010,
"end_ms": 14880
},
{ "role": "user", "text": "haan theek hai", "start_ms": 15900, "end_ms": 17240 }
]
}
```
| Field | Notes |
| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `role` | `assistant` or `user`. Nothing else appears — internal steps are not conversation. |
| `text` | The turn as spoken. Hindi, Hinglish and English arrive as they were said; nothing is translated. |
| `start_ms`, `end_ms` | Milliseconds from the start of the **recording**, so a turn lines up with the audio. `end_ms` can be `null` on the last turn of a call that was cut off. |
`404 not_found` while the call is still running, and for a call that produced no
speech at all. Check `transcript_available` on the
[call object](#the-call-object) if you want to tell those apart from a mistyped
id.
```json title="404 Not Found" theme={null}
{
"error": {
"code": "not_found",
"message": "transcript not available (call still in progress or produced no speech)"
}
}
```
***
## Get the recording
```http theme={null}
GET /v2/calls/{id}/recording
```
`302` to a signed, time-limited URL for the call's audio (WAV). Follow the
redirect — most HTTP clients do by default; `curl` needs `-L`.
```bash theme={null}
curl -L https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/recording \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-o call.wav
```
```http title="302 Found" theme={null}
Location: https://storage.miraiminds.co/voice-agents/production/call_records/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/call.wav?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…
```
**The signed link expires in 15 minutes and it is a bearer token.**
It is a way to fetch this recording *now*, not a permanent URL. Store the
`call_id` and ask again; do not store the link. Anyone holding it can fetch the
audio until it expires, so treat it exactly as you would treat the recording.
`404 not_found` while the call is running, for a call that produced no audio, and
for one whose audio was never stored. `recording_available` on the call object
tells you which requests are worth making.
Transcripts and recordings are kept for **90 days**, the same as the call
record. Pull anything you need to keep longer into your own system.
***
## Abort a call
```http theme={null}
POST /v2/calls/{id}/abort
```
Cancels a call that has not ended yet. Accepted while the call is `queued`,
`dialing` **or `in_progress`** — a live call is hung up on the SIP server
mid-conversation, not merely flagged. Only a call that has already reached a
terminal status is refused: there is nothing left to stop.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls/call_01JZQ9B4M8N3P6R2S5T7V9W1YA/abort \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="202 Accepted" theme={null}
{ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "aborted" }
```
`202` means the cancel was signalled. The call reaches `aborted` shortly after
with `ended_reason: "aborted-by-api"`, and a
[`call.aborted`](/v2/webhooks#callaborted) event fires. An aborted call is never
billed — including one aborted after media went live.
| Status | `error.code` | Cause |
| :----- | :----------- | :-------------------------------------------------------------------------------- |
| `404` | `not_found` | Unknown call ID, or a call in another workspace |
| `409` | `conflict` | The call has already ended — its status is terminal, so there is nothing to abort |
***
## List calls
```http theme={null}
GET /v2/calls?agent_id=&status=&from=&to=&limit=&cursor=
```
| Query param | Description |
| :---------- | :----------------------------------------------- |
| `agent_id` | Only calls run by this agent. |
| `status` | Filter by one [status](#statuses). |
| `from` | RFC 3339 lower bound on `created_at`, inclusive. |
| `to` | RFC 3339 upper bound on `created_at`, exclusive. |
| `limit` | 1–100, default 20. |
| `cursor` | From `next_cursor` of the previous page. |
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/calls \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "agent_id=agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ" \
--data-urlencode "status=completed" \
--data-urlencode "from=2026-07-01T00:00:00Z" \
--data-urlencode "limit=100"
```
```json title="200 OK" theme={null}
{
"data": [
{
"id": "call_01K7Q9B4M8N3P6R2S5T7V9W1YA",
"object": "call",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"status": "completed",
"tier": "t3",
"ended_reason": "customer-ended-call",
"created_at": "2026-08-10T09:15:00Z",
"started_at": "2026-08-10T09:15:02Z",
"ended_at": "2026-08-10T09:16:38Z",
"duration_secs": 96,
"cost_inr": 6,
"recording_available": true,
"transcript_available": true,
"analysis": null
}
],
"has_more": true,
"next_cursor": "call_01K7Q9B4M8N3P6R2S5T7V9W1YB"
}
```
Results are newest first. See [pagination](/v2/overview#pagination).
The list endpoint is for reconciliation and reporting, not for driving your
application state. For that, use [webhooks](/v2/webhooks).
# Campaigns
Source: https://docs.miraiminds.co/v2/campaigns
Upload a contact list, set a calling window and a budget, and let the platform dial it — with retries, pacing, do-not-call suppression and a report.
A **campaign** is a contact list plus the rules for dialling it: which
[agent](/v2/agents) runs, what hours it may ring people, how many lines it may
use at once, how often to retry, and how much it is allowed to spend.
You upload the list once and start it. From there the platform owns the dialling
— it paces itself, sleeps out the night, retries what did not connect, skips
anything on your [do-not-call list](/v2/dnc), pauses itself when the money runs
out, and hands you a per-contact report at the end.
Base URL `https://sandbox.voice.miraiminds.co`.
A campaign's calls are ordinary [calls](/v2/calls). They appear in
`GET /v2/calls`, they emit the same `call.*` [webhooks](/v2/webhooks), and they
debit the same wallet at the same per-minute rate. A campaign is a dialler on
top of the call API, not a separate billing path.
## The campaign object
```json theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"name": "Order confirmations — Mumbai batch",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"tier": "t3",
"status": "play",
"pause_reason": null,
"timezone": "Asia/Kolkata",
"start_date": "2026-08-11",
"end_date": "2026-08-18",
"slots": [{ "start": "10:00", "end": "19:00" }],
"max_concurrent": 2,
"retry_count": 1,
"re_attempt_period_secs": 900,
"max_duration_secs": 300,
"budget_paise": 50000,
"spent_paise": 12900,
"webhook_url": "https://example.com/mirai/webhook",
"created_at": "2026-08-10T09:21:44Z",
"updated_at": "2026-08-10T11:04:02Z",
"counters": { "pending": 812, "dialing": 2, "completed": 174, "no_answer": 9, "suppressed": 3 }
}
```
| Field | Type | Description |
| :------------------------ | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string | `cmp_`. Opaque. |
| `name` | string | Yours. 1–120 characters. |
| `agent_id` | string | The agent every contact is dialled with. Must exist in your workspace **at create time**. |
| `tier` | string | The rate card every call in this campaign bills at. Defaults to your key's tier — `t3` on a partner key. See [tiers](/general/tiers). |
| `status` | enum | `draft` \| `play` \| `paused` \| `stopped` \| `completed`. See [lifecycle](#lifecycle). |
| `pause_reason` | string \| null | Why *we* paused it. `null` when a human paused it, and `null` whenever it is not paused. See [pause reasons](#pause-reasons). |
| `timezone` | string | IANA name, e.g. `Asia/Kolkata`. Slots and dates are **local to this**. |
| `start_date` / `end_date` | string | `YYYY-MM-DD`, inclusive, local dates. |
| `slots` | array | 1–4 `{start, end}` windows in `HH:MM`, local time. See [calling window](#calling-window). |
| `max_concurrent` | integer | 1–50. How many of this campaign's calls may be live at once. |
| `retry_count` | integer | 0–5. Retries **after** the first attempt. `0` means one attempt per contact. |
| `re_attempt_period_secs` | integer | 60–86400. How long a contact waits before it is eligible again. |
| `max_duration_secs` | integer | 30–1800. Per-call cap. Defaults to the agent's. |
| `budget_paise` | integer \| null | Spend cap in **paise**. `null` = no cap. |
| `spent_paise` | integer | Billed so far, in paise. |
| `webhook_url` | string | Receives both this campaign's `campaign.*` events and every child call's `call.*` events. |
| `counters` | object | Contacts by [status](#contact-statuses). Present on `GET /v2/campaigns/{id}`, omitted from list pages. |
**Campaign money is in paise, call money is in rupees.**
`budget_paise` and `spent_paise` are integer paise (100 paise = ₹1), so a ₹500
cap is `"budget_paise": 50000`. The [call object](/v2/calls#the-call-object) and
the [wallet](/v2/wallet) use rupee fields (`amount_inr`, `balance_inr`). Nothing
mixes the two units inside one object.
## Lifecycle
```mermaid theme={null}
stateDiagram-v2
[*] --> draft: POST /v2/campaigns
draft --> play: POST /start
draft --> stopped: POST /stop
play --> paused: POST /pause
play --> paused: budget gate
paused --> play: POST /resume
paused --> play: POST /start
play --> completed: nothing left to dial
play --> stopped: POST /stop
paused --> stopped: POST /stop
completed --> [*]
stopped --> [*]
```
| Status | Dials? | Meaning |
| :---------- | :----: | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `draft` | ❌ | Created. Accepts contacts and edits. Nothing rings until you start it. |
| `play` | ✅ | Running. It may still be *asleep* — outside the calling window `play` is the correct status, the dialler is simply waiting for the window to open. |
| `paused` | ❌ | Reversible. New calls stop; **calls already in progress are not hung up.** |
| `stopped` | ❌ | Terminal, by you. Live calls are aborted, pending contacts are marked `aborted`. |
| `completed` | ❌ | Terminal, by us. Every contact reached a final state, or the date range ran out. |
Only `draft` and `paused` can be edited or started. `stopped` and `completed`
never dial again — and a campaign in either refuses new contacts with `409`,
rather than accepting rows that would never ring.
### Pause reasons
`pause_reason` distinguishes "the operator stopped this" from "we stopped it for
them". A pause you asked for carries **no reason** (`null`); the two reasons
below are set by the money gate.
| `pause_reason` | Set when | What to do |
| :--------------------- | :-------------------------------------------------------------- | :------------------------------------------------------------ |
| `null` | You called [`POST /pause`](#pause-a-campaign). | Resume when you are ready. |
| `insufficient_balance` | The wallet cannot cover another minute at this campaign's tier. | [Top up](/v2/wallet#top-up), then `POST /start` or `/resume`. |
| `budget_exhausted` | `spent_paise` has reached `budget_paise`. | Raise `budget_paise` with `PATCH`, then resume. |
Being outside the calling window is **not** a pause. The campaign stays `play`
and sleeps until the next opening edge — a durable sleep, so a campaign
scheduled for 10:00 tomorrow costs nothing overnight and survives our restarts.
An offline fleet is not a pause either: it is a `503 fleet_offline` refusal at
[start](#start-a-campaign) time.
## Contact statuses
Every row you upload carries its own status. The first block mirrors the
[call status](/v2/calls#statuses) vocabulary, so a campaign report reads the same
way the call list does; the last three exist only inside campaigns.
| Status | Terminal | Meaning |
| :----------- | :------: | :--------------------------------------------------------------------------------------- |
| `pending` | | Waiting to be dialled — either never attempted, or waiting out `re_attempt_period_secs`. |
| `claimed` | | Picked up by the dialler this tick. |
| `dialing` | | A call has been placed for this contact. |
| `completed` | ✅ | Connected and finished. **Billed.** |
| `voicemail` | ✅ | An answering machine answered. **Billed.** Never retried. |
| `timeout` | ✅ | Hit `max_duration_secs`. **Billed.** Never retried. |
| `no_answer` | ✅ | Rang out. Free. Retried while attempts remain. |
| `busy` | ✅ | Line busy. Free. Retried while attempts remain. |
| `failed` | ✅ | Did not connect, or our pipeline errored. Free. Retried while attempts remain. |
| `exhausted` | ✅ | Retried up to `retry_count + 1` attempts and never connected. |
| `suppressed` | ✅ | The number is on your [do-not-call list](/v2/dnc). Never dialled, never billed. |
| `aborted` | ✅ | The campaign was stopped while this contact was pending or in flight. |
`exhausted` is the one to watch: it is the honest count of "we tried everything
you paid for and never reached this person". A contact that ends `no_answer`
still had attempts left; one that ends `exhausted` did not.
## Calling window
Three things together decide whether the dialler may ring right now, all in the
campaign's own `timezone`:
1. today's local date is between `start_date` and `end_date`, inclusive; **and**
2. the local `HH:MM` falls inside at least one `slots` entry.
```json theme={null}
{
"timezone": "Asia/Kolkata",
"start_date": "2026-08-11",
"end_date": "2026-08-18",
"slots": [
{ "start": "10:00", "end": "13:00" },
{ "start": "16:00", "end": "19:00" }
]
}
```
* **1 to 4 slots.** Fewer is a `400`; five is a `400`.
* **A slot may cross midnight.** `21:00`–`06:00` is open on both sides of it.
Legal, and almost never what you want in India — see
[calling rules](/v2/limits#india-calling-rules).
* **A zero-length slot is a `400`.** `{"start":"10:00","end":"10:00"}` is
rejected rather than read as "all day": guessing wrong there means ringing
somebody at 03:00.
* **Local means local.** Times are stored and evaluated in `timezone`, not UTC,
so `Asia/Kolkata`'s half-hour offset and every daylight-saving boundary are
handled for you. This is the difference between a campaign that respects the
window and one that respects it except twice a year.
When the date range runs out entirely, the campaign finishes as `completed` —
even with contacts still `pending`. A window that never opens again cannot
dial them.
## Retries
| Knob | Range | Effect |
| :----------------------- | :------- | :--------------------------------------------------------------------- |
| `retry_count` | 0–5 | Retries **after** the first attempt. `1` means up to 2 attempts total. |
| `re_attempt_period_secs` | 60–86400 | Minimum wait before a contact becomes eligible again. |
Only outcomes that never reached a human are retried: `no_answer`, `busy` and
`failed`. `completed`, `voicemail` and `timeout` all produced live media — the
call happened, and re-dialling somebody who already spoke to us is worse than
not calling at all.
A retry that lands outside the calling window simply waits: eligibility is a
lower bound, not a schedule.
## Budget and pacing
**`max_concurrent`** (1–50) is this campaign's own ceiling on live calls. It is
independent of — and additionally bounded by — your workspace
[concurrency and queue-depth limits](/v2/limits). Two campaigns at
`max_concurrent: 20` on a workspace provisioned for 5 concurrent calls will
share those 5.
**`budget_paise`** caps the spend of this campaign alone. When `spent_paise`
reaches it, the campaign pauses with `budget_exhausted` — contacts keep their
place, so raising the budget and resuming continues where it stood.
**The wallet** is checked before every dialling tick. If it cannot cover another
minute at the campaign's tier, the campaign pauses with `insufficient_balance`.
[`POST /start`](#start-a-campaign) also does a wallet estimate up front and
tells you how far your balance goes, in `funded_calls`.
Set `budget_paise` on every campaign, even when you trust the list. It is the
one control that bounds a mistake in the *list* — a duplicated CSV, a column
shifted by one — rather than a mistake in the code.
***
## Create a campaign
```http theme={null}
POST /v2/campaigns
```
### Request
| Field | Type | Required | Description |
| :------------------------ | :------ | :------- | :--------------------------------------------------------------------------- |
| `name` | string | yes | 1–120 characters. |
| `agent_id` | string | yes | Must exist in your workspace. |
| `tier` | string | no | `t1` \| `t3`. Defaults to your key's tier. `t5` answers `501`. |
| `timezone` | string | yes | IANA name, e.g. `Asia/Kolkata`. |
| `start_date` / `end_date` | string | yes | `YYYY-MM-DD`, local, inclusive. |
| `slots` | array | yes | 1–4 × `{start, end}` as `HH:MM`. |
| `max_concurrent` | integer | no | 1–50. Default `1`. |
| `retry_count` | integer | no | 0–5. Default `0`. |
| `re_attempt_period_secs` | integer | no | 60–86400. Default `900`. |
| `max_duration_secs` | integer | no | 30–1800. Defaults to the agent's. |
| `budget_paise` | integer | no | Positive paise. Omit for no cap. |
| `webhook_url` | string | no | HTTPS. Gets `campaign.*` **and** `call.*` events. |
| `contacts` | array | no | Up to **1000** rows per request. `{phone, variables?, id?}`. Add more later. |
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order confirmations — Mumbai batch",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"tier": "t3",
"timezone": "Asia/Kolkata",
"start_date": "2026-08-11",
"end_date": "2026-08-18",
"slots": [{ "start": "10:00", "end": "19:00" }],
"max_concurrent": 2,
"retry_count": 1,
"re_attempt_period_secs": 900,
"max_duration_secs": 300,
"budget_paise": 50000,
"webhook_url": "https://example.com/mirai/webhook",
"contacts": [
{ "id": "cust-1001", "phone": "+919876543210",
"variables": { "customer_name": "Rohit Sharma", "order_id": "AC-88213" } },
{ "id": "cust-1002", "phone": "+919876543211",
"variables": { "customer_name": "Anita Desai", "order_id": "AC-88219" } }
]
}'
```
```json title="201 Created" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"name": "Order confirmations — Mumbai batch",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"tier": "t3",
"status": "draft",
"pause_reason": null,
"timezone": "Asia/Kolkata",
"start_date": "2026-08-11",
"end_date": "2026-08-18",
"slots": [{ "start": "10:00", "end": "19:00" }],
"max_concurrent": 2,
"retry_count": 1,
"re_attempt_period_secs": 900,
"max_duration_secs": 300,
"budget_paise": 50000,
"spent_paise": 0,
"webhook_url": "https://example.com/mirai/webhook",
"created_at": "2026-08-10T09:21:44Z",
"updated_at": "2026-08-10T09:21:44Z",
"contacts_accepted": 2,
"contacts_duplicate": 0,
"contacts_rejected": []
}
```
**The response is the campaign object itself**, with three extra top-level keys —
not `{"campaign": …, "contacts": …}`. Read the id at `.id`, and the upload result
at `.contacts_accepted` / `.contacts_duplicate` / `.contacts_rejected`.
### Contacts are reported row by row, not rejected as a batch
A 900-row upload with two bad numbers is **not** an all-or-nothing `400`. The
good rows are stored and the bad ones come back by index:
```json title="201 Created — two rows rejected, one duplicate" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"status": "draft",
"contacts_accepted": 3,
"contacts_duplicate": 1,
"contacts_rejected": [
{ "index": 1, "phone": "9876543211", "reason": "phone must be an E.164 number" },
{ "index": 4, "phone": "+91984500112", "reason": "phone must be an E.164 number" }
]
}
```
| Counter | Meaning |
| :------------------- | :----------------------------------------------------------------------------------------------------------------------- |
| `contacts_accepted` | Rows stored and dialable. |
| `contacts_duplicate` | The phone number was already in this campaign, or repeated inside this batch. Not an error — deduplication is the point. |
| `contacts_rejected` | Rows we could not store, each with the **index in the array you sent** and why. |
Two things *do* fail the whole request: more than 1000 rows
(`400 invalid_request`), and a malformed body. Everything else is a row report.
`variables` follow the same rules as [call variables](/v2/calls#variables) — a
flat string map, 32 keys, 512 characters per value — and fill the same
`{{placeholders}}` in the agent's prompt. `id` is your own key for the row; omit
it and we mint one.
### Errors
| Status | `error.code` | Cause |
| :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `400` | `invalid_request` | Missing `name`/`agent_id`/`timezone`/dates/`slots`, a knob out of range, a bad window, or more than 1000 contacts. |
| `404` | `not_found` | `agent_id` does not exist in your workspace. Checked now, so a typo is not a campaign that fails every contact tomorrow morning. |
| `501` | `tier_unavailable` | `tier` is `t5`. |
| `503` | `campaigns_unavailable` | Campaigns are not enabled for your workspace. Not your request — ask your Mirai contact. |
***
## List campaigns
```http theme={null}
GET /v2/campaigns?limit=&cursor=
```
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/campaigns \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "limit=20"
```
Returns the standard [paginated envelope](/v2/overview#pagination) of campaign
objects, newest first. `counters` is omitted on list pages — fetch one campaign
to get them.
***
## Get a campaign
```http theme={null}
GET /v2/campaigns/{id}
```
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"name": "Order confirmations — Mumbai batch",
"tier": "t3",
"status": "paused",
"pause_reason": "insufficient_balance",
"spent_paise": 49800,
"budget_paise": 50000,
"counters": {
"pending": 612,
"completed": 174,
"no_answer": 9,
"exhausted": 2,
"suppressed": 3
},
"created_at": "2026-08-10T09:21:44Z",
"updated_at": "2026-08-10T11:04:02Z"
}
```
`counters` comes from the contact table, not from the running dialler, so a
finished campaign answers exactly like a live one. That is what makes this
endpoint safe to build a dashboard on.
`404 not_found` for an unknown id and for one in another workspace — the two are
deliberately indistinguishable.
***
## Update a campaign
```http theme={null}
PATCH /v2/campaigns/{id}
```
Editable while `draft` or `paused` only. Anything else is `409 conflict`:
changing the slots, the retry policy or the budget under a live dialler would
mean the campaign's own report describes settings that were never in force for
half of its calls. Pause it, patch it, resume it.
Patchable: `name`, `slots`, `start_date`, `end_date`, `max_concurrent`,
`retry_count`, `re_attempt_period_secs`, `max_duration_secs`, `budget_paise`,
`webhook_url`. Not patchable: `agent_id`, `tier`, `timezone`.
```bash theme={null}
curl -X PATCH https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "budget_paise": 120000, "max_concurrent": 4 }'
```
Returns `200 OK` with the campaign object.
***
## Add contacts
```http theme={null}
POST /v2/campaigns/{id}/contacts
```
Up to **1000 rows per request**. Call it repeatedly for a larger list — a
running campaign accepts new contacts and will pick them up on its next tick,
which is the whole point of keeping the list in a table rather than in the
dialler.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/contacts \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "id": "cust-2001", "phone": "+919812345678",
"variables": { "customer_name": "Vikram Rao", "order_id": "AC-88224" } },
{ "id": "cust-2002", "phone": "+919833221100",
"variables": { "customer_name": "Meera Nair", "order_id": "AC-88231" } }
]
}'
```
```json title="200 OK" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"status": "play",
"contacts_accepted": 2,
"contacts_duplicate": 0,
"contacts_rejected": []
}
```
Same body as create, for the same reason: one shape for the two routes that add
contacts means one parser in your client.
`409 conflict` if the campaign is `stopped` or `completed` — nothing would ever
dial those rows, and accepting them silently is worse than refusing them.
***
## List contacts
```http theme={null}
GET /v2/campaigns/{id}/contacts?status=&limit=&cursor=
```
| Query param | Description |
| :---------- | :------------------------------------------------------------------- |
| `status` | Filter by one [contact status](#contact-statuses), e.g. `exhausted`. |
| `limit` | 1–100, default 20. |
| `cursor` | From `next_cursor`. |
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/contacts \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "status=exhausted" \
--data-urlencode "limit=100"
```
```json title="200 OK" theme={null}
{
"data": [
{
"id": "cust-1002",
"phone": "+919876543211",
"status": "exhausted",
"attempts": 2,
"variables": { "customer_name": "Anita Desai", "order_id": "AC-88219" },
"last_call_id": "call_01K7QB4M8N3P6R2S5T7V9W1YA",
"last_status": "no_answer",
"eligible_at": "2026-08-11T11:19:02Z",
"updated_at": "2026-08-11T11:34:10Z"
}
],
"has_more": false,
"next_cursor": null
}
```
`last_call_id` is an ordinary `call_` id: fetch it from
[`GET /v2/calls/{id}`](/v2/calls#get-a-call) for the full outcome.
***
## Start a campaign
```http theme={null}
POST /v2/campaigns/{id}/start
```
Legal from `draft` and from `paused` — starting a paused campaign is how you
resume one whose dialler is no longer running (after a top-up, say), because
`start` re-runs the fleet and wallet preflight that a bare
[resume](#resume-a-campaign) skips.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/start \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="202 Accepted" theme={null}
{ "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" }
```
`202` means the dialler is running — **not** that a phone is ringing. Outside the
calling window it will sit and wait, correctly, in `play`.
### `funded_calls`: an underfunded campaign still starts
If your wallet cannot cover every remaining contact, the response carries a
warning rather than a refusal:
```json title="202 Accepted — partially funded" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"status": "play",
"funded_calls": 166
}
```
The campaign starts, dials what the balance covers, and then pauses itself with
`pause_reason: "insufficient_balance"`. Top up and start it again to continue.
`funded_calls` is a one-minute-per-call estimate at this campaign's tier — a
floor on how far you get, not a promise.
### Errors
| Status | `error.code` | Cause |
| :----- | :---------------------- | :------------------------------------------------------------------------------------------------------ |
| `409` | `conflict` | The campaign is already `play`, or is `stopped`/`completed`. |
| `429` | `rate_limited` | Your workspace queue is full (500 calls accepted and not yet ended). Honour `Retry-After`. |
| `503` | `fleet_offline` | Speech capacity is offline. `Retry-After: 300`. Nothing is charged and the campaign stays where it was. |
| `503` | `campaigns_unavailable` | Campaigns are not enabled for your workspace. |
***
## Pause a campaign
```http theme={null}
POST /v2/campaigns/{id}/pause
```
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/pause \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="202 Accepted" theme={null}
{ "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "paused" }
```
Takes effect immediately, including while the campaign is asleep waiting for
tomorrow's window. **Calls already live are not hung up** — pause stops new
calls, it does not cut off people mid-conversation. Use
[`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call) if you really need a live
call to end now.
Only a `play` campaign can be paused; anything else is `409 conflict`.
***
## Resume a campaign
```http theme={null}
POST /v2/campaigns/{id}/resume
```
```json title="202 Accepted" theme={null}
{ "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" }
```
`409 conflict` if the campaign is not `paused` — and also if it is paused but
has **no running dialler**, in which case the message tells you to use
[`POST /start`](#start-a-campaign) instead. That is deliberate: a bare resume
would skip the wallet and fleet preflight.
***
## Stop a campaign
```http theme={null}
POST /v2/campaigns/{id}/stop
```
```json title="202 Accepted" theme={null}
{ "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "stopped" }
```
**Terminal and irreversible.** Live calls are hung up, pending contacts are
marked `aborted`, and the campaign will never dial again. Legal from `draft`,
`play` and `paused`.
If you might want to continue later, [pause](#pause-a-campaign) instead.
***
## Campaign report
```http theme={null}
GET /v2/campaigns/{id}/report
GET /v2/campaigns/{id}/report?format=csv
```
### JSON — the aggregate
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"campaign_id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"status": "completed",
"counters": {
"completed": 174,
"voicemail": 12,
"no_answer": 9,
"exhausted": 21,
"suppressed": 3
},
"contacts": 219,
"dialed": 216,
"attempts": 251,
"connected": 186,
"spent_paise": 60300,
"spent_inr": 603,
"budget_paise": 120000
}
```
| Field | Meaning |
| :-------------------------- | :----------------------------------------------------------------------------------------------------- |
| `counters` | Contacts by final status. |
| `contacts` | Rows uploaded. |
| `dialed` | Contacts with at least one attempt. |
| `attempts` | Total dial attempts, retries included. `attempts − dialed` is what retrying cost you. |
| `connected` | `completed` + `voicemail` + `timeout` — the calls that produced live media, and the ones you paid for. |
| `spent_paise` / `spent_inr` | The same number in both units, for convenience. |
`connected / dialed` is your answer rate. `attempts / dialed` is whether
`retry_count` is earning its place.
### CSV — per contact
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "format=csv" \
-o campaign.csv
```
```csv theme={null}
id,phone,status,attempts,last_call_id
cust-1001,+919876543210,completed,1,call_01K7QB4M8N3P6R2S5T7V9W1YA
cust-1002,+919876543211,exhausted,2,call_01K7QB4M8N3P6R2S5T7V9W1YB
cust-1003,+919812345678,suppressed,0,
```
Streamed, not buffered, so it is safe on a six-figure campaign. Join
`last_call_id` against [`GET /v2/calls`](/v2/calls#list-calls) for durations and
costs.
***
## Webhooks
Set `webhook_url` on the campaign and you receive **both** its lifecycle events
and every child call's [`call.*` events](/v2/webhooks#event-types), signed with
the same workspace secret and verified by the same code path.
| Type | Fires when |
| :------------------- | :------------------------------------------------------------------------------------- |
| `campaign.started` | The first `POST /start` takes effect. Once per campaign, never on an internal restart. |
| `campaign.paused` | You paused it, or the money gate did. Read `data.campaign.pause_reason`. |
| `campaign.resumed` | Dialling resumed. |
| `campaign.completed` | Every contact reached a final state, or the date range ran out. |
| `campaign.stopped` | You stopped it. |
```json title="POST https://example.com/mirai/webhook" theme={null}
{
"id": "evt_01K7QC5N9P4R7S3T6V8W2X4YB",
"type": "campaign.paused",
"created_at": "2026-08-11T13:04:02Z",
"data": {
"campaign": {
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"name": "Order confirmations — Mumbai batch",
"status": "paused",
"pause_reason": "insufficient_balance",
"counters": { "pending": 612, "completed": 174, "no_answer": 9 },
"dialed": 216
}
}
}
```
Same envelope as a call event — `{id, type, created_at, data}` — with
`data.campaign` where `data.call` would be. Verification, retries and replay
protection are identical: see [Webhooks](/v2/webhooks#signature-verification).
Alert on `campaign.paused` with a `pause_reason`. That event is the platform
telling you a campaign has stopped spending money for a reason you can fix in
one API call.
***
## A campaign that behaves
1. **Scrub first.** Push known opt-outs to [`POST /v2/dnc`](/v2/dnc) *before*
you upload the list. Suppression is checked at dial time, so late additions
still work — but a number dialled at 10:00 cannot be un-dialled at 10:05.
2. **Keep the window legal.** `09:00`–`21:00` IST is the outer bound for
commercial calls in India, and most enterprise programmes run `10:00`–`19:00`.
See [calling rules](/v2/limits#india-calling-rules).
3. **Budget every campaign.** `budget_paise` is the cheapest insurance against a
bad CSV there is.
4. **Retry once, wait fifteen minutes.** `retry_count: 1`,
`re_attempt_period_secs: 900` is the shape that works for most Indian mobile
lists. Retrying five times in five minutes annoys people and does not connect.
5. **Start small.** Ten contacts, one slot, `max_concurrent: 1`. Read the
transcripts. Then upload the rest into the same campaign.
6. **Watch `exhausted` and `suppressed` in the report.** They are the two
numbers that tell you about your *list* rather than about your agent.
# Do-not-call list
Source: https://docs.miraiminds.co/v2/dnc
Your workspace's own suppression list — numbers campaigns will never dial.
The **do-not-call list** is a per-workspace set of phone numbers that
[campaigns](/v2/campaigns) will not dial. Add a number and every campaign in the
workspace — running, paused or not yet created — skips it.
Base URL `https://sandbox.voice.miraiminds.co`.
**This is your suppression list, not the national registry.**
It suppresses *your* campaigns on *our* platform. It is not TRAI's DND/NCPR
register, we do not scrub against that register for you, and having a number on
this list is not a substitute for the consent and DND obligations that sit with
the business placing the calls. See
[India calling rules](/v2/limits#india-calling-rules).
## How suppression works
Numbers are checked **at dial time**, in the same database statement that hands
contacts to the dialler. Three consequences worth knowing:
* **Late additions work.** A number added while a campaign is running is
suppressed from that moment on, even though it was uploaded in the original
list.
* **Suppression is not deletion.** The contact stays in the campaign with
[`status: "suppressed"`](/v2/campaigns#contact-statuses) and shows up in the
report, so you can prove the number was uploaded and not called.
* **Suppressed contacts are never billed.** No dial, no minute, no charge.
It does **not** apply to [`POST /v2/calls`](/v2/calls#create-a-call). A direct
single call is an explicit instruction to ring one number now; refusing it based
on a list would silently swallow transactional calls — a delivery confirmation
to a customer who opted out of marketing, say. Check the list yourself before
placing single calls if that is the behaviour you want.
## The entry object
```json theme={null}
{
"object": "dnc_entry",
"phone": "+919876543210",
"created_at": "2026-08-10T09:44:12Z"
}
```
***
## Add a number
```http theme={null}
POST /v2/dnc
```
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/dnc \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+919876543210" }'
```
```json title="201 Created" theme={null}
{ "object": "dnc_entry", "phone": "+919876543210", "added": true }
```
Adding a number that is already on the list is **not an error** — you asked for
it to be suppressed and it is suppressed. You get `200 OK` with
`"added": false` instead of `201`:
```json title="200 OK — already on the list" theme={null}
{ "object": "dnc_entry", "phone": "+919876543210", "added": false }
```
Branch on `added` if you are keeping your own count; branch on nothing at all if
you are just making sure. Both answers mean the number will not be dialled.
| Status | `error.code` | Cause |
| :----- | :---------------------- | :---------------------------------------------------------------------- |
| `400` | `invalid_request` | `phone` is not E.164 — `+919876543210`, not `9876543210`. |
| `503` | `campaigns_unavailable` | Campaigns (and with them this list) are not enabled for your workspace. |
***
## List numbers
```http theme={null}
GET /v2/dnc?limit=&cursor=
```
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/dnc \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "limit=100"
```
```json title="200 OK" theme={null}
{
"data": [
{ "object": "dnc_entry", "phone": "+919876543210", "created_at": "2026-08-10T09:44:12Z" },
{ "object": "dnc_entry", "phone": "+919812345678", "created_at": "2026-08-09T16:02:55Z" }
],
"has_more": false,
"next_cursor": null
}
```
Standard [pagination](/v2/overview#pagination): `limit` 1–100, default 20, and
`cursor` from `next_cursor`.
***
## Remove a number
```http theme={null}
DELETE /v2/dnc/{phone}
```
The number goes in the path, URL-encoded — `+` becomes `%2B`.
```bash theme={null}
curl -X DELETE "https://sandbox.voice.miraiminds.co/v2/dnc/%2B919876543210" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
`204 No Content` on success. `404 not_found` if the number was not on the list.
**Removing a number un-suppresses it immediately.** Contacts already marked
`suppressed` in a campaign stay marked — that status is a record of a decision
already taken — but any future campaign, and any contact not yet claimed, will
dial it. Keep an audit trail of who removed what: an opt-out you reversed is the
complaint you will be asked about.
***
## Keeping it in sync
The list is a set of numbers, and the operations are idempotent, so the simplest
correct integration is also the right one: on every opt-out event in your own
system, `POST` the number. No read-modify-write, no diffing.
```python theme={null}
def on_opt_out(phone: str) -> None:
r = httpx.post(f"{API}/v2/dnc", headers=auth, json={"phone": phone}, timeout=30)
if r.status_code not in (200, 201):
raise RuntimeError(r.json()["error"]["message"])
# 201 -> newly added, 200 -> already there. Both mean suppressed.
```
Three habits worth having:
1. **Suppress on the call, not after the campaign.** If your agent hears "stop
calling me", write it to the list from the webhook handler that same minute.
2. **Suppress before you upload.** Scrubbing the list you upload *and* keeping
the DNC list current are belt and braces; the DNC list is the one that
protects you from the campaign you forgot to scrub.
3. **Never remove in bulk.** Removals should be individual, deliberate, and
logged with a reason.
# Errors
Source: https://docs.miraiminds.co/v2/errors
One error envelope, every status code and error code, and what to do about each.
Every v2 error — validation, auth, billing, rate limiting — uses one envelope.
There is no second shape to special-case.
```json theme={null}
{
"error": {
"code": "invalid_request",
"message": "to must be an E.164 phone number, got '9876543210'"
}
}
```
| Field | Description |
| :-------- | :------------------------------------------------------------------------------------------------------------------------- |
| `code` | Stable, `snake_case`, machine-readable. **Branch on this.** |
| `message` | Human-readable, written for a developer reading a log. Not stable — never parse it, never show it to an end user verbatim. |
## Status codes
| Status | Meaning | Retry? |
| :----- | :--------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------- |
| `400` | The request is malformed or a field is invalid. | No — fix the request. |
| `401` | Missing, malformed or unknown key. | No. |
| `402` | Wallet cannot cover the call. | After topping up. |
| `403` | The key is valid but **revoked**. That is the only thing that returns `403`. | No. |
| `404` | No such resource in your workspace — including one that exists in someone else's. | No. |
| `409` | State conflict — a request with the same `Idempotency-Key` still in flight, or an operation the current state forbids. | No. |
| `429` | Request-rate limit. Concurrency does not `429`. | Yes, with backoff. |
| `500` | Our fault. | Yes, with backoff. |
| `501` | The feature exists in the contract but is not live yet. | No. |
| `503` | Temporarily unavailable (deploy, capacity) — or a feature that is not provisioned for your workspace. | Yes for `fleet_offline`; no for `campaigns_unavailable`, which needs provisioning, not patience. |
## Error codes
| `error.code` | Status | When | Fix |
| :---------------------- | :----- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | `400` | Missing required field, wrong type, `to` not E.164, `max_duration_secs` out of range, unknown `voice_id`, malformed JSON. | Read `message`; it names the field. |
| `unauthorized` | `401` | No `Authorization` header, not `Bearer `-prefixed, or the key is unknown. | Check the header. A trailing newline from `cat`-ing a key file is the usual culprit. |
| `forbidden` | `403` | The key was revoked. Nothing else returns `403`. | Get a new key. |
| `insufficient_balance` | `402` | Pre-dial wallet gate. No call was placed and nothing was charged. The same condition inside a running [campaign](/v2/campaigns) is not an error — the campaign pauses with `pause_reason: "insufficient_balance"`. | [Top up](/v2/wallet#top-up) and retry with the same `Idempotency-Key`. |
| `not_found` | `404` | Unknown `agt_`/`call_`/`cmp_` ID, a soft-deleted agent, a number not on the [do-not-call list](/v2/dnc), a transcript or recording that does not exist, or an ID that belongs to another workspace. | Check the ID. For artifacts, check `transcript_available` / `recording_available` on the call. |
| `duplicate_call` | `409` | A request with this `Idempotency-Key` is still being processed; when the original completes, the same key replays its stored response. | Not an error if you are retrying — no second call was placed. |
| `conflict` | `409` | The operation is illegal in the current state: aborting a call that has already ended, editing or starting a campaign that is `play`, adding contacts to one that is `stopped`/`completed`, or resuming one with no running dialler. | Read the resource's `status` first. A paused campaign with no dialler is resumed with [`POST /start`](/v2/campaigns#start-a-campaign). |
| `rate_limited` | `429` | Request rate exceeded, **or** your queue is full (500 calls accepted and not yet ended). Being at your *concurrency* ceiling does **not** produce this: those calls queue. `error.message` says which limit you hit. | Honour `Retry-After` and back off. See [Limits](/v2/limits). |
| `at_capacity` | `429` | Every line on the deployment is busy — a fleet-wide ceiling, not your workspace's. Nothing was charged. | Honour `Retry-After` (30s) and retry. |
| `tier_unavailable` | `501` | `tier` was `t5`. It is announced but not live. | Use `t3` (the default) or `t1`. See [tiers](/general/tiers). |
| `campaigns_unavailable` | `503` | Campaigns are not enabled for your workspace. Every `/v2/campaigns` and `/v2/dnc` route answers this; everything else keeps working. | Not a mistake in your request, and retrying will not clear it — ask your Mirai contact to enable them. |
| `fleet_offline` | `503` | Speech capacity is offline, so a [campaign start](/v2/campaigns#start-a-campaign) was refused. Nothing was charged and the campaign stayed where it was. | Honour `Retry-After` (300s) and start it again. |
| `internal` | `500` | Our bug. Already alarming on our side. | Retry with backoff; if it persists, send us the `call_id`. |
`error.code` is an **open enum**. New codes are added without a version bump.
Always have a default branch keyed on the HTTP status.
**Foreign IDs are `404`, never `403`**
An `agt_`, `call_` or `cmp_` ID that exists in another workspace is answered exactly as
an ID that never existed: `404 not_found`. Anything else would be an enumeration
oracle — a way to probe which IDs are real on the platform by watching the
status code change. `403` is reserved for one situation only: your own key has
been revoked.
## Handling errors
The pattern that covers everything: branch on status, then on code.
```python theme={null}
import random
import time
import httpx
class MiraiError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.message = status, code, message
RETRYABLE = {429, 500, 502, 503, 504}
def request(client: httpx.Client, method: str, path: str, **kw) -> dict:
for attempt in range(5):
r = client.request(method, path, **kw)
if r.is_success:
return r.json() if r.content else {}
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
err = body.get("error", {})
if r.status_code in RETRYABLE and attempt < 4:
# honour Retry-After when we send it, else exponential + jitter
wait = float(r.headers.get("Retry-After", 2**attempt))
time.sleep(wait + random.random())
continue
raise MiraiError(r.status_code, err.get("code", "unknown"), err.get("message", r.text))
raise MiraiError(r.status_code, "retries_exhausted", "gave up after 5 attempts")
```
At the call site, only two codes deserve their own branch:
```python theme={null}
try:
call = request(client, "POST", "/v2/calls", json=payload,
headers={"Idempotency-Key": key})
except MiraiError as e:
if e.code == "insufficient_balance":
pause_campaign(); alert_ops(e.message)
elif e.code == "duplicate_call":
pass # same key still in flight — no second call
else:
raise
```
```javascript theme={null}
export class MiraiError extends Error {
constructor(status, code, message) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
}
}
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function request(path, init = {}, attempt = 0) {
const res = await fetch(`https://sandbox.voice.miraiminds.co${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.MIRAI_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
if (res.ok) return res.status === 204 ? null : res.json();
const body = await res.json().catch(() => ({}));
const { code = "unknown", message = res.statusText } = body.error ?? {};
if (RETRYABLE.has(res.status) && attempt < 4) {
const retryAfter = Number(res.headers.get("Retry-After"));
const wait = (Number.isFinite(retryAfter) ? retryAfter : 2 ** attempt) * 1000;
await sleep(wait + Math.random() * 1000);
return request(path, init, attempt + 1);
}
throw new MiraiError(res.status, code, message);
}
```
At the call site:
```javascript theme={null}
try {
const call = await request("/v2/calls", {
method: "POST",
headers: { "Idempotency-Key": key },
body: JSON.stringify(payload),
});
} catch (e) {
if (e.code === "insufficient_balance") {
await pauseCampaign();
await alertOps(e.message);
} else if (e.code !== "duplicate_call") {
throw e; // duplicate_call means the same key is still in flight
}
}
```
### Retry rules
* **Retry** `429`, `500`, `502`, `503`, `504` and network errors. Exponential
backoff with jitter; honour `Retry-After` when present.
* **Never retry** `400`, `401`, `403`, `404`, `501` — the same request will fail
the same way. `503 campaigns_unavailable` belongs in this group too: it is a
provisioning fact, not a wobble.
* **`402` is retryable only after a top-up**, not on a timer.
* **Always send `Idempotency-Key` on `POST /v2/calls`.** A timeout tells you
nothing about whether the phone rang; without the key, your retry is a second
call to a real person.
### What a `409 duplicate_call` actually means
It means a request with this `Idempotency-Key` is **still being processed**. It
is the narrow race, not the normal retry path: we cannot replay a response that
has not been produced yet, and we must not place a second call, so we say so.
When the original request completes, the same key replays its stored response —
so a retry a moment later returns the original `202` and the original `call_id`.
Either way **no second call is placed**. That is usually the *success* path of a
retry, not a failure: log it and move on, do not surface it as an error to your
users.
# FAQ
Source: https://docs.miraiminds.co/v2/faq
Short answers to the questions developers actually ask about the Mirai Voice API.
## Getting started
**How do I get a key?**
In the [console](https://sandbox.voice.miraiminds.co), under **Developers →
Create key**. You get a secret key, a webhook secret and a starting wallet
balance immediately, and you can rotate or revoke from the same page. If you do
not have console access yet, [talk to us](https://cal.com/srikrishna-pothel-y5vpeq).
**Is there a sandbox or test key?**
Not yet. There is no `sk_test_` and no simulated call — every call is a real
call and costs real money. Test against your own phone with a small wallet
balance. The [webhook test vector](/v2/webhooks#test-vector) lets you build and
verify your handler without placing a call at all.
**Is there an SDK?**
Yes, for Python:
```bash theme={null}
pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice
```
It carries the rules you would otherwise have to re-derive: an
`Idempotency-Key` on every call create so a retry never dials twice,
`Retry-After`-aware backoff, cursor iterators, and
`mirai.webhooks.verify()` over the raw body. Sync and async, both credential
generations. The copy-pasteable cURL, Python and Node on every page here work
just as well — the API is small on purpose.
## Calls
**Why did `POST /v2/calls` return `202` and not `200`?**
Because the phone has not rung yet. `202` means queued. The outcome arrives by
[webhook](/v2/webhooks), or from [`GET /v2/calls/{id}`](/v2/calls#get-a-call).
**How long until the phone rings?**
Normally a couple of seconds from `202`. If you are at your concurrency ceiling
the call sits in `queued` until a slot frees — it is queued, not rejected, so
being over the ceiling never returns `429`.
**Can I do inbound calls?**
Not yet — outbound only.
**Can I transfer a call to a human?**
Yes — warm transfer to a human is part of every tier, see the
[feature matrix](/general/tiers#feature-matrix). The transfer rollout is
completing now; see the [roadmap](/general/roadmap) for status and ask your
account contact to enable it on your workspace.
**Can I change the prompt or variables mid-call?**
No. `variables` are static per call — create the call with what you want it to
say.
**Can I get the recording or transcript?**
Not through the v2 API. Recordings are retained 30 days; ask your Mirai contact if you need
access. If your use case requires that we do not record, tell us up front.
**What happens if the customer does not answer?**
`status: no_answer`, a `call.failed` webhook, and no charge. v2 does not retry
for you — retry from your own dialler if you want to.
One caveat: when the carrier gives us no verdict at all, an unanswered call is
reported as `failed` with `ended_reason: "no-media"` instead. Both are free, and
both mean "did not connect" — so treat `no_answer`, `busy` and `failed` +
`no-media` as one bucket in retry logic. See
[ended reasons](/v2/calls#ended-reasons).
**How do I stop a call I just started?**
[`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call). It works while the call is
queued, while it is ringing, and while it is live — a live call is hung up
mid-conversation. Only a call that has already ended returns `409`. Aborted calls
are never billed.
**Which languages work?**
Hindi and Indian English are what the stack is tuned for, including
Hindi-English code-switching, which is how most real calls actually sound. Write
the prompt in the language you want spoken — Devanagari for Hindi. Other
languages: ask before you build on them.
**Does the agent sound like a human?**
It sounds like a good phone voice. Callers usually work out it is a machine
within a turn or two, which is why [telling them up front](/v2/limits#disclosure-that-it-is-an-ai)
costs you nothing and drops early hang-ups.
## Billing
**What does a call cost?**
₹1 per minute on `t1`, billed in whole minutes rounded up, minimum one minute.
A 96-second call costs ₹2.00; an 8-second call costs ₹1.00. See
[Billing & tiers](/general/tiers).
**Am I charged for calls that do not connect?**
No. `no_answer`, `busy`, `failed` and `aborted` are free. `completed`,
`voicemail` and `timeout` are billed.
**Am I charged for a voicemail?**
Yes — media went live and audio was generated. An 18-second voicemail is one
billed minute: ₹1.00 at `t1`.
**What happens when I run out of credit?**
`POST /v2/calls` returns [`402 insufficient_balance`](/v2/wallet#402-insufficient-balance)
and nothing is dialled. Calls already in flight finish normally.
**How do I top up?**
Ask your Mirai contact — self-serve top-up is not built yet, and is on the
[roadmap](/general/roadmap). Credits land as a `credit` row in
[`GET /v2/wallet/transactions`](/v2/wallet#list-transactions) and take effect
immediately.
**When are `t3` and `t5` available?**
They are documented but not live. Requesting them returns
[`501 tier_unavailable`](/v2/errors#error-codes). Current targets: `t3` in
August 2026, `t5` in Q4 2026 — see the [roadmap](/general/roadmap) and the
[feature matrix](/general/tiers#feature-matrix) for what they will include.
## Webhooks
**My signature never verifies. Why?**
Almost always because you signed a re-serialised body. Sign the **raw bytes**.
See [raw body](/v2/webhooks#raw-body). Second most common: the signed string is
`"."` — the timestamp and the dot are part of it, not just the body.
**Do you retry failed deliveries?**
Yes — six attempts with exponential backoff over roughly 36 minutes, then
dead-letter. Any `2xx` stops it. See [retries](/v2/webhooks#retries).
**Can events arrive out of order or twice?**
Yes to both. Dedupe on `event.id` and order your state machine by
`data.call.status`, not by arrival time.
**Can I have more than one webhook URL?**
One per call, set as `webhook_url` at create time. Fan out on your side.
**What if my endpoint is down for an hour?**
The event lands in the dead-letter queue after \~36 minutes of retries. Ops can
replay it. You can also reconcile from
[`GET /v2/calls`](/v2/calls#list-calls) — that is what the list endpoint is
for.
## Limits and compliance
**How many concurrent calls can I run?**
Pilot default is 5. It is a provisioning setting, not a code change — ask for
more. See [Limits](/v2/limits).
**Do you enforce the 9am–9pm calling window?**
No. Your scheduler does. We dial when you tell us to dial. See
[India calling rules](/v2/limits#india-calling-rules).
**Do you scrub DND numbers?**
No. That is your list and your obligation.
**Do I have to tell people it is an AI?**
We strongly recommend it, and it belongs in `first_message`. Your legal
obligations are your own to confirm.
## Operations
**What is your uptime target?**
The API and the call path are operated as a pilot service. Ask your account
contact for the current SLA before you put a revenue-critical flow behind it.
**Where does my data go?**
Media (audio, ASR, TTS, the model) runs in India. The control plane — workflow
orchestration and the API — runs in Europe, off the audio path, so it adds no
latency to what the caller hears.
**Something is broken. What do you need from me?**
The `call_id` (or `evt_id`), the UTC timestamp, and what you expected. That is
enough to pull the whole workflow history.
# Limits & compliance
Source: https://docs.miraiminds.co/v2/limits
Rate limits, concurrency, payload ceilings, and the India calling-window and DNC rules you are responsible for.
Two kinds of limit apply to you: ours (technical, we enforce them) and India's
(regulatory, **you** are responsible for them).
## Rate limits
Limits are per **secret key**.
| Limit | Pilot default | Applies to | Over the limit |
| :-------------------- | :--------------------------- | :---------------------------------- | :------------------ |
| Request rate | 10 requests/second, burst 20 | all `/v2/*` endpoints | `429 rate_limited` |
| Concurrent live calls | 5 | calls in `dialing` or `in_progress` | the call **queues** |
| Queue depth | 500 | calls accepted and not yet ended | `429 rate_limited` |
**Pilot keys are provisioned individually**
The numbers above are the defaults a new pilot key is issued with. Yours may
differ — confirm your actual ceilings with your account contact before you size
a campaign. Higher concurrency is a provisioning change, not a code change: ask.
### Request rate
Exceeding the **request rate** returns `429` with `error.code: rate_limited` and
a `Retry-After` header in seconds. It is about how fast you call the API, not
about how many phones are ringing.
```json title="429 Too Many Requests" theme={null}
{
"error": {
"code": "rate_limited",
"message": "rate limit exceeded, retry in 0.4s"
}
}
```
### Concurrency
Concurrency does **not** produce a `429`. A `POST /v2/calls` beyond your
concurrent-call ceiling is still accepted with `202` and sits in `queued` until a
slot frees, then dials — nothing is rejected and nothing is lost. `429` is
request-rate only.
That makes the ceiling a **pacer**, not a gate. Two consequences:
* A queued call's phone rings later than you asked. If a call is only useful
inside a window, check `status` before you assume it went out — or do not
submit it until you have the capacity.
* Submitting a burst does not fail; it builds a queue that drains at your
concurrency.
### Queue depth
The queue itself **is** bounded. Once you have **500 calls accepted and not yet
ended**, further `POST /v2/calls` return `429 rate_limited` with a `Retry-After`
header until calls drain. The message names the ceiling, so you can tell it apart
from a request-rate `429`:
```json title="429 Too Many Requests" theme={null}
{
"error": {
"code": "rate_limited",
"message": "queue depth limit reached for this workspace (500 calls accepted and not yet ended, limit 500); wait for calls to drain or ask ops to raise it"
}
}
```
Both limits share the `rate_limited` code on purpose: the correct client
behaviour is identical — honour `Retry-After` and retry. Read `error.message` if
you want to log which one you hit.
The count is calls **accepted and not yet ended** — `queued` plus the handful
actually `dialing` or `in_progress` — not strictly `queued`. With a concurrency
of 5 the difference is at most 5 calls.
Queue depth is provisioned per workspace, like concurrency. If a campaign
genuinely needs to submit more than 500 rows in one batch, ask your Mirai contact before the
campaign, not during it.
Keeping the queue short is still the better shape, because a queue you own is
one you can reorder, cancel and re-prioritise. The simplest correct pattern:
```python theme={null}
# keep N calls in flight; refill as terminal webhooks arrive
in_flight = 0
MAX_IN_FLIGHT = 5 # your provisioned concurrency
def on_terminal_webhook(event):
global in_flight
in_flight -= 1
pump()
def pump():
global in_flight
while in_flight < MAX_IN_FLIGHT and queue:
place_call(queue.pop())
in_flight += 1
```
Do not drive pacing off a fixed `sleep()`. Answer rates vary by hour, and a
fixed delay either wastes capacity or hammers the limit.
## Payload and duration ceilings
| Thing | Limit |
| :------------------ | :-------------------------------- |
| Request body | 1 MB |
| `system_prompt` | 8,000 characters |
| `first_message` | 500 characters |
| `variables` | 32 keys, 512 characters per value |
| `max_duration_secs` | 30–1800, default 300 |
| `webhook_url` | HTTPS only, 2,048 characters |
| Idempotency key | 255 characters, 24-hour window |
## Campaign ceilings
[Campaigns](/v2/campaigns) carry their own bounds, all enforced at the API edge
with `400 invalid_request`.
| Thing | Limit | Notes |
| :----------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------ |
| `contacts` per request | **1000 rows** | Create and `POST /contacts` both. Call it repeatedly for a longer list — a running campaign accepts new rows. |
| `slots` | 1–4 per campaign | `HH:MM` local. A zero-length slot is rejected, not read as "all day". |
| `max_concurrent` | 1–50 | Bounded again by your workspace concurrency — two campaigns share one ceiling. |
| `retry_count` | 0–5 | Retries after the first attempt. |
| `re_attempt_period_secs` | 60–86,400 | 1 minute to 24 hours. |
| `max_duration_secs` | 30–1800 | Defaults to the agent's. |
| `budget_paise` | positive integer | Paise, not rupees. Omit for no cap. |
| `name` | 120 characters | |
| Contact `variables` | 32 keys, 512 chars per value | Same rule as call variables. |
Bad contact rows do **not** fail the batch: they come back by index in
`contacts_rejected`. Only exceeding 1000 rows fails the whole request.
Long prompts are a latency problem before they are a limit problem. The
transactional tier is tuned for prompts in the hundreds of tokens, not
thousands.
## Timeouts
| Stage | Behaviour |
| :--------------- | :--------------------------------------------------------------------------------------------------------------------------- |
| API request | Respond within 30 seconds or treat as failed and retry with the same `Idempotency-Key`. |
| Ring / answer | \~60 seconds. No media by then → `no_answer`. |
| Call duration | Ends at `max_duration_secs` with `status: timeout`. |
| Webhook delivery | Your endpoint should answer well under a second. Slow responses are treated as failures and [retried](/v2/webhooks#retries). |
## Data retention
| Data | Retained |
| :----------------------------------- | :---------------------------- |
| Call record (status, duration, cost) | 90 days |
| Call recordings and transcripts | 90 days, then deleted |
| Wallet ledger | 12 months |
| Campaigns and their contact rows | For the life of the workspace |
Recordings and transcripts are available through the API —
[`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) and
[`/transcript`](/v2/calls#get-the-transcript). Pull anything you need to keep
beyond 90 days into your own system, and tell us up front if your use case
requires that we **not** record at all.
***
## India calling rules
You are the sender. Under Indian telecom regulation the obligations for
commercial voice calls sit with the business placing them, not with the platform
carrying them. Reading this page is not legal advice, and it is not a
substitute for your own compliance review.
### Calling window
Commercial and promotional voice calls are restricted to daytime hours. The
window applied in practice is **09:00–21:00 IST**, and several enterprise
programmes tighten it further to 10:00–19:00.
**On a [campaign](/v2/campaigns), the platform enforces the window you declare.**
`timezone`, `start_date`/`end_date` and 1–4 `slots` are evaluated in local time
on every dialling tick: outside the window the campaign sleeps in `play` and
rings nobody. Declaring `10:00`–`19:00 Asia/Kolkata` is enough — you do not need
your own scheduler in front of it.
```json theme={null}
{
"timezone": "Asia/Kolkata",
"slots": [{ "start": "10:00", "end": "19:00" }]
}
```
**On a single [`POST /v2/calls`](/v2/calls#create-a-call) there is no window.**
A direct call is an explicit instruction to ring one number now, and it will
dial at 02:00 IST if you ask it to. Gate those in your scheduler:
```python theme={null}
from datetime import datetime
from zoneinfo import ZoneInfo
IST = ZoneInfo("Asia/Kolkata")
def within_calling_window(now=None) -> bool:
now = now or datetime.now(IST)
return 9 <= now.hour < 21
```
### Consent and DNC/DND
* **Get consent before you call.** Explicit, recorded, and revocable. Keep the
record — it is what you produce when a complaint arrives.
* **Push your opt-outs to [`POST /v2/dnc`](/v2/dnc).** That list is enforced by
us: every campaign checks it at dial time, so a number added mid-campaign is
suppressed from that moment, marked `suppressed` in the report, and never
billed. It does not apply to single `POST /v2/calls` — those are explicit.
* **Scrub against the DND registry** (TRAI's Do Not Disturb / NCPR list) as
well. We do **not** scrub against the national register for you, and our
suppression list is not a substitute for it. A number can register between two
campaigns.
* **Honour opt-outs immediately.** If a customer says "stop calling me" during
a call, that is an opt-out — capture it and suppress the number, on every
channel it applies to.
* **Transactional vs promotional matters.** A delivery confirmation to an
existing customer is treated differently from a marketing pitch to a cold
list. Classify each campaign, and do not let a transactional pretext carry a
promotional payload.
* **Register as required.** Commercial communication under TCCCPR 2018 runs
through registered entities, headers and consent templates. Confirm your
registration status with your telecom provider or counsel.
### Disclosure that it is an AI
Tell people. Put it in the agent's `first_message` or its opening turn:
```json theme={null}
{
"first_message": "नमस्ते {{customer_name}}, मैं Acme की AI असिस्टेंट Priya बोल रही हूँ।"
}
```
This is the direction regulation is moving in worldwide, it costs you one
clause, and in practice it *reduces* early hang-ups — callers who feel misled
hang up harder than callers who were told.
### Recording
If you record calls, say so in the opening turn and keep the recordings under
the same retention and access controls as the rest of your customer data.
### What we do enforce
| Rule | Enforced by us |
| :--------------------------------------- | :------------------------------------------------------------- |
| E.164 destination format | ✅ `400 invalid_request` |
| Wallet balance before dialling | ✅ `402 insufficient_balance` |
| Request rate | ✅ `429 rate_limited` |
| Concurrency ceiling | ✅ over-cap calls wait in `queued` |
| Queue depth | ✅ `429 rate_limited` past 500 outstanding |
| Maximum call duration | ✅ `status: timeout` |
| Campaign budget cap | ✅ campaign pauses, `pause_reason: budget_exhausted` |
| Calling window — **campaigns** | ✅ `slots` + `timezone`, evaluated in local time every tick |
| Calling window — single `POST /v2/calls` | ❌ your scheduler |
| Your own opt-out list | ✅ [`/v2/dnc`](/v2/dnc), checked at dial time on every campaign |
| National DND / NCPR scrubbing | ❌ your list |
| Consent records | ❌ your records |
| AI disclosure | ❌ your prompt |
Persistent complaint patterns against a workspace will get its keys suspended.
That is not a regulatory mechanism, it is ours — we would rather suspend one
campaign than lose the numbering range for everyone on the platform.
# Migrating from v1
Source: https://docs.miraiminds.co/v2/migration
Endpoint-by-endpoint and field-by-field mapping from the v1 API to v2, and what has no v2 equivalent yet.
**You do not have to migrate.** [v1](/v1/overview) is stable, hosted separately at
`api.voice-agents.miraiminds.co`, and is not going away. Nothing in your
integration changes because v2 exists.
Migrate when you want one of these:
* `GET` on a call — v1 has no way to read a call's status, only webhooks.
* Wallet balance and ledger over the API.
* One auth header instead of three.
* Idempotent call creation.
* The published tier rate card.
* **[Campaigns](/v2/campaigns)** — a server-side dialler with calling windows,
retries, budgets, do-not-call suppression and a per-contact report.
Stay on v1 if you need: inbound calls, phone-number purchase, knowledge
base/RAG, API tools (function calling), web calls, or post-call analysis.
**v2 has none of these yet.** The two APIs can address the same workspace side
by side — migrate calls first, keep the rest on v1.
## Hosts
| | v1 | v2 |
| :---------- | :--------------------------------------- | :------------------------------------ |
| Base URL | `https://api.voice-agents.miraiminds.co` | `https://sandbox.voice.miraiminds.co` |
| Path prefix | mixed `/v1/…` and `/v2/…` | `/v2/…` throughout |
**The v1 host has `/v2/` paths on it**
v1's call endpoints are literally named `POST /v2/call/initiate`. That `/v2/` is
a path segment inside the **v1 product**, not this API. The distinguishing thing
is the **host**, not the prefix.
## Authentication
| v1 | v2 |
| :------------------------------------ | :-------------------------------- |
| `x-public-key: pk_…` | — |
| `x-private-key: sk_…` | `Authorization: Bearer sk_live_…` |
| `workspace: 6690a1b2c3d4e5f600000002` | — (the key is workspace-scoped) |
Three headers become one. The workspace is implied by the key, so there is no
way to accidentally address the wrong workspace with the right credentials.
## Endpoints
| v1 | v2 | Notes |
| :------------------------------------------------------------ | :---------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ |
| `POST /v2/workspace/onboard/custom` | — | Your workspace is provisioned with your key. |
| `POST /v1/admin/assistant/create` | [`POST /v2/agents`](/v2/agents#create-an-agent) | "Assistant" is now "agent". |
| `GET /v1/admin/assistant/get/{id}` | [`GET /v2/agents/{id}`](/v2/agents#get-an-agent) | |
| `GET /v1/admin/assistant/list` | [`GET /v2/agents`](/v2/agents#list-agents) | Now cursor-paginated. |
| `PUT /v1/admin/assistant/update/{id}` | [`PATCH /v2/agents/{id}`](/v2/agents#update-an-agent) | `PATCH`, and partial. |
| — | [`DELETE /v2/agents/{id}`](/v2/agents#delete-an-agent) | New. Soft delete. |
| `POST /v2/call/initiate` | [`POST /v2/calls`](/v2/calls#create-a-call) | `200` becomes `202`. |
| — | [`GET /v2/calls/{id}`](/v2/calls#get-a-call) | **New.** v1 had no call read. |
| — | [`GET /v2/calls`](/v2/calls#list-calls) | **New.** |
| `POST /v2/call/abort` with `{callId}` in body | [`POST /v2/calls/{id}/abort`](/v2/calls#abort-a-call) | ID moves into the path. |
| `PUT /v2/call/{callId}` (update payload) | — | No equivalent. Set everything at create time. |
| `POST /v2/call/web` | — | Web calls are v1-only. |
| `GET /v1/number-pool/*` | — | [v1 Telephony](/v1/telephony). |
| `/v1/admin/tool/api` (all methods) | — | [v1 Tools](/v1/tools). |
| `/v1/knowledge-base/*` | — | [v1 Knowledge Base](/v1/knowledge-base). |
| [`GET /v1/admin/voice-gallery`](/v1/assistants#voice-gallery) | — | Still v1. v2 `voice_id` values come from this same catalogue — see [Voices](/v2/voices). |
| — | [`GET /v2/calls/{id}/transcript`](/v2/calls#get-the-transcript) | **New.** Turn-by-turn, as spoken. |
| — | [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) | **New.** `302` to a 15-minute signed URL. v1's `recordingUrl` has a v2 home at last. |
| `callSettings.slots` + your own dialler | [`POST /v2/campaigns`](/v2/campaigns#create-a-campaign) and the rest of `/v2/campaigns/*` | **New.** The calling window, retries and pacing that lived in v1's assistant config are now a first-class resource. |
| — | [`POST /v2/dnc`](/v2/dnc#add-a-number) | **New.** Workspace suppression list, enforced at dial time. |
| — | [`GET /v2/wallet`](/v2/wallet#get-balance) | **New.** |
| — | [`GET /v2/wallet/transactions`](/v2/wallet#list-transactions) | **New.** |
## Fields
### Creating an agent / assistant
| v1 | v2 |
| :----------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `name` |
| `agent.systemPrompt` | `system_prompt` |
| — (first line lived in the prompt) | `first_message` |
| `agent.identity.voice` | `voice.voice_id` |
| `icpContext.language` (`"english"`) | `language` (BCP-47, `"en-IN"`) |
| `callSettings.maxCallDuration` | `max_duration_secs` |
| `variant.type` / `variant.config` | — (no variants; one shape) |
| `variant.config.inputSchema` | — (declare nothing; just pass `variables`) |
| `agent.tools` | — (v1 only) |
| `telephony.inbound` / `telephony.outbound` | — (managed) |
| `callSettings.slots` (calling window) | `slots` + `timezone` on a [campaign](/v2/campaigns#calling-window) (or [your scheduler](/v2/limits#calling-window) for single calls) |
| `callSettings.concurrentCallCount` | `max_concurrent` on a [campaign](/v2/campaigns); the workspace ceiling is provisioned per key, see [Limits](/v2/limits) |
| `callSettings.retryProtocol` | `retry_count` + `re_attempt_period_secs` on a [campaign](/v2/campaigns#retries) |
| `analysisPlan` | — (`t3`/`t5`, [not live](/general/tiers)) |
### Placing a call
| v1 | v2 |
| :-------------------------------- | :------------------------------------------------------------------------ |
| `phoneNumber` | `to` |
| `assistant` | `agent_id` |
| `callbackUrl` | `webhook_url` |
| `payload` (variant-shaped object) | `variables` (flat string map) |
| `metadata` (echoed in webhooks) | — no equivalent; put correlation keys in `variables` or key off `call.id` |
| `priority` | — (use `tier`) |
| — | `max_duration_secs` per-call override |
| — | `tier` |
| — | `Idempotency-Key` header |
```diff title="v1 → v2 call request" theme={null}
-POST https://api.voice-agents.miraiminds.co/v2/call/initiate
-x-public-key: pk_1234…
-x-private-key: sk_abcd…
-workspace: 6690a1b2c3d4e5f600000002
+POST https://sandbox.voice.miraiminds.co/v2/calls
+Authorization: Bearer sk_live_YOUR_API_KEY
+Idempotency-Key: order-8842-confirm-1
{
- "phoneNumber": "+919876543210",
- "assistant": "68c128a658cd7d0668bce78d",
- "callbackUrl": "https://example.com/mirai/webhook",
- "payload": { "customerName": "Rahul", "orderId": "8842" }
+ "to": "+919876543210",
+ "agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
+ "webhook_url": "https://example.com/mirai/webhook",
+ "variables": { "customer_name": "Rahul", "order_id": "8842" }
}
```
```diff title="v1 → v2 response" theme={null}
-200 OK
-{ "callId": "68f0…", "status": "initiate" }
+202 Accepted
+{ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" }
```
## Statuses
v1's status vocabulary is hyphenated and mixes phases with outcomes. v2 uses
snake\_case and separates `status` from `ended_reason`.
| v1 status | v2 `status` |
| :---------------------------- | :--------------------------------------------------------------------------------------------------------------- |
| `initiate` | `queued`, then `dialing` |
| `in-progress` | `in_progress` |
| `ended`, `completed` | `completed` |
| `no-answer` | `no_answer` |
| `busy` | `busy` |
| `failed`, `validation-failed` | `failed` (a bad `to` is a `400` at create time in v2) |
| `timeout` | `timeout` |
| `aborted` | `aborted` |
| `skip` | contact status [`suppressed`](/v2/campaigns#contact-statuses) inside a campaign; nothing for single calls |
| `rescheduled` | contact status `pending` again, until [`retry_count`](/v2/campaigns#retries) runs out and it becomes `exhausted` |
| — | `voicemail` (**new**) |
## Webhooks
| | v1 | v2 |
| :---------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------ |
| Signature header | `x-signature` (bare hex) + `x-public-key` | `X-Mirai-Signature: t=…,v1=…` |
| Signed material | raw body | `"."` |
| Replay protection | none | 5-minute timestamp window |
| Secret | organization private key | per-workspace `whsec_…` |
| Event ID | none | `id: evt_`, dedupe on it |
| Envelope | `{ event: { type, data } }` | `{ id, type, created_at, data }` |
| Event count | 15 types incl. `call.initiate`, `call.ended`, `end-of-call`, `action` | 6 call types + 5 [`campaign.*`](/v2/campaigns#webhooks) types |
| Retries | — | 1s / 10s / 60s / 5m / 30m, then DLQ |
```diff title="v1 → v2 webhook body" theme={null}
{
- "metadata": { "orderId": "8842" },
- "event": {
- "type": "call.completed",
- "data": {
- "call": {
- "id": "68f0…",
- "status": "completed",
- "durationSeconds": 96,
- "recordingUrl": "https://…"
- },
- "credits": { "used": 1.5, "available": 98.5 }
- }
- }
+ "id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB",
+ "type": "call.completed",
+ "created_at": "2026-07-26T09:16:38Z",
+ "data": {
+ "call": {
+ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA",
+ "status": "completed",
+ "tier": "t3",
+ "ended_reason": "assistant-ended-call",
+ "duration_secs": 96,
+ "cost_inr": 6
+ }
+ }
}
```
Note the differences that will bite:
* **`metadata` is gone.** v1 echoed an arbitrary object back on every event. In
v2, correlate on `data.call.id` — store the `call_id` we return from
`POST /v2/calls` against your own record.
* **`recordingUrl` is not on the event.** The audio is a separate, authenticated
request — [`GET /v2/calls/{id}/recording`](/v2/calls#get-the-recording) — which
redirects to a 15-minute signed URL. A permanent link in a webhook body is a
recording anybody who ever saw that body can still fetch.
* **`credits` becomes `cost`** on the call itself, in rupees rather than credit
units, and the running balance moves to [`GET /v2/wallet`](/v2/wallet).
* **No `end-of-call` analysis event yet.** Post-call analytics (AI summary +
QA score on every call) is [part of every tier](/general/tiers#feature-matrix)
and its v2 API surface is rolling out — see the
[roadmap](/general/roadmap). Until it lands, v1's `analysisPlan` is the only
API surface for post-call analysis.
## Errors
| v1 | v2 |
| :--------------------------------------------------------------- | :------------------------------------------------------------------- |
| `{ "code": 400, "message": "…" }` | `{ "error": { "code": "invalid_request", "message": "…" } }` |
| `{ "status_code": 200, "message": "…", "data": {…} }` on success | the resource object directly |
| `code` is a number | `error.code` is a `snake_case` string; the number is the HTTP status |
v1's success responses wrap the payload in `{ status_code, message, data }`. v2
returns the resource itself. See the [error reference](/v2/errors).
## A migration that fits in an afternoon
1. Get a v2 key. Keep your v1 credentials — you will run both for a while.
2. Recreate your assistants as [agents](/v2/agents). Move the opening line out
of `systemPrompt` into `first_message`; rename your `payload` keys to the
`variables` keys your prompt references.
3. Point a **new** webhook route at the v2 [signature scheme](/v2/webhooks#signature-verification).
Do not modify the v1 route — the schemes are incompatible and you need both
live during cutover.
4. Switch one low-volume campaign to `POST /v2/calls`. Compare outcomes against
the same campaign's v1 numbers for a day.
5. Move the rest. Leave numbers, knowledge base and tools on v1.
# API overview
Source: https://docs.miraiminds.co/v2/overview
REST API for placing AI voice calls. One auth header, predictable envelopes, server-minted IDs.
Four resources — agents, calls, campaigns and your do-not-call list — one auth
header, and the same error envelope everywhere.
```bash theme={null}
https://sandbox.voice.miraiminds.co
```
Key → agent → call → webhook in five minutes. [Start here](/v2/quickstart).
The reusable configuration a call runs. [Reference](/v2/agents).
Place, inspect and abort outbound calls; read transcripts and recordings. [Reference](/v2/calls).
Dial a whole list — windows, retries, budgets, reports. [Reference](/v2/campaigns).
Your suppression list, enforced at dial time. [Reference](/v2/dnc).
Signed lifecycle events. [Guide](/v2/webhooks).
## Authentication
Every request carries one header:
```bash theme={null}
Authorization: Bearer sk_live_YOUR_API_KEY
```
A secret key is `sk_live_` followed by 32 hex characters. It is scoped to one
workspace and carries your default [tier](/general/tiers) — `t3`, ₹3 a minute,
unless you asked for something else. Create, rotate and
revoke keys yourself in the [console](https://sandbox.voice.miraiminds.co)
under **Developers**.
We store only the SHA-256 of your key, so a lost key cannot be recovered — it is
rotated. Never ship a secret key to a browser or a mobile app.
| Situation | Status | `error.code` |
| :---------------------------- | :----- | :------------- |
| Header missing or key unknown | `401` | `unauthorized` |
| Key revoked | `403` | `forbidden` |
## Conventions
**JSON only.** Send `Content-Type: application/json` on every request with a
body. Responses are always JSON, including errors.
**IDs are server-minted** and prefixed by resource type. Treat them as opaque
strings — do not parse them.
| Resource | Format | Example |
| :------------ | :------------ | :-------------------------------- |
| Agent | `agt_` | `agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ` |
| Call | `call_` | `call_01K7Q9B4M8N3P6R2S5T7V9W1YA` |
| Campaign | `cmp_` | `cmp_01K7QAC5N9P4R7S3T6V8W2X4YB` |
| Webhook event | `evt_` | `evt_01K7QC5N9P4R7S3T6V8W2X4YB` |
**Errors** always use one envelope. See the full [error reference](/v2/errors).
```json theme={null}
{
"error": {
"code": "insufficient_balance",
"message": "wallet balance 0.40 INR is below the minimum for one minute at t3"
}
}
```
**Timestamps** are RFC 3339 in UTC: `2026-07-26T09:14:02Z`.
**Money** is INR — `cost_inr`, `balance_inr`, `amount_inr` are numbers, not
strings. The one exception is [campaigns](/v2/campaigns), whose `budget_paise`
and `spent_paise` are integer **paise** (100 paise = ₹1). Nothing mixes the two
units inside one object.
**Phone numbers** are E.164 with the leading `+`: `+919876543210`.
## Pagination
List endpoints take `limit` and `cursor` and return a cursor-paginated envelope.
```bash theme={null}
curl "https://sandbox.voice.miraiminds.co/v2/calls?limit=50&cursor=call_01JZQ9B4M8N3P6R2S5T7V9W1YA" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json theme={null}
{
"data": [ { "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "…": "…" } ],
"has_more": true,
"next_cursor": "call_01JZQ9B4M8N3P6R2S5T7V9W1YB"
}
```
Pass `next_cursor` back as `cursor` to get the following page. Stop when
`has_more` is `false`. Default `limit` is 20, maximum 100.
## Idempotency
`POST /v2/calls` accepts an `Idempotency-Key` header. Reuse the key and you get
the original response replayed instead of a second phone call. Keys are held for
24 hours.
```bash theme={null}
-H "Idempotency-Key: order-8842-reminder-1"
```
Use something derived from your own domain object (an order ID, a job ID) rather
than a random UUID per attempt — the point is that your retry produces the same
key. See [Calls](/v2/calls#idempotency).
## Not built yet
Outbound calling is what this API does. These are known gaps, listed so you
find out here rather than halfway through a build:
* **Inbound calls.** Outbound only.
* **Buying phone numbers.** We provide the line.
* **Knowledge base / RAG** and **function calling** during a call.
* **Self-serve wallet top-up** — see the [roadmap](/general/roadmap).
* **OAuth or user-scoped tokens.** Secret keys only.
If one of these is in your way, say so — several are on the
[roadmap](/general/roadmap) and the order is not fixed.
# Prompting guide
Source: https://docs.miraiminds.co/v2/prompting-guide
How to write a system prompt and opening line for a voice agent that sounds like a person on an Indian phone call — structure, language rules, numbers, endings, and the test loop.
A voice prompt is not a chatbot persona. It is a script for a two-minute phone
call: one job, one caller, one ending. Everything the model writes is spoken out
loud, at speaking speed, to someone holding a phone to their ear — so length is
a cost, ambiguity is a hang-up, and a paragraph is a monologue.
The method is the same every time. **Design** the call as a structure, **test**
it against a caller who says no, **refine** one line at a time. This page is the
mechanics; the thinking behind it is on
[The Zen of Voice Agents](/v2/zen-of-voice-agents).
## Why voice is not chat
Three constraints. Everything below follows from them.
**The caller cannot scroll, and cannot re-read.** A sentence they missed is
gone. Anything that matters gets said once, short, and gets read back.
**A long reply is the thing they wait through.** They hear every word before
they can answer. Reply length is the single biggest latency lever you own —
bigger than anything in the stack. Cap it in the prompt, in words the model
cannot round up: "one short sentence, maximum two".
**Turn-taking replaces reading.** In chat you can ask three things in one
message and let the reader sort it out. On a call, two questions in one turn get
one answer and you will not know which. One ask, then stop.
## What the engine already does
A lot of what people write into voice prompts is already handled below the
prompt. Writing it again does not make it truer; it just spends turns and
tokens.
* **The opening line runs before the model does.** `first_message` is spoken the
moment the callee answers. It is your first-audio latency. The model is told
what was said, so it does not greet twice.
* **Refusals already end the call.** When `end_call.enabled` is true (the
default), the engine appends an ending rule to every prompt: if the caller
refuses or asks to end — "नहीं चाहिए", "बात नहीं करनी", "रखता हूं",
"call खत्म करो" — the agent does not re-sell, says one farewell sentence and
calls `end_call`. Same on a second refusal. You do not write this.
* **A bare "ok" does not hang up.** With
[`end_call.confirm: true`](/v2/agents#ending-a-call) (the default), the first
`end_call` inside the window asks the caller to confirm; the second one ends
the call.
* **Barge-in is automatic.** The caller can talk over the agent — after two
words of caller speech the agent stops and listens. If they barge in without
actual words, the agent says "हाँ, बोलिए ना, क्या बात है?".
* **Silence is handled.** If the caller goes quiet, the agent checks in after
about 15 seconds; after three unanswered check-ins the call ends. Nothing to
write.
* **Voicemail is detected** by the engine and handled by the agent's
[`voicemail`](/v2/agents#voicemail) setting. Nothing to write.
* **Per-call data is substituted for you.** `{{placeholders}}` in both
`system_prompt` and `first_message` are filled from the call's `variables`.
So do not write any of that into the prompt. Write the job.
**The agent's only tool today is `end_call`.**
No transfer to a human, no SMS, WhatsApp or email, no CRM lookup, no calendar,
no payment link — nothing that fetches or sends mid-call. A prompt must
therefore never promise "I'll transfer you", "I'll send you the link" or
"I'm booking it in the system". The honest line is "the team will call you back"
/ "आपको team से call आएगा". Mid-call data and branching flows are the
node-graph runtime — see [tiers](/general/tiers).
## The structure
Every house template follows one discipline. Six blocks, in this order, nothing
else:
```text theme={null}
ROLE who the agent is, for whom, calling why — one sentence
FACTS the specific, verifiable things it may say (edit these)
CALL FLOW numbered steps; one ask per step; never assume what wasn't said
IF THEY PUSH BACK one light nudge, then accept and close
EXAMPLE EXCHANGE 2–4 turns showing the hard moment done right
LANGUAGE RULES + CONVERSATION STYLE the fixed block, pasted verbatim
```
**ROLE** is one sentence: name, company, and the reason for the call. Not a
character study. "You are मीरा, a polite female voice agent calling from Mirai
Finance to remind the customer their EMI is due and capture a clear payment
commitment" is a complete role.
**FACTS** is the boundary of what the agent may say. Amounts, dates, policy,
what it cannot do. If a claim is not in FACTS the agent does not make it — the
answer is that the team will call back. This is the block your customer edits.
**CALL FLOW** is numbered steps, one ask per step, in the order a person would
ask them. Steps are instructions to the agent, not lines to read aloud.
**IF THEY PUSH BACK** is one nudge and then acceptance. Not three. The engine
already stops a hard refusal; this block is for the soft "not right now".
**EXAMPLE EXCHANGE** is two to four turns of the hardest moment done right.
Models copy tone from examples far more reliably than from adjectives — two good
turns beat a paragraph about being warm and professional.
**LANGUAGE RULES + CONVERSATION STYLE** is fixed. Paste it verbatim at the end
of every prompt. It is the block below.
## The opening line
`first_message` is spoken before the model runs, so it is pure first-audio
latency and it should be short: name, brand, person check. Nothing else.
```text theme={null}
नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?
```
The pitch does not go in the opener. The model already knows what was said and
will carry on from there.
One thing catches everybody: **Hindi verbs are gendered, and the verb has to
match the voice.**
| `voice_id` | Voice | Say |
| :--------- | :----- | :---------------- |
| `ashu` | male | मैं … बोल रहा हूं |
| `aishe` | female | मैं … बोल रही हूं |
The console renders this for you when you pick a template. The API does not —
if you switch `voice_id` on an existing agent, fix the opening line in the same
request.
## Language rules for Hinglish
Real Indian phone calls are not Hindi and are not English. They are Hindi
grammar carrying English nouns, and an agent that picks one pure language sounds
like a recording. This block is what the console appends to every template.
Paste it at the end of your prompt, unchanged:
```text theme={null}
LANGUAGE RULES — keep these; they make the agent sound natural
- Everyday Hinglish: Hindi words in Devanagari script, NEVER romanized Hindi ('aap kab tak kar payenge' is WRONG — write 'आप कब तक कर पाएंगे').
- Everyday English words stay in Roman script: payment, EMI, order, address, delivery, challan, invoice, penalty, policy, service, slot.
- No formal shuddh Hindi (भुगतान, देय, राशि, पुष्टि).
- Every number is English words in Roman script — never digits, never Hindi numbers ('pandra' is WRONG, 'fifteen' is right). Identifiers digit-by-digit: 'three nine five zero zero seven'.
- When the customer states or corrects any number — flat, pincode, phone, amount — read it back digit-by-digit and confirm (e.g. 'आपका pincode है four zero zero zero five three, सही है?'). If it sounds unclear or incomplete, do NOT guess — ask them to repeat it slowly.
CONVERSATION STYLE
- One short sentence, maximum two.
- धन्यवाद appears exactly ONCE in the whole call — only in the final goodbye.
- After a clear promise or confirmation: repeat it back, say goodbye, end the call.
- Never invent policy or waive penalties; unknown questions → the team will call back.
```
Why each rule is there:
* **Devanagari for Hindi words.** Romanized Hindi in the prompt gets read as
English. "kar payenge" comes out with English vowels and the caller hears an
accent, not a person.
* **Roman script for English words.** Nobody on a phone call says भुगतान. They
say payment. Writing the English word in Roman is not laziness — it is how the
sentence is actually spoken.
* **No shuddh Hindi.** Formal Hindi is the register of a government notice, not
a phone call. It reads as a recording even when the audio is perfect.
* **Numbers as English words.** The voice reads English number words most
reliably. Digits and Hindi number words are read inconsistently.
* **धन्यवाद once.** Models over-thank. Thanking four times in ninety seconds is
the clearest tell that nobody is there.
**For English calls** (`language: "en-IN"`) the shape is identical — drop the
Devanagari rule and keep everything else. Numbers still go in as words, and
identifiers are still read digit by digit.
## Numbers, dates, amounts and identifiers
The model copies what you write straight into speech. Write for the ear.
| Written | Put it in the prompt as |
| :-------------- | :------------------------------------------------------------------------------ |
| ₹5,200 | `five thousand two hundred rupees` |
| 15/07 | `fifteen July` |
| 2:15 PM | `two fifteen in the afternoon` — or `दोपहर two fifteen` |
| Pincode 395007 | `three nine five zero zero seven` |
| +91 98765 43210 | digit by digit, in groups: `nine eight seven six five, four three two one zero` |
| MH02AB1234 | `MH zero two AB one two three four` |
Amounts and quantities are natural words; quantities can sit inline in a Hindi
sentence ("दो items"). Identifiers — pincode, phone, order id, vehicle number,
challan number — are always digit by digit, never grouped into "ninety-eight
thousand".
**Digit-count sanity.** An Indian pincode is six digits. A mobile number is ten.
If a read-back is short, the recogniser dropped digits — the agent must push
back, never accept:
```text theme={null}
मुझे सिर्फ three digits मिले, दोबारा बोलिए
```
**The read-back protocol** is three moves, and it belongs in every flow that
touches a number: the caller states it → the agent reads it back digit by digit
→ the agent asks "सही है?". Nothing gets written down until the caller confirms.
## Facts, and the customer
FACTS is a contract. The agent may say what is in it and nothing more. Anything
outside — a policy question, an exception, a price you did not list — is
answered with "the team will call you back". Never invent policy, never waive a
penalty, never quote a number that is not in FACTS.
**Per-call data goes in `variables`, not in the prompt.** One agent runs
thousands of calls; the customer's name, amount and date change every time.
Hard-coding them means one agent per customer, and nobody wants to manage that.
```json theme={null}
{ "variables": { "customer_name": "राहुल", "emi_amount": "five thousand two hundred", "due_date": "five July" } }
```
Notice that those values are already in spoken form. That is deliberate: the
model copies a variable's value verbatim into speech, so format it for the ear
at the point you build the map, not in the prompt. `5200` is a gamble;
`five thousand two hundred` is not.
A few properties worth knowing:
* Names are matched loosely. `{{Customer Name}}`, `{{customer_name}}` and
`{{CustomerName}}` are the same variable — letters and digits only,
case-insensitive.
* An unknown placeholder in the system prompt becomes an empty string. It does
not error, and it does not leave braces for the agent to read aloud.
* Values are plain strings, and braces inside a value are stripped.
* Variables are static per call. There is no way to change one mid-call.
* Keep them short. They are prompt tokens on every turn.
**Tell the agent what day it is.** Without it the model does not know, and
"tomorrow" or "this Friday" becomes a guess. The prompt supports Liquid filters,
so put the real date in at call time, in IST:
```text theme={null}
Today is {{ "now" | date: "%A, %d %B %Y", "Asia/Kolkata" }}.
```
With the clock too, when the script mentions a time window:
```text theme={null}
{{ "now" | date: "%A, %d %B %Y, %I:%M %p", "Asia/Kolkata" }}
```
## The flow
* **One ask per step.** Two questions in one turn get one answer, and you will
not know which one it was.
* **Never assume what was not clearly said.** If the caller mumbled a date, the
agent asks again. A confidently wrong confirmation is worse than a second
question.
* **Confirm by exception.** Read back only what changed, and do one consolidated
read-back at the end. Confirming every field turns a ninety-second call into
three minutes.
* **Listen when they are dictating.** While the caller reads out an address, the
agent's job is backchannels — "जी", "हम्म" — and then one read-back at the
end. Interrupting a dictated address to confirm line one is how you lose the
rest of it.
* **Mishear recovery is surgical.** Re-ask the one field that was wrong. Never
restart the flow.
* **Answer, then return.** If the caller derails with a question, answer it in
one line from FACTS and go straight back to the step you were on.
* **Every turn moves forward.** Acknowledge and ask the next thing in one
utterance. A turn that only says "ठीक है" costs the caller time and buys
nothing.
## Pushback and endings
One light nudge, then accept. The engine already enforces the refusal ending, so
your job is the **success** ending — and it is the line people forget:
```text theme={null}
When the payment date is confirmed, repeat it back in one line, say goodbye, and end the call.
```
Without it the model keeps talking. It has nothing telling it the job is done.
"No" is an outcome, not a failure. Capture it and close cleanly — a refusal that
ends in twenty seconds is a good call.
The close is three moves: a one-line summary of what was agreed, a goodbye, then
`end_call`. Say धन्यवाद once, in that goodbye. Leave
[`end_call.message`](/v2/agents#ending-a-call) empty — the default — and the
model's own goodbye is the farewell. If you do set a message, it is spoken
*after* the model's goodbye, so it must not repeat it: a धन्यवाद in both means
the caller hears it twice.
Finally, size the call. `max_duration_secs` is a hard cap, and hitting it ends
the call with `ended_reason: exceeded-max-duration` mid-sentence. For a one-job
call, 180–300 seconds is right. The default is 300; the range is 30–1800.
## Sounding like a person
* **Lead word up front.** "जी।", "अच्छा—", "ठीक है।" at the start of a turn does
two things: the voice starts sooner, and it sounds like somebody who was
listening rather than a system that was processing.
* **Punctuation is your only expressiveness control.** Interjections and
emphasis at the moments that carry them — an apology, a pushback — and flat
everywhere else. Expressiveness everywhere is the same as expressiveness
nowhere.
* **No stage directions, no tags, no emoji, no markdown.** The voice reads the
text it is given. Asterisks, bullet characters and `(warmly)` come out as
noise.
* **Match the caller's register.** If they answer in English, the agent
continues in English. If they switch to Hindi mid-call, so does it.
* **Contractions and everyday words.** Write the sentence you would say out
loud, not the one you would put in an email.
## What not to write
| Anti-pattern | What happens | Write instead |
| :-------------------------------------------------------------------------- | :------------------------------------------------------------------------ | :------------------------------------------------------------- |
| A persona paragraph — "you are a friendly, empathetic AI assistant" | Adjectives do not survive contact with a call. The model still runs long. | One ROLE sentence, and an EXAMPLE EXCHANGE that shows the tone |
| Digits and currency symbols: `₹5,200`, `15/07` | Read inconsistently, sometimes as separate digits | `five thousand two hundred rupees`, `fifteen July` |
| Romanized Hindi: `aap kab tak kar payenge` | Read with English vowels; sounds like an accent, not a person | Devanagari: `आप कब तक कर पाएंगे` |
| Shuddh Hindi: भुगतान, देय, राशि | The register of a notice, not a call | The words people use: payment, due date |
| Two questions in one turn | One answer, and you cannot tell which question it belongs to | One ask per step |
| Multi-branch scripts — "if they say X, ask the following seven questions" | The model loses its place and the call sprawls | A linear flow, plus one IF THEY PUSH BACK block |
| "I'll transfer you" / "I'll send the link" / "I'm booking it in the system" | The agent has no tool but `end_call`. It is a promise nobody keeps. | "the team will call you back" / "आपको team से call आएगा" |
| Customer data hard-coded: "Rahul's EMI is 5200" | One agent per customer, forever | `{{customer_name}}`, `{{emi_amount}}` in `variables` |
| A long list of forbidden phrases | Naming a phrase primes it. Listing what not to say teaches it to say it. | The positive rule: "one short sentence, maximum two" |
| "Wait for the user to finish speaking" | The engine owns turn-taking. The prompt controls what is said, not when. | Nothing — delete the line |
| "Remind them about last week's call" | There is no memory across calls | Pass the fact in `variables` |
| A 600-word FACTS block | Re-read on every turn, and the model picks the wrong fact | The ten facts this call actually needs |
| Emoji, markdown, stage directions | Spoken aloud as noise | Plain sentences |
| An English prompt for a Hindi call | Code-switches badly and drifts back to English | Write the prompt in the language the call will be in |
## Test, then refine
Every real phone call costs money — there is no test key — so the loop is
designed to find problems in the cheapest place first.
Agent Studio in the [console](https://sandbox.voice.miraiminds.co) has six house
scripts in the template dropdown — Govt · Traffic challan, BFSI · EMI reminder,
BFSI · GSTR-1 filing, D2C · Order confirmation, D2C · Service reminder,
Hospitality · Booking confirmation. All six are already in the structure above.
If none of them is close, use **Describe the call and we draft it → Generate
script**: a one-line brief comes back as a house-style prompt and opening line
to edit.
The web phone in the right rail runs the agent without dialling a phone. Play
the customer you are afraid of: say no, correct a digit half-way through, ask
something that is not in FACTS, then go quiet and see what happens. Most prompt
bugs die here, for free.
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7Q9B4M8N3P6R2S5T7V9W1YA/transcript \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
Turns come back as they were spoken, nothing translated — see
[Get the transcript](/v2/calls#get-the-transcript). Read where it went wrong,
change one line, run it again. Changing three lines at once tells you nothing
about which one worked.
The phone line is a different acoustic world from a browser mic: carrier codecs,
background noise, a caller who is walking. Prompts that pass on the web phone and
fail on a real handset are common, and they fail on numbers first.
Twenty to fifty contacts. Read the transcripts and the campaign report before
you scale — see [Campaigns](/v2/campaigns). Fix what you find, then go wide.
Measure by outcome, not vibe. Did the call reach a defined end state —
confirmed, refused, callback? "It sounded nice" is not a metric, and a warm
agent that never gets a commitment is a failed call.
## A worked example
The EMI reminder, rendered for the `aishe` voice with the agent named मीरा.
`system_prompt`:
```text theme={null}
ROLE
You are मीरा, a polite female voice agent calling from Mirai Finance to remind the customer their EMI is due and capture a clear payment commitment.
FACTS — edit these for your use case
- Customer: {{customer_name}}
- EMI: {{emi_amount}} rupees, due {{due_date}}
- After {{due_date}}: late payment penalty may apply
- Payment: Mirai Finance app or website
- You cannot extend the due date or waive any penalty.
CALL FLOW
1. Confirm the person.
2. State the EMI amount and due date.
3. Use the penalty hook gently; ask when they will pay.
4. Never assume a date they did not clearly say; read the promised date back.
5. Close.
IF THEY REFUSE OR CAN'T COMMIT
One light nudge about the penalty, then accept and note that the team will follow up.
EXAMPLE EXCHANGE
[User] थोड़ा टाइम मिलेगा क्या?
[You] {{customer_name}} जी, {{due_date}} के बाद payment करने पर late payment penalty लग सकती है, आप कब तक payment कर पाएंगे?
[User] मैं दस जुलाई तक कर दूंगा।
[You] ठीक है {{customer_name}} जी, आपने बताया कि आप ten July तक payment कर देंगे, मैंने note कर लिया है. धन्यवाद, आपका दिन शुभ हो!
```
The last line is where the fixed block from
[Language rules](#language-rules-for-hinglish) gets pasted, verbatim.
`first_message`:
```text theme={null}
नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?
```
### Create the agent
```json title="emi-agent.json" theme={null}
{
"name": "EMI reminder",
"system_prompt": "ROLE\nYou are मीरा, a polite female voice agent calling from Mirai Finance to remind the customer their EMI is due and capture a clear payment commitment.\n\nFACTS — edit these for your use case\n- Customer: {{customer_name}}\n- EMI: {{emi_amount}} rupees, due {{due_date}}\n- After {{due_date}}: late payment penalty may apply\n- Payment: Mirai Finance app or website\n- You cannot extend the due date or waive any penalty.\n\nCALL FLOW\n1. Confirm the person.\n2. State the EMI amount and due date.\n3. Use the penalty hook gently; ask when they will pay.\n4. Never assume a date they did not clearly say; read the promised date back.\n5. Close.\n\nIF THEY REFUSE OR CAN'T COMMIT\nOne light nudge about the penalty, then accept and note that the team will follow up.\n\nEXAMPLE EXCHANGE\n[User] थोड़ा टाइम मिलेगा क्या?\n[You] {{customer_name}} जी, {{due_date}} के बाद payment करने पर late payment penalty लग सकती है, आप कब तक payment कर पाएंगे?\n[User] मैं दस जुलाई तक कर दूंगा।\n[You] ठीक है {{customer_name}} जी, आपने बताया कि आप ten July तक payment कर देंगे, मैंने note कर लिया है. धन्यवाद, आपका दिन शुभ हो!\n\n",
"first_message": "नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?",
"voice": { "voice_id": "aishe", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 240,
"voicemail": { "action": "hangup" }
}
```
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d @emi-agent.json
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
SYSTEM_PROMPT = open("emi_prompt.txt", encoding="utf-8").read()
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "EMI reminder",
"system_prompt": SYSTEM_PROMPT,
"first_message": "नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?",
"voice": {"voice_id": "aishe", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 240,
"voicemail": {"action": "hangup"},
},
timeout=30,
).raise_for_status().json()
print(agent["id"])
```
```javascript theme={null}
import { readFileSync } from "node:fs";
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const systemPrompt = readFileSync("emi_prompt.txt", "utf8");
const res = await fetch(`${API}/v2/agents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
name: "EMI reminder",
system_prompt: systemPrompt,
first_message:
"नमस्ते, मैं मीरा बोल रही हूं Mirai Finance से. क्या मेरी बात {{customer_name}} जी से हो रही है?",
voice: { voice_id: "aishe", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 240,
voicemail: { action: "hangup" },
}),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const agent = await res.json();
```
Keep the prompt in a file next to your code and read it in — it is a script, it
will change often, and it does not belong inline in a JSON literal. `end_call`
is left at its defaults on purpose: confirmation on, no canned farewell, so the
धन्यवाद in the EXAMPLE EXCHANGE is the only one the caller hears.
### Place the call
The variable values are already in spoken form. That is the point.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: emi-88213-jul-1" \
-d '{
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"variables": {
"customer_name": "राहुल",
"emi_amount": "five thousand two hundred",
"due_date": "five July"
},
"webhook_url": "https://example.com/mirai/webhook"
}'
```
## Copy-paste skeleton
Fill the angle brackets, keep everything else.
```text theme={null}
ROLE
You are , a polite voice agent calling from to .
FACTS — edit these for your use case
- Customer: {{customer_name}}
-
-
-
- You cannot .
Today is {{ "now" | date: "%A, %d %B %Y", "Asia/Kolkata" }}.
CALL FLOW
1. Confirm the person.
2.
3.
4. Never assume anything they did not clearly say; read it back digit-by-digit and confirm.
5. When is confirmed, summarise it in one line, say goodbye, and end the call.
IF THEY PUSH BACK
One light nudge about , then accept and note that the team will follow up.
EXAMPLE EXCHANGE
[User]
[You]
[User]
[You]
LANGUAGE RULES — keep these; they make the agent sound natural
- Everyday Hinglish: Hindi words in Devanagari script, NEVER romanized Hindi ('aap kab tak kar payenge' is WRONG — write 'आप कब तक कर पाएंगे').
- Everyday English words stay in Roman script: payment, EMI, order, address, delivery, challan, invoice, penalty, policy, service, slot.
- No formal shuddh Hindi (भुगतान, देय, राशि, पुष्टि).
- Every number is English words in Roman script — never digits, never Hindi numbers ('pandra' is WRONG, 'fifteen' is right). Identifiers digit-by-digit: 'three nine five zero zero seven'.
- When the customer states or corrects any number — flat, pincode, phone, amount — read it back digit-by-digit and confirm (e.g. 'आपका pincode है four zero zero zero five three, सही है?'). If it sounds unclear or incomplete, do NOT guess — ask them to repeat it slowly.
CONVERSATION STYLE
- One short sentence, maximum two.
- धन्यवाद appears exactly ONCE in the whole call — only in the final goodbye.
- After a clear promise or confirmation: repeat it back, say goodbye, end the call.
- Never invent policy or waive penalties; unknown questions → the team will call back.
```
***
That is the whole method: one job, six blocks, numbers as words, one ending you
wrote yourself. When a prompt still is not landing and the structure is right,
the problem is usually a belief about what a call *is* — that is
[The Zen of Voice Agents](/v2/zen-of-voice-agents).
# Quickstart
Source: https://docs.miraiminds.co/v2/quickstart
Get a key, create an agent, place a real call and receive the webhook — in five minutes.
Five minutes, four requests, one real phone call. Calls bill at your key's
[tier](/general/tiers) — `t3`, ₹3 a minute, unless you ask for `t1`.
Partner? [**Start building**](/v2/start-building) is the longer version of this
page: the same first call, plus your first campaign and the go-live checklist.
**You need:** a secret key, a phone number you are allowed to call, and a public
HTTPS URL for webhooks (ngrok is fine while you build).
Open the [console](https://sandbox.voice.miraiminds.co) and go to
**Developers → Create key**. You get three things on the spot:
| | Looks like | Used for |
| :---------------------- | :--------------------------------------- | :--------------------------- |
| Secret key | `sk_live_YOUR_API_KEY` | every API request |
| Webhook secret | `whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8` | verifying events we send you |
| Starting wallet balance | `₹500` | pays for call minutes |
**The key is shown once.** We store only its SHA-256, so a lost key is rotated,
never recovered. Put both secrets in your secret manager, not in git.
Rotate and revoke from the same page. Rotating issues the replacement before
it revokes the old key, so there is no window where neither works — deploy the
new one, then revoke.
Check it works:
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/wallet \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json theme={null}
{ "balance_inr": 500, "currency": "INR", "updated_at": "2026-07-26T09:12:44Z" }
```
An agent is the reusable configuration a call runs: prompt, opening line,
voice, language, limits. Create it once, call it thousands of times.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Confirmation",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} and ask whether the delivery address is unchanged. Keep replies to one or two short sentences. When the customer is done, thank them and end the call.",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300
}'
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "Order Confirmation",
"system_prompt": (
"You are Priya from Acme. Confirm order {{order_id}} with "
"{{customer_name}} and ask whether the delivery address is "
"unchanged. Keep replies to one or two short sentences. When "
"the customer is done, thank them and end the call."
),
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": {"voice_id": "ashutosh", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 300,
},
timeout=30,
).raise_for_status().json()
print(agent["id"]) # agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ
```
```javascript theme={null}
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const res = await fetch(`${API}/v2/agents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Order Confirmation",
system_prompt:
"You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} " +
"and ask whether the delivery address is unchanged. Keep replies to one or " +
"two short sentences. When the customer is done, thank them and end the call.",
first_message: "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
voice: { voice_id: "ashutosh", language: "hi-IN" },
language: "hi-IN",
max_duration_secs: 300,
}),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const agent = await res.json();
console.log(agent.id); // agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ
```
```json title="201 Created" theme={null}
{
"id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"object": "agent",
"name": "Order Confirmation",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"created_at": "2026-07-26T09:14:02Z"
}
```
Keep that `id`.
That prompt is the minimum that works. Before you run it at volume, read the
[prompting guide](/v2/prompting-guide) — the structure, the Hinglish rules, and
how to end a call cleanly.
`variables` fill the `{{placeholders}}` in `system_prompt` and
`first_message`. `webhook_url` is where lifecycle events land.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8842-confirm-1" \
-d '{
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t3",
"variables": { "customer_name": "Rahul", "order_id": "8842" },
"webhook_url": "https://example.com/mirai/webhook"
}'
```
```python theme={null}
call = httpx.post(
f"{API}/v2/calls",
headers={**auth, "Idempotency-Key": "order-8842-confirm-1"},
json={
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t3",
"variables": {"customer_name": "Rahul", "order_id": "8842"},
"webhook_url": "https://example.com/mirai/webhook",
},
timeout=30,
).raise_for_status().json()
print(call["id"], call["status"]) # call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued
```
```javascript theme={null}
const res = await fetch(`${API}/v2/calls`, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Idempotency-Key": "order-8842-confirm-1",
},
body: JSON.stringify({
agent_id: "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
to: "+919876543210",
tier: "t3",
variables: { customer_name: "Rahul", order_id: "8842" },
webhook_url: "https://example.com/mirai/webhook",
}),
});
const call = await res.json();
console.log(call.id, call.status); // call_01JZQ9B4M8N3P6R2S5T7V9W1YA queued
```
```json title="202 Accepted" theme={null}
{ "id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA", "status": "queued" }
```
`202` means accepted, not connected — the phone has not rung yet. Poll
[`GET /v2/calls/{id}`](/v2/calls#get-a-call) or, better, wait for the
webhook.
We POST a signed JSON event to your `webhook_url` at each lifecycle
transition. **Verify the signature before you trust the body.**
```python theme={null}
import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
WHSEC = "whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8"
TOLERANCE_SECS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECS:
return False
expected = hmac.new(
secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/mirai/webhook")
def hook():
if not verify(request.get_data(), request.headers.get("X-Mirai-Signature", ""), WHSEC):
return "", 401
event = request.get_json()
print(event["type"], event["data"]["call"]["status"])
return "", 200 # 2xx stops our retries
```
```javascript theme={null}
import express from "express";
import crypto from "node:crypto";
const app = express();
const WHSEC = "whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8";
const TOLERANCE_SECS = 300;
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
})
);
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// raw body is required — a re-serialized JSON object will not match
app.post("/mirai/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Mirai-Signature") ?? "", WHSEC)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data.call.status);
res.sendStatus(200); // 2xx stops our retries
});
app.listen(3000);
```
The final event for a connected call:
```json title="POST https://example.com/mirai/webhook" theme={null}
{
"id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB",
"type": "call.completed",
"created_at": "2026-07-26T09:16:38Z",
"data": {
"call": {
"id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA",
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"status": "completed",
"tier": "t3",
"ended_reason": "assistant-ended-call",
"started_at": "2026-07-26T09:15:02Z",
"ended_at": "2026-07-26T09:16:38Z",
"duration_secs": 96,
"cost_inr": 6
}
}
}
```
96 seconds is billed as 2 minutes — ₹6.00 at `t3`.
[Minutes are whole and rounded up](/general/tiers#metering), with a one-minute
minimum.
## Next
* **Python?** `pip install --extra-index-url https://sandbox.voice.miraiminds.co/pypi/simple mirai-voice`
gives you the same four requests as `mirai.agents.create` / `mirai.calls.create`,
with idempotency, retries and signature verification already handled.
* [Campaigns](/v2/campaigns) — upload a list and let the platform dial it, with
windows, retries, budgets and a report.
* [Webhooks](/v2/webhooks) — every event type, retries, replay protection.
* [Calls](/v2/calls) — statuses, `ended_reason`, transcripts, recordings, abort.
* [Billing & tiers](/general/tiers) — what a minute costs and what each tier can do.
* [Limits](/v2/limits) — rate limits, concurrency, India calling-window rules.
## Troubleshooting the first call
| Symptom | Cause | Fix |
| :--------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------------------------- |
| `401 unauthorized` | Missing `Bearer ` prefix, or a copy-paste with a newline | Re-copy the key; check `Authorization: Bearer sk_live_…` |
| `402 insufficient_balance` | Wallet cannot cover one minute at your tier (₹3 on `t3`) | Check `GET /v2/wallet`; ask your Mirai contact for a top-up |
| `400 invalid_request` on `voice_id` | The voice is not in your tier's catalogue | `t1` says `ashu` or `aishe` only; see [Voices](/v2/voices) |
| `400 invalid_request` on `to` | Number not E.164 | Use `+919876543210`, not `9876543210` or `091 98765 43210` |
| Call ends instantly, `status: no_answer` | Number unreachable or DND-blocked | Try another handset; see [compliance](/v2/limits#india-calling-rules) |
| No webhook arrives | URL not publicly reachable, or not HTTPS | Test with `curl -X POST` against your own URL first |
| Signature never verifies | Framework parsed and re-serialized the body | Sign over the **raw** bytes — see [Webhooks](/v2/webhooks#raw-body) |
# Start building
Source: https://docs.miraiminds.co/v2/start-building
From a secret key to a running campaign in ten minutes — the whole journey, copy-paste.
This is the partner on-ramp. Ten minutes, start to finish: check your key, make
one agent, ring one phone, run one campaign, read the report. Every request
below is copy-paste — change the key and the phone number and nothing else.
**You need:** your `sk_live_` key, a phone number you are allowed to call, and
(for step 5) a public HTTPS URL. That is all.
```bash theme={null}
https://sandbox.voice.miraiminds.co
```
**Your key arrives from your Mirai contact**, in the same message as this link —
or you mint it yourself in the [console](https://sandbox.voice.miraiminds.co)
under **Developers → Create key**. It looks like `sk_live_` followed by 32 hex
characters and it is shown **once**: we store only its SHA-256, so a lost key is
rotated, never recovered. Put it in your secret manager now, before you go
further.
Issued alongside it is a webhook signing secret, `whsec_…`. That one belongs to
the **workspace**, not the key — rotating a key does not change it, so your
receiver keeps verifying without an outage.
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/wallet \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{ "balance_inr": 500, "currency": "INR", "updated_at": "2026-08-10T09:12:44Z" }
```
A `401` here is almost always a trailing newline from `cat`-ing a key file.
Paste the key; do not pipe it.
That balance is real money and it is what pays for the calls below. Billing is
**per minute, rounded up, one-minute minimum**, at your key's
[tier](/general/tiers) — `t3` at ₹3/minute on a partner key.
Download both files:
| File | What it is |
| :------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- |
| [mirai-voice-sandbox.postman\_collection.json](/files/mirai-voice-sandbox.postman_collection.json) | The collection — every endpoint, with worked example responses. |
| [sandbox.postman\_environment.json](/files/sandbox.postman_environment.json) | The environment — base URL, your key, your test number. |
Postman → **Import** → drop in both → select the **Voice Infra — Sandbox**
environment → fill in `api_key` (your `sk_live_…`) and `test_phone` (a number
**you own**, E.164). Then open **0-Quickstart** and run it top to bottom: each
step saves the id the next one needs, so there is nothing to copy and paste.
You can skip this entirely and use the `curl` below. The collection is the
faster path if more than one person on your side will touch the API.
An agent is the reusable configuration a call runs: prompt, opening line, voice,
language, limits. Make it once, call it thousands of times.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order confirmations",
"system_prompt": "You are Priya from Acme. Confirm order {{order_id}} with {{customer_name}} and ask whether the delivery address is unchanged. Keep replies to one or two short sentences. When the customer is done, thank them and end the call.",
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ। एक मिनट बात कर सकती हूँ?",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300
}'
```
```json title="201 Created" theme={null}
{
"id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"object": "agent",
"name": "Order confirmations",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN",
"max_duration_secs": 300,
"created_at": "2026-08-10T09:14:02Z"
}
```
Keep that `id`.
`{{order_id}}` and `{{customer_name}}` are **agent placeholders**, filled per
call from `variables`. They are not shell or Postman variables — leave them
exactly as they are.
`voice_id` depends on your tier. On `t3` it is a name from the Sarvam catalogue
(`ashutosh` above); on `t1` it is `ashu` or `aishe` and nothing else. See
[Voices](/v2/voices).
Put your own number in `to` for this one. It will actually ring.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/calls \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: first-call-1" \
-d '{
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"tier": "t3",
"variables": { "customer_name": "Rohit", "order_id": "AC-88213" },
"webhook_url": "https://example.com/mirai/webhook"
}'
```
```json title="202 Accepted" theme={null}
{ "id": "call_01K7QB4M8N3P6R2S5T7V9W1YA", "status": "queued" }
```
`202` means **accepted, not connected** — the phone has not rung yet. Read the
outcome when it ends:
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/calls/call_01K7QB4M8N3P6R2S5T7V9W1YA \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"id": "call_01K7QB4M8N3P6R2S5T7V9W1YA",
"object": "call",
"status": "completed",
"ended_reason": "assistant-ended-call",
"duration_secs": 96,
"tier": "t3",
"cost_inr": 6
}
```
96 seconds is 2 billed minutes — ₹6 at `t3`. See
[what a minute costs](/general/tiers#what-a-minute-costs).
**Always send `Idempotency-Key` on `POST /v2/calls`.** A network timeout tells
you nothing about whether the phone rang. With the key, a retry replays the
original response and places no second call. Without it, your retry rings a real
person twice.
Poll if you must; subscribe if you can. We `POST` a signed JSON event at each
lifecycle transition, and **you verify the signature before you trust the body**.
```
X-Mirai-Signature: t=1785057398,v1=5f3c…
```
`v1` is `HMAC-SHA256(whsec, "{t}.{raw_body}")`, hex. Sign the **raw** bytes — a
framework that parsed and re-serialized the JSON produces a different string and
will never verify.
```python theme={null}
import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
WHSEC = "whsec_YOUR_WEBHOOK_SECRET"
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(WHSEC.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/mirai/webhook")
def hook():
if not verify(request.get_data(), request.headers.get("X-Mirai-Signature", "")):
return "", 401
event = request.get_json()
print(event["type"], event["data"])
return "", 200 # any 2xx stops our retries
```
The events: `call.started`, `call.completed`, `call.voicemail`, `call.failed`,
`call.aborted`, plus `campaign.started` / `paused` / `resumed` / `completed` /
`stopped`. Dedupe on `id` — a retried delivery repeats it. Full reference and a
test vector in [Webhooks](/v2/webhooks).
One call proves the plumbing. A campaign is the product: upload a list, set a
window and a budget, and let the platform dial it — pacing, retries, do-not-call
suppression and all.
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "First campaign",
"agent_id": "agt_01K7Q9F3K7M2N5P9R4T6V8W0XZ",
"tier": "t3",
"timezone": "Asia/Kolkata",
"start_date": "2026-08-11",
"end_date": "2026-08-13",
"slots": [{ "start": "10:00", "end": "19:00" }],
"max_concurrent": 1,
"retry_count": 1,
"re_attempt_period_secs": 900,
"budget_paise": 30000,
"webhook_url": "https://example.com/mirai/webhook",
"contacts": [
{ "id": "cust-1001", "phone": "+919876543210",
"variables": { "customer_name": "Rohit", "order_id": "AC-88213" } },
{ "id": "cust-1002", "phone": "+919876543211",
"variables": { "customer_name": "Anita", "order_id": "AC-88219" } }
]
}'
```
```json title="201 Created" theme={null}
{
"id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"object": "campaign",
"status": "draft",
"tier": "t3",
"contacts_accepted": 2,
"contacts_duplicate": 0,
"contacts_rejected": []
}
```
Note the shape: the campaign object itself, with the upload result as top-level
keys. Bad rows come back **by index** rather than failing the whole upload.
Start it:
```bash theme={null}
curl -X POST https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/start \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="202 Accepted" theme={null}
{ "id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB", "status": "play" }
```
`play` means the dialler is running. Outside `10:00`–`19:00` it waits, in `play`,
until the window opens — that is correct, not stuck. Full semantics in
[Campaigns](/v2/campaigns).
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```json title="200 OK" theme={null}
{
"campaign_id": "cmp_01K7QAC5N9P4R7S3T6V8W2X4YB",
"status": "completed",
"counters": { "completed": 1, "no_answer": 1 },
"contacts": 2,
"dialed": 2,
"attempts": 3,
"connected": 1,
"spent_paise": 600,
"spent_inr": 6,
"budget_paise": 30000
}
```
Per contact, as CSV:
```bash theme={null}
curl -G https://sandbox.voice.miraiminds.co/v2/campaigns/cmp_01K7QAC5N9P4R7S3T6V8W2X4YB/report \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--data-urlencode "format=csv" -o campaign.csv
```
That is the whole loop. Everything after this is scale and hygiene.
## Before you go live
Verify `X-Mirai-Signature` over the **raw** body, enforce the 5-minute timestamp
window, and return `401` when it does not match. Dedupe on the event `id`;
retries repeat it. Do not order your state machine by arrival order — a retried
`call.started` can land after `call.completed`. Order by `data.call.status`.
[Reference](/v2/webhooks#signature-verification).
Every "stop calling me" — on a call, over WhatsApp, by email — should become a
`POST /v2/dnc` in the same minute. It is idempotent, so there is no read-modify-
write. Suppression is checked at dial time, so it takes effect on campaigns that
are already running. [Reference](/v2/dnc).
`budget_paise` bounds a mistake in the *list* — a duplicated CSV, a column
shifted by one — which is the failure mode that no amount of code review
catches. When the cap is hit the campaign pauses with
`pause_reason: "budget_exhausted"`, keeps its place, and continues when you raise
it. Alert on `campaign.paused`.
`GET /v2/wallet` at a threshold covering a day of traffic. A `402` mid-campaign
is an expensive way to find out you are empty — and an underfunded campaign
starts, dials what it can, and then pauses itself on `insufficient_balance`.
Top-ups are not self-serve yet: ask your Mirai contact.
| Limit | Default | Over it |
| :-------------------- | :----------------------------- | :--------------------------------------- |
| Request rate | 10 req/s, burst 20 | `429 rate_limited`, honour `Retry-After` |
| Concurrent live calls | 5 | nothing fails — calls wait in `queued` |
| Queue depth | 500 accepted-and-not-yet-ended | `429 rate_limited`, `Retry-After: 30` |
Concurrency is a **pacer, not a gate**. Higher ceilings are a provisioning
change, not a code change — ask before the campaign, not during it.
[Limits](/v2/limits).
| `error.code` | Status | Do |
| :---------------------- | :----- | :-------------------------------------------------------------------------------- |
| `insufficient_balance` | `402` | Stop dialling, alert, top up. No call was placed and nothing was charged. |
| `rate_limited` | `429` | Back off, honour `Retry-After`. Read `message` to see which ceiling. |
| `duplicate_call` | `409` | Not an error — the same `Idempotency-Key` is still in flight. No second call. |
| `at_capacity` | `429` | Every line is busy. Retry in 30 seconds. |
| `fleet_offline` | `503` | Speech capacity is down. Retry in a few minutes; nothing was charged. |
| `tier_unavailable` | `501` | You asked for `t5`. Use `t3` or `t1`. |
| `campaigns_unavailable` | `503` | Campaigns are not enabled on your workspace. Ask us — retrying will not clear it. |
Branch on status first, then on `error.code`. Full table and a retry wrapper in
two languages: [Errors](/v2/errors).
The calling window, consent records, national DND scrubbing and telling people
they are speaking to an AI are obligations of the business placing the calls.
Campaigns enforce **your** window and **your** suppression list — they do not
make the calls compliant for you. Put the AI disclosure in `first_message`; it
costs one clause and it reduces early hang-ups.
[India calling rules](/v2/limits#india-calling-rules).
## Where to go next
Windows, retries, budgets, per-contact reporting. [Reference](/v2/campaigns).
Prompts, voices, voicemail behaviour, duration caps. [Reference](/v2/agents).
Every event, the signature scheme, retries. [Guide](/v2/webhooks).
What a minute costs and what each tier can do. [Reference](/general/tiers).
Stuck on something that is not in here? [help@miraiminds.co](mailto:help@miraiminds.co) —
send the `call_id` or `campaign_id` and we can see exactly what happened.
# Speech endpoint (legacy)
Source: https://docs.miraiminds.co/v2/text-to-speech
The original /tts/v1 speech endpoint — model mira-tts-v51, separate key. Kept for existing callers; new integrations start at the TTS quickstart.
This page documents the **original** speech endpoint (`/tts/v1`, model
`mira-tts-v51`, separate key). New integrations should start at the
[TTS quickstart](/v2/tts-quickstart) instead — the current endpoint lives at
`/v1/audio/speech`, uses model `mira-tts`, streams, and takes the same
`sk_live_` key as the rest of the API. This endpoint keeps working for
existing callers.
The speech endpoint is the voice from our agents, on its own. Send text, get a
WAV back. It takes the same request shape as the OpenAI audio API, so any
OpenAI SDK works by swapping `base_url` and the key.
This is a **separate key** from your `sk_live_` API key — ask us for one; it is
not minted from the dashboard.
The engine speaks Hindi, English and the Hinglish in between. Six more
languages — Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali — arrive
over the next two months: see the [language roadmap](/v2/tts-languages).
## Quickstart
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \
-H "Authorization: Bearer $MIRA_TTS_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mira-tts-v51",
"input": "नमस्ते, मैं आशु बोल रहा हूं। आपका ऑर्डर तैयार है।",
"voice": "aishe"
}' \
--output speech.wav
```
The response body **is** the audio: `audio/wav`, 48 kHz mono PCM16. On a
400-character input expect roughly 3 seconds end to end — the whole file is
rendered before the first byte is sent, so this endpoint is built for
generating clips, not for driving a live conversation. For interactive
latency, use an [agent](/v2/agents) and place a [call](/v2/calls).
## Request
| Field | Type | Notes |
| :---------------- | :------ | :------------------------------------------------------------------------------------------------------------ |
| `model` | string | **Required.** Must be `mira-tts-v51`. |
| `input` | string | **Required.** Up to **600 characters**. |
| `voice` | string | `ashu` (default) or `aishe`. Same catalogue as [Voices](/v2/voices). |
| `response_format` | string | `wav` only, and it is the default. Anything else is a 400. |
| `speech_ready` | boolean | `true` (default) runs the rewrite pass below. `false` sends your text to the engine untouched. |
| `instructions` | string | Up to 500 characters, steers the rewrite (tone, pacing). Ignored when `speech_ready` is `false`. |
| `lexicon` | object | Pronunciation overrides, `{"Gallabox": "Galla Box"}`. Applied as a deterministic replace, never by the model. |
| `numbers` | string | `auto` (default), `english`, or `native` — which language digits are spoken in. |
`model` is a stable public name, not the build number. It always points at the
current production voice, which we upgrade underneath you — so a clip you
generate today may sound better than one from last month without your code
changing.
## The rewrite pass
Raw text is rarely speech-ready. `97%` should be read as "ninety-seven
percent", a product name should not be transliterated, and a bare URL should
not be spelled out character by character. By default we run your text through
a rewrite that fixes exactly that, preserving meaning, before it reaches the
engine.
Every response carries a header naming what happened:
| `X-Mira-Speech-Ready` | Meaning |
| :-------------------- | :---------------------------------------------------------------------- |
| `rewritten` | The rewrite ran and its output was synthesised. |
| `bypassed` | You sent `"speech_ready": false`. Your text, untouched. |
| `fallback` | The rewrite was unavailable, so your **original** text was synthesised. |
`fallback` is a degradation, never an error: you still get audio and still get
a 200. If you are debugging pronunciation and the header says `fallback`, the
rewrite is not what shaped that clip.
```bash title="Skip the rewrite" theme={null}
curl https://sandbox.voice.miraiminds.co/tts/v1/audio/speech \
-H "Authorization: Bearer $MIRA_TTS_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"mira-tts-v51","input":"Order #4417 ready","voice":"ashu","speech_ready":false}' \
--output raw.wav
```
## Listing models
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/tts/v1/models \
-H "Authorization: Bearer $MIRA_TTS_KEY"
```
```json theme={null}
{
"object": "list",
"data": [
{ "id": "mira-tts-v51", "object": "model", "owned_by": "miraiminds" }
]
}
```
## Errors
Errors use the same envelope as the rest of the API — see [Errors](/v2/errors).
| Status | `code` | Cause |
| :----- | :------------------------ | :------------------------------------ |
| 400 | `missing_input` | `input` absent or empty. |
| 400 | `input_too_long` | Over 600 characters. Split it. |
| 400 | `invalid_voice` | Not `ashu` or `aishe`. |
| 400 | `invalid_response_format` | Anything other than `wav`. |
| 400 | `invalid_instructions` | Not a string, or over 500 characters. |
| 400 | `invalid_lexicon` | Not an object of string → string. |
| 400 | `invalid_numbers` | Not `auto`, `english` or `native`. |
| 400 | `invalid_json` | Body is not valid JSON. |
| 401 | `invalid_api_key` | Missing or unknown key. |
| 404 | `model_not_found` | `model` is not `mira-tts-v51`. |
```json title="404 Not Found" theme={null}
{
"error": {
"message": "Unknown model 'tts-1'. Use 'mira-tts-v51'.",
"type": "invalid_request_error",
"code": "model_not_found"
}
}
```
## Using an OpenAI SDK
The shape matches, so point the client at us and keep your code:
```python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://sandbox.voice.miraiminds.co/tts/v1",
api_key=MIRA_TTS_KEY,
)
client.audio.speech.create(
model="mira-tts-v51",
voice="aishe",
input="नमस्ते, आपका ऑर्डर तैयार है।",
).stream_to_file("speech.wav")
```
`lexicon`, `numbers`, `instructions` and `speech_ready` are ours, not
OpenAI's. SDKs that validate their request body may reject them — send those
with a plain HTTP client, or use `extra_body` where your SDK supports it.
# Language roadmap
Source: https://docs.miraiminds.co/v2/tts-languages
Six new languages — Telugu, Tamil, Marathi, Kannada, Gujarati and Bengali — join the voice engine over the next two months.
Today the engine speaks **Hindi, English, and the Hinglish most real calls
actually are**. Over the next two months it learns six more: **Telugu, Tamil,
Marathi, Kannada, Gujarati and Bengali**.
Per-language dates are announced as each one clears evaluation — the same
rule as everything else on the [roadmap](/general/roadmap): dates are targets,
quality wins every time. A language does not ship because the calendar says
so; it ships when it sounds like a person.
## Why these six
Together these languages are the mother tongue of **429 million people** —
roughly every third Indian. And Hindi is not a substitute for most of them:
the census records, for each language, how many of its speakers also speak
Hindi, and for four of the six the answer is *almost none*.
| Language | First-language speakers | Also speak Hindi | Reachable only in their own language |
| :-------- | ----------------------: | ---------------: | -----------------------------------: |
| Bengali | 97.2M | 8.6% | **88.9M** |
| Marathi | 83.0M | 41.7% | **48.4M** |
| Telugu | 81.1M | 5.7% | **76.5M** |
| Tamil | 69.0M | 1.5% | **68.0M** |
| Gujarati | 55.5M | 39.0% | **33.8M** |
| Kannada | 43.7M | 4.7% | **41.7M** |
| **Total** | **429.5M** | | **357.3M** |
**357 million people cannot be called in Hindi at all.** Only 1.5% of Tamil
speakers report speaking Hindi; for Telugu it is 5.7%, Kannada 4.7%, Bengali
8.6%. A voice agent that speaks only Hindi does not reach these customers
badly — it does not reach them.
With Hindi and these six together, an agent can speak the mother tongue of
**about four in five Indians**.
Speaker counts are first-language speakers from the 2011 Census of India;
"also speak Hindi" is Hindi reported as a second language (table C-17). These
are the most recent official figures — the next census is due in 2027.
## What support means
* **Same endpoint, same request shape.** The
[speech endpoint](/v2/tts-quickstart) does not change — you send text in the
language's own script, code-switched with English the way real conversation
is, and get audio back. No new API to learn.
* **Native voices, not accents.** Each language ships with its own voices in
the [voice gallery](/v2/voices), evaluated by native speakers — not a Hindi
voice reading Tamil.
* **TTS ships first.** This page tracks the voice. A full agent conversation
in a language also needs our speech recognition and LLM to clear the same
bar, and that follows the voice — the speech endpoint is where each
language lands first.
## Get early access
Each language runs an alpha with a small set of customers before it reaches
the gallery. If calls in one of these languages matter to your business, tell
your account contact which one — alpha slots are allocated in order of ask,
and alpha customers' text is what we tune the final voices on.
# TTS quickstart
Source: https://docs.miraiminds.co/v2/tts-quickstart
Key → curl → Hindi audio in two minutes, then the same endpoint inside your own orchestrator.
The voice from our agents, as a standalone API. It speaks OpenAI's
`POST /v1/audio/speech` protocol, so every OpenAI SDK and every framework
with an OpenAI TTS integration already knows how to call it — you change a
base URL and a model name, not your code.
## 1. Get a key
Sign in to the [console](https://sandbox.voice.miraiminds.co) with your access
code, open **Developers**, and create an API key. It is a `sk_live_` secret,
shown **once** — only its hash is stored, so a lost key is rotated, never
recovered. The same key works for TTS and the rest of the
[v2 API](/v2/overview).
## 2. First audio
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v1/audio/speech \
-H "Authorization: Bearer $MIRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mira-tts",
"voice": "ashu",
"response_format": "wav",
"input": "नमस्ते, मैं आशु बोल रहा हूं। आपका ऑर्डर तैयार है।"
}' \
--output speech.wav
```
The response body **is** the audio — `--output` matters, because a terminal
handed 300 KB of binary is a terminal you have to reset. Play `speech.wav`
and you have completed the integration; everything below is refinement.
Two formats exist, and only two:
| `response_format` | You get |
| :---------------- | :------------------------------------------------------------------------------------------------------------------------ |
| `wav` | A playable file — 48 kHz mono PCM16 with a header. |
| `pcm` | The same samples, raw and **streamed** — first bytes arrive while the rest is still rendering. Use this in anything live. |
Anything else (including OpenAI's default `mp3`) is a 400 naming these two.
Always send `response_format` explicitly.
## 3. Pick a voice
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/voices \
-H "Authorization: Bearer $MIRA_API_KEY"
```
Currently `ashu`, `aishe` and `neha` — Hindi voices that code-switch through
the English of real Indian speech. Each entry carries a `sampleAudio` URL;
listen before you choose. An unknown voice is a 400, not a silent
substitution. Six more languages are on the way — see the
[language roadmap](/v2/tts-languages).
## 4. Stream it with the OpenAI SDK
```python theme={null}
from openai import OpenAI
client = OpenAI(
api_key="sk_live_...",
base_url="https://sandbox.voice.miraiminds.co/v1",
)
with client.audio.speech.with_streaming_response.create(
model="mira-tts",
voice="ashu",
response_format="pcm",
input="चलिए numbers से start करते हैं।",
) as response:
for chunk in response.iter_bytes():
... # raw s16le mono @ 48 kHz — feed your player as it arrives
```
## 5. Drop it into your orchestrator
Frameworks with an OpenAI TTS integration need no custom code. pipecat, for
example — the stock `OpenAITTSService` is the whole integration:
```python theme={null}
from pipecat.services.openai.tts import VALID_VOICES, OpenAITTSService
# pipecat validates voices against OpenAI's own hardcoded list (alloy, nova, …)
# before it ever sends a request — without this line every Mirai voice raises
# KeyError: 'ashu' client-side.
VALID_VOICES.update({name: name for name in ("ashu", "aishe", "neha")})
tts = OpenAITTSService(
api_key=os.environ["MIRA_API_KEY"],
base_url="https://sandbox.voice.miraiminds.co/v1",
sample_rate=48000,
settings=OpenAITTSService.Settings(model="mira-tts", voice="ashu"),
)
```
`sample_rate=48000` makes pipecat log "OpenAI TTS only supports 24000Hz" at
startup. Cosmetic — our audio is 48 kHz native and the frames are tagged
correctly. A complete \~100-line agent (browser mic → ASR → LLM → Mirai TTS)
built this way is available on request — ask on your onboarding thread.
## Billing
Per character. Every response reports its own cost in headers, so you can
meter spend without a second request:
| Header | Meaning |
| :--------------- | :---------------------------------- |
| `X-Chars-Billed` | Characters billed for this request. |
| `X-Cost-Paise` | What this request cost, in paise. |
## When something fails
| Status | Cause |
| :----- | :-------------------------------------------------------------------------------------------------- |
| 400 | Unknown voice, or a `response_format` other than `wav` / `pcm`. The message names the valid values. |
| 401 | Missing or unknown key. Check the `Authorization: Bearer sk_live_…` header. |
| 404 | `model` is not `mira-tts`. |
Errors use the same envelope as the rest of the API — see
[Errors](/v2/errors). Stuck on something this page does not cover? Reply on
your onboarding thread — a human reads it.
# Voices
Source: https://docs.miraiminds.co/v2/voices
Choosing a voice_id for your agent — the two t1 voices, the t3 catalogue, and the one caveat about the language field.
Every agent speaks in one voice, set by `voice.voice_id`. **Which voices exist
depends on the [tier](/general/tiers)**, because the tiers speak through
different engines.
## `t3` — the default
`t3` speaks through a premium third-party catalogue (Sarvam). There is no short
allowlist to memorise: pass the vendor's voice id, e.g. `ashutosh`.
```json theme={null}
{
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN"
}
```
On `t3` **`voice` is required** when you create an agent. There is no safe
default to pick from somebody else's catalogue, so an agent without one is a
`400`.
## `t1` — exactly two voices
`t1` speaks through our own stack, and it loads exactly two voices:
| `voice_id` | Notes |
| :--------- | :---------------------------------------------------------------------------------- |
| `ashu` | Hindi / Hinglish. **The default** — omit `voice` entirely on `t1` and you get this. |
| `aishe` | Hindi / Hinglish, second option. |
Anything else is rejected at agent-create time, and the error names both so you
are not left guessing:
```json title="400 Bad Request" theme={null}
{
"error": {
"code": "invalid_request",
"message": "voice.voice_id 'priya' is not available. Use 'ashu' or 'aishe'."
}
}
```
The same agent can be run at either tier — `tier` is set per call and per
[campaign](/v2/campaigns), not on the agent. If you create an agent with a `t3`
voice and then place a `t1` call with it, the voice will not be one `t1` can
say. Keep one agent per tier when you use both.
## Hearing them first
There is no `GET /v2/voices` yet. The live catalogue is the voice picker in the
[console](https://sandbox.voice.miraiminds.co) — open Agent Studio and you can
**hear each voice** before you commit to one. The name in the picker is the
`voice_id`, and the catalogue grows monthly; treat the picker, not this page, as
the source of truth.
**Listen before you pick.** Voice choice moves answer-through rates more than
prompt wording does.
## Setting a voice on an agent
```json theme={null}
{
"name": "Order confirmations",
"system_prompt": "…",
"voice": { "voice_id": "ashutosh", "language": "hi-IN" },
"language": "hi-IN"
}
```
| Field | Type | Description |
| :--------- | :----- | :--------------------------------------------------------------------------------------------------------------------------- |
| `voice_id` | string | A voice your tier can say. Required on `t3`; optional on `t1`, where it defaults to `ashu`. Unknown on `t1` ⇒ `400`. |
| `language` | string | Rendering language. Omit to inherit the agent's top-level `language`. **Accepted and stored, not yet honoured** — see below. |
Change it later with `PATCH /v2/agents/{id}`:
```bash theme={null}
curl -X PATCH https://sandbox.voice.miraiminds.co/v2/agents/agt_… \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "voice": { "voice_id": "aishe", "language": "hi-IN" } }'
```
See the [Agents reference](/v2/agents#voice) for the full agent shape.
## The language caveat
**`voice.language` is recorded, not yet applied**
It is validated, stored, echoed back and carried with the call payload — but
the voice renders in the agent's **top-level** `language`. Set both to the same
value and you get what you expect; set them differently and the top-level one
wins.
Keep `voice.language` and the agent's `language` identical.
# Wallet
Source: https://docs.miraiminds.co/v2/wallet
Check your prepaid balance and read the transaction ledger.
Mirai Voice is **prepaid**. Every workspace has one INR wallet. Calls debit it
when they end; ops credits it when you top up. If the wallet cannot cover a
minute, `POST /v2/calls` returns [`402`](#402-insufficient-balance) and no call
is placed.
For rates and how minutes are counted, see [Billing & tiers](/general/tiers).
## Get balance
```http theme={null}
GET /v2/wallet
```
```bash theme={null}
curl https://sandbox.voice.miraiminds.co/v2/wallet \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python theme={null}
import os, httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
wallet = httpx.get(f"{API}/v2/wallet", headers=auth, timeout=30) \
.raise_for_status().json()
print(wallet["balance_inr"]) # 500
```
```javascript theme={null}
const API = "https://sandbox.voice.miraiminds.co";
const auth = { Authorization: `Bearer ${process.env.MIRAI_API_KEY}` };
const wallet = await fetch(`${API}/v2/wallet`, { headers: auth }).then((r) => r.json());
console.log(wallet.balance_inr); // 500
```
```json title="200 OK" theme={null}
{
"balance_inr": 500,
"currency": "INR",
"updated_at": "2026-07-26T09:12:44Z"
}
```
| Field | Type | Description |
| :------------ | :----- | :--------------------------------- |
| `balance_inr` | number | Spendable balance. Never negative. |
| `currency` | string | Always `INR`. |
| `updated_at` | string | When the balance last changed. |
**Alert on this, don't discover it**
Poll the balance on a schedule (hourly is plenty) and alert your own team below
a threshold that covers a day of traffic. A `402` in the middle of a campaign is
a much worse way to learn you are out of credit.
## List transactions
```http theme={null}
GET /v2/wallet/transactions?limit=&cursor=
```
The ledger. One row per credit or debit, newest first.
```bash theme={null}
curl "https://sandbox.voice.miraiminds.co/v2/wallet/transactions?limit=50" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python theme={null}
def all_transactions():
cursor = None
while True:
params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
page = httpx.get(
f"{API}/v2/wallet/transactions", headers=auth, params=params, timeout=30
).raise_for_status().json()
yield from page["data"]
if not page["has_more"]:
return
cursor = page["next_cursor"]
spent = sum(t["amount_inr"] for t in all_transactions() if t["type"] == "debit")
```
```javascript theme={null}
async function* allTransactions() {
let cursor = null;
for (;;) {
const qs = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
const page = await fetch(`${API}/v2/wallet/transactions?${qs}`, { headers: auth })
.then((r) => r.json());
yield* page.data;
if (!page.has_more) return;
cursor = page.next_cursor;
}
}
let spent = 0;
for await (const t of allTransactions()) if (t.type === "debit") spent += t.amount_inr;
```
```json title="200 OK" theme={null}
{
"data": [
{
"id": "txn_01JZQ9D6P2R5S8T4V7W9X1Y3ZB",
"object": "wallet_transaction",
"type": "debit",
"amount_inr": 6,
"balance_after_inr": 494,
"call_id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA",
"description": "call 96s (2 min, t3)",
"created_at": "2026-07-26T09:16:39Z"
},
{
"id": "txn_01JZQ8E2M4N6P8R1S3T5V7W9XA",
"object": "wallet_transaction",
"type": "credit",
"amount_inr": 500,
"balance_after_inr": 500,
"call_id": null,
"description": "pilot top-up",
"created_at": "2026-07-26T08:40:11Z"
}
],
"has_more": false,
"next_cursor": null
}
```
| Field | Type | Description |
| :------------------ | :------------------ | :----------------------------------------------------- |
| `type` | `credit` \| `debit` | Direction. |
| `amount_inr` | number | Always positive; read `type` for direction. |
| `balance_after_inr` | number | Balance immediately after this row. |
| `call_id` | string \| null | Set on call debits, `null` on top-ups and adjustments. |
| `description` | string | Human-readable. Do not parse it. |
Reconcile against `call_id`: every billable call produces exactly one debit row.
## Top up
There is no self-serve top-up yet — it is on the
[roadmap](/general/roadmap). Until then your Mirai contact credits the
workspace; the credit appears as a `credit` row within minutes and takes effect
immediately. Your live balance is always `GET /v2/wallet`, and on the
**Developers** page of the [console](https://sandbox.voice.miraiminds.co).
## `402` insufficient balance
Before dialling, we check the wallet can cover at least one minute at the
call's tier. If it cannot:
```json title="402 Payment Required" theme={null}
{
"error": {
"code": "insufficient_balance",
"message": "wallet balance 0.40 INR is below the minimum for one minute at t3"
}
}
```
* **The gate runs pre-dial.** No phone rings, nothing is charged, no call object
is created.
* **Retry after topping up**, with the same `Idempotency-Key` — the failed
request did not consume it.
* **Calls already in flight are not killed.** A call that started with credit
runs to its natural end, then debits. A wallet can therefore end a busy minute
slightly lower than the pre-dial check implied.
Handle it explicitly — it is the one error that is a business condition rather
than a bug:
```python theme={null}
r = httpx.post(f"{API}/v2/calls", headers=auth, json=payload, timeout=30)
if r.status_code == 402:
pause_campaign()
alert_ops(r.json()["error"]["message"])
else:
r.raise_for_status()
```
```javascript theme={null}
const res = await fetch(`${API}/v2/calls`, { method: "POST", headers, body });
if (res.status === 402) {
const { error } = await res.json();
await pauseCampaign();
await alertOps(error.message);
} else if (!res.ok) {
throw new Error(await res.text());
}
```
# Webhooks
Source: https://docs.miraiminds.co/v2/webhooks
Signed lifecycle events — event types, signature verification, retries and idempotency.
Pass `webhook_url` when you [create a call](/v2/calls#create-a-call) and we POST
a signed JSON event at each lifecycle transition. Webhooks are how you learn a
call's outcome — polling is the fallback, not the design.
Your endpoint must be **public HTTPS** and must return a **2xx** quickly. Any
other status, or a connection failure, triggers [retries](#retries).
## Event envelope
Every event has the same shape.
```json theme={null}
{
"id": "evt_01JZQ9C5N9P4R7S3T6V8W2X4YB",
"type": "call.completed",
"created_at": "2026-07-26T09:16:38Z",
"data": {
"call": {
"id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YA",
"object": "call",
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543210",
"status": "completed",
"tier": "t3",
"ended_reason": "assistant-ended-call",
"started_at": "2026-07-26T09:15:02Z",
"ended_at": "2026-07-26T09:16:38Z",
"duration_secs": 96,
"cost_inr": 6
}
}
}
```
| Field | Description |
| :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `evt_`. Unique per **delivery attempt group** — retries of the same event reuse it. [Dedupe on this](#idempotency). |
| `type` | See [event types](#event-types). |
| `created_at` | When the event was generated, not when it was delivered. |
| `data.call` | The [call object](/v2/calls#the-call-object), exactly as `GET /v2/calls/{id}` would return it at that moment. On a `campaign.*` event this is `data.campaign` instead. |
Treat the envelope as **additive**: new fields appear without a version bump.
Ignore what you do not recognise.
## Event types
| Type | Fires when | `data.call.status` |
| :--------------- | :--------------------------------------------------------------------------------------------------- | :--------------------------------------- |
| `call.queued` | The call is admitted to the queue. **Opt-in** — ask your Mirai contact to enable it. | `queued` |
| `call.started` | Media is live; the conversation has begun. | `in_progress` |
| `call.completed` | The call connected and finished normally. | `completed` |
| `call.voicemail` | An answering machine answered. | `voicemail` |
| `call.failed` | The call did not connect, or the pipeline errored. | `no_answer`, `busy`, `failed`, `timeout` |
| `call.aborted` | You cancelled it with [`POST /abort`](/v2/calls#abort-a-call) — queued, ringing or mid-conversation. | `aborted` |
Exactly one terminal event fires per call: `call.completed`, `call.voicemail`,
`call.failed` or `call.aborted`.
[Campaigns](/v2/campaigns) add five more. They are delivered to the campaign's
own `webhook_url`, signed with the same workspace secret, in the same envelope —
with `data.campaign` where `data.call` would be.
| Type | Fires when |
| :------------------- | :----------------------------------------------------------------------- |
| `campaign.started` | The first `POST /start` takes effect. Once per campaign. |
| `campaign.paused` | You paused it, or the money gate did. Read `data.campaign.pause_reason`. |
| `campaign.resumed` | Dialling resumed. |
| `campaign.completed` | Every contact reached a final state, or the date range ran out. |
| `campaign.stopped` | You stopped it. |
A campaign's calls emit the ordinary `call.*` events as well, to the same URL.
See [Campaigns → Webhooks](/v2/campaigns#webhooks) for the payload.
```mermaid theme={null}
sequenceDiagram
participant You
participant API as Mirai Voice
participant Phone as Customer
You->>API: POST /v2/calls
API-->>You: 202 {id, status:"queued"}
API-->>You: call.queued (opt-in)
API->>Phone: dial
Phone-->>API: answer
API-->>You: call.started
Note over API,Phone: conversation
Phone-->>API: hangup
API-->>You: call.completed
```
### `call.failed`
Read `data.call.status` to tell the failure modes apart — `no_answer` is a
retry-tomorrow, `failed` is a page-someone.
```json theme={null}
{
"id": "evt_01JZQ9E7Q3S6T9V5W8X1Y4Z6AC",
"type": "call.failed",
"created_at": "2026-07-26T09:15:44Z",
"data": {
"call": {
"id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YB",
"object": "call",
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543211",
"status": "no_answer",
"tier": "t3",
"ended_reason": "customer-did-not-answer",
"started_at": null,
"ended_at": "2026-07-26T09:15:44Z",
"duration_secs": 0,
"cost_inr": null
}
}
}
```
### `call.voicemail`
```json theme={null}
{
"id": "evt_01JZQ9F8R4T7V2W5X8Y1Z4A6BD",
"type": "call.voicemail",
"created_at": "2026-07-26T09:18:20Z",
"data": {
"call": {
"id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YC",
"object": "call",
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543212",
"status": "voicemail",
"tier": "t3",
"ended_reason": "voicemail",
"started_at": "2026-07-26T09:18:02Z",
"ended_at": "2026-07-26T09:18:20Z",
"duration_secs": 18,
"cost_inr": 3
}
}
}
```
`status: voicemail` always carries `ended_reason: "voicemail"` — that reason is
what identifies the answering machine in the first place, so the pair is the
only combination you will see.
Voicemail calls **are billed** — media was live. 18 seconds bills as
18 seconds: ₹0.30 at `t1`. See [metering](/general/tiers#metering).
### `call.aborted`
```json theme={null}
{
"id": "evt_01JZQ9G9S5V8W3X6Y9Z2A5B7CE",
"type": "call.aborted",
"created_at": "2026-07-26T09:14:51Z",
"data": {
"call": {
"id": "call_01JZQ9B4M8N3P6R2S5T7V9W1YD",
"object": "call",
"agent_id": "agt_01JZQ8F3K7M2N5P9R4T6V8W0XZ",
"to": "+919876543213",
"status": "aborted",
"tier": "t3",
"ended_reason": "aborted-by-api",
"started_at": null,
"ended_at": "2026-07-26T09:14:51Z",
"duration_secs": 0,
"cost_inr": null
}
}
}
```
`aborted-by-api` is the only `ended_reason` an abort produces, whether you
cancelled the call in the queue, mid-ring or mid-conversation. Aborted calls are
never billed.
***
## Signature verification
Every delivery carries a timestamped HMAC-SHA256 signature.
```http theme={null}
X-Mirai-Signature: t=1785057398,v1=771bca27e1647dfbe59ff5d8f32d9de69be246f6e604c05bddcbaefd8604ccb3
```
| Element | Meaning |
| :------ | :----------------------------------------------------- |
| `t` | Unix seconds when we signed the delivery. |
| `v1` | Lowercase hex `HMAC-SHA256(secret, ".")`. |
The signed string is the timestamp, a literal `.`, then the **raw request body
bytes**. The secret is your per-workspace `whsec_…`, shown once when your key is
issued.
Verification is four steps:
1. Parse `t` and `v1` out of the header.
2. Reject if `|now − t| > 300` seconds — this is the [replay window](#replay-protection).
3. Recompute the HMAC over `t + "." + raw_body`.
4. Compare with `v1` in **constant time**.
```python theme={null}
# verify.py — framework-independent core
import hashlib
import hmac
import time
TOLERANCE_SECS = 300
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""Return True iff the delivery is authentic and inside the replay window.
raw_body must be the exact bytes we sent. A parsed-and-re-serialised dict
will not match: key order and whitespace both change the digest.
"""
if not signature_header:
return False
parts = dict(
p.split("=", 1) for p in signature_header.split(",") if "=" in p
)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
try:
sent_at = int(t)
except ValueError:
return False
if abs(time.time() - sent_at) > TOLERANCE_SECS:
return False
expected = hmac.new(
secret.encode("utf-8"),
t.encode("utf-8") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
```
Flask:
```python theme={null}
import os
from flask import Flask, request
from verify import verify_signature
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"]
@app.post("/mirai/webhook")
def mirai_webhook():
if not verify_signature(
request.get_data(), # raw bytes, before any JSON parsing
request.headers.get("X-Mirai-Signature", ""),
WEBHOOK_SECRET,
):
return {"error": "invalid signature"}, 401
event = request.get_json()
if already_processed(event["id"]):
return "", 200 # duplicate delivery, already handled
enqueue(event) # do the slow work off the request
mark_processed(event["id"])
return "", 200 # any 2xx stops our retries
```
FastAPI:
```python theme={null}
import json
import os
from fastapi import FastAPI, Request, Response
app = FastAPI()
WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"]
@app.post("/mirai/webhook")
async def mirai_webhook(request: Request):
raw = await request.body()
if not verify_signature(
raw, request.headers.get("x-mirai-signature", ""), WEBHOOK_SECRET
):
return Response(status_code=401)
event = json.loads(raw)
enqueue(event)
return Response(status_code=200)
```
```javascript theme={null}
// verify.js — framework-independent core
import crypto from "node:crypto";
const TOLERANCE_SECS = 300;
/**
* @param {Buffer} rawBody exact bytes as received, before JSON.parse
* @param {string} signatureHeader value of X-Mirai-Signature
* @param {string} secret your whsec_… webhook secret
*/
export function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const i = p.indexOf("=");
return i === -1 ? ["", ""] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
})
);
const { t, v1 } = parts;
if (!t || !v1) return false;
const sentAt = Number(t);
if (!Number.isFinite(sentAt)) return false;
if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
Express — note `express.raw`, not `express.json`:
```javascript theme={null}
import express from "express";
import { verifySignature } from "./verify.js";
const app = express();
const WEBHOOK_SECRET = process.env.MIRAI_WEBHOOK_SECRET;
app.post(
"/mirai/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verifySignature(req.body, req.get("X-Mirai-Signature") ?? "", WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
await enqueue(event); // do the slow work off the request
await markProcessed(event.id);
res.sendStatus(200); // any 2xx stops our retries
}
);
app.listen(3000);
```
Next.js App Router:
```javascript theme={null}
// app/api/mirai/webhook/route.js
import { verifySignature } from "@/lib/verify";
export async function POST(request) {
const raw = Buffer.from(await request.arrayBuffer());
const ok = verifySignature(
raw,
request.headers.get("x-mirai-signature") ?? "",
process.env.MIRAI_WEBHOOK_SECRET
);
if (!ok) return new Response(null, { status: 401 });
const event = JSON.parse(raw.toString("utf8"));
await enqueue(event);
return new Response(null, { status: 200 });
}
```
### Test vector
Check your implementation against this before you go anywhere near a real call.
```text title="secret" theme={null}
whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8
```
```text title="raw body (177 bytes, exactly as shown — no trailing newline)" theme={null}
{"id":"evt_01JZQ9C5N9P4R7S3T6V8W2X4YB","type":"call.completed","created_at":"2026-07-26T09:16:38Z","data":{"call":{"id":"call_01JZQ9B4M8N3P6R2S5T7V9W1YA","status":"completed"}}}
```
```text title="header" theme={null}
X-Mirai-Signature: t=1785057398,v1=771bca27e1647dfbe59ff5d8f32d9de69be246f6e604c05bddcbaefd8604ccb3
```
Your HMAC must equal that `v1`. (The timestamp is in the past, so skip the
window check while testing this vector.)
```bash theme={null}
printf '%s' '1785057398.{"id":"evt_01JZQ9C5N9P4R7S3T6V8W2X4YB","type":"call.completed","created_at":"2026-07-26T09:16:38Z","data":{"call":{"id":"call_01JZQ9B4M8N3P6R2S5T7V9W1YA","status":"completed"}}}' \
| openssl dgst -sha256 -hmac 'whsec_7d4f1a09c2e58b36a1f0d7c93b2e64a8' -hex
```
### Raw body
The single most common integration bug: signing a re-serialised body.
```js theme={null}
// ✗ wrong — key order, spacing and unicode escaping all differ from what we sent
hmac(JSON.stringify(req.body));
// ✓ right — the bytes we sent
hmac(rawBodyBuffer);
```
Capture the raw bytes **before** any body parser touches them. In Express that
means `express.raw({ type: "application/json" })` on this route (mount it before
any global `express.json()`); in Django, `request.body`; in Rails,
`request.raw_post`; behind API Gateway, make sure the payload is not
base64-transformed on the way in.
### Replay protection
We reject nothing on your behalf — the timestamp is there so **you** can. Drop
deliveries where `|now − t| > 300` seconds. Without that check, anyone who
captures one valid delivery can replay it forever.
If legitimate deliveries fail the window check, your server clock is wrong.
Run NTP.
### Rotating the secret
Ask ops. During rotation both the old and the new secret are considered valid
for a short overlap, so verify against a list:
```python theme={null}
def verify_any(raw_body, header, secrets):
return any(verify_signature(raw_body, header, s) for s in secrets)
```
***
## Retries
A delivery succeeds on any `2xx`. Anything else — `4xx`, `5xx`, timeout, TLS
error, DNS failure — is retried: **six attempts with exponential backoff over
roughly 36 minutes**, the first fired immediately and the gaps widening up to a
half-hour cap.
Design against the window, not against the individual gaps — the exact delays
are an implementation detail and we tune them. What is contractual is that a
brief outage on your side is survivable without losing the event, and that an
endpoint down for the whole 36 minutes will lose it.
After the sixth attempt the event goes to a dead-letter queue. We can replay
from the DLQ — a replayed event is byte-identical to the original, including its
`id`, and carries a **fresh** `t` and `v1` so it passes your window check.
Consequences worth planning for:
* **Order is not guaranteed.** A retried `call.started` can arrive after
`call.completed`. Order your own state machine by `data.call.status`, not by
arrival.
* **Slow endpoints get retried.** If your handler takes longer than our client
timeout we treat it as a failure and send again. Acknowledge first, work
later.
* **A `401` from your signature check is a retryable failure to us.** That is
intentional — a transient secret-loading bug on your side should not lose the
event.
## Linking an event to your own records
An event carries the call, not your database. The join key is `call_id`, and
you have it **before** any event can arrive — `POST /v2/calls` returns it
synchronously in the `202`:
```python theme={null}
call = mirai.calls.create(agent_id=agent.id, to=customer.phone)
Order.objects.filter(pk=order.id).update(mirai_call_id=call.id) # store it here
```
Do that write before you return from the request that placed the call. A
webhook can land while the placing request is still in flight, and a handler
that cannot find the call yet has to either drop the event or retry blindly.
`variables` are substituted into the prompt; they are **not** echoed back on
the event. Anything you need at webhook time belongs in your own row, keyed by
`call_id`.
## Idempotency
Assume at-least-once delivery. Dedupe on `id`:
```python theme={null}
# Redis SETNX with a TTL longer than the full retry schedule (~36 min).
def already_processed(event_id: str) -> bool:
return not redis.set(f"mirai:evt:{event_id}", "1", nx=True, ex=86_400)
```
```javascript theme={null}
// Redis SETNX with a TTL longer than the full retry schedule (~36 min).
async function alreadyProcessed(eventId) {
const set = await redis.set(`mirai:evt:${eventId}`, "1", "NX", "EX", 86_400);
return set === null;
}
```
Or make the handler naturally idempotent — `UPDATE orders SET call_status = ?
WHERE id = ?` needs no dedupe table at all.
## Local development
Webhooks need a public URL. Tunnel to your laptop:
```bash theme={null}
ngrok http 3000
# → https://a1b2c3d4.ngrok-free.app
```
Then pass `"webhook_url": "https://a1b2c3d4.ngrok-free.app/mirai/webhook"` when
creating a call. Replay a captured delivery against your handler with `curl` to
iterate without burning wallet balance:
```bash theme={null}
curl -X POST http://localhost:3000/mirai/webhook \
-H "Content-Type: application/json" \
-H "X-Mirai-Signature: t=$(date +%s),v1=$(printf '%s' "$(date +%s).$(cat event.json)" \
| openssl dgst -sha256 -hmac "$MIRAI_WEBHOOK_SECRET" -hex | cut -d' ' -f2)" \
--data-binary @event.json
```
## Checklist
* [ ] Endpoint is public HTTPS and returns 2xx in well under a second.
* [ ] Signature verified against the **raw** body, in constant time.
* [ ] Timestamp window enforced at 300 seconds.
* [ ] Events deduped on `id`.
* [ ] Unknown `type` values ignored, not crashed on.
* [ ] Handler does no slow work inline — enqueue and return.
# The Zen of Voice Agents
Source: https://docs.miraiminds.co/v2/zen-of-voice-agents
Nineteen lines on writing for the ear, in the tradition of the Zen of Python. Read them before you write a prompt.
Every rule in the [prompting guide](/v2/prompting-guide) started as a phone
call that went wrong. These are the rules with the explanations taken out, the
way the Zen of Python does it. Read them before you write a prompt; come back
to them when a transcript reads badly and you cannot say why.
```text theme={null}
The Zen of Voice Agents
Spoken is better than written.
Short is better than complete.
One question is better than two.
The first word matters more than the last paragraph.
Silence is a bug. So is a monologue.
Numbers are words. Identifiers are digits, one at a time.
Read back what changed, not what was already agreed.
When a digit is unclear, ask again. Never guess.
Write in the language you will speak.
Facts live in the prompt. The customer lives in the variables.
What the prompt leaves out, the model will make up.
The caller may interrupt. The agent may not.
No is an answer. Nudge once, then accept it.
Never promise what the agent cannot do.
Thank them once.
Every turn moves the call forward.
The ending is part of the call.
Three real calls beat thirty imagined ones.
Hinglish is one honking great idea — let's do more of that.
```
## Each line, in one breath
**Spoken is better than written.** The caller cannot scroll, skim or re-read.
Whatever you write is heard once, at talking speed, on a phone. Write what a
person would say, not what a page would show.
**Short is better than complete.** A reply is the thing the caller waits
through. One short sentence, two at most; the rest can wait for the next turn.
**One question is better than two.** Ask two things and the caller answers
neither. One ask, then stop and listen.
**The first word matters more than the last paragraph.** The opening line is
spoken before the model even runs — it is your first-audio latency and your
first impression. Name, brand, person check, done. And a lead word at the top
of every reply ("जी।", "अच्छा—", "ठीक है।") starts the voice sooner and sounds
like someone who was listening.
**Silence is a bug. So is a monologue.** Dead air makes people hang up; a
lecture makes them stop listening. Both are fixed by the same thing: short
turns that keep moving.
**Numbers are words. Identifiers are digits, one at a time.** "five thousand
two hundred rupees", never ₹5,200. A pincode is "three nine five zero zero
seven", and it has six digits — if the agent heard three, the recogniser dropped
some, and the agent should say so.
**Read back what changed, not what was already agreed.** Confirmation by
exception. One consolidated read-back at the end, not a receipt after every
turn.
**When a digit is unclear, ask again. Never guess.** A wrong address is a failed
delivery; a wrong phone number is a lost customer. "सिर्फ pincode दोबारा बोलिए"
costs three seconds.
**Write in the language you will speak.** A Hindi call driven by an English
prompt code-switches badly. Hindi words in Devanagari, everyday English words in
Roman, and no romanized Hindi anywhere.
**Facts live in the prompt. The customer lives in the variables.** The prompt is
what the agent may say about the business. Names, amounts, dates and order IDs
arrive per call, already in spoken form. One agent, thousands of calls.
**What the prompt leaves out, the model will make up.** Unstated policy gets
invented; an unlisted price gets quoted. Write the facts, and write what to say
when the answer is not in them: "आपको team से call आएगा."
**The caller may interrupt. The agent may not.** The engine lets the caller cut
in, and the agent stops. When they are dictating an address, the agent's whole
job is "जी", "हम्म" — and one read-back when they are done.
**No is an answer. Nudge once, then accept it.** One light nudge is persuasion;
a second is harassment. Capture the refusal as an outcome and close warmly. The
engine enforces this ending — the prompt has to want it too.
**Never promise what the agent cannot do.** Today the agent can talk, and it can
end the call. It cannot transfer, send a link, or book anything in your system.
"The team will call you back" is the honest line.
**Thank them once.** In the goodbye, nowhere else. Models over-thank, and a
धन्यवाद in every turn sounds like a machine.
**Every turn moves the call forward.** No turn is only an acknowledgement. "जी,
order confirm हो गया — अब address एक बार check कर लेते हैं" is one utterance,
and it advances the call.
**The ending is part of the call.** Write the success ending explicitly:
summarise in one line, say goodbye, end the call. Without it the model keeps
talking until the timeout, and the caller sits through the awkwardness.
**Three real calls beat thirty imagined ones.** Call yourself. Say no, correct a
digit, ask something off-script, go quiet. Read the transcript, change one
thing, call again. A phone line is a different acoustic world from a browser
mic.
**Hinglish is one honking great idea — let's do more of that.** Real calls are
not Hindi or English; they are "payment कब तक हो जाएगा", with the Hindi in
Devanagari and the English in Roman. Write the way people already talk, and the
agent sounds like one of them.
***
The long version, with the structure, the number tables and the test loop, is
the [prompting guide](/v2/prompting-guide).
After Tim Peters, *The Zen of Python* (PEP 20). Python got nineteen lines and
an unwritten twentieth; so did we.